diff --git a/.claude/skills/ai-sdk-development/SKILL.md b/.claude/skills/ai-sdk-development/SKILL.md index d74e342e..30320b09 100644 --- a/.claude/skills/ai-sdk-development/SKILL.md +++ b/.claude/skills/ai-sdk-development/SKILL.md @@ -1,6 +1,9 @@ --- name: ai-sdk-development -description: Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for Prism PHP or other AI packages used directly. +license: MIT +metadata: + author: laravel --- # Developing with the Laravel AI SDK @@ -53,6 +56,7 @@ ### Agents ```php use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; class SalesCoach implements Agent @@ -69,6 +73,17 @@ ### Agents $response = (new SalesCoach)->prompt('Analyze this transcript...'); echo $response->text; +// Container resolution with dependency injection +$agent = SalesCoach::make(user: $user); + +// Override provider, model, or timeout per-prompt +$response = (new SalesCoach)->prompt( + 'Analyze this transcript...', + provider: Lab::Anthropic, + model: 'claude-haiku-4-5-20251001', + timeout: 120, +); + // Streaming (returns SSE response from a route) return (new SalesCoach)->stream('Analyze this transcript...'); @@ -245,9 +260,11 @@ ## Agent Configuration ### PHP Attributes ```php -use Laravel\Ai\Attributes\{Provider, MaxSteps, MaxTokens, Temperature, Timeout}; +use Laravel\Ai\Attributes\{Provider, Model, MaxSteps, MaxTokens, Temperature, Timeout}; +use Laravel\Ai\Enums\Lab; -#[Provider('anthropic')] +#[Provider(Lab::Anthropic)] +#[Model('claude-haiku-4-5-20251001')] #[MaxSteps(10)] #[MaxTokens(4096)] #[Temperature(0.7)] @@ -313,7 +330,7 @@ ### Conversation Memory ### Failover ```php -$response = (new MyAgent)->prompt('Hello', provider: ['openai', 'anthropic']); +$response = (new MyAgent)->prompt('Hello', provider: [Lab::OpenAI, Lab::Anthropic]); ``` ## Testing and Faking @@ -370,6 +387,7 @@ ## Key Patterns - Agent pattern: Implement the `Agent` interface and use the `Promptable` trait - Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational` - Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores` +- Provider enum: `Laravel\Ai\Enums\Lab` (prefer over plain strings) - Artisan commands: `php artisan make:agent`, `php artisan make:tool` - Global helper: `agent()` for anonymous agents @@ -400,14 +418,23 @@ ### Never Use Prism Directly ## Provider Support -| Provider | Text | Image | Audio | STT | Embeddings | Reranking | Files | Stores | -| ---------- | ---- | ----- | ----- | --- | ---------- | --------- | ----- | ------ | -| OpenAI | Y | Y | Y | Y | Y | - | Y | Y | -| Anthropic | Y | - | - | - | - | - | Y | - | -| Gemini | Y | Y | - | - | Y | - | Y | Y | -| xAI | Y | Y | - | - | - | - | - | - | -| Groq | Y | - | - | - | - | - | - | - | -| OpenRouter | Y | - | - | - | - | - | - | - | -| ElevenLabs | - | - | Y | Y | - | - | - | - | -| Cohere | - | - | - | - | Y | Y | - | - | -| Jina | - | - | - | - | Y | Y | - | - | \ No newline at end of file +| Feature | Providers | +| ---------- | --------------------------------------------------------------- | +| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama | +| Images | OpenAI, Gemini, xAI | +| TTS | OpenAI, ElevenLabs | +| STT | OpenAI, ElevenLabs, Mistral | +| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | +| Reranking | Cohere, Jina | +| Files | OpenAI, Anthropic, Gemini | + +Use the `Laravel\Ai\Enums\Lab` enum to reference providers in code instead of plain strings: + +```php +use Laravel\Ai\Enums\Lab; + +Lab::Anthropic; +Lab::OpenAI; +Lab::Gemini; +// ... +``` \ No newline at end of file diff --git a/.claude/skills/inertia-vue-development/SKILL.md b/.claude/skills/inertia-vue-development/SKILL.md index a0612246..c69fd98e 100644 --- a/.claude/skills/inertia-vue-development/SKILL.md +++ b/.claude/skills/inertia-vue-development/SKILL.md @@ -1,6 +1,6 @@ --- name: inertia-vue-development -description: "Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using ,
, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation." +description: "Develops Inertia.js v3 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using , , useForm, useHttp, setLayoutProps, or router; working with deferred props, prefetching, optimistic updates, instant visits, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation." license: MIT metadata: author: laravel @@ -8,9 +8,19 @@ # Inertia Vue Development +## When to Apply + +Activate this skill when: + +- Creating or modifying Vue page components for Inertia +- Working with forms in Vue (using ``, `useForm`, or `useHttp`) +- Implementing client-side navigation with `` or `router` +- Using v3 features: deferred props, prefetching, optimistic updates, instant visits, layout props, HTTP requests, WhenVisible, InfiniteScroll, once props, flash data, or polling +- Building Vue-specific features with the Inertia protocol + ## Documentation -Use `search-docs` for detailed Inertia v2 Vue patterns and documentation. +Use `search-docs` for detailed Inertia v3 Vue patterns and documentation. ## Basic Usage @@ -20,8 +30,6 @@ ### Page Components Location ### Page Component Structure -Important: Vue components must have a single root element. - ```vue + + +``` + +### Optimistic Updates + +Apply data changes instantly before the server responds, with automatic rollback on failure: + + +```vue + +``` + +Optimistic updates also work with `useForm` and the `` component: + + +```vue + +``` + +### Instant Visits + +Navigate to a new page immediately without waiting for the server response. The target component renders right away with shared props, while page-specific props load in the background. + + +```vue + + + +``` + +### Layout Props + +Share dynamic data between pages and persistent layouts: + + +```vue + + + +``` + + +```vue + + + +``` ### Deferred Props @@ -358,42 +496,69 @@ ### Polling ``` -- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function -- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive +- `autoStart` (default `true`) - set to `false` to start polling manually via the returned `start()` function +- `keepAlive` (default `false`) - set to `true` to prevent throttling when the browser tab is inactive -### WhenVisible (Infinite Scroll) +### WhenVisible -Load more data when user scrolls to a specific element: +Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold: - + ```vue + + +``` + +### InfiniteScroll + +Automatically load additional pages of paginated data as users scroll: + + +```vue + ``` +The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements. + ## Server-Side Patterns Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines. @@ -405,4 +570,6 @@ ## Common Pitfalls - Forgetting to add loading states (skeleton screens) when using deferred props - Not handling the `undefined` state of deferred props before data loads - Using `` without preventing default submission (use `` component or `@submit.prevent`) -- Forgetting to check if `` component is available in your Inertia version \ No newline at end of file +- Forgetting to check if `` component is available in your Inertia version +- Using `router.cancel()` instead of `router.cancelAll()` (v3 breaking change) +- Using `router.on('invalid', ...)` or `router.on('exception', ...)` instead of the renamed `httpException` and `networkError` events \ No newline at end of file diff --git a/.claude/skills/mcp-development/SKILL.md b/.claude/skills/mcp-development/SKILL.md index 71269844..f2f0ba48 100644 --- a/.claude/skills/mcp-development/SKILL.md +++ b/.claude/skills/mcp-development/SKILL.md @@ -8,148 +8,88 @@ # MCP Development -## Documentation First +## Documentation -**CRITICAL**: Always use `search-docs` BEFORE writing MCP code. The documentation is version-specific, comprehensive, and always up-to-date. +Use `search-docs` for detailed Laravel MCP patterns and documentation. - -```bash +## Basic Usage -# Example searches +Register MCP servers in `routes/ai.php`: -search-docs(['mcp tools', 'mcp resources', 'mcp validation']) -``` - -## Quick Reference - -### Artisan Commands - -Create MCP Primitives" -```bash -php artisan make:mcp-tool ToolName -php artisan make:mcp-resource ResourceName -php artisan make:mcp-prompt PromptName -php artisan make:mcp-server ServerName -``` - -### Basic Tool Implementation - - + +```php +use Laravel\Mcp\Facades\Mcp; + +Mcp::web(); +``` + +### Creating MCP Primitives + +Create MCP tools, resources, prompts, and servers using artisan commands: + +```bash +php artisan make:mcp-tool ToolName # Create a tool + +php artisan make:mcp-resource ResourceName # Create a resource + +php artisan make:mcp-prompt PromptName # Create a prompt + +php artisan make:mcp-server ServerName # Create a server + +``` + +After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties. + +### Tools + + ```php -use Illuminate\Contracts\JsonSchema\JsonSchema; -use Laravel\Mcp\Request; -use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Request; +use Laravel\Mcp\Server\Response; class MyTool extends Tool { - protected string $description = 'Tool description for LLM'; - - public function schema(JsonSchema $schema): array - { - return [ - 'param' => $schema->string()->required(), - ]; - } - public function handle(Request $request): Response { - return Response::text($request->get('param')); + return new Response(['result' => 'success']); } } ``` -### Basic Resource Implementation +### Registering Primitives in a Server - +Each MCP server must explicitly declare the tools, resources, and prompts it exposes. + + ```php -use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Resource; +use Laravel\Mcp\Server; -class MyResource extends Resource +class AppServer extends Server { - protected string $description = 'Resource description'; - protected string $uri = 'file://path/to/resource'; - protected string $mimeType = 'text/markdown'; + protected array $tools = [ + \App\Mcp\Tools\MyTool::class, + ]; - public function handle(): Response - { - return Response::text($content); - } + protected array $resources = [ + \App\Mcp\Resources\MyResource::class, + ]; + + protected array $prompts = [ + \App\Mcp\Prompts\MyPrompt::class, + ]; } ``` -### Response Methods +## Verification - -```php -Response::text('Text content'); -Response::error('Error message'); -Response::structured(['key' => 'value']); -``` - -## Testing MCP Primitives - -Test tools, resources, and prompts directly on their server: - - -```php -// Test a tool -$response = MyServer::tool(MyTool::class, ['param' => 'value']); -$response->assertOk()->assertSee('Expected text'); - -// Test as authenticated user -$response = MyServer::actingAs($user)->tool(MyTool::class, [...]); - -// Available assertions -$response->assertOk(); -$response->assertSee('text'); -$response->assertHasErrors(); -$response->assertHasNoErrors(); -$response->assertName('tool-name'); -$response->assertSentNotification('event/type', ['data' => 'value']); -``` - -### MCP Inspector - -Test interactively using the inspector: - - -```bash -php artisan mcp:inspector mcp/my-server # Web server - -php artisan mcp:inspector my-server # Local server - -``` - -## Available Features - -The following features exist—**use `search-docs` for implementation details**: - -- **Tools**: `schema()`, validation, annotations (`#[IsReadOnly]`, `#[IsDestructive]`, etc.) -- **Resources**: URI templates (`HasUriTemplate`), Dynamic resources -- **Prompts**: Arguments, multi-message responses -- **All primitives**: Dependency injection, `shouldRegister()`, validation -- **Responses**: Text, error, structured, streaming, metadata -- **Server registration**: Web routes, local routes, OAuth - -## Critical Imports - - -```php -use Laravel\Mcp\Request; // NOT Laravel\Mcp\Server\Request -use Laravel\Mcp\Response; // NOT Laravel\Mcp\Server\Response -use Laravel\Mcp\Server\Tool; -use Laravel\Mcp\Server\Resource; -use Laravel\Mcp\Server\Prompt; -use Illuminate\Contracts\JsonSchema\JsonSchema; -``` +1. Check `routes/ai.php` for proper registration +2. Test tool via MCP client ## Common Pitfalls -- **Not using `search-docs` before implementation** -- Wrong imports: `Laravel\Mcp\Server\Request` (wrong) vs `Laravel\Mcp\Request` (correct) -- Forgetting `schema()` method for tools with parameters -- Missing required properties: `$description`, `$uri`, `$mimeType` -- Wrong response pattern: `new Response()` instead of `Response::text()` -- Running `mcp:start` command locally (hangs waiting for stdin) \ No newline at end of file +- Running `mcp:start` command (it hangs waiting for input) +- Using HTTPS locally with Node-based MCP clients +- Not using `search-docs` for the latest MCP documentation +- Not registering MCP server routes in `routes/ai.php` +- Do not register `ai.php` in `bootstrap.php`; it is registered automatically. \ No newline at end of file diff --git a/.env.example b/.env.example index cad93284..572d5646 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ APP_URL=http://localhost # Self-hosted mode (skips payment requirements) SELF_HOSTED=true +TELESCOPE_ENABLED=false + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US @@ -120,19 +122,30 @@ THREADS_CLIENT_ID= THREADS_CLIENT_SECRET= THREADS_CLIENT_REDIRECT="${APP_URL}/accounts/threads/callback" -# YouTube / Google (https://console.cloud.google.com) +# Google (https://console.cloud.google.com) +# Used for YouTube social account connection AND Google login/signup GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback" +GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback" # Pinterest (https://developers.pinterest.com) PINTEREST_CLIENT_ID= PINTEREST_CLIENT_SECRET= PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback" +# Google Tag Manager (optional - analytics) +GTM_ID= + +# PostHog (optional - analytics) +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_API_KEY="${POSTHOG_API_KEY}" +VITE_POSTHOG_HOST="${POSTHOG_HOST}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa1df6f0..e0311201 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,23 +68,21 @@ jobs: key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- + - name: Prepare environment + run: cp .env.ci .env + - name: Install Composer dependencies run: composer install --no-interaction --prefer-dist --optimize-autoloader - name: Install npm dependencies run: npm ci + - name: Generate application key + run: php artisan key:generate + - name: Build assets run: npm run build - - name: Prepare environment - run: | - cp .env.ci .env - php artisan key:generate - - - name: Clear Config - run: php artisan config:clear - - name: Run migrations run: php artisan migrate --force env: diff --git a/.mcp.json b/.mcp.json index d33af9ab..e016a81d 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,7 +1,7 @@ { "mcpServers": { "laravel-boost": { - "command": "vendor/bin/sail", + "command": "php", "args": [ "artisan", "boost:mcp" diff --git a/CLAUDE.md b/CLAUDE.md index e8113c85..12cd394f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,24 +12,23 @@ ## Foundational Context - php - 8.4 - inertiajs/inertia-laravel (INERTIA_LARAVEL) - v3 - laravel/ai (AI) - v0 +- laravel/boost (BOOST) - v2 - laravel/cashier (CASHIER) - v16 - laravel/framework (LARAVEL) - v13 - laravel/horizon (HORIZON) - v5 - laravel/mcp (MCP) - v0 - laravel/nightwatch (NIGHTWATCH) - v1 -- laravel/pennant (PENNANT) - v1 - laravel/prompts (PROMPTS) - v0 - laravel/reverb (REVERB) - v1 - laravel/socialite (SOCIALITE) - v5 - laravel/wayfinder (WAYFINDER) - v0 -- laravel/boost (BOOST) - v2 - laravel/pail (PAIL) - v1 - laravel/pint (PINT) - v1 - laravel/sail (SAIL) - v1 - laravel/telescope (TELESCOPE) - v5 - pestphp/pest (PEST) - v4 - phpunit/phpunit (PHPUNIT) - v12 -- @inertiajs/vue3 (INERTIA_VUE) - v2 +- @inertiajs/vue3 (INERTIA_VUE) - v3 - tailwindcss (TAILWINDCSS) - v4 - vue (VUE) - v3 - @laravel/echo-vue (ECHO_VUE) - v2 @@ -45,15 +44,13 @@ ## Skills Activation - `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues. - `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. - `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching. -- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP. -- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems. +- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP. - `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication. - `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task - `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code. -- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using , , useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation. +- `inertia-vue-development` — Develops Inertia.js v3 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using , , useForm, useHttp, setLayoutProps, or router; working with deferred props, prefetching, optimistic updates, instant visits, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation. - `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. -- `ai-sdk-development` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). -- `medialibrary-development` — Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths. +- `ai-sdk-development` — TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for Prism PHP or other AI packages used directly. ## Conventions @@ -119,13 +116,12 @@ ## Tinker - Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. - Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` - - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === # PHP -- Always declare `declare(strict_types=1);` at the top of every `.php` file. - Always use curly braces for control structures, even for single-line bodies. - Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. - Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` @@ -226,23 +222,8 @@ ## Pest # Inertia + Vue Vue components must have a single root element. - - IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. -=== laravel/ai rules === - -## Laravel AI SDK - -- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. -- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). - -=== spatie/laravel-medialibrary rules === - -## Media Library - -- `spatie/laravel-medialibrary` associates files with Eloquent models, with support for collections, conversions, and responsive images. -- Always activate the `medialibrary-development` skill when working with media uploads, conversions, collections, responsive images, or any code that uses the `HasMedia` interface or `InteractsWithMedia` trait. - # Project-Specific Rules @@ -257,6 +238,8 @@ ## Icons (@tabler/icons-vue) - All Tabler icons are prefixed with `Icon`, e.g. `IconCheck`, `IconChevronRight`, `IconMail`. - Import icons from `@tabler/icons-vue`: `import { IconCheck, IconX } from '@tabler/icons-vue'`. - Browse available icons at https://tabler.io/icons +- Button never use icon + text, it's icon or text, never together +- dropdown lists always use icon + text, and both have the same color ## Dates @@ -303,6 +286,82 @@ ## Sendkit Documentation - All our documentation to final user it's under https://sendkit.dev/docs +## Social Platform API References + +Always verify against official docs before making changes to publishers, media handling, or content formatting. + +### Content Format + +| Platform | Content field | Format | Official docs | +|---|---|---|---| +| Instagram | `caption` | Plain text | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| Facebook | `message` | Plain text | [Page Feed](https://developers.facebook.com/docs/graph-api/reference/page/feed/) | +| X/Twitter | `text` | Plain text | [Post creation](https://docs.x.com/x-api/posts/creation-of-a-post) | +| TikTok | `title` | Plain text | [Content Posting](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post) | +| YouTube | `title`, `description` | Plain text | [Videos.insert](https://developers.google.com/youtube/v3/docs/videos/insert) | +| Threads | `text` | Plain text | Same as Instagram | +| Pinterest | `description` | Plain text | [Pin creation](https://developers.pinterest.com/docs/api/v5/pins-create/) | +| LinkedIn | `commentary` | Plain text (Unicode bold/underline works visually) | [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) | +| Bluesky | `text` + facets | Plain text + facets for links/mentions | [AT Protocol posts](https://docs.bsky.app/docs/advanced-guides/posts) | +| Mastodon | `status` | HTML subset (p, strong, em, a, br, span) | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | + +### Image Specs + +| Platform | Max size | Formats | Max resolution | Official docs | +|---|---|---|---|---| +| Instagram | 8 MB | JPEG only | 1440px width | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| Facebook | 4 MB | JPEG, PNG, BMP, GIF, TIFF | Auto-resized | [Page Photos](https://developers.facebook.com/docs/graph-api/reference/page/photos/) | +| X/Twitter | 5 MB | JPG, PNG, GIF, WEBP | No hard limit | [Media best practices](https://docs.x.com/x-api/media/quickstart/best-practices) | +| TikTok | 20 MB | JPEG, WebP | 1080px max | [Media transfer](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | +| LinkedIn | < 36M pixels | JPG, GIF, PNG | < 36,152,320 pixels | [Images API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api) | +| Pinterest | 20 MB | PNG, JPEG | 1000x1500 recommended | [Product specs](https://help.pinterest.com/en/business/article/pinterest-product-specs) | +| Bluesky | 1 MB | Any | No hard limit | [AT Protocol](https://docs.bsky.app/docs/advanced-guides/posts) | +| Mastodon | ~10 MB | JPG, PNG, GIF, WebP | No hard limit | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | + +### Video Specs + +| Platform | Max size | Formats | Duration | Official docs | +|---|---|---|---|---| +| Instagram Reel | 300 MB | MP4, MOV | 3s-15min | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| Instagram Story | 100 MB | MP4, MOV | 3-60s | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| Facebook | 2 GB | MP4 | 1s-40min | [Video API errors](https://developers.facebook.com/docs/video-api/reference/error-codes/) | +| X/Twitter | 512 MB | MP4 | 0.5-140s | [Media best practices](https://docs.x.com/x-api/media/quickstart/best-practices) | +| TikTok | 4 GB | MP4, WebM, MOV | Up to 10min | [Media transfer](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | +| YouTube Shorts | 128 GB | MP4, MOV, AVI, WebM | Up to 60s | [Videos.insert](https://developers.google.com/youtube/v3/docs/videos/insert) | +| LinkedIn | 500 MB | MP4 | 3s-30min | [Videos API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api) | +| Pinterest | 2 GB | MP4, MOV, M4V | 4s-15min | [Product specs](https://help.pinterest.com/en/business/article/pinterest-product-specs) | +| Bluesky | 50 MB | MP4 | Up to 60s | [AT Protocol](https://docs.bsky.app/docs/advanced-guides/posts) | +| Mastodon | ~40 MB | MP4, WebM | No limit | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | + +### Error Codes + +| Platform | Error docs | +|---|---| +| Instagram | [Error codes](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/) | +| Facebook | [Graph API errors](https://developers.facebook.com/docs/graph-api/guides/error-handling/) + [Video API errors](https://developers.facebook.com/docs/video-api/reference/error-codes/) | +| X/Twitter | [Response codes](https://docs.x.com/x-api/fundamentals/response-codes-and-errors) | +| TikTok | [Error handling](https://developers.tiktok.com/doc/tiktok-api-v2-error-handling) + [Publish status](https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status) | +| YouTube | [Videos.insert errors](https://developers.google.com/youtube/v3/docs/videos/insert) | +| LinkedIn | [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) + [Videos API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api) | +| Pinterest | [API v5](https://developers.pinterest.com/docs/api/v5/) | +| Bluesky | [AT Protocol](https://docs.bsky.app/docs/advanced-guides/posts) | +| Mastodon | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | + +### Upload Method Per Platform + +| Platform | Method | Notes | +|---|---|---| +| Instagram | URL pull (`image_url`/`video_url`) | Platform downloads from our CDN | +| Facebook | URL pull (`url`/`file_url`) for posts; binary upload for reels/stories | | +| X/Twitter | Binary upload (chunked for video/GIF) | | +| TikTok | URL pull (`PULL_FROM_URL`) | | +| YouTube | Google SDK chunked upload | Uses `google/apiclient` PHP SDK | +| Threads | URL pull (`image_url`/`video_url`) | | +| LinkedIn | Binary upload (image PUT, video chunked) | | +| Pinterest | Base64 for images, multipart for video | | +| Bluesky | Binary blob upload | 1MB hard limit per blob | +| Mastodon | Binary upload via multipart | | + ## Git - NEVER add `Co-Authored-By` lines to commit messages. diff --git a/README.md b/README.md index b1519da5..ae9335a5 100644 --- a/README.md +++ b/README.md @@ -19,36 +19,40 @@

- 📚 Documentation • - 🗺️ Roadmap • - 💬 Community + Documentation • + Roadmap • + Community

--- -## 🤔 Why TryPost? +## Why TryPost? Tired of paying expensive monthly fees for social media scheduling tools? Want full control over your data? TryPost is the solution. -| | | -|---|---| -| 🔓 **100% Open Source** | Inspect the code, contribute, make it yours | -| 🏠 **Self-Hosted** | Your data stays on your servers | -| ♾️ **No Limits** | Schedule unlimited posts, connect unlimited accounts | -| 🔒 **Privacy First** | No tracking, no analytics, no data selling | +| | | +| ----------------------- | ---------------------------------------------------- | +| **100% Open Source** | Inspect the code, contribute, make it yours | +| **Self-Hosted** | Your data stays on your servers | +| **No Limits** | Schedule unlimited posts, connect unlimited accounts | +| **Privacy First** | No tracking, no analytics, no data selling | -## ✨ Features +## Features -| | | -|---|---| -| 📅 **Visual Calendar** | Drag and drop posts across your content calendar | -| ✍️ **Post Composer** | Create and preview posts for multiple platforms at once | -| 🖼️ **Media Library** | Upload images and videos with automatic optimization | -| 👥 **Team Collaboration** | Invite team members with role-based permissions | -| 🏢 **Workspaces** | Manage multiple brands or clients separately | -| 👀 **Multi-Platform Preview** | See exactly how your post will look on each platform | +| | | +| ----------------------------- | ------------------------------------------------------- | +| **Visual Calendar** | Drag and drop posts across your content calendar | +| **Post Composer** | Create and preview posts for multiple platforms at once | +| **Media Library** | Upload images and videos with automatic optimization | +| **Team Collaboration** | Invite team members with role-based permissions (Owner, Admin, Member) | +| **Workspaces** | Manage multiple brands or clients separately | +| **REST API** | Full API with Bearer token authentication | +| **MCP Server** | AI-ready with Model Context Protocol support | +| **Google Login** | Sign up and log in with Google OAuth | +| **Notifications** | In-app and email notifications for post status | +| **i18n** | Available in English, Spanish, and Portuguese | -## 🌐 Supported Platforms +## Supported Platforms @@ -67,34 +71,75 @@ ## 🌐 Supported Platforms
-## 🚀 Getting Started +## Tech Stack + +| Layer | Technology | +| ----- | ---------- | +| **Backend** | Laravel 13, PHP 8.4 | +| **Frontend** | Vue 3, Inertia.js v3, Tailwind CSS v4 | +| **Database** | PostgreSQL | +| **Queue** | Redis + Laravel Horizon | +| **WebSockets** | Laravel Reverb | +| **API** | REST with Bearer token auth | +| **MCP** | Laravel MCP for AI integrations | +| **Payments** | Laravel Cashier (Stripe) | + +## Getting Started Get TryPost running in minutes: | | | -|---|---| -| 📖 [Installation Guide](https://docs.trypost.it/getting-started/installation) | Step-by-step setup | -| 🐳 [Docker Setup](https://docs.trypost.it/self-hosting/docker) | Run with Laravel Sail | -| ⚙️ [Configuration](https://docs.trypost.it/getting-started/configuration) | Environment setup | -| 🔗 [Platform Setup](https://docs.trypost.it/platforms/) | Connect your social accounts | +| --- | --- | +| [Installation Guide](https://trypost.it/docs/getting-started/installation) | Step-by-step setup | +| [Docker Setup](https://trypost.it/docs/self-hosting/docker) | Run with Laravel Sail | +| [Configuration](https://trypost.it/docs/getting-started/configuration) | Environment setup | +| [Platform Setup](https://trypost.it/docs/platforms/) | Connect your social accounts | -## 🤝 Contributing +### Quick Start -We love contributions! See our [Contributing Guide](https://docs.trypost.it/contributing) to get started. +```bash +git clone https://github.com/trypost-it/trypost.git +cd trypost +cp .env.example .env +composer install +npm install +php artisan key:generate +php artisan migrate +npm run build +``` -- 💬 [Discussions](https://github.com/trypost-it/trypost/discussions) - Ask questions, share ideas -- 🐛 [Issues](https://github.com/trypost-it/trypost/issues) - Report bugs, request features +## API -## 📄 License +TryPost includes a REST API for programmatic access. All endpoints are under `/api` and require a Bearer token. + +```bash +curl -H "Authorization: Bearer tp_your_token" \ + https://your-domain.com/api/posts +``` + +See the [API documentation](https://trypost.it/docs/api) for all available endpoints. + +## MCP Server + +TryPost ships with a Model Context Protocol (MCP) server at `/mcp/trypost`, enabling AI assistants to manage your social media directly. + +## Contributing + +We love contributions! Check the [issues](https://github.com/trypost-it/trypost/issues) for open tasks. + +- [Discussions](https://github.com/trypost-it/trypost/discussions) - Ask questions, share ideas +- [Issues](https://github.com/trypost-it/trypost/issues) - Report bugs, request features + +## License TryPost is licensed under the [Functional Source License (FSL)](LICENSE.md). -✅ **You can:** Use for personal or business use, self-host, modify and contribute. +**You can:** Use for personal or business use, self-host, modify and contribute. -❌ **You cannot:** Offer as a competing SaaS, white-label and resell. +**You cannot:** Offer as a competing SaaS, white-label and resell. ---

- If TryPost helps you, please give us a ⭐ + If TryPost helps you, please give us a star

diff --git a/app/Actions/ApiKey/CreateApiKey.php b/app/Actions/ApiKey/CreateApiKey.php new file mode 100644 index 00000000..1594cf9d --- /dev/null +++ b/app/Actions/ApiKey/CreateApiKey.php @@ -0,0 +1,34 @@ + $workspace->id, + 'name' => data_get($data, 'name'), + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + 'expires_at' => data_get($data, 'expires_at') + ? now()->parse(data_get($data, 'expires_at'))->endOfDay() + : null, + ]); + + return ['token' => $apiToken, 'plain_token' => $plainToken]; + } +} diff --git a/app/Actions/ApiKey/DeleteApiKey.php b/app/Actions/ApiKey/DeleteApiKey.php new file mode 100644 index 00000000..3957d656 --- /dev/null +++ b/app/Actions/ApiKey/DeleteApiKey.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Hashtag/CreateHashtag.php b/app/Actions/Hashtag/CreateHashtag.php new file mode 100644 index 00000000..47e2eaa4 --- /dev/null +++ b/app/Actions/Hashtag/CreateHashtag.php @@ -0,0 +1,19 @@ +hashtags()->create([ + 'name' => data_get($data, 'name'), + 'hashtags' => data_get($data, 'hashtags'), + ]); + } +} diff --git a/app/Actions/Hashtag/DeleteHashtag.php b/app/Actions/Hashtag/DeleteHashtag.php new file mode 100644 index 00000000..82119e6b --- /dev/null +++ b/app/Actions/Hashtag/DeleteHashtag.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Hashtag/UpdateHashtag.php b/app/Actions/Hashtag/UpdateHashtag.php new file mode 100644 index 00000000..ec9f98fb --- /dev/null +++ b/app/Actions/Hashtag/UpdateHashtag.php @@ -0,0 +1,20 @@ +update([ + 'name' => data_get($data, 'name'), + 'hashtags' => data_get($data, 'hashtags'), + ]); + + return $hashtag; + } +} diff --git a/app/Actions/Invite/CreateInvite.php b/app/Actions/Invite/CreateInvite.php new file mode 100644 index 00000000..465e17e7 --- /dev/null +++ b/app/Actions/Invite/CreateInvite.php @@ -0,0 +1,26 @@ +invites()->create([ + 'email' => data_get($data, 'email'), + 'role' => data_get($data, 'role', WorkspaceRole::Member), + ]); + + Mail::to($invite->email)->send(new WorkspaceInviteMail($invite)); + + return $invite; + } +} diff --git a/app/Actions/Invite/DeleteInvite.php b/app/Actions/Invite/DeleteInvite.php new file mode 100644 index 00000000..1170f4ad --- /dev/null +++ b/app/Actions/Invite/DeleteInvite.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Invite/RemoveMember.php b/app/Actions/Invite/RemoveMember.php new file mode 100644 index 00000000..0e978ec8 --- /dev/null +++ b/app/Actions/Invite/RemoveMember.php @@ -0,0 +1,15 @@ +members()->detach($userId); + } +} diff --git a/app/Actions/Label/CreateLabel.php b/app/Actions/Label/CreateLabel.php new file mode 100644 index 00000000..7ab1d95a --- /dev/null +++ b/app/Actions/Label/CreateLabel.php @@ -0,0 +1,19 @@ +labels()->create([ + 'name' => data_get($data, 'name'), + 'color' => data_get($data, 'color'), + ]); + } +} diff --git a/app/Actions/Label/DeleteLabel.php b/app/Actions/Label/DeleteLabel.php new file mode 100644 index 00000000..6e58fc22 --- /dev/null +++ b/app/Actions/Label/DeleteLabel.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Label/UpdateLabel.php b/app/Actions/Label/UpdateLabel.php new file mode 100644 index 00000000..bfc0cb86 --- /dev/null +++ b/app/Actions/Label/UpdateLabel.php @@ -0,0 +1,20 @@ +update([ + 'name' => data_get($data, 'name'), + 'color' => data_get($data, 'color'), + ]); + + return $label; + } +} diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php new file mode 100644 index 00000000..608d3ab7 --- /dev/null +++ b/app/Actions/Post/CreatePost.php @@ -0,0 +1,46 @@ +timezone)->format('Y-m-d'); + $scheduledAt = Carbon::parse($date, $workspace->timezone) + ->setTime(9, 0) + ->utc(); + + $post = $workspace->posts()->create([ + 'user_id' => $user->id, + 'status' => PostStatus::Draft, + 'synced' => true, + 'scheduled_at' => $scheduledAt, + ]); + + $socialAccounts = $workspace->socialAccounts()->active()->get(); + + foreach ($socialAccounts as $account) { + $post->postPlatforms()->create([ + 'social_account_id' => $account->id, + 'platform' => $account->platform->value, + 'content' => '', + 'content_type' => ContentType::defaultFor($account->platform), + 'status' => PostPlatformStatus::Pending, + 'enabled' => true, + ]); + } + + return $post; + } +} diff --git a/app/Actions/Post/DeletePost.php b/app/Actions/Post/DeletePost.php new file mode 100644 index 00000000..071933c9 --- /dev/null +++ b/app/Actions/Post/DeletePost.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Post/UpdatePost.php b/app/Actions/Post/UpdatePost.php new file mode 100644 index 00000000..2123e455 --- /dev/null +++ b/app/Actions/Post/UpdatePost.php @@ -0,0 +1,84 @@ +status === PostStatus::Published) { + return ['post' => $post, 'action' => PostAction::AlreadyPublished]; + } + + $scheduledAt = $post->scheduled_at; + if (data_get($data, 'scheduled_at')) { + $scheduledAt = Carbon::parse(data_get($data, 'scheduled_at'), $workspace->timezone)->utc(); + } + + $status = data_get($data, 'status', $post->status); + + $post->update([ + 'status' => $status === PostStatus::Publishing->value ? PostStatus::Publishing : $status, + 'synced' => data_get($data, 'synced', $post->synced), + 'scheduled_at' => $scheduledAt, + ]); + + if (Arr::has($data, 'label_ids')) { + $post->labels()->sync(data_get($data, 'label_ids', [])); + } + + DB::transaction(function () use ($post, $data) { + $post->postPlatforms()->update(['enabled' => false]); + + foreach (data_get($data, 'platforms', []) as $platformData) { + $updateData = [ + 'enabled' => true, + 'content' => data_get($platformData, 'content'), + ]; + + if (data_get($platformData, 'content_type') !== null) { + $updateData['content_type'] = data_get($platformData, 'content_type'); + } + + if (data_get($platformData, 'meta') !== null) { + $postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first(); + + if ($postPlatform) { + $updateData['meta'] = array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta')); + } + } + + $post->postPlatforms() + ->where('id', data_get($platformData, 'id')) + ->update($updateData); + } + }); + + if ($status === PostStatus::Publishing->value) { + $post->update(['scheduled_at' => now()]); + PublishPost::dispatch($post); + + return ['post' => $post, 'action' => PostAction::Publishing]; + } + + if ($status === PostStatus::Scheduled->value) { + return ['post' => $post, 'action' => PostAction::Scheduled]; + } + + return ['post' => $post, 'action' => null]; + } +} diff --git a/app/Actions/SocialAccount/ToggleSocialAccount.php b/app/Actions/SocialAccount/ToggleSocialAccount.php new file mode 100644 index 00000000..51087051 --- /dev/null +++ b/app/Actions/SocialAccount/ToggleSocialAccount.php @@ -0,0 +1,17 @@ +update(['is_active' => ! $account->is_active]); + + return $account; + } +} diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php new file mode 100644 index 00000000..988434a4 --- /dev/null +++ b/app/Actions/User/CreateUser.php @@ -0,0 +1,44 @@ + data_get($data, 'name'), + 'email' => data_get($data, 'email'), + 'password' => data_get($data, 'password'), + 'setup' => data_get($data, 'setup', $isInviteRegistration ? Setup::Completed : Setup::Role), + 'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null), + ]); + + $workspace = Workspace::create([ + 'user_id' => $user->id, + 'name' => $user->name."'s Workspace", + 'timezone' => data_get($data, 'timezone', 'UTC'), + ]); + + $workspace->members()->attach($user->id, ['role' => Role::Owner->value]); + + $user->update(['current_workspace_id' => $workspace->id]); + + return $user; + }); + } +} diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php new file mode 100644 index 00000000..1bf6f440 --- /dev/null +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -0,0 +1,30 @@ + $user->id, + ...$data, + 'timezone' => config('app.timezone', 'UTC'), + ]); + + $workspace->members()->attach($user->id, ['role' => Role::Owner->value]); + $user->switchWorkspace($workspace); + + if ($user->hasActiveSubscription()) { + $user->incrementWorkspaceQuantity(); + } + + return $workspace; + } +} diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php new file mode 100644 index 00000000..67f55e37 --- /dev/null +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -0,0 +1,22 @@ +id)->update(['current_workspace_id' => null]); + + $workspace->delete(); + + if ($user->hasActiveSubscription()) { + $user->decrementWorkspaceQuantity(); + } + } +} diff --git a/app/Broadcasting/PostChannel.php b/app/Broadcasting/PostChannel.php index de8639b6..b653acc2 100644 --- a/app/Broadcasting/PostChannel.php +++ b/app/Broadcasting/PostChannel.php @@ -1,5 +1,7 @@ |string> + * @return array|string> */ protected function passwordRules(): array { @@ -19,7 +22,7 @@ protected function passwordRules(): array /** * Get the validation rules used to validate the current password. * - * @return array|string> + * @return array|string> */ protected function currentPasswordRules(): array { diff --git a/app/Concerns/ProfileValidationRules.php b/app/Concerns/ProfileValidationRules.php index 92878f9c..40bb7b45 100644 --- a/app/Concerns/ProfileValidationRules.php +++ b/app/Concerns/ProfileValidationRules.php @@ -1,5 +1,7 @@ whereHas('socialAccounts', function ($query) { - $query->where('status', Status::Connected); + $query->whereIn('status', [Status::Connected, Status::TokenExpired]); }) ->with('owner') ->chunk(100, function ($workspaces) { diff --git a/app/Console/Commands/ProcessScheduledPosts.php b/app/Console/Commands/ProcessScheduledPosts.php index 867888cf..37a4789e 100644 --- a/app/Console/Commands/ProcessScheduledPosts.php +++ b/app/Console/Commands/ProcessScheduledPosts.php @@ -1,7 +1,10 @@ due() - ->with(['postPlatforms.socialAccount', 'postPlatforms.media']) - ->chunk(100, function ($posts) { - foreach ($posts as $post) { + ->each(function (Post $post) { + // Atomically claim the post — only dispatch if we successfully change its status + $claimed = Post::where('id', $post->id) + ->where('status', PostStatus::Scheduled) + ->update(['status' => PostStatus::Publishing]); + + if ($claimed) { PublishPost::dispatch($post); } }); diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php new file mode 100644 index 00000000..2b4a574b --- /dev/null +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -0,0 +1,59 @@ +where('status', PostStatus::Publishing) + ->where('updated_at', '<=', now()->subHour()) + ->each(function (Post $post) use (&$count) { + // Mark stuck platforms as failed + $post->postPlatforms() + ->where('enabled', true) + ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending]) + ->where('updated_at', '<=', now()->subHour()) + ->update([ + 'status' => PlatformStatus::Failed, + 'error_message' => 'Publishing timed out. Please try again.', + 'error_context' => json_encode([ + 'category' => 'timeout', + 'failed_at' => now()->toIso8601String(), + ]), + ]); + + // Recalculate post status + $enabledPlatforms = $post->postPlatforms()->where('enabled', true)->get(); + $total = $enabledPlatforms->count(); + $publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count(); + $failedCount = $enabledPlatforms->where('status', PlatformStatus::Failed)->count(); + + if ($publishedCount === $total) { + $post->markAsPublished(); + } elseif ($publishedCount > 0) { + $post->markAsPartiallyPublished(); + } else { + $post->markAsFailed(); + } + + $count++; + }); + + $this->info("Recovered {$count} stuck posts."); + } +} diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php new file mode 100644 index 00000000..170d08f2 --- /dev/null +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -0,0 +1,36 @@ +where('status', Status::Connected) + ->whereNotNull('token_expires_at') + ->where('token_expires_at', '<=', now()->addHours(2)) + ->where('token_expires_at', '>', now()) + ->chunk(50, function ($accounts) use (&$count) { + foreach ($accounts as $account) { + RefreshSocialToken::dispatch($account); + $count++; + } + }); + + $this->info("Dispatched {$count} token refresh jobs."); + } +} diff --git a/app/Enums/ApiToken/Status.php b/app/Enums/ApiToken/Status.php new file mode 100644 index 00000000..8c2f96e9 --- /dev/null +++ b/app/Enums/ApiToken/Status.php @@ -0,0 +1,11 @@ + 'Rascunho', - self::Scheduled => 'Agendado', - self::Publishing => 'Publicando', - self::Published => 'Publicado', - self::PartiallyPublished => 'Parcialmente Publicado', - self::Failed => 'Falhou', + self::Draft => __('posts.status.draft'), + self::Scheduled => __('posts.status.scheduled'), + self::Publishing => __('posts.status.publishing'), + self::Published => __('posts.status.published'), + self::PartiallyPublished => __('posts.status.partially_published'), + self::Failed => __('posts.status.failed'), }; } diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index a51e4895..7e3f996a 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -1,5 +1,7 @@ + */ + public function requiredPublishScopes(): array + { + return match ($this) { + self::Instagram => ['instagram_business_content_publish'], + self::Facebook => ['pages_manage_posts'], + self::TikTok => ['video.publish'], + self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'], + self::LinkedIn => ['w_member_social'], + self::LinkedInPage => ['w_organization_social'], + self::X => ['tweet.write'], + self::Threads => ['threads_content_publish'], + self::Pinterest => ['pins:write'], + self::Bluesky => [], + self::Mastodon => ['write:statuses'], + }; + } + public function supportsTextOnly(): bool { return match ($this) { @@ -115,6 +137,27 @@ public function supportsTextOnly(): bool }; } + public function requiresContent(): bool + { + return match ($this) { + self::YouTube => true, + default => false, + }; + } + + public function queue(): string + { + return 'social-'.$this->value; + } + + /** + * @return array + */ + public static function allQueues(): array + { + return array_map(fn (self $platform) => $platform->queue(), self::cases()); + } + public function isEnabled(): bool { return config("trypost.platforms.{$this->value}.enabled", true); diff --git a/app/Enums/SocialAccount/Status.php b/app/Enums/SocialAccount/Status.php index cf378442..ab1413d8 100644 --- a/app/Enums/SocialAccount/Status.php +++ b/app/Enums/SocialAccount/Status.php @@ -1,5 +1,7 @@ 'Founder', - self::Creator => 'Creator', - self::Agency => 'Agency', - self::Enterprise => 'Enterprise', - self::SmallBusiness => 'Small Business', - self::Personal => 'Personal', - }; + return __("onboarding.personas.{$this->value}.label"); } public function description(): string { - return match ($this) { - self::Founder => 'Building a startup or new venture', - self::Creator => 'Content creator or influencer', - self::Agency => 'Marketing or social media agency', - self::Enterprise => 'Large company or corporation', - self::SmallBusiness => 'Small to medium business', - self::Personal => 'Personal brand or hobby', - }; + return __("onboarding.personas.{$this->value}.description"); } public function icon(): string diff --git a/app/Enums/User/Setup.php b/app/Enums/User/Setup.php index 37bd964c..f8bb1e42 100644 --- a/app/Enums/User/Setup.php +++ b/app/Enums/User/Setup.php @@ -1,5 +1,7 @@ [ 'id' => $this->postPlatform->id, - 'status' => $this->postPlatform->status, + 'status' => $this->postPlatform->status->value, 'platform_url' => $this->postPlatform->platform_url, 'error_message' => $this->postPlatform->error_message, 'published_at' => $this->postPlatform->published_at?->toISOString(), diff --git a/app/Events/SubscriptionCreated.php b/app/Events/SubscriptionCreated.php index 4927c7c2..07477861 100644 --- a/app/Events/SubscriptionCreated.php +++ b/app/Events/SubscriptionCreated.php @@ -1,5 +1,7 @@ status(); + $body = $response->json(); + $rawResponse = $response->body(); + + $error = data_get($body, 'error', ''); + $errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.'); + + if (in_array($error, ['ExpiredToken', 'InvalidToken'], true)) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $error, + ); + } + + if (str_contains($rawResponse, 'BlobTooLarge') || $status === 413) { + return new static( + userMessage: "Image exceeds Bluesky's 1MB limit.", + category: ErrorCategory::MediaFormat, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + if ($error === 'InvalidRequest') { + return new static( + userMessage: 'Invalid post data.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $error, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Bluesky server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $errorMessage, + category: ErrorCategory::Unknown, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'bluesky'; + } +} diff --git a/app/Exceptions/Social/ErrorCategory.php b/app/Exceptions/Social/ErrorCategory.php new file mode 100644 index 00000000..4000c1a1 --- /dev/null +++ b/app/Exceptions/Social/ErrorCategory.php @@ -0,0 +1,15 @@ +json(); + $rawResponse = $response->body(); + + $errorType = data_get($body, 'error.type'); + $errorCode = data_get($body, 'error.code'); + $errorSubcode = data_get($body, 'error.error_subcode'); + $errorMessage = data_get($body, 'error.message', 'An unknown Facebook error occurred.'); + + $tokenSubcodes = [458, 459, 460, 463, 464, 467]; + + if ($errorCode === 190 || in_array($errorSubcode, $tokenSubcodes, true)) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + ); + } + + [$message, $category] = match ($errorCode) { + 6000 => ['Problem with file. Try with another file.', ErrorCategory::MediaFormat], + 1363042 => ['No permission to upload video here.', ErrorCategory::Permission], + 1363023 => ['Video exceeds 2GB maximum size.', ErrorCategory::MediaFormat], + 1363022 => ['Video below 1KB minimum size.', ErrorCategory::MediaFormat], + 1363030 => ['Upload timed out. Please try again.', ErrorCategory::ServerError], + 1363019 => ['Problem uploading video. Please try again.', ErrorCategory::ServerError], + 1363031 => ['Unsupported file format.', ErrorCategory::MediaFormat], + 1363032 => ['File is not a valid video.', ErrorCategory::MediaFormat], + 1363024 => ['Unsupported video format.', ErrorCategory::MediaFormat], + 1363025 => ['Video is too short (minimum 1 second).', ErrorCategory::MediaFormat], + 1363026 => ['Video is too long (maximum 40 minutes).', ErrorCategory::MediaFormat], + 1363033 => ['Upload interrupted. Please try again.', ErrorCategory::ServerError], + 1363037 => ['Invalid upload offset.', ErrorCategory::ServerError], + 1363020 => ['No video file selected.', ErrorCategory::MediaFormat], + 1363045 => ['Upload size mismatch.', ErrorCategory::ServerError], + 1363041 => ['Upload session expired. Please try again.', ErrorCategory::ServerError], + 1363021 => ['Problem during video upload. Please try again.', ErrorCategory::ServerError], + 1363005 => ['No permission to edit this video.', ErrorCategory::Permission], + 1363047 => ['Reel encoding issue. Please try a different video.', ErrorCategory::MediaFormat], + 1609008 => ['Video format not supported for Reels.', ErrorCategory::MediaFormat], + 1609010 => ['Reel encoding requirements not met.', ErrorCategory::MediaFormat], + 1366046 => ['Reels require a video.', ErrorCategory::ContentPolicy], + 2061006 => ['Video is too short for this format.', ErrorCategory::MediaFormat], + 1390008 => ['Caption is too long.', ErrorCategory::ContentPolicy], + 1346003 => ['Thumbnail is incompatible.', ErrorCategory::ContentPolicy], + 1349125 => ['Rate limit exceeded. Try again later.', ErrorCategory::RateLimit], + 4 => ['Too many API calls. Please try again later.', ErrorCategory::RateLimit], + 17 => ['User call limit reached.', ErrorCategory::RateLimit], + 506 => ['Duplicate post detected. Please modify content.', ErrorCategory::ContentPolicy], + default => [$errorMessage, ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'facebook'; + } +} diff --git a/app/Exceptions/Social/InstagramPublishException.php b/app/Exceptions/Social/InstagramPublishException.php new file mode 100644 index 00000000..4b836d46 --- /dev/null +++ b/app/Exceptions/Social/InstagramPublishException.php @@ -0,0 +1,72 @@ +json(); + $rawResponse = $response->body(); + + $errorType = data_get($body, 'error.type'); + $errorCode = data_get($body, 'error.code'); + $errorSubcode = data_get($body, 'error.error_subcode'); + $errorUserMsg = data_get($body, 'error.error_user_msg'); + $errorMessage = data_get($body, 'error.message', 'An unknown Instagram error occurred.'); + + if ($errorCode === 190) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + ); + } + + [$message, $category] = match ($errorSubcode) { + 2207026 => ['Unsupported video format. Please upload MP4 or MOV.', ErrorCategory::MediaFormat], + 2207005 => ['Unsupported image format.', ErrorCategory::MediaFormat], + 2207004 => ['Image is too large (max 8MB).', ErrorCategory::MediaFormat], + 2207009 => ['Aspect ratio not supported (must be between 4:5 and 1.91:1).', ErrorCategory::MediaFormat], + 2207057 => ['Thumbnail offset is outside the video duration.', ErrorCategory::MediaFormat], + 2207023 => ['Unknown media type.', ErrorCategory::MediaFormat], + 2207003 => ['Media download timed out. Please try again.', ErrorCategory::ServerError], + 2207020 => ['Media has expired. Please upload again.', ErrorCategory::ServerError], + 2207032 => ['Failed to create media. Please try again.', ErrorCategory::ServerError], + 2207053 => ['Unknown upload error. Please try again.', ErrorCategory::ServerError], + 2207052 => ['Could not fetch media from URL. Please try again.', ErrorCategory::ServerError], + 2207006 => ['Media not found. Please upload again.', ErrorCategory::ServerError], + 2207008 => ['Media container expired. Please try again in a few minutes.', ErrorCategory::ServerError], + 2207027 => ['Media is not ready for publishing. Please wait and try again.', ErrorCategory::ServerError], + 2207001 => ['Instagram server error. Please try again.', ErrorCategory::ServerError], + 2207010 => ['Caption is too long (max 2,200 characters, 30 hashtags, 20 @mentions).', ErrorCategory::ContentPolicy], + 2207028 => ['Carousel needs between 2 and 10 photos/videos.', ErrorCategory::ContentPolicy], + 2207051 => ['Instagram restricted this action to protect the community.', ErrorCategory::ContentPolicy], + 2207035 => ['Product tag positions are not supported for videos.', ErrorCategory::ContentPolicy], + 2207036 => ['Product tag positions are required for photos.', ErrorCategory::ContentPolicy], + 2207037 => ['Invalid product tag. The product may be deleted or not permitted.', ErrorCategory::ContentPolicy], + 2207040 => ['Too many tags (max 20).', ErrorCategory::ContentPolicy], + 2207042 => ['Daily publishing limit reached. Please try again tomorrow.', ErrorCategory::RateLimit], + 2207050 => ['Instagram account is restricted or inactive. Please check the Instagram app.', ErrorCategory::Permission], + 2207081 => ["This account doesn't support Trial Reels.", ErrorCategory::Permission], + default => [$errorUserMsg ?? $errorMessage, ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $errorSubcode !== null ? (string) $errorSubcode : null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'instagram'; + } +} diff --git a/app/Exceptions/Social/LinkedInPublishException.php b/app/Exceptions/Social/LinkedInPublishException.php new file mode 100644 index 00000000..aad4a088 --- /dev/null +++ b/app/Exceptions/Social/LinkedInPublishException.php @@ -0,0 +1,64 @@ +json(); + $rawResponse = $response->body(); + $statusCode = $response->status(); + + $errorMessage = data_get($body, 'message', $rawResponse); + + if ($statusCode === 401) { + throw new TokenExpiredException( + message: $errorMessage ?? 'Access token has expired or been revoked', + platformErrorCode: (string) $statusCode, + ); + } + + if (str_contains((string) $rawResponse, 'Unable to obtain activity')) { + return new static( + userMessage: 'LinkedIn server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + if (str_contains((string) $rawResponse, 'resource is forbidden')) { + return new static( + userMessage: 'Not authorized to post to this account.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + [$message, $category] = match ($statusCode) { + 403 => ['Not authorized to post to this account.', ErrorCategory::Permission], + 422 => ['Invalid post data. Please check your content.', ErrorCategory::ContentPolicy], + default => [$errorMessage ?? 'An unknown LinkedIn error occurred.', ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'linkedin'; + } +} diff --git a/app/Exceptions/Social/MastodonPublishException.php b/app/Exceptions/Social/MastodonPublishException.php new file mode 100644 index 00000000..782f8ab4 --- /dev/null +++ b/app/Exceptions/Social/MastodonPublishException.php @@ -0,0 +1,94 @@ +status(); + $body = $response->json(); + $rawResponse = $response->body(); + + $errorMessage = data_get($body, 'error', 'An unknown Mastodon error occurred.'); + + if ($status === 401) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: (string) $status, + ); + } + + if ($status === 403) { + return new static( + userMessage: 'This action is not allowed.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 422 && str_contains(strtolower($rawResponse), "text can't be blank")) { + return new static( + userMessage: 'Post text is required when no media is attached.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 422) { + return new static( + userMessage: 'Media validation failed.', + category: ErrorCategory::MediaFormat, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 413) { + return new static( + userMessage: 'File is too large for this Mastodon instance.', + category: ErrorCategory::MediaFormat, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 503) { + return new static( + userMessage: 'Mastodon server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $errorMessage, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'mastodon'; + } +} diff --git a/app/Exceptions/Social/PinterestPublishException.php b/app/Exceptions/Social/PinterestPublishException.php new file mode 100644 index 00000000..58ad92c2 --- /dev/null +++ b/app/Exceptions/Social/PinterestPublishException.php @@ -0,0 +1,93 @@ +status(); + $body = $response->json(); + $rawResponse = $response->body(); + + if ($status === 401) { + throw new TokenExpiredException( + message: data_get($body, 'message', 'Access token has expired or been revoked'), + platformErrorCode: (string) $status, + ); + } + + if ($status === 403) { + return new static( + userMessage: 'Not authorized to create pins on this board.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 400 && str_contains(strtolower($rawResponse), 'board')) { + return new static( + userMessage: 'Invalid board. Please select a valid board.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Pinterest server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $rawResponse, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + public static function fromProcessingStatus(string $status, ?string $rawResponse = null): static + { + if ($status === 'failed') { + return new static( + userMessage: 'Media processing failed. Please try a different file.', + category: ErrorCategory::MediaFormat, + platformErrorCode: null, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $rawResponse ?? 'An unknown Pinterest error occurred.', + category: ErrorCategory::Unknown, + platformErrorCode: null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'pinterest'; + } +} diff --git a/app/Exceptions/Social/SocialPublishException.php b/app/Exceptions/Social/SocialPublishException.php new file mode 100644 index 00000000..384695e6 --- /dev/null +++ b/app/Exceptions/Social/SocialPublishException.php @@ -0,0 +1,59 @@ + static::platform(), + 'category' => $this->category->value, + 'platform_error_code' => $this->platformErrorCode, + 'user_message' => $this->userMessage, + 'raw_response' => $this->redactTokens($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; +} diff --git a/app/Exceptions/Social/ThreadsPublishException.php b/app/Exceptions/Social/ThreadsPublishException.php new file mode 100644 index 00000000..7ab5f07a --- /dev/null +++ b/app/Exceptions/Social/ThreadsPublishException.php @@ -0,0 +1,69 @@ +json(); + $rawResponse = $response->body(); + $statusCode = $response->status(); + + $errorType = data_get($body, 'error.type'); + $errorCode = data_get($body, 'error.code'); + $errorMessage = data_get($body, 'error.message', 'An unknown Threads error occurred.'); + + if ($errorCode === 190) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + ); + } + + if (stripos((string) $rawResponse, "text can't be blank") !== false) { + return new static( + userMessage: 'Post text is required.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + rawResponse: $rawResponse, + ); + } + + if ($statusCode === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + if ($statusCode >= 500) { + return new static( + userMessage: 'Threads server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $errorMessage, + category: ErrorCategory::Unknown, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'threads'; + } +} diff --git a/app/Exceptions/Social/TikTokPublishException.php b/app/Exceptions/Social/TikTokPublishException.php new file mode 100644 index 00000000..5d8cf1c6 --- /dev/null +++ b/app/Exceptions/Social/TikTokPublishException.php @@ -0,0 +1,82 @@ +json(); + $rawResponse = $response->body(); + + $errorCode = data_get($body, 'error.code'); + $errorMessage = data_get($body, 'error.message', 'An unknown TikTok error occurred.'); + + if ($errorCode === 'access_token_invalid') { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $errorCode, + ); + } + + [$message, $category] = match ($errorCode) { + 'scope_not_authorized' => ['Missing required permissions. Please reconnect with all scopes.', ErrorCategory::Permission], + 'scope_permission_missed' => ['Additional permissions required. Please reconnect.', ErrorCategory::Permission], + 'rate_limit_exceeded' => ['TikTok rate limit exceeded. Please try again later.', ErrorCategory::RateLimit], + 'invalid_file_upload' => ['File does not meet API specifications.', ErrorCategory::MediaFormat], + 'invalid_params' => ['Invalid request parameters.', ErrorCategory::MediaFormat], + 'internal_error' => ['TikTok server error. Please try again later.', ErrorCategory::ServerError], + 'reached_active_user_cap' => ['Daily active user quota reached.', ErrorCategory::RateLimit], + 'unaudited_client_can_only_post_to_private_accounts' => ['App not approved for public posting.', ErrorCategory::Permission], + 'url_ownership_unverified' => ['Domain ownership not verified.', ErrorCategory::Permission], + 'privacy_level_option_mismatch' => ['Privacy level not available for this account.', ErrorCategory::Permission], + 'app_version_check_failed' => ['TikTok app update required.', ErrorCategory::Permission], + default => [$errorMessage, ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + rawResponse: $rawResponse, + ); + } + + public static function fromFailReason(string $failReason, ?string $rawResponse = null): static + { + [$message, $category] = match ($failReason) { + 'file_format_check_failed' => ['Unsupported media format.', ErrorCategory::MediaFormat], + 'duration_check_failed' => ['Video duration is not within allowed limits.', ErrorCategory::MediaFormat], + 'frame_rate_check_failed' => ['Video frame rate is not supported.', ErrorCategory::MediaFormat], + 'picture_size_check_failed' => ['Image dimensions exceed limits.', ErrorCategory::MediaFormat], + 'video_pull_failed' => ['Failed to download video from URL.', ErrorCategory::ServerError], + 'photo_pull_failed' => ['Failed to download photo from URL.', ErrorCategory::ServerError], + 'publish_cancelled' => ['Publishing was cancelled.', ErrorCategory::ContentPolicy], + 'auth_removed' => ['App access was revoked during processing.', ErrorCategory::Permission], + 'spam_risk_too_many_posts' => ['Daily posting limit reached. Try again tomorrow.', ErrorCategory::RateLimit], + 'spam_risk_user_banned_from_posting' => ['Account is banned from posting.', ErrorCategory::ContentPolicy], + 'spam_risk_text' => ['TikTok detected spam in the description.', ErrorCategory::ContentPolicy], + 'spam_risk' => ['Publishing request flagged as high-risk.', ErrorCategory::ContentPolicy], + 'internal' => ['TikTok server error. Please try again.', ErrorCategory::ServerError], + default => [$failReason, ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $failReason, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'tiktok'; + } +} diff --git a/app/Exceptions/Social/XPublishException.php b/app/Exceptions/Social/XPublishException.php new file mode 100644 index 00000000..97f8dd18 --- /dev/null +++ b/app/Exceptions/Social/XPublishException.php @@ -0,0 +1,81 @@ +json(); + $rawResponse = $response->body(); + $statusCode = $response->status(); + + $type = data_get($body, 'type', ''); + $title = data_get($body, 'title', ''); + $detail = data_get($body, 'detail', $title); + + $typeSuffix = $type !== '' ? basename((string) $type) : ''; + + if ($statusCode === 401 || str_contains((string) $type, 'unsupported-authentication')) { + throw new TokenExpiredException( + message: $detail ?: 'Access token has expired or been revoked', + platformErrorCode: $typeSuffix ?: (string) $statusCode, + ); + } + + if (str_contains((string) $rawResponse, 'invalid URL')) { + return new static( + userMessage: 'Post contains an invalid URL.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + + if (str_contains((string) $rawResponse, 'video longer than 2 minutes')) { + return new static( + userMessage: 'Video exceeds the 2-minute limit.', + category: ErrorCategory::MediaFormat, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + + if (in_array($statusCode, [500, 502, 503, 504], true)) { + return new static( + userMessage: 'X server error. Please try again later.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + [$message, $category] = match ($typeSuffix) { + 'usage-capped' => ['Usage limit exceeded. Please try again later.', ErrorCategory::RateLimit], + 'rate-limit-exceeded' => ['Rate limit exceeded. Please try again later.', ErrorCategory::RateLimit], + 'invalid-request' => ['Invalid request. Check your post content.', ErrorCategory::ContentPolicy], + 'client-forbidden' => ['App not enrolled or lacks required access.', ErrorCategory::Permission], + 'not-authorized-for-resource' => ['Not authorized for this resource.', ErrorCategory::Permission], + 'resource-not-found' => ['Resource not found.', ErrorCategory::ContentPolicy], + default => [$detail ?: $title ?: 'An unknown X error occurred.', ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'x'; + } +} diff --git a/app/Exceptions/Social/YouTubePublishException.php b/app/Exceptions/Social/YouTubePublishException.php new file mode 100644 index 00000000..45acd79f --- /dev/null +++ b/app/Exceptions/Social/YouTubePublishException.php @@ -0,0 +1,89 @@ +json(); + $rawResponse = $response->body(); + + $reason = data_get($body, 'error.errors.0.reason'); + + [$message, $category] = self::mapReasonToMessageAndCategory( + reason: $reason, + fallbackMessage: data_get($body, 'error.message', 'An unknown YouTube error occurred.'), + ); + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $reason, + rawResponse: $rawResponse, + ); + } + + public static function fromGoogleException(GoogleServiceException $e): static + { + $errors = $e->getErrors(); + $reason = data_get($errors, '0.reason'); + $rawMessage = data_get($errors, '0.message', $e->getMessage()); + + if ($e->getCode() === 401 || in_array($reason, ['authError', 'unauthorized'], true)) { + throw new TokenExpiredException( + message: $rawMessage, + platformErrorCode: $reason, + ); + } + + [$message, $category] = self::mapReasonToMessageAndCategory( + reason: $reason, + fallbackMessage: $rawMessage, + ); + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $reason, + rawResponse: $e->getMessage(), + ); + } + + public function platform(): string + { + return 'youtube'; + } + + /** + * @return array{string, ErrorCategory} + */ + private static function mapReasonToMessageAndCategory(?string $reason, string $fallbackMessage): array + { + return match ($reason) { + 'invalidTitle' => ['Video title is invalid or empty.', ErrorCategory::ContentPolicy], + 'invalidDescription' => ['Video description is invalid.', ErrorCategory::ContentPolicy], + 'invalidTags' => ['Video tags are invalid.', ErrorCategory::ContentPolicy], + 'invalidCategoryId' => ['Video category is invalid.', ErrorCategory::ContentPolicy], + 'invalidVideoMetadata' => ['Video metadata is invalid. Title and category are required.', ErrorCategory::ContentPolicy], + 'invalidPublishAt' => ['Scheduled publishing time is invalid.', ErrorCategory::ContentPolicy], + 'invalidRecordingDetails' => ['Recording details are invalid.', ErrorCategory::ContentPolicy], + 'invalidVideoGameRating' => ['Video game rating is invalid.', ErrorCategory::ContentPolicy], + 'invalidFilename' => ['Video filename is invalid.', ErrorCategory::MediaFormat], + 'mediaBodyRequired' => ['Video file is missing from the request.', ErrorCategory::MediaFormat], + 'failedPrecondition' => ['Thumbnail too large or account not verified.', ErrorCategory::MediaFormat], + 'uploadLimitExceeded' => ['Daily upload limit reached. Try again tomorrow.', ErrorCategory::RateLimit], + 'forbidden' => ["You don't have permission to upload to this channel.", ErrorCategory::Permission], + 'forbiddenLicenseSetting' => ['Invalid video license setting.', ErrorCategory::Permission], + 'forbiddenPrivacySetting' => ['Invalid video privacy setting.', ErrorCategory::Permission], + default => [$fallbackMessage, ErrorCategory::Unknown], + }; + } +} diff --git a/app/Exceptions/TokenExpiredException.php b/app/Exceptions/TokenExpiredException.php index 78435c03..6689b8f6 100644 --- a/app/Exceptions/TokenExpiredException.php +++ b/app/Exceptions/TokenExpiredException.php @@ -1,5 +1,7 @@ body(), 'public'); return $filename; - } catch (\Exception $e) { + } catch (Exception $e) { Log::warning('uploadFromUrl: Exception', [ 'url' => $url, 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Api/ApiKeyController.php b/app/Http/Controllers/Api/ApiKeyController.php new file mode 100644 index 00000000..83d5bb68 --- /dev/null +++ b/app/Http/Controllers/Api/ApiKeyController.php @@ -0,0 +1,46 @@ +workspace->apiTokens()->latest()->get(); + + return ApiKeyResource::collection($apiKeys); + } + + public function store(StoreApiKeyRequest $request): JsonResponse + { + $result = CreateApiKey::execute($request->workspace, $request->validated()); + + return response()->json([ + 'token' => new ApiKeyResource(data_get($result, 'token')), + 'plain_token' => data_get($result, 'plain_token'), + ], Response::HTTP_CREATED); + } + + public function destroy(Request $request, ApiToken $apiToken): JsonResponse + { + if ($apiToken->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + DeleteApiKey::execute($apiToken); + + return response()->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/Controller.php b/app/Http/Controllers/Api/Controller.php new file mode 100644 index 00000000..2fda368d --- /dev/null +++ b/app/Http/Controllers/Api/Controller.php @@ -0,0 +1,12 @@ +workspace->hashtags()->latest()->get(); + + return HashtagResource::collection($hashtags); + } + + public function store(StoreHashtagRequest $request): JsonResponse + { + $hashtag = CreateHashtag::execute($request->workspace, $request->validated()); + + return (new HashtagResource($hashtag)) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + public function update(UpdateHashtagRequest $request, WorkspaceHashtag $hashtag): HashtagResource + { + if ($hashtag->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + $hashtag = UpdateHashtag::execute($hashtag, $request->validated()); + + return new HashtagResource($hashtag); + } + + public function destroy(Request $request, WorkspaceHashtag $hashtag): JsonResponse + { + if ($hashtag->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + DeleteHashtag::execute($hashtag); + + return response()->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/LabelController.php b/app/Http/Controllers/Api/LabelController.php new file mode 100644 index 00000000..a453ace0 --- /dev/null +++ b/app/Http/Controllers/Api/LabelController.php @@ -0,0 +1,58 @@ +workspace->labels()->latest()->get(); + + return LabelResource::collection($labels); + } + + public function store(StoreLabelRequest $request): JsonResponse + { + $label = CreateLabel::execute($request->workspace, $request->validated()); + + return (new LabelResource($label)) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + public function update(UpdateLabelRequest $request, WorkspaceLabel $label): LabelResource + { + if ($label->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + $label = UpdateLabel::execute($label, $request->validated()); + + return new LabelResource($label); + } + + public function destroy(Request $request, WorkspaceLabel $label): JsonResponse + { + if ($label->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + DeleteLabel::execute($label); + + return response()->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php new file mode 100644 index 00000000..85f20cf5 --- /dev/null +++ b/app/Http/Controllers/Api/PostController.php @@ -0,0 +1,86 @@ +workspace->posts() + ->with(['postPlatforms.socialAccount', 'user', 'labels']) + ->latest('scheduled_at') + ->paginate(15); + + return PostResource::collection($posts); + } + + public function show(Request $request, Post $post): PostResource + { + if ($post->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + $post->load(['postPlatforms.socialAccount', 'user', 'labels']); + + return new PostResource($post); + } + + public function store(StorePostRequest $request): JsonResponse + { + $post = CreatePost::execute( + $request->workspace, + $request->workspace->owner, + $request->validated() + ); + + $post->load(['postPlatforms.socialAccount']); + + return (new PostResource($post)) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + public function update(UpdatePostRequest $request, Post $post): PostResource|JsonResponse + { + if ($post->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + $result = UpdatePost::execute($request->workspace, $post, $request->validated()); + + if (data_get($result, 'action') === PostAction::AlreadyPublished) { + return response()->json( + ['message' => 'Cannot edit a published post.'], + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } + + return new PostResource(data_get($result, 'post')); + } + + public function destroy(Request $request, Post $post): JsonResponse + { + if ($post->workspace_id !== $request->workspace->id) { + abort(Response::HTTP_NOT_FOUND); + } + + DeletePost::execute($post); + + return response()->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/SocialAccountController.php b/app/Http/Controllers/Api/SocialAccountController.php new file mode 100644 index 00000000..cd01ee0c --- /dev/null +++ b/app/Http/Controllers/Api/SocialAccountController.php @@ -0,0 +1,37 @@ +workspace->socialAccounts()->orderBy('platform')->get(); + + return SocialAccountResource::collection($accounts); + } + + public function toggle(Request $request, SocialAccount $account): SocialAccountResource|JsonResponse + { + if ($account->workspace_id !== $request->workspace->id) { + return response()->json( + ['message' => 'Account not found.'], + Response::HTTP_NOT_FOUND, + ); + } + + ToggleSocialAccount::execute($account); + + return new SocialAccountResource($account); + } +} diff --git a/app/Http/Controllers/Api/WorkspaceController.php b/app/Http/Controllers/Api/WorkspaceController.php new file mode 100644 index 00000000..4bb7340a --- /dev/null +++ b/app/Http/Controllers/Api/WorkspaceController.php @@ -0,0 +1,16 @@ +workspace); + } +} diff --git a/app/Http/Controllers/App/ApiKeyController.php b/app/Http/Controllers/App/ApiKeyController.php new file mode 100644 index 00000000..7cb497fb --- /dev/null +++ b/app/Http/Controllers/App/ApiKeyController.php @@ -0,0 +1,78 @@ +user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageTeam', $workspace); + + return Inertia::render('settings/ApiKeys', [ + 'workspace' => $workspace, + 'apiTokens' => $workspace->apiTokens()->latest()->get(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageTeam', $workspace); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'expires_at' => ['nullable', 'date', 'after:today'], + ]); + + $result = CreateApiKey::execute($workspace, $validated); + + session()->flash('flash.banner', __('settings.api_keys.flash.created')); + session()->flash('flash.bannerStyle', 'success'); + session()->flash('flash.plainToken', data_get($result, 'plain_token')); + + return back(); + } + + public function destroy(Request $request, ApiToken $apiToken): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageTeam', $workspace); + + if ($apiToken->workspace_id !== $workspace->id) { + abort(404); + } + + DeleteApiKey::execute($apiToken); + + session()->flash('flash.banner', __('settings.api_keys.flash.deleted')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } +} diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/App/BillingController.php similarity index 54% rename from app/Http/Controllers/BillingController.php rename to app/Http/Controllers/App/BillingController.php index 9cd5e470..17520fd3 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -1,7 +1,10 @@ user(); - // If already subscribed, redirect to billing - if ($user->subscribed('default')) { - return redirect()->route('billing.index'); + $this->authorizeBilling($request); + + if ($user->subscribed(User::SUBSCRIPTION_NAME)) { + return redirect()->route('app.billing.index'); } return Inertia::render('billing/Subscribe', [ @@ -27,16 +28,15 @@ public function subscribe(Request $request): Response|RedirectResponse ]); } - /** - * Show the billing dashboard. - */ - public function index(Request $request): Response + public function index(Request $request): Response|RedirectResponse { + $this->authorizeBilling($request); + $user = $request->user(); - $subscription = $user->subscription('default'); + $subscription = $user->subscription(User::SUBSCRIPTION_NAME); return Inertia::render('billing/Index', [ - 'hasSubscription' => $user->subscribed('default'), + 'hasSubscription' => $user->subscribed(User::SUBSCRIPTION_NAME), 'onTrial' => $subscription?->onTrial() ?? false, 'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(), 'subscription' => $subscription?->only([ @@ -61,40 +61,34 @@ public function index(Request $request): Response ]); } - /** - * Create a Stripe Checkout session for new subscription with trial. - */ public function checkout(Request $request): SymfonyResponse { + $this->authorizeBilling($request); + $user = $request->user(); - $subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id')) + $subscription = $user->newSubscription(User::SUBSCRIPTION_NAME, config('cashier.plans.monthly.price_id')) ->allowPromotionCodes() ->trialDays(config('cashier.trial_days')) ->quantity(1); $checkoutSession = $subscription->checkout([ - 'success_url' => route('billing.processing').'?status=success', - 'cancel_url' => route('billing.processing').'?status=cancelled', + 'success_url' => route('app.billing.processing').'?status=success', + 'cancel_url' => route('app.billing.processing').'?status=cancelled', ]); return Inertia::location($checkoutSession->url); } - /** - * Show the checkout processing page. - */ public function processing(Request $request): Response|RedirectResponse { $user = $request->user(); $status = $request->query('status', 'processing'); - // If already subscribed, redirect to calendar - if ($user->subscribed('default')) { - return redirect()->route('calendar'); + if ($user->subscribed(User::SUBSCRIPTION_NAME)) { + return redirect()->route('app.calendar'); } - // Validate status if (! in_array($status, ['processing', 'success', 'cancelled'])) { $status = 'processing'; } @@ -105,46 +99,21 @@ public function processing(Request $request): Response|RedirectResponse ]); } - /** - * Redirect to Stripe Customer Portal. - */ public function portal(Request $request): RedirectResponse { + $this->authorizeBilling($request); + return $request->user()->redirectToBillingPortal( - route('billing.index') + route('app.billing.index') ); } - /** - * Add a workspace to subscription (increment quantity). - */ - public function addWorkspace(Request $request): RedirectResponse + private function authorizeBilling(Request $request): void { - $user = $request->user(); + $workspace = $request->user()->currentWorkspace; - if (! $user->subscribed('default')) { - return redirect()->route('billing.index') - ->withErrors(['subscription' => 'You need an active subscription.']); + if ($workspace) { + $this->authorize('manageBilling', $workspace); } - - $user->incrementWorkspaceQuantity(); - - return back()->with('success', 'Workspace added to subscription.'); - } - - /** - * Remove a workspace from subscription (decrement quantity). - */ - public function removeWorkspace(Request $request): RedirectResponse - { - $user = $request->user(); - - if (! $user->subscribed('default')) { - return back(); - } - - $user->decrementWorkspaceQuantity(); - - return back()->with('success', 'Workspace removed from subscription.'); } } diff --git a/app/Http/Controllers/App/Controller.php b/app/Http/Controllers/App/Controller.php new file mode 100644 index 00000000..e5cc1701 --- /dev/null +++ b/app/Http/Controllers/App/Controller.php @@ -0,0 +1,12 @@ +resolveModel($request->input('model'), $request->input('model_id')); + $this->authorizeModelOwnership($model, $request); $collection = $request->input('collection', 'default'); $media = $model->addMedia( @@ -45,6 +53,7 @@ public function storeChunked(StoreChunkedMediaRequest $request): JsonResponse } $model = $this->resolveModel($request->input('model'), $request->input('model_id')); + $this->authorizeModelOwnership($model, $request); $media = $model->addMediaFromPath( $tempFile, @@ -64,12 +73,15 @@ public function storeChunked(StoreChunkedMediaRequest $request): JsonResponse ]); } - public function destroy(string $modelId, Media $media): JsonResponse + public function destroy(string $modelId, Media $media, Request $request): JsonResponse { if ($media->mediable_id !== $modelId) { abort(403); } + $model = $media->mediable; + $this->authorizeModelOwnership($model, $request); + $media->delete(); return response()->json(['success' => true]); @@ -83,22 +95,37 @@ public function reorder(Request $request): JsonResponse 'media.*.order' => 'required|integer|min:0', ]); + $workspace = $request->user()->currentWorkspace; + $mediaIds = collect($request->input('media'))->pluck('id'); + + // Verify all media belongs to the current workspace + $ownedCount = Media::whereIn('id', $mediaIds) + ->whereHasMorph('mediable', [PostPlatform::class], fn ($query) => $query->whereHas('post', fn ($q) => $q->where('workspace_id', $workspace->id)) + )->count(); + + if ($ownedCount !== $mediaIds->count()) { + abort(403); + } + foreach ($request->input('media') as $item) { - Media::where('id', $item['id'])->update(['order' => $item['order']]); + Media::where('id', data_get($item, 'id'))->update(['order' => data_get($item, 'order')]); } return response()->json(['success' => true]); } - public function duplicate(Media $media, Request $request): JsonResponse + public function duplicate(Media $media, DuplicateMediaRequest $request): JsonResponse { - $targets = $request->input('targets', []); + $sourceModel = $media->mediable; + $this->authorizeModelOwnership($sourceModel, $request); + $targets = $request->validated('targets', []); $duplicates = []; foreach ($targets as $target) { - $model = $this->resolveModel($target['model'], $target['model_id']); - $collection = $target['collection'] ?? $media->collection; + $model = $this->resolveModel(data_get($target, 'model'), data_get($target, 'model_id')); + $this->authorizeModelOwnership($model, $request); + $collection = data_get($target, 'collection', $media->collection); $duplicate = $model->media()->create([ 'group_id' => $media->group_id, @@ -126,9 +153,38 @@ public function duplicate(Media $media, Request $request): JsonResponse return response()->json($duplicates); } + private function authorizeModelOwnership(Model $model, Request $request): void + { + $workspace = $request->user()->currentWorkspace; + + if ($model instanceof PostPlatform) { + if ($model->post->workspace_id !== $workspace->id) { + abort(403); + } + } elseif ($model instanceof Post) { + if ($model->workspace_id !== $workspace->id) { + abort(403); + } + } elseif ($model instanceof Workspace) { + if ($model->id !== $workspace->id) { + abort(403); + } + } elseif ($model instanceof User) { + if ($model->id !== $request->user()->id) { + abort(403); + } + } else { + abort(403); + } + } + private function resolveModel(string $alias, string $id): Model { - $modelClass = Relation::getMorphedModel($alias) ?? $alias; + $modelClass = Relation::getMorphedModel($alias); + + if (! $modelClass) { + abort(404); + } return $modelClass::findOrFail($id); } diff --git a/app/Http/Controllers/App/NotificationController.php b/app/Http/Controllers/App/NotificationController.php new file mode 100644 index 00000000..5c5cb00a --- /dev/null +++ b/app/Http/Controllers/App/NotificationController.php @@ -0,0 +1,87 @@ +user()->currentWorkspace; + + if (! $workspace) { + return response()->json(['notifications' => [], 'unread_count' => 0]); + } + + $notifications = $request->user() + ->notifications() + ->where('workspace_id', $workspace->id) + ->whereNull('archived_at') + ->latest() + ->limit(50) + ->get(); + + $unreadCount = $request->user() + ->notifications() + ->where('workspace_id', $workspace->id) + ->whereNull('archived_at') + ->whereNull('read_at') + ->count(); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $unreadCount, + ]); + } + + public function markAsRead(Request $request, Notification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + abort(Response::HTTP_FORBIDDEN); + } + + $notification->markAsRead(); + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return response()->json(['success' => true]); + } + + $request->user() + ->notifications() + ->where('workspace_id', $workspace->id) + ->whereNull('read_at') + ->update(['read_at' => now()]); + + return response()->json(['success' => true]); + } + + public function archiveAll(Request $request): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return response()->json(['success' => true]); + } + + $request->user() + ->notifications() + ->where('workspace_id', $workspace->id) + ->whereNull('archived_at') + ->update(['archived_at' => now()]); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php new file mode 100644 index 00000000..29f619eb --- /dev/null +++ b/app/Http/Controllers/App/OnboardingController.php @@ -0,0 +1,139 @@ +enforceStep($request->user(), Setup::Role); + if ($redirect) { + return $redirect; + } + + return Inertia::render('onboarding/Role', [ + 'personas' => Persona::toSelectArray(), + ]); + } + + public function storeRole(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'persona' => ['required', Rule::enum(Persona::class)], + ]); + + $request->user()->update([ + 'persona' => data_get($validated, 'persona'), + 'setup' => Setup::Connections, + ]); + + return redirect()->route('app.onboarding.connect'); + } + + public function connect(Request $request): Response|RedirectResponse + { + $redirect = $this->enforceStep($request->user(), Setup::Connections); + if ($redirect) { + return $redirect; + } + + $user = $request->user(); + $workspace = $user->currentWorkspace; + + $platforms = collect(); + + if ($workspace) { + $connectedAccounts = $workspace->socialAccounts; + + $platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [ + 'value' => $platform->value, + 'label' => $platform->label(), + 'color' => $platform->color(), + 'connected' => $connectedAccounts->firstWhere('platform', $platform) !== null, + 'account' => $connectedAccounts->firstWhere('platform', $platform), + ])->values(); + } + + return Inertia::render('onboarding/Connect', [ + 'platforms' => $platforms, + 'hasWorkspace' => $workspace !== null, + ]); + } + + public function storeConnect(Request $request): SymfonyResponse|RedirectResponse + { + $user = $request->user(); + + if (config('trypost.self_hosted')) { + $user->update([ + 'setup' => Setup::Completed, + ]); + + session()->flash('flash.banner', __('auth.flash.welcome')); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('app.calendar'); + } + + $user->update([ + 'setup' => Setup::Subscription, + ]); + + $subscription = $user->newSubscription(User::SUBSCRIPTION_NAME, config('cashier.plans.monthly.price_id')) + ->allowPromotionCodes() + ->trialDays(config('cashier.trial_days')) + ->quantity(1); + + $checkoutSession = $subscription->checkout([ + 'success_url' => route('app.onboarding.complete').'?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('app.onboarding.connect'), + ]); + + return Inertia::location($checkoutSession->url); + } + + public function complete(Request $request): RedirectResponse + { + $user = $request->user(); + + $user->update([ + 'setup' => Setup::Completed, + ]); + + session()->flash('flash.banner', __('auth.flash.welcome_trial')); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('app.calendar'); + } + + private function enforceStep(User $user, Setup $expectedStep): ?RedirectResponse + { + if ($user->setup === $expectedStep) { + return null; + } + + if ($user->setup === Setup::Completed) { + return redirect()->route('app.calendar'); + } + + return match ($user->setup) { + Setup::Role => redirect()->route('app.onboarding.role'), + Setup::Connections => redirect()->route('app.onboarding.connect'), + Setup::Subscription => redirect()->route('app.subscribe'), + default => redirect()->route('app.onboarding.role'), + }; + } +} diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/App/PostController.php similarity index 52% rename from app/Http/Controllers/PostController.php rename to app/Http/Controllers/App/PostController.php index 6bc98ec1..f5e55361 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -1,12 +1,16 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('view', $workspace); $query = $workspace->posts() - ->with(['postPlatforms' => function ($query) { - $query->where('enabled', true)->with('socialAccount'); - }, 'user', 'labels']); + ->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount'), 'user', 'labels']); - // Apply status filter if provided if ($status) { $query = match ($status) { - 'draft' => $query->draft(), - 'scheduled' => $query->scheduled(), - 'published' => $query->published(), + PostStatus::Draft->value => $query->draft(), + PostStatus::Scheduled->value => $query->scheduled(), + PostStatus::Published->value => $query->published(), default => $query, }; } + if ($search = $request->input('search')) { + $query->whereHas('postPlatforms', fn ($q) => $q->where('content', 'ilike', "%{$search}%")); + } + return Inertia::render('posts/Index', [ 'workspace' => $workspace, - 'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(15)), + 'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(config('app.pagination.default'))), 'currentStatus' => $status, + 'filters' => [ + 'search' => $request->input('search', ''), + ], ]); } @@ -54,7 +62,7 @@ public function calendar(Request $request): Response|RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('view', $workspace); @@ -62,25 +70,21 @@ public function calendar(Request $request): Response|RedirectResponse $tz = $workspace->timezone; $view = $request->input('view', 'week'); - // Day view $currentDay = $request->input('day') ? Carbon::parse($request->input('day'), $tz)->startOfDay() : Carbon::now($tz)->startOfDay(); - // Week view $weekStart = $request->input('week') ? Carbon::parse($request->input('week'), $tz)->startOfWeek() : Carbon::now($tz)->startOfWeek(); $weekEnd = $weekStart->copy()->endOfWeek(); - // Month view $monthDate = $request->input('month') ? Carbon::parse($request->input('month'), $tz)->startOfMonth() : Carbon::now($tz)->startOfMonth(); $monthStart = $monthDate->copy()->startOfMonth()->startOfWeek(); $monthEnd = $monthDate->copy()->endOfMonth()->endOfWeek(); - // Get posts based on view $rangeStart = match ($view) { 'day' => $currentDay, 'month' => $monthStart, @@ -93,9 +97,7 @@ public function calendar(Request $request): Response|RedirectResponse }; $posts = $workspace->posts() - ->with(['postPlatforms' => function ($query) { - $query->where('enabled', true)->with('socialAccount'); - }]) + ->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount')]) ->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()]) ->orderBy('scheduled_at') ->get() @@ -116,46 +118,25 @@ public function store(Request $request): RedirectResponse|\Symfony\Component\Htt $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); - $socialAccounts = $workspace->socialAccounts; + $socialAccounts = $workspace->socialAccounts()->active()->get(); if ($socialAccounts->isEmpty()) { session()->flash('flash.banner', __('posts.flash.connect_first')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('accounts'); + return redirect()->route('app.accounts'); } - // Create a draft post - default to today if no date provided - $date = $request->input('date') ?: Carbon::now($workspace->timezone)->format('Y-m-d'); - $scheduledAt = Carbon::parse($date, $workspace->timezone) - ->setTime(9, 0) - ->utc(); - - $post = $workspace->posts()->create([ - 'user_id' => $request->user()->id, - 'status' => PostStatus::Draft, - 'synced' => true, - 'scheduled_at' => $scheduledAt, + $post = CreatePost::execute($workspace, $request->user(), [ + 'date' => $request->input('date'), ]); - // Create post_platforms for each connected account - foreach ($socialAccounts as $account) { - $post->postPlatforms()->create([ - 'social_account_id' => $account->id, - 'platform' => $account->platform->value, - 'content' => '', - 'content_type' => ContentType::defaultFor($account->platform), - 'status' => 'pending', - 'enabled' => true, - ]); - } - - return Inertia::location(route('posts.edit', $post)); + return Inertia::location(route('app.posts.edit', $post)); } public function edit(Request $request, Post $post): Response|RedirectResponse @@ -163,7 +144,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('view', $workspace); @@ -173,24 +154,20 @@ public function edit(Request $request, Post $post): Response|RedirectResponse } $post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels']); - $socialAccounts = $workspace->socialAccounts; + $socialAccounts = $workspace->socialAccounts()->active()->get(); $labels = $workspace->labels; $hashtags = $workspace->hashtags; - $platformConfigs = $socialAccounts->mapWithKeys(function ($account) { - $platform = $account->platform; + $platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [ + $account->id => [ + 'maxContentLength' => $account->platform->maxContentLength(), + 'maxImages' => $account->platform->maxImages(), + 'allowedMediaTypes' => array_map(fn ($type) => $type->value, $account->platform->allowedMediaTypes()), + 'supportsTextOnly' => $account->platform->supportsTextOnly(), + 'requiresContent' => $account->platform->requiresContent(), + ], + ]); - return [ - $account->id => [ - 'maxContentLength' => $platform->maxContentLength(), - 'maxImages' => $platform->maxImages(), - 'allowedMediaTypes' => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes()), - 'supportsTextOnly' => $platform->supportsTextOnly(), - ], - ]; - }); - - // Fetch Pinterest boards if Pinterest account exists $pinterestBoards = []; $pinterestAccount = $socialAccounts->firstWhere('platform', Platform::Pinterest); if ($pinterestAccount) { @@ -217,80 +194,38 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); if ($post->workspace_id !== $workspace->id) { abort(404); } - if ($post->status === PostStatus::Published) { + $result = UpdatePost::execute($workspace, $post, $request->validated()); + + $action = data_get($result, 'action'); + + if ($action === PostAction::AlreadyPublished) { session()->flash('flash.banner', __('posts.flash.cannot_edit_published')); session()->flash('flash.bannerStyle', 'danger'); return back(); } - $scheduledAt = $post->scheduled_at; - if ($request->has('scheduled_at') && $request->input('scheduled_at')) { - $scheduledAt = Carbon::parse($request->input('scheduled_at'), $workspace->timezone)->utc(); - } - - $status = $request->input('status', $post->status); - - $post->update([ - 'status' => $status === 'publishing' ? PostStatus::Publishing : $status, - 'synced' => $request->input('synced', $post->synced), - 'scheduled_at' => $scheduledAt, - ]); - - // Sync labels - if ($request->has('label_ids')) { - $post->labels()->sync($request->input('label_ids', [])); - } - - // Get selected platform IDs - $selectedPlatformIds = collect($request->input('platforms', []))->pluck('id')->toArray(); - - // Update all platforms - disable those not selected, update content for selected ones - $post->postPlatforms()->update(['enabled' => false]); - - foreach ($request->input('platforms', []) as $platformData) { - $updateData = [ - 'enabled' => true, - 'content' => $platformData['content'], - 'content_type' => $platformData['content_type'] ?? null, - ]; - - if (isset($platformData['meta'])) { - $postPlatform = $post->postPlatforms()->where('id', $platformData['id'])->first(); - $updateData['meta'] = array_merge($postPlatform->meta ?? [], $platformData['meta']); - } - - $post->postPlatforms() - ->where('id', $platformData['id']) - ->update($updateData); - } - - // Dispatch publish job if publishing now - if ($status === 'publishing') { - $post->update(['scheduled_at' => now()]); - PublishPost::dispatch($post); - + if ($action === PostAction::Publishing) { session()->flash('flash.banner', __('posts.flash.publishing')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('posts.edit', $post); + return redirect()->route('app.posts.edit', $post); } - // Redirect to show page for schedule action - if ($status === 'scheduled') { + if ($action === PostAction::Scheduled) { session()->flash('flash.banner', __('posts.flash.scheduled')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('posts.edit', $post); + return redirect()->route('app.posts.edit', $post); } return back(); @@ -301,24 +236,28 @@ public function destroy(Request $request, Post $post): RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); if ($post->workspace_id !== $workspace->id) { abort(404); } - $post->delete(); + DeletePost::execute($post); session()->flash('flash.banner', __('posts.flash.deleted')); session()->flash('flash.bannerStyle', 'success'); + $allowedRedirects = ['app.posts.index', 'app.calendar']; + if ($redirect = $request->input('redirect')) { - return redirect()->route($redirect); + if (in_array($redirect, $allowedRedirects)) { + return redirect()->route($redirect); + } } - return back(); + return redirect()->route('app.posts.index'); } } diff --git a/app/Http/Controllers/App/Settings/NotificationPreferenceController.php b/app/Http/Controllers/App/Settings/NotificationPreferenceController.php new file mode 100644 index 00000000..5d4cef90 --- /dev/null +++ b/app/Http/Controllers/App/Settings/NotificationPreferenceController.php @@ -0,0 +1,50 @@ + $request->user()->id], + [ + 'post_published' => true, + 'post_failed' => true, + 'account_disconnected' => true, + ], + ); + + return Inertia::render('settings/Notifications', [ + 'preferences' => $preferences, + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'post_published' => ['required', 'boolean'], + 'post_failed' => ['required', 'boolean'], + 'account_disconnected' => ['required', 'boolean'], + ]); + + NotificationPreference::updateOrCreate( + ['user_id' => $request->user()->id], + $validated, + ); + + session()->flash('flash.banner', __('settings.flash.notifications_updated')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } +} diff --git a/app/Http/Controllers/App/Settings/PasswordController.php b/app/Http/Controllers/App/Settings/PasswordController.php new file mode 100644 index 00000000..416393d0 --- /dev/null +++ b/app/Http/Controllers/App/Settings/PasswordController.php @@ -0,0 +1,38 @@ +validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make(data_get($validated, 'password')), + ]); + + session()->flash('flash.banner', __('settings.flash.password_updated')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } +} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/App/Settings/ProfileController.php similarity index 66% rename from app/Http/Controllers/Settings/ProfileController.php rename to app/Http/Controllers/App/Settings/ProfileController.php index a4f5eb4b..58d62630 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/App/Settings/ProfileController.php @@ -1,11 +1,14 @@ user()->fill($request->validated()); @@ -43,60 +40,71 @@ public function update(ProfileUpdateRequest $request): RedirectResponse session()->flash('flash.banner', __('settings.flash.profile_updated')); session()->flash('flash.bannerStyle', 'success'); - return to_route('profile.edit'); + return to_route('app.profile.edit'); } - /** - * Update the user's language. - */ - public function updateLanguage(Request $request): RedirectResponse + public function uploadPhoto(Request $request): RedirectResponse { $request->validate([ - 'language_id' => ['required', 'exists:languages,id'], + 'photo' => ['required', 'image', 'max:2048'], ]); - $request->user()->update([ - 'language_id' => $request->language_id, - ]); + $user = $request->user(); + $user->clearMediaCollection('avatar'); + $user->addMedia($request->file('photo'), 'avatar'); + $user->unsetRelation('media'); - // Refresh the user model to clear cached language relationship - $request->user()->refresh(); - - session()->flash('flash.banner', __('settings.flash.language_updated')); + session()->flash('flash.banner', __('settings.flash.photo_updated')); session()->flash('flash.bannerStyle', 'success'); return back(); } - /** - * Delete the user's profile. - */ + public function deletePhoto(Request $request): RedirectResponse + { + $user = $request->user(); + $user->clearMediaCollection('avatar'); + $user->unsetRelation('media'); + + session()->flash('flash.banner', __('settings.flash.photo_deleted')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + + public function updateLanguage(Request $request): RedirectResponse + { + $request->validate([ + 'locale' => ['required', 'string', 'in:'.implode(',', array_keys(config('languages.available')))], + ]); + + session()->flash('flash.banner', __('settings.flash.language_updated')); + session()->flash('flash.bannerStyle', 'success'); + + return back()->withCookie( + cookie()->forever('locale', $request->locale, '/', config('session.domain')) + ); + } + public function destroy(ProfileDeleteRequest $request): RedirectResponse { $user = $request->user(); DB::transaction(function () use ($user) { - // Cancel active Stripe subscription and delete subscription records - if ($user->subscribed('default')) { - $user->subscription('default')->cancelNow(); + if ($user->subscribed(User::SUBSCRIPTION_NAME)) { + $user->subscription(User::SUBSCRIPTION_NAME)->cancelNow(); } $user->subscriptions()->delete(); - - // Clear current workspace reference $user->update(['current_workspace_id' => null]); - // Delete all workspaces owned by the user $ownedWorkspaces = $user->workspaces()->wherePivot('role', Role::Owner->value)->get(); foreach ($ownedWorkspaces as $workspace) { - // Update members who have this as current_workspace_id foreach ($workspace->members as $member) { if ($member->id !== $user->id && $member->current_workspace_id === $workspace->id) { - // Find another workspace for this member $otherWorkspace = $member->workspaces() ->where('workspaces.id', '!=', $workspace->id) ->first(); - $member->update(['current_workspace_id' => $otherWorkspace?->id]); } } @@ -110,14 +118,11 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse $workspace->delete(); } - // Remove user from workspaces where they are a member $user->workspaces()->detach(); }); Auth::logout(); - $user->delete(); - $request->session()->invalidate(); $request->session()->regenerateToken(); diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php similarity index 53% rename from app/Http/Controllers/WorkspaceController.php rename to app/Http/Controllers/App/WorkspaceController.php index 99315d14..a4c43721 100644 --- a/app/Http/Controllers/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -1,9 +1,14 @@ user(); $workspaces = $user->workspaces() + ->with('media') ->withCount(['socialAccounts', 'posts']) ->latest() ->get(); @@ -30,59 +33,33 @@ public function index(Request $request): Response ]); } - /** - * Show create workspace form. - */ public function create(Request $request): Response|RedirectResponse { $user = $request->user(); - // First workspace is free, subsequent ones require subscription if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) { - return redirect()->route('billing.index') + return redirect()->route('app.billing.index') ->with('message', 'Subscribe to create more workspaces.'); } return Inertia::render('workspaces/Create'); } - /** - * Store a new workspace. - */ public function store(StoreWorkspaceRequest $request): RedirectResponse { $user = $request->user(); - // First workspace is free, subsequent ones require subscription if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) { - return redirect()->route('billing.index') + return redirect()->route('app.billing.index') ->with('message', 'Subscribe to create more workspaces.'); } - $workspace = Workspace::create([ - 'user_id' => $user->id, - ...$request->validated(), - 'timezone' => config('app.timezone', 'UTC'), - ]); + CreateWorkspace::execute($user, $request->validated()); - // Add user as owner member - $workspace->members()->attach($user->id, ['role' => 'owner']); - - // Set as current workspace - $user->switchWorkspace($workspace); - - // Increment subscription quantity if user has subscription - if ($user->hasActiveSubscription()) { - $user->incrementWorkspaceQuantity(); - } - - return redirect()->route('calendar') + return redirect()->route('app.calendar') ->with('success', 'Workspace created successfully!'); } - /** - * Switch to a different workspace. - */ public function switch(Request $request, Workspace $workspace): RedirectResponse { $user = $request->user(); @@ -93,19 +70,16 @@ public function switch(Request $request, Workspace $workspace): RedirectResponse $user->switchWorkspace($workspace); - return redirect()->route('calendar'); + return redirect()->route('app.calendar'); } - /** - * Show workspace settings. - */ public function settings(Request $request): Response|RedirectResponse { $user = $request->user(); $workspace = $user->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('update', $workspace); @@ -114,22 +88,71 @@ public function settings(Request $request): Response|RedirectResponse ->mapWithKeys(fn ($tz) => [$tz => $tz]) ->toArray(); + $members = $workspace->members() + ->get() + ->map(fn ($member) => [ + 'id' => $member->id, + 'name' => $member->name, + 'email' => $member->email, + 'role' => $member->pivot->role, + 'is_owner' => $member->pivot->role === Role::Owner->value, + ]); + + $invitations = $workspace->invites() + ->select('id', 'email', 'role') + ->latest() + ->get(); + return Inertia::render('settings/Workspace', [ 'workspace' => $workspace, + 'members' => $members, + 'invitations' => $invitations, 'timezones' => $timezones, ]); } - /** - * Update workspace settings. - */ + public function uploadLogo(Request $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + $this->authorize('update', $workspace); + + $request->validate([ + 'photo' => ['required', 'image', 'max:2048'], + ]); + + $workspace->clearMediaCollection('logo'); + $workspace->addMedia($request->file('photo'), 'logo'); + $workspace->unsetRelation('media'); + + session()->flash('flash.banner', __('settings.flash.logo_updated')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + + public function deleteLogo(Request $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + $this->authorize('update', $workspace); + + $workspace->clearMediaCollection('logo'); + $workspace->unsetRelation('media'); + + session()->flash('flash.banner', __('settings.flash.logo_deleted')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + public function updateSettings(UpdateWorkspaceRequest $request): RedirectResponse { $user = $request->user(); $workspace = $user->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('update', $workspace); @@ -139,31 +162,18 @@ public function updateSettings(UpdateWorkspaceRequest $request): RedirectRespons session()->flash('flash.banner', __('settings.flash.workspace_updated')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('workspace.settings'); + return redirect()->route('app.workspace.settings'); } - /** - * Delete a workspace. - */ public function destroy(Request $request, Workspace $workspace): RedirectResponse { $this->authorize('delete', $workspace); $user = $request->user(); - // If deleting current workspace, clear it - if ($user->current_workspace_id === $workspace->id) { - $user->update(['current_workspace_id' => null]); - } + DeleteWorkspace::execute($user, $workspace); - $workspace->delete(); - - // Decrement subscription quantity if user has subscription - if ($user->hasActiveSubscription()) { - $user->decrementWorkspaceQuantity(); - } - - return redirect()->route('workspaces.index') + return redirect()->route('app.workspaces.index') ->with('success', 'Workspace deleted successfully!'); } } diff --git a/app/Http/Controllers/WorkspaceHashtagController.php b/app/Http/Controllers/App/WorkspaceHashtagController.php similarity index 59% rename from app/Http/Controllers/WorkspaceHashtagController.php rename to app/Http/Controllers/App/WorkspaceHashtagController.php index 6dd79d52..3666c097 100644 --- a/app/Http/Controllers/WorkspaceHashtagController.php +++ b/app/Http/Controllers/App/WorkspaceHashtagController.php @@ -1,7 +1,12 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); + + $hashtags = $workspace->hashtags() + ->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%")) + ->latest() + ->paginate(config('app.pagination.default')); return Inertia::render('hashtags/Index', [ 'workspace' => $workspace, - 'hashtags' => $workspace->hashtags()->latest()->get(), + 'hashtags' => Inertia::scroll(fn () => $hashtags), + 'filters' => [ + 'search' => $request->input('search', ''), + ], ]); } @@ -31,22 +44,22 @@ public function store(Request $request): RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'hashtags' => ['required', 'string'], ]); - $workspace->hashtags()->create($validated); + CreateHashtag::execute($workspace, $validated); session()->flash('flash.banner', __('hashtags.flash.created')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('hashtags.index'); + return redirect()->route('app.hashtags.index'); } public function update(Request $request, WorkspaceHashtag $hashtag): RedirectResponse @@ -54,10 +67,10 @@ public function update(Request $request, WorkspaceHashtag $hashtag): RedirectRes $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); if ($hashtag->workspace_id !== $workspace->id) { abort(404); @@ -68,12 +81,12 @@ public function update(Request $request, WorkspaceHashtag $hashtag): RedirectRes 'hashtags' => ['required', 'string'], ]); - $hashtag->update($validated); + UpdateHashtag::execute($hashtag, $validated); session()->flash('flash.banner', __('hashtags.flash.updated')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('hashtags.index'); + return redirect()->route('app.hashtags.index'); } public function destroy(Request $request, WorkspaceHashtag $hashtag): RedirectResponse @@ -81,20 +94,20 @@ public function destroy(Request $request, WorkspaceHashtag $hashtag): RedirectRe $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); if ($hashtag->workspace_id !== $workspace->id) { abort(404); } - $hashtag->delete(); + DeleteHashtag::execute($hashtag); session()->flash('flash.banner', __('hashtags.flash.deleted')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('hashtags.index'); + return redirect()->route('app.hashtags.index'); } } diff --git a/app/Http/Controllers/WorkspaceInviteController.php b/app/Http/Controllers/App/WorkspaceInviteController.php similarity index 64% rename from app/Http/Controllers/WorkspaceInviteController.php rename to app/Http/Controllers/App/WorkspaceInviteController.php index ab891eba..5931d5b2 100644 --- a/app/Http/Controllers/WorkspaceInviteController.php +++ b/app/Http/Controllers/App/WorkspaceInviteController.php @@ -2,15 +2,17 @@ declare(strict_types=1); -namespace App\Http\Controllers; +namespace App\Http\Controllers\App; +use App\Actions\Invite\CreateInvite; +use App\Actions\Invite\DeleteInvite; +use App\Actions\Invite\RemoveMember; use App\Enums\UserWorkspace\Role as WorkspaceRole; -use App\Http\Requests\StoreWorkspaceInviteRequest; -use App\Mail\WorkspaceInvite as WorkspaceInviteMail; +use App\Http\Requests\App\Invite\StoreWorkspaceInviteRequest; use App\Models\WorkspaceInvite; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Mail; +use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; @@ -21,7 +23,7 @@ public function index(Request $request): Response|RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageTeam', $workspace); @@ -32,7 +34,7 @@ public function index(Request $request): Response|RedirectResponse ->latest() ->get(), 'members' => $workspace->members() - ->where('user_id', '!=', $workspace->user_id) + ->wherePivot('role', '!=', WorkspaceRole::Owner->value) ->get() ->map(fn ($member) => [ 'id' => $member->id, @@ -60,7 +62,7 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageTeam', $workspace); @@ -81,12 +83,7 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse ]); } - $invite = $workspace->invites()->create([ - 'email' => $request->email, - 'role' => $request->role ?? WorkspaceRole::Member, - ]); - - Mail::to($invite->email)->send(new WorkspaceInviteMail($invite)); + CreateInvite::execute($workspace, $request->validated()); session()->flash('flash.banner', __('settings.members.flash.invite_sent')); session()->flash('flash.bannerStyle', 'success'); @@ -99,7 +96,7 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageTeam', $workspace); @@ -108,7 +105,7 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp abort(404); } - $invite->delete(); + DeleteInvite::execute($invite); session()->flash('flash.banner', __('settings.members.flash.invite_deleted')); session()->flash('flash.bannerStyle', 'success'); @@ -121,20 +118,52 @@ public function removeMember(Request $request, string $userId): RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageTeam', $workspace); - if ($workspace->user_id === $userId) { + $memberPivot = $workspace->members()->where('user_id', $userId)->first()?->pivot; + + if ($memberPivot && $memberPivot->role === WorkspaceRole::Owner->value) { return back()->withErrors(['member' => 'Cannot remove the workspace owner.']); } - $workspace->members()->detach($userId); + RemoveMember::execute($workspace, $userId); session()->flash('flash.banner', __('settings.members.flash.member_removed')); session()->flash('flash.bannerStyle', 'success'); return back(); } + + public function updateRole(Request $request, string $userId): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageTeam', $workspace); + + $memberPivot = $workspace->members()->where('user_id', $userId)->first()?->pivot; + + if ($memberPivot && $memberPivot->role === WorkspaceRole::Owner->value) { + return back()->withErrors(['role' => 'Cannot change the workspace owner role.']); + } + + $validated = $request->validate([ + 'role' => ['required', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])], + ]); + + $workspace->members()->updateExistingPivot($userId, [ + 'role' => data_get($validated, 'role'), + ]); + + session()->flash('flash.banner', __('settings.members.flash.role_updated')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } } diff --git a/app/Http/Controllers/WorkspaceLabelController.php b/app/Http/Controllers/App/WorkspaceLabelController.php similarity index 60% rename from app/Http/Controllers/WorkspaceLabelController.php rename to app/Http/Controllers/App/WorkspaceLabelController.php index 01b56e58..bd904c22 100644 --- a/app/Http/Controllers/WorkspaceLabelController.php +++ b/app/Http/Controllers/App/WorkspaceLabelController.php @@ -1,7 +1,12 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); + + $labels = $workspace->labels() + ->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%")) + ->latest() + ->paginate(config('app.pagination.default')); return Inertia::render('labels/Index', [ 'workspace' => $workspace, - 'labels' => $workspace->labels()->latest()->get(), + 'labels' => Inertia::scroll(fn () => $labels), + 'filters' => [ + 'search' => $request->input('search', ''), + ], ]); } @@ -31,22 +44,22 @@ public function store(Request $request): RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], ]); - $workspace->labels()->create($validated); + CreateLabel::execute($workspace, $validated); session()->flash('flash.banner', __('labels.flash.created')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('labels.index'); + return redirect()->route('app.labels.index'); } public function update(Request $request, WorkspaceLabel $label): RedirectResponse @@ -54,10 +67,10 @@ public function update(Request $request, WorkspaceLabel $label): RedirectRespons $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); if ($label->workspace_id !== $workspace->id) { abort(404); @@ -68,12 +81,12 @@ public function update(Request $request, WorkspaceLabel $label): RedirectRespons 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], ]); - $label->update($validated); + UpdateLabel::execute($label, $validated); session()->flash('flash.banner', __('labels.flash.updated')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('labels.index'); + return redirect()->route('app.labels.index'); } public function destroy(Request $request, WorkspaceLabel $label): RedirectResponse @@ -81,20 +94,20 @@ public function destroy(Request $request, WorkspaceLabel $label): RedirectRespon $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('createPost', $workspace); if ($label->workspace_id !== $workspace->id) { abort(404); } - $label->delete(); + DeleteLabel::execute($label); session()->flash('flash.banner', __('labels.flash.deleted')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('labels.index'); + return redirect()->route('app.labels.index'); } } diff --git a/app/Http/Controllers/Auth/AcceptInviteController.php b/app/Http/Controllers/Auth/AcceptInviteController.php index 4ae3ebf5..cc960fe4 100644 --- a/app/Http/Controllers/Auth/AcceptInviteController.php +++ b/app/Http/Controllers/Auth/AcceptInviteController.php @@ -48,7 +48,7 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo session()->flash('flash.banner', __('settings.members.flash.wrong_email')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('calendar'); + return redirect()->route('app.calendar'); } // Check if already a member @@ -58,7 +58,7 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo session()->flash('flash.banner', __('settings.members.flash.already_member')); session()->flash('flash.bannerStyle', 'info'); - return redirect()->route('calendar'); + return redirect()->route('app.calendar'); } // Accept the invite @@ -69,7 +69,7 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo session()->flash('flash.banner', __('settings.members.flash.invite_accepted')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('calendar'); + return redirect()->route('app.calendar'); } /** @@ -84,7 +84,7 @@ public function decline(Request $request, WorkspaceInvite $invite): RedirectResp session()->flash('flash.banner', __('settings.members.flash.wrong_email')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('calendar'); + return redirect()->route('app.calendar'); } $invite->delete(); @@ -92,6 +92,6 @@ public function decline(Request $request, WorkspaceInvite $invite): RedirectResp session()->flash('flash.banner', __('settings.members.flash.invite_declined')); session()->flash('flash.bannerStyle', 'info'); - return redirect()->route('calendar'); + return redirect()->route('app.calendar'); } } diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index a93a85d1..423f408f 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use App\Http\Requests\Auth\LoginRequest; +use App\Http\Requests\App\Auth\LoginRequest; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -37,10 +37,13 @@ public function store(LoginRequest $request): RedirectResponse // Check for redirect param if ($redirect = $request->input('redirect')) { - return redirect($redirect); + // Only allow internal redirects (paths starting with /) + if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) { + return redirect($redirect); + } } - return redirect()->intended(route('calendar')); + return redirect()->intended(route('app.calendar')); } /** diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index 68d03180..13b1b8d2 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -45,7 +47,7 @@ public function store(Request $request): View|RedirectResponse $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -77,9 +79,9 @@ public function store(Request $request): View|RedirectResponse $data = $response->json(); // Get profile - $profileResponse = Http::withToken($data['accessJwt']) + $profileResponse = Http::withToken(data_get($data, 'accessJwt')) ->get("{$service}/xrpc/app.bsky.actor.getProfile", [ - 'actor' => $data['did'], + 'actor' => data_get($data, 'did'), ]); $profile = $profileResponse->successful() ? $profileResponse->json() : []; @@ -93,16 +95,16 @@ public function store(Request $request): View|RedirectResponse return back()->withErrors(['identifier' => 'Bluesky is already connected.']); } - $avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null; + $avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null; $accountData = [ 'platform' => $this->platform->value, - 'platform_user_id' => $data['did'], - 'username' => $data['handle'], - 'display_name' => $profile['displayName'] ?? $data['handle'], + 'platform_user_id' => data_get($data, 'did'), + 'username' => data_get($data, 'handle'), + 'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')), 'avatar_url' => $avatarPath, - 'access_token' => $data['accessJwt'], - 'refresh_token' => $data['refreshJwt'], + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), 'token_expires_at' => now()->addHours(2), 'meta' => [ 'service' => $service, diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php index bc769e67..4b28463b 100644 --- a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -16,7 +16,7 @@ class EmailVerificationNotificationController extends Controller public function store(Request $request): RedirectResponse { if ($request->user()->hasVerifiedEmail()) { - return redirect()->intended(route('calendar')); + return redirect()->intended(route('app.calendar')); } $request->user()->sendEmailVerificationNotification(); diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php index 6364157f..01645504 100644 --- a/app/Http/Controllers/Auth/EmailVerificationPromptController.php +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -18,7 +18,7 @@ class EmailVerificationPromptController extends Controller public function __invoke(Request $request): RedirectResponse|Response { return $request->user()->hasVerifiedEmail() - ? redirect()->intended(route('calendar')) + ? redirect()->intended(route('app.calendar')) : Inertia::render('auth/VerifyEmail', [ 'status' => session('status'), ]); diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 16972390..9847afad 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -99,21 +102,21 @@ public function callback(Request $request): View|RedirectResponse // If only one page, connect directly if (count($pages) === 1) { $page = $pages[0]; - $avatarPath = uploadFromUrl($page['picture']); + $avatarPath = uploadFromUrl(data_get($page, 'picture')); if ($existingAccount) { // Reconnect existing account $existingAccount->update([ - 'platform_user_id' => $page['id'], - 'username' => $page['username'] ?? null, - 'display_name' => $page['name'], + 'platform_user_id' => data_get($page, 'id'), + 'username' => data_get($page, 'username', null), + 'display_name' => data_get($page, 'name'), 'avatar_url' => $avatarPath, - 'access_token' => $page['access_token'], + 'access_token' => data_get($page, 'access_token'), 'refresh_token' => null, 'token_expires_at' => null, 'scopes' => $this->scopes, 'meta' => [ - 'page_id' => $page['id'], + 'page_id' => data_get($page, 'id'), 'user_id' => $socialUser->getId(), 'user_token' => $socialUser->token, ], @@ -128,17 +131,17 @@ public function callback(Request $request): View|RedirectResponse // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, - 'platform_user_id' => $page['id'], - 'username' => $page['username'] ?? null, - 'display_name' => $page['name'], + 'platform_user_id' => data_get($page, 'id'), + 'username' => data_get($page, 'username', null), + 'display_name' => data_get($page, 'name'), 'avatar_url' => $avatarPath, - 'access_token' => $page['access_token'], + 'access_token' => data_get($page, 'access_token'), 'refresh_token' => null, // Page tokens don't expire if user token is long-lived 'token_expires_at' => null, 'scopes' => $this->scopes, 'status' => Status::Connected, 'meta' => [ - 'page_id' => $page['id'], + 'page_id' => data_get($page, 'id'), 'user_id' => $socialUser->getId(), 'user_token' => $socialUser->token, ], @@ -159,7 +162,7 @@ public function callback(Request $request): View|RedirectResponse ], ]); - return redirect()->route('social.facebook.select-page'); + return redirect()->route('app.social.facebook.select-page'); } catch (\Exception $e) { Log::error('Facebook OAuth Error', [ 'error' => $e->getMessage(), @@ -179,7 +182,7 @@ public function selectPage(Request $request) session()->flash('flash.banner', __('accounts.flash.session_expired')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('accounts'); + return redirect()->route('app.accounts'); } $workspace = Workspace::find($workspaceId); @@ -188,12 +191,16 @@ public function selectPage(Request $request) session()->flash('flash.banner', __('accounts.flash.workspace_not_found')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('accounts'); + return redirect()->route('app.accounts'); } + $pages = collect(data_get($oauthData, 'pages')) + ->map(fn ($page) => Arr::except($page, ['access_token'])) + ->toArray(); + return Inertia::render('accounts/FacebookPageSelect', [ 'workspace' => $workspace, - 'pages' => $oauthData['pages'], + 'pages' => $pages, ]); } @@ -217,14 +224,14 @@ public function select(Request $request): View } try { - $selectedPage = collect($oauthData['pages'])->firstWhere('id', $request->page_id); + $selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('id', $request->page_id); if (! $selectedPage) { return $this->popupCallback(false, 'Page not found.', $this->platform->value); } - $avatarPath = uploadFromUrl($selectedPage['picture']); - $reconnectId = $oauthData['reconnect_id'] ?? null; + $avatarPath = uploadFromUrl(data_get($selectedPage, 'picture')); + $reconnectId = data_get($oauthData, 'reconnect_id'); if ($reconnectId) { // Reconnect existing account @@ -232,18 +239,18 @@ public function select(Request $request): View if ($existingAccount) { $existingAccount->update([ - 'platform_user_id' => $selectedPage['id'], - 'username' => $selectedPage['username'] ?? null, - 'display_name' => $selectedPage['name'], + 'platform_user_id' => data_get($selectedPage, 'id'), + 'username' => data_get($selectedPage, 'username') ?? null, + 'display_name' => data_get($selectedPage, 'name'), 'avatar_url' => $avatarPath, - 'access_token' => $selectedPage['access_token'], + 'access_token' => data_get($selectedPage, 'access_token'), 'refresh_token' => null, 'token_expires_at' => null, 'scopes' => $this->scopes, 'meta' => [ - 'page_id' => $selectedPage['id'], - 'user_id' => $oauthData['user_id'], - 'user_token' => $oauthData['user_token'], + 'page_id' => data_get($selectedPage, 'id'), + 'user_id' => data_get($oauthData, 'user_id'), + 'user_token' => data_get($oauthData, 'user_token'), ], ]); $existingAccount->markAsConnected(); @@ -257,19 +264,19 @@ public function select(Request $request): View // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, - 'platform_user_id' => $selectedPage['id'], - 'username' => $selectedPage['username'] ?? null, - 'display_name' => $selectedPage['name'], + 'platform_user_id' => data_get($selectedPage, 'id'), + 'username' => data_get($selectedPage, 'username') ?? null, + 'display_name' => data_get($selectedPage, 'name'), 'avatar_url' => $avatarPath, - 'access_token' => $selectedPage['access_token'], + 'access_token' => data_get($selectedPage, 'access_token'), 'refresh_token' => null, 'token_expires_at' => null, 'scopes' => $this->scopes, 'status' => Status::Connected, 'meta' => [ - 'page_id' => $selectedPage['id'], - 'user_id' => $oauthData['user_id'], - 'user_token' => $oauthData['user_token'], + 'page_id' => data_get($selectedPage, 'id'), + 'user_id' => data_get($oauthData, 'user_id'), + 'user_token' => data_get($oauthData, 'user_token'), ], ]); @@ -304,12 +311,12 @@ private function fetchPages(string $userToken): array $data = $response->json(); - return collect($data['data'] ?? [])->map(fn ($page) => [ - 'id' => $page['id'], - 'name' => $page['name'], - 'username' => $page['username'] ?? null, - 'picture' => $page['picture']['data']['url'] ?? null, - 'access_token' => $page['access_token'], + return collect(data_get($data, 'data', []))->map(fn ($page) => [ + 'id' => data_get($page, 'id'), + 'name' => data_get($page, 'name'), + 'username' => data_get($page, 'username', null), + 'picture' => data_get($page, 'picture.data.url'), + 'access_token' => data_get($page, 'access_token'), ])->toArray(); } catch (\Exception $e) { Log::error('Facebook pages fetch error', [ diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index d26473ca..dd8ebb14 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index 89413d4b..93c3374c 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php index 6b151cea..d983cc47 100644 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ b/app/Http/Controllers/Auth/LinkedInPageController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -116,7 +118,7 @@ public function callback(Request $request): View|RedirectResponse ], ]); - return redirect()->route('social.linkedin-page.select-page'); + return redirect()->route('app.social.linkedin-page.select-page'); } catch (\Exception $e) { Log::error('LinkedIn Page OAuth Error', [ 'error' => $e->getMessage(), @@ -134,7 +136,7 @@ public function selectPage(Request $request): Response|RedirectResponse session()->flash('flash.banner', __('accounts.flash.session_expired')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('accounts'); + return redirect()->route('app.accounts'); } $workspace = Workspace::find($pendingData['workspace_id']); @@ -143,7 +145,7 @@ public function selectPage(Request $request): Response|RedirectResponse session()->flash('flash.banner', __('accounts.flash.workspace_not_found')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('accounts'); + return redirect()->route('app.accounts'); } return Inertia::render('accounts/LinkedInPageSelect', [ @@ -260,18 +262,16 @@ private function fetchOrganizations(string $accessToken): array $data = $response->json(); $organizations = []; - foreach ($data['elements'] ?? [] as $element) { - $org = $element['organization~'] ?? null; + foreach (data_get($data, 'elements', []) as $element) { + $org = data_get($element, 'organization~', null); if ($org) { $logoUrl = null; - if (isset($org['logoV2']['original~']['elements'][0]['identifiers'][0]['identifier'])) { - $logoUrl = $org['logoV2']['original~']['elements'][0]['identifiers'][0]['identifier']; - } + $logoUrl = data_get($org, 'logoV2.original~.elements.0.identifiers.0.identifier'); $organizations[] = [ - 'id' => $org['id'], - 'name' => $org['localizedName'] ?? 'Unknown', - 'vanity_name' => $org['vanityName'] ?? null, + 'id' => data_get($org, 'id'), + 'name' => data_get($org, 'localizedName', 'Unknown'), + 'vanity_name' => data_get($org, 'vanityName', null), 'logo' => $logoUrl, ]; } diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index 6613b2c8..c0bbbce6 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -54,7 +56,7 @@ public function authorizeInstance(Request $request): SymfonyResponse|RedirectRes $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -65,7 +67,7 @@ public function authorizeInstance(Request $request): SymfonyResponse|RedirectRes // Register app on the instance $appResponse = Http::post("{$instance}/api/v1/apps", [ 'client_name' => config('app.name'), - 'redirect_uris' => route('social.mastodon.callback'), + 'redirect_uris' => route('app.social.mastodon.callback'), 'scopes' => self::SCOPES, 'website' => config('app.url'), ]); @@ -96,7 +98,7 @@ public function authorizeInstance(Request $request): SymfonyResponse|RedirectRes $params = http_build_query([ 'client_id' => $app['client_id'], 'response_type' => 'code', - 'redirect_uri' => route('social.mastodon.callback'), + 'redirect_uri' => route('app.social.mastodon.callback'), 'scope' => self::SCOPES, 'state' => $state, ]); @@ -149,7 +151,7 @@ public function callback(Request $request): View 'grant_type' => 'authorization_code', 'client_id' => $clientId, 'client_secret' => $clientSecret, - 'redirect_uri' => route('social.mastodon.callback'), + 'redirect_uri' => route('app.social.mastodon.callback'), 'code' => $request->code, ]); @@ -189,13 +191,13 @@ public function callback(Request $request): View return $this->popupCallback(false, 'Mastodon is already connected.', $this->platform->value); } - $avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null; + $avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null; $accountData = [ 'platform' => $this->platform->value, - 'platform_user_id' => $profile['id'], - 'username' => $profile['acct'], - 'display_name' => $profile['display_name'] ?: $profile['username'], + 'platform_user_id' => data_get($profile, 'id'), + 'username' => data_get($profile, 'acct'), + 'display_name' => data_get($profile, 'display_name') ?: data_get($profile, 'username'), 'avatar_url' => $avatarPath, 'access_token' => $accessToken, 'refresh_token' => null, // Mastodon tokens don't expire diff --git a/app/Http/Controllers/Auth/PinterestController.php b/app/Http/Controllers/Auth/PinterestController.php index 4f335947..760d2701 100644 --- a/app/Http/Controllers/Auth/PinterestController.php +++ b/app/Http/Controllers/Auth/PinterestController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 85284ffb..54c95c14 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -4,26 +4,20 @@ namespace App\Http\Controllers\Auth; -use App\Enums\User\Setup; +use App\Actions\User\CreateUser; use App\Http\Controllers\Controller; -use App\Rules\Timezone; -use App\Models\Language; use App\Models\User; -use App\Models\Workspace; +use App\Rules\Timezone; use Illuminate\Auth\Events\Registered; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rules; use Inertia\Inertia; use Inertia\Response; class RegisteredUserController extends Controller { - /** - * Display the registration view. - */ public function create(Request $request): Response { return Inertia::render('auth/Register', [ @@ -32,9 +26,6 @@ public function create(Request $request): Response ]); } - /** - * Handle an incoming registration request. - */ public function store(Request $request): RedirectResponse { $request->validate([ @@ -44,46 +35,28 @@ public function store(Request $request): RedirectResponse 'timezone' => ['nullable', 'string', new Timezone], ]); - // Check if registering via invite link (redirect contains /invites/) $isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/'); - $user = DB::transaction(function () use ($request, $isInviteRegistration) { - $defaultLanguage = Language::where('code', 'en')->first(); - - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => $request->password, - 'setup' => $isInviteRegistration ? Setup::Completed : Setup::Role, - 'language_id' => $defaultLanguage?->id, - 'email_verified_at' => $isInviteRegistration ? now() : null, - ]); - - // Create default workspace for new user - $workspace = Workspace::create([ - 'user_id' => $user->id, - 'name' => $user->name."'s Workspace", - 'timezone' => $request->input('timezone', 'UTC'), - ]); - - // Add user as owner member - $workspace->members()->attach($user->id, ['role' => 'owner']); - - // Set as current workspace - $user->update(['current_workspace_id' => $workspace->id]); - - return $user; - }); + $user = CreateUser::execute([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => $request->password, + 'timezone' => $request->input('timezone', 'UTC'), + 'is_invite' => $isInviteRegistration, + ]); event(new Registered($user)); Auth::login($user); - // Check for redirect param if ($redirect = $request->input('redirect')) { - return redirect($redirect); + if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) { + return redirect($redirect); + } } - return redirect()->route('onboarding.step1'); + session()->flash('auth_provider', 'email'); + + return redirect()->route('register.success'); } } diff --git a/app/Http/Controllers/Auth/SignupSuccessController.php b/app/Http/Controllers/Auth/SignupSuccessController.php new file mode 100644 index 00000000..d4236586 --- /dev/null +++ b/app/Http/Controllers/Auth/SignupSuccessController.php @@ -0,0 +1,20 @@ + $request->session()->get('auth_provider', 'email'), + ]); + } +} diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index f65737e5..3d7e6fea 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -1,7 +1,10 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('view', $workspace); @@ -62,7 +65,7 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -79,12 +82,35 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe return back(); } + public function toggleActive(Request $request, SocialAccount $account): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + if ($account->workspace_id !== $workspace->id) { + abort(403); + } + + ToggleSocialAccount::execute($account); + + $status = $account->is_active ? 'activated' : 'deactivated'; + session()->flash('flash.banner', __("accounts.flash.{$status}")); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + protected function redirectToProvider(Request $request, string $driver, array $scopes): SymfonyResponse { $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } session(['social_connect_workspace' => $workspace->id]); @@ -177,7 +203,7 @@ protected function forgetSocialConnectSession(): void protected function getRedirectRoute(): string { - return session('social_connect_onboarding', false) ? 'onboarding.step2' : 'accounts'; + return session('social_connect_onboarding', false) ? 'onboarding.connect' : 'accounts'; } /** diff --git a/app/Http/Controllers/Auth/SocialLoginController.php b/app/Http/Controllers/Auth/SocialLoginController.php new file mode 100644 index 00000000..40c552d9 --- /dev/null +++ b/app/Http/Controllers/Auth/SocialLoginController.php @@ -0,0 +1,66 @@ +redirect(); + } + + public function callback(): RedirectResponse + { + try { + $googleUser = Socialite::driver('google-auth')->user(); + } catch (\Exception) { + return redirect()->route('login'); + } + + $user = User::where('email', $googleUser->getEmail())->first(); + + if ($user) { + return $this->loginExistingUser($user); + } + + return $this->registerNewUser($googleUser); + } + + private function loginExistingUser(User $user): RedirectResponse + { + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + + Auth::login($user, remember: true); + + return redirect()->route('app.home'); + } + + private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse + { + $user = CreateUser::execute([ + 'name' => $googleUser->getName(), + 'email' => $googleUser->getEmail(), + 'email_verified_at' => now(), + ]); + + event(new Registered($user)); + + Auth::login($user, remember: true); + + session()->flash('auth_provider', 'google'); + + return redirect()->route('register.success'); + } +} diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index d5a61b9c..20f63c06 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -151,17 +153,17 @@ public function callback(Request $request): View } $profile = $profileResponse->json(); - $avatarPath = uploadFromUrl($profile['threads_profile_picture_url'] ?? null); + $avatarPath = uploadFromUrl(data_get($profile, 'threads_profile_picture_url', null)); if ($existingAccount) { // Reconnect existing account $existingAccount->update([ - 'platform_user_id' => $profile['id'], - 'username' => $profile['username'], - 'display_name' => $profile['name'] ?? $profile['username'], + 'platform_user_id' => data_get($profile, 'id'), + 'username' => data_get($profile, 'username'), + 'display_name' => data_get($profile, 'name', data_get($profile, 'username')), 'avatar_url' => $avatarPath, 'access_token' => $longLivedToken, - 'refresh_token' => null, + 'refresh_token' => $longLivedToken, 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, 'scopes' => $this->scopes, ]); @@ -175,9 +177,9 @@ public function callback(Request $request): View // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, - 'platform_user_id' => $profile['id'], - 'username' => $profile['username'], - 'display_name' => $profile['name'] ?? $profile['username'], + 'platform_user_id' => data_get($profile, 'id'), + 'username' => data_get($profile, 'username'), + 'display_name' => data_get($profile, 'name', data_get($profile, 'username')), 'avatar_url' => $avatarPath, 'access_token' => $longLivedToken, 'refresh_token' => null, diff --git a/app/Http/Controllers/Auth/TikTokController.php b/app/Http/Controllers/Auth/TikTokController.php index 597a6cf5..3be174a7 100644 --- a/app/Http/Controllers/Auth/TikTokController.php +++ b/app/Http/Controllers/Auth/TikTokController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php index 567e31d6..aa17b64e 100644 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -17,13 +17,13 @@ class VerifyEmailController extends Controller public function __invoke(EmailVerificationRequest $request): RedirectResponse { if ($request->user()->hasVerifiedEmail()) { - return redirect()->intended(route('calendar').'?verified=1'); + return redirect()->intended(route('app.calendar').'?verified=1'); } if ($request->user()->markEmailAsVerified()) { event(new Verified($request->user())); } - return redirect()->intended(route('calendar').'?verified=1'); + return redirect()->intended(route('app.calendar').'?verified=1'); } } diff --git a/app/Http/Controllers/Auth/XController.php b/app/Http/Controllers/Auth/XController.php index 481dc998..f26d64e6 100644 --- a/app/Http/Controllers/Auth/XController.php +++ b/app/Http/Controllers/Auth/XController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index e5e07c74..6ec81df4 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -1,5 +1,7 @@ user()->currentWorkspace; if (! $workspace) { - return redirect()->route('workspaces.create'); + return redirect()->route('app.workspaces.create'); } $this->authorize('manageAccounts', $workspace); @@ -91,21 +95,21 @@ public function callback(Request $request): View|RedirectResponse // If only one channel, connect directly (most common case) if (count($channels) === 1) { $channel = $channels[0]; - $avatarPath = uploadFromUrl($channel['thumbnail']); + $avatarPath = uploadFromUrl(data_get($channel, 'thumbnail')); if ($existingAccount) { // Reconnect existing account $existingAccount->update([ - 'platform_user_id' => $channel['id'], - 'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'), - 'display_name' => $channel['title'], + 'platform_user_id' => data_get($channel, 'id'), + 'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'), + 'display_name' => data_get($channel, 'title'), 'avatar_url' => $avatarPath, 'access_token' => $socialUser->token, 'refresh_token' => $socialUser->refreshToken, 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, 'scopes' => $this->scopes, 'meta' => [ - 'channel_id' => $channel['id'], + 'channel_id' => data_get($channel, 'id'), 'google_user_id' => $socialUser->getId(), ], ]); @@ -119,9 +123,9 @@ public function callback(Request $request): View|RedirectResponse // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, - 'platform_user_id' => $channel['id'], - 'username' => ltrim($channel['custom_url'] ?? $channel['id'], '@'), - 'display_name' => $channel['title'], + 'platform_user_id' => data_get($channel, 'id'), + 'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'), + 'display_name' => data_get($channel, 'title'), 'avatar_url' => $avatarPath, 'access_token' => $socialUser->token, 'refresh_token' => $socialUser->refreshToken, @@ -129,7 +133,7 @@ public function callback(Request $request): View|RedirectResponse 'scopes' => $this->scopes, 'status' => Status::Connected, 'meta' => [ - 'channel_id' => $channel['id'], + 'channel_id' => data_get($channel, 'id'), 'google_user_id' => $socialUser->getId(), ], ]); @@ -150,7 +154,7 @@ public function callback(Request $request): View|RedirectResponse ], ]); - return redirect()->route('social.youtube.select-channel'); + return redirect()->route('app.social.youtube.select-channel'); } catch (\Exception $e) { Log::error('YouTube OAuth Error', [ 'error' => $e->getMessage(), @@ -170,7 +174,7 @@ public function selectChannel(Request $request) session()->flash('flash.banner', __('accounts.flash.session_expired')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('accounts'); + return redirect()->route('app.accounts'); } $workspace = Workspace::find($workspaceId); @@ -179,11 +183,11 @@ public function selectChannel(Request $request) session()->flash('flash.banner', __('accounts.flash.workspace_not_found')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('accounts'); + return redirect()->route('app.accounts'); } // Fetch YouTube channels - $channels = $this->fetchChannels($oauthData['access_token']); + $channels = $this->fetchChannels(data_get($oauthData, 'access_token')); if (empty($channels)) { $redirectRoute = $this->getRedirectRoute(); @@ -222,15 +226,15 @@ public function select(Request $request): View } try { - $channels = $this->fetchChannels($oauthData['access_token']); + $channels = $this->fetchChannels(data_get($oauthData, 'access_token')); $selectedChannel = collect($channels)->firstWhere('id', $request->channel_id); if (! $selectedChannel) { return $this->popupCallback(false, 'Channel not found.', $this->platform->value); } - $avatarPath = uploadFromUrl($selectedChannel['thumbnail']); - $reconnectId = $oauthData['reconnect_id'] ?? null; + $avatarPath = uploadFromUrl(data_get($selectedChannel, 'thumbnail')); + $reconnectId = data_get($oauthData, 'reconnect_id', null); if ($reconnectId) { // Reconnect existing account @@ -238,17 +242,17 @@ public function select(Request $request): View if ($existingAccount) { $existingAccount->update([ - 'platform_user_id' => $selectedChannel['id'], - 'username' => ltrim($selectedChannel['custom_url'] ?? $selectedChannel['id'], '@'), - 'display_name' => $selectedChannel['title'], + 'platform_user_id' => data_get($selectedChannel, 'id'), + 'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'), + 'display_name' => data_get($selectedChannel, 'title'), 'avatar_url' => $avatarPath, - 'access_token' => $oauthData['access_token'], - 'refresh_token' => $oauthData['refresh_token'], - 'token_expires_at' => $oauthData['expires_in'] ? now()->addSeconds($oauthData['expires_in']) : null, + 'access_token' => data_get($oauthData, 'access_token'), + 'refresh_token' => data_get($oauthData, 'refresh_token'), + 'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null, 'scopes' => $this->scopes, 'meta' => [ - 'channel_id' => $selectedChannel['id'], - 'google_user_id' => $oauthData['user_id'], + 'channel_id' => data_get($selectedChannel, 'id'), + 'google_user_id' => data_get($oauthData, 'user_id'), ], ]); $existingAccount->markAsConnected(); @@ -262,18 +266,18 @@ public function select(Request $request): View // Create new account $workspace->socialAccounts()->create([ 'platform' => $this->platform->value, - 'platform_user_id' => $selectedChannel['id'], - 'username' => ltrim($selectedChannel['custom_url'] ?? $selectedChannel['id'], '@'), - 'display_name' => $selectedChannel['title'], + 'platform_user_id' => data_get($selectedChannel, 'id'), + 'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'), + 'display_name' => data_get($selectedChannel, 'title'), 'avatar_url' => $avatarPath, - 'access_token' => $oauthData['access_token'], - 'refresh_token' => $oauthData['refresh_token'], - 'token_expires_at' => $oauthData['expires_in'] ? now()->addSeconds($oauthData['expires_in']) : null, + 'access_token' => data_get($oauthData, 'access_token'), + 'refresh_token' => data_get($oauthData, 'refresh_token'), + 'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null, 'scopes' => $this->scopes, 'status' => Status::Connected, 'meta' => [ - 'channel_id' => $selectedChannel['id'], - 'google_user_id' => $oauthData['user_id'], + 'channel_id' => data_get($selectedChannel, 'id'), + 'google_user_id' => data_get($oauthData, 'user_id'), ], ]); @@ -291,7 +295,7 @@ public function select(Request $request): View private function redirectToGoogle(): Response { - return \Inertia\Inertia::location( + return Inertia::location( Socialite::driver($this->driver) ->scopes($this->scopes) ->with([ @@ -307,7 +311,7 @@ private function redirectToGoogle(): Response private function fetchChannels(string $accessToken): array { try { - $response = \Illuminate\Support\Facades\Http::withToken($accessToken) + $response = Http::withToken($accessToken) ->get('https://www.googleapis.com/youtube/v3/channels', [ 'part' => 'snippet,contentDetails,statistics', 'mine' => 'true', @@ -324,13 +328,13 @@ private function fetchChannels(string $accessToken): array $data = $response->json(); - return collect($data['items'] ?? [])->map(fn ($channel) => [ - 'id' => $channel['id'], - 'title' => $channel['snippet']['title'], - 'description' => $channel['snippet']['description'] ?? '', - 'thumbnail' => $channel['snippet']['thumbnails']['default']['url'] ?? null, - 'custom_url' => $channel['snippet']['customUrl'] ?? null, - 'subscriber_count' => $channel['statistics']['subscriberCount'] ?? 0, + return collect(data_get($data, 'items', []))->map(fn ($channel) => [ + 'id' => data_get($channel, 'id'), + 'title' => data_get($channel, 'snippet.title'), + 'description' => data_get($channel, 'snippet.description', ''), + 'thumbnail' => data_get($channel, 'snippet.thumbnails.default.url'), + 'custom_url' => data_get($channel, 'snippet.customUrl'), + 'subscriber_count' => data_get($channel, 'statistics.subscriberCount', 0), ])->toArray(); } catch (\Exception $e) { Log::error('YouTube channels fetch error', [ diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index e7f7c94b..ded7c1ff 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -1,5 +1,7 @@ Persona::toSelectArray(), - ]); - } - - /** - * Store step 1 and proceed to step 2. - */ - public function storeStep1(Request $request): RedirectResponse - { - $validated = $request->validate([ - 'persona' => ['required', Rule::enum(Persona::class)], - ]); - - $request->user()->update([ - 'persona' => $validated['persona'], - 'setup' => Setup::Connections, - ]); - - return redirect()->route('onboarding.step2'); - } - - /** - * Step 2: Connect social accounts. - */ - public function step2(Request $request): Response - { - $user = $request->user(); - $workspace = $user->currentWorkspace; - - $platforms = collect(); - - if ($workspace) { - $connectedAccounts = $workspace->socialAccounts; - - $platforms = collect(SocialPlatform::enabled())->map(function ($platform) use ($connectedAccounts) { - $connected = $connectedAccounts->firstWhere('platform', $platform); - - return [ - 'value' => $platform->value, - 'label' => $platform->label(), - 'color' => $platform->color(), - 'connected' => $connected !== null, - 'account' => $connected, - ]; - })->values(); - } - - return Inertia::render('onboarding/Step2', [ - 'platforms' => $platforms, - 'hasWorkspace' => $workspace !== null, - ]); - } - - /** - * Store step 2 and redirect to Stripe checkout (or complete if self-hosted). - */ - public function storeStep2(Request $request): SymfonyResponse|RedirectResponse - { - $user = $request->user(); - - // Skip payment for self-hosted mode - if (config('trypost.self_hosted')) { - $user->update([ - 'setup' => Setup::Completed, - ]); - - session()->flash('flash.banner', __('auth.flash.welcome')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('calendar'); - } - - $user->update([ - 'setup' => Setup::Subscription, - ]); - - // Redirect to Stripe checkout - $subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id')) - ->allowPromotionCodes() - ->trialDays(config('cashier.trial_days')) - ->quantity(1); - - $checkoutSession = $subscription->checkout([ - 'success_url' => route('onboarding.complete').'?session_id={CHECKOUT_SESSION_ID}', - 'cancel_url' => route('onboarding.step2'), - ]); - - return Inertia::location($checkoutSession->url); - } - - /** - * Complete onboarding after successful Stripe checkout. - */ - public function complete(Request $request): RedirectResponse - { - $user = $request->user(); - - $user->update([ - 'setup' => Setup::Completed, - ]); - - session()->flash('flash.banner', __('auth.flash.welcome_trial')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('calendar'); - } -} diff --git a/app/Http/Controllers/Settings/PasswordController.php b/app/Http/Controllers/Settings/PasswordController.php deleted file mode 100644 index 5f96fee0..00000000 --- a/app/Http/Controllers/Settings/PasswordController.php +++ /dev/null @@ -1,35 +0,0 @@ -user()->update([ - 'password' => $request->password, - ]); - - session()->flash('flash.banner', __('settings.flash.password_updated')); - session()->flash('flash.bannerStyle', 'success'); - - return back(); - } -} diff --git a/app/Http/Middleware/Api/AuthenticateApiToken.php b/app/Http/Middleware/Api/AuthenticateApiToken.php new file mode 100644 index 00000000..0f5f71a4 --- /dev/null +++ b/app/Http/Middleware/Api/AuthenticateApiToken.php @@ -0,0 +1,66 @@ +bearerToken(); + + if (! $token) { + return response()->json(['message' => 'Missing API key.'], Response::HTTP_UNAUTHORIZED); + } + + if (! str_starts_with($token, 'tp_') || strlen($token) !== 51) { + return response()->json(['message' => 'Invalid API key.'], Response::HTTP_UNAUTHORIZED); + } + + $lookup = substr($token, 3, 16); + $apiToken = ApiToken::where('token_lookup', $lookup)->first(); + + if (! $apiToken || ! Hash::check($token, $apiToken->token_hash)) { + return response()->json(['message' => 'Invalid API key.'], Response::HTTP_UNAUTHORIZED); + } + + if ($apiToken->status === 'expired') { + return response()->json(['message' => 'API key has expired.'], Response::HTTP_UNAUTHORIZED); + } + + $apiToken->update(['last_used_at' => now()]); + + $workspace = $apiToken->workspace; + + if (! config('trypost.self_hosted') && ! $this->hasActiveSubscription($workspace)) { + return response()->json(['message' => 'Active subscription required.'], Response::HTTP_PAYMENT_REQUIRED); + } + + $request->merge([ + 'workspace' => $workspace, + 'api_token' => $apiToken, + ]); + + return $next($request); + } + + private function hasActiveSubscription(Workspace $workspace): bool + { + $owner = $workspace->owner; + + if (! $owner) { + return false; + } + + return $owner->subscribed(User::SUBSCRIPTION_NAME) || $owner->onTrial(User::SUBSCRIPTION_NAME); + } +} diff --git a/app/Http/Middleware/EnsureSubscribed.php b/app/Http/Middleware/App/EnsureSubscribed.php similarity index 72% rename from app/Http/Middleware/EnsureSubscribed.php rename to app/Http/Middleware/App/EnsureSubscribed.php index 8558d87d..cbe1817f 100644 --- a/app/Http/Middleware/EnsureSubscribed.php +++ b/app/Http/Middleware/App/EnsureSubscribed.php @@ -1,7 +1,10 @@ subscribed('default') || $user->onTrial('default')) { + if ($user->subscribed(User::SUBSCRIPTION_NAME) || $user->onTrial(User::SUBSCRIPTION_NAME)) { return $next($request); } @@ -37,12 +40,12 @@ public function handle(Request $request, Closure $next): Response if ($currentWorkspace && $currentWorkspace->owner && $currentWorkspace->owner->id !== $user->id) { $owner = $currentWorkspace->owner; - if ($owner->subscribed('default') || $owner->onTrial('default')) { + if ($owner->subscribed(User::SUBSCRIPTION_NAME) || $owner->onTrial(User::SUBSCRIPTION_NAME)) { return $next($request); } } // Redirect to subscription page - return redirect()->route('subscribe'); + return redirect()->route('app.subscribe'); } } diff --git a/app/Http/Middleware/EnsureUserSetupIsComplete.php b/app/Http/Middleware/App/EnsureUserSetupIsComplete.php similarity index 57% rename from app/Http/Middleware/EnsureUserSetupIsComplete.php rename to app/Http/Middleware/App/EnsureUserSetupIsComplete.php index 17ba7e3b..d716848b 100644 --- a/app/Http/Middleware/EnsureUserSetupIsComplete.php +++ b/app/Http/Middleware/App/EnsureUserSetupIsComplete.php @@ -1,6 +1,8 @@ setup) { - Setup::Role => ['onboarding.step1', 'onboarding.step1.store'], - Setup::Connections => ['onboarding.step2', 'onboarding.step2.store', 'social.*'], - Setup::Subscription => ['onboarding.complete', 'onboarding.step2'], - default => ['onboarding.step1', 'onboarding.step1.store'], + Setup::Role => ['app.onboarding.role', 'app.onboarding.role.store'], + Setup::Connections => ['app.onboarding.connect', 'app.onboarding.connect.store', 'app.social.*'], + Setup::Subscription => ['app.subscribe', 'app.billing.*', 'app.onboarding.complete'], + default => ['app.onboarding.role', 'app.onboarding.role.store'], }; $currentRoute = $request->route()?->getName(); @@ -46,10 +48,10 @@ public function handle(Request $request, Closure $next): Response // Redirect to appropriate step return match ($user->setup) { - Setup::Role => redirect()->route('onboarding.step1'), - Setup::Connections => redirect()->route('onboarding.step2'), - Setup::Subscription => redirect()->route('onboarding.step2'), - default => redirect()->route('onboarding.step1'), + Setup::Role => redirect()->route('app.onboarding.role'), + Setup::Connections => redirect()->route('app.onboarding.connect'), + Setup::Subscription => redirect()->route('app.subscribe'), + default => redirect()->route('app.onboarding.role'), }; } } diff --git a/app/Http/Middleware/HandleAppearance.php b/app/Http/Middleware/App/HandleAppearance.php similarity index 75% rename from app/Http/Middleware/HandleAppearance.php rename to app/Http/Middleware/App/HandleAppearance.php index f1a02bbc..5e7d6a3c 100644 --- a/app/Http/Middleware/HandleAppearance.php +++ b/app/Http/Middleware/App/HandleAppearance.php @@ -1,6 +1,8 @@ + */ + public function share(Request $request): array + { + $user = $request->user(); + + $currentWorkspace = $user?->currentWorkspace?->load('media'); + + return [ + ...parent::share($request), + 'name' => config('app.name'), + 'auth' => [ + 'user' => $user ? AuthUserResource::make($user) : null, + 'currentWorkspace' => $currentWorkspace ? AuthWorkspaceResource::make($currentWorkspace, $user) : null, + 'workspaces' => $user + ? $user->workspaces()->with('media')->get()->map(fn ($ws) => AuthWorkspaceResource::summary($ws)) + : [], + ], + 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'flash' => $request->session()->get('flash', []), + 'applicationUrl' => config('app.url'), + 'env' => config('app.env'), + 'locale' => app()->getLocale(), + 'languages' => collect(config('languages.available'))->map(fn ($name, $code) => [ + 'code' => $code, + 'name' => $name, + ])->values()->all(), + 'selfHosted' => config('trypost.self_hosted'), + ]; + } +} diff --git a/app/Http/Middleware/App/SetLocale.php b/app/Http/Middleware/App/SetLocale.php new file mode 100644 index 00000000..fecf0fe0 --- /dev/null +++ b/app/Http/Middleware/App/SetLocale.php @@ -0,0 +1,35 @@ +cookie('locale'); + $isValid = $locale && array_key_exists($locale, $available); + + App::setLocale($isValid ? $locale : config('languages.default')); + + $response = $next($request); + + if (! $isValid) { + $response->withCookie( + cookie()->forever('locale', config('languages.default'), '/', config('session.domain')), + ); + } + + return $response; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php deleted file mode 100644 index 15fd2b14..00000000 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ /dev/null @@ -1,74 +0,0 @@ - - */ - public function share(Request $request): array - { - $user = $request->user(); - - if ($user) { - App::setLocale($user->language->code); - } - - $currentWorkspace = $user?->currentWorkspace; - $currentRole = null; - - if ($user && $currentWorkspace) { - $currentRole = $user->workspaces() - ->where('workspaces.id', $currentWorkspace->id) - ->first() - ?->pivot - ?->role; - } - - return [ - ...parent::share($request), - 'name' => config('app.name'), - 'auth' => [ - 'user' => $user, - 'role' => $currentRole, - ], - 'currentWorkspace' => $currentWorkspace, - 'workspaces' => $user ? $user->workspaces()->select('workspaces.id', 'workspaces.name')->get() : [], - 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', - 'flash' => $request->session()->get('flash', []), - 'env' => config('app.env'), - 'locale' => app()->getLocale(), - 'selfHosted' => config('trypost.self_hosted'), - 'languages' => Language::all(), - ]; - } -} diff --git a/app/Http/Middleware/Mcp/AuthenticateMcpToken.php b/app/Http/Middleware/Mcp/AuthenticateMcpToken.php new file mode 100644 index 00000000..1dee934d --- /dev/null +++ b/app/Http/Middleware/Mcp/AuthenticateMcpToken.php @@ -0,0 +1,70 @@ +bearerToken(); + + if (! $token) { + return response()->json(['message' => 'Missing API key.'], Response::HTTP_UNAUTHORIZED); + } + + if (! str_starts_with($token, 'tp_') || strlen($token) !== 51) { + return response()->json(['message' => 'Invalid API key.'], Response::HTTP_UNAUTHORIZED); + } + + $lookup = substr($token, 3, 16); + $apiToken = ApiToken::where('token_lookup', $lookup)->first(); + + if (! $apiToken || ! Hash::check($token, $apiToken->token_hash)) { + return response()->json(['message' => 'Invalid API key.'], Response::HTTP_UNAUTHORIZED); + } + + if ($apiToken->status === 'expired') { + return response()->json(['message' => 'API key has expired.'], Response::HTTP_UNAUTHORIZED); + } + + $apiToken->update(['last_used_at' => now()]); + + $workspace = $apiToken->workspace; + $user = $workspace->owner; + + if (! $user) { + return response()->json(['message' => 'No workspace owner found.'], Response::HTTP_UNAUTHORIZED); + } + + if (! config('trypost.self_hosted') && ! $this->hasActiveSubscription($workspace)) { + return response()->json(['message' => 'Active subscription required.'], Response::HTTP_PAYMENT_REQUIRED); + } + + $user->current_workspace_id = $workspace->id; + Auth::setUser($user); + + return $next($request); + } + + private function hasActiveSubscription(Workspace $workspace): bool + { + $owner = $workspace->owner; + + if (! $owner) { + return false; + } + + return $owner->subscribed(User::SUBSCRIPTION_NAME) || $owner->onTrial(User::SUBSCRIPTION_NAME); + } +} diff --git a/app/Http/Requests/Api/ApiKey/StoreApiKeyRequest.php b/app/Http/Requests/Api/ApiKey/StoreApiKeyRequest.php new file mode 100644 index 00000000..404a552e --- /dev/null +++ b/app/Http/Requests/Api/ApiKey/StoreApiKeyRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'expires_at' => ['nullable', 'date', 'after:today'], + ]; + } +} diff --git a/app/Http/Requests/Api/Hashtag/StoreHashtagRequest.php b/app/Http/Requests/Api/Hashtag/StoreHashtagRequest.php new file mode 100644 index 00000000..7e3ac266 --- /dev/null +++ b/app/Http/Requests/Api/Hashtag/StoreHashtagRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'hashtags' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Api/Hashtag/UpdateHashtagRequest.php b/app/Http/Requests/Api/Hashtag/UpdateHashtagRequest.php new file mode 100644 index 00000000..15b48407 --- /dev/null +++ b/app/Http/Requests/Api/Hashtag/UpdateHashtagRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'hashtags' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Api/Label/StoreLabelRequest.php b/app/Http/Requests/Api/Label/StoreLabelRequest.php new file mode 100644 index 00000000..eb30d954 --- /dev/null +++ b/app/Http/Requests/Api/Label/StoreLabelRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]; + } +} diff --git a/app/Http/Requests/Api/Label/UpdateLabelRequest.php b/app/Http/Requests/Api/Label/UpdateLabelRequest.php new file mode 100644 index 00000000..9cc9532e --- /dev/null +++ b/app/Http/Requests/Api/Label/UpdateLabelRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]; + } +} diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php new file mode 100644 index 00000000..4db0aa00 --- /dev/null +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -0,0 +1,30 @@ + ['required', 'array', 'min:1'], + 'platforms.*.social_account_id' => ['required', 'uuid', Rule::exists('social_accounts', 'id')->where('workspace_id', $this->workspace->id)], + 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], + 'platforms.*.content' => ['nullable', 'string', 'max:63206'], + 'scheduled_at' => ['nullable', 'date', 'after:now'], + 'status' => ['nullable', 'string', Rule::in(array_column(Status::cases(), 'value'))], + ]; + } +} diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php new file mode 100644 index 00000000..65db9c2d --- /dev/null +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -0,0 +1,42 @@ + ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])], + 'synced' => ['required', 'boolean'], + 'platforms' => ['required', 'array'], + 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))], + 'platforms.*.content' => ['nullable', 'string', 'max:63206'], + 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], + 'platforms.*.meta' => ['nullable', 'array'], + 'scheduled_at' => [ + 'nullable', + 'date', + Rule::when( + $this->input('status') === Status::Scheduled->value, + ['after:now'] + ), + ], + 'label_ids' => ['sometimes', 'array'], + 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->workspace->id)], + ]; + } +} diff --git a/app/Http/Requests/App/ApiKey/StoreApiKeyRequest.php b/app/Http/Requests/App/ApiKey/StoreApiKeyRequest.php new file mode 100644 index 00000000..b4f881ca --- /dev/null +++ b/app/Http/Requests/App/ApiKey/StoreApiKeyRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'expires_at' => ['nullable', 'date', 'after:today'], + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/App/Auth/LoginRequest.php similarity index 89% rename from app/Http/Requests/Auth/LoginRequest.php rename to app/Http/Requests/App/Auth/LoginRequest.php index 7befcea5..e240dfc8 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/App/Auth/LoginRequest.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace App\Http\Requests\Auth; +namespace App\Http\Requests\App\Auth; use Illuminate\Auth\Events\Lockout; +use Illuminate\Contracts\Validation\Rule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; @@ -24,7 +25,7 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ public function rules(): array { @@ -37,7 +38,7 @@ public function rules(): array /** * Attempt to authenticate the request's credentials. * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function authenticate(): void { @@ -57,7 +58,7 @@ public function authenticate(): void /** * Ensure the login request is not rate limited. * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function ensureIsNotRateLimited(): void { diff --git a/app/Http/Requests/App/Hashtag/StoreHashtagRequest.php b/app/Http/Requests/App/Hashtag/StoreHashtagRequest.php new file mode 100644 index 00000000..860be3bd --- /dev/null +++ b/app/Http/Requests/App/Hashtag/StoreHashtagRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'hashtags' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StoreWorkspaceInviteRequest.php b/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php similarity index 57% rename from app/Http/Requests/StoreWorkspaceInviteRequest.php rename to app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php index 73d549aa..6b57e4e6 100644 --- a/app/Http/Requests/StoreWorkspaceInviteRequest.php +++ b/app/Http/Requests/App/Invite/StoreWorkspaceInviteRequest.php @@ -1,8 +1,11 @@ |string> + * @return array|string> */ public function rules(): array { return [ 'email' => ['required', 'email', 'max:255'], - 'role' => ['nullable', Rule::enum(WorkspaceRole::class)], + 'role' => ['nullable', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])], ]; } @@ -37,9 +40,9 @@ public function rules(): array public function messages(): array { return [ - 'email.required' => 'O email é obrigatório.', - 'email.email' => 'Digite um email válido.', - 'email.max' => 'O email deve ter no máximo 255 caracteres.', + 'email.required' => __('validation.required', ['attribute' => 'email']), + 'email.email' => __('validation.email', ['attribute' => 'email']), + 'email.max' => __('validation.max.string', ['attribute' => 'email', 'max' => 255]), ]; } } diff --git a/app/Http/Requests/App/Label/StoreLabelRequest.php b/app/Http/Requests/App/Label/StoreLabelRequest.php new file mode 100644 index 00000000..8f8d9d13 --- /dev/null +++ b/app/Http/Requests/App/Label/StoreLabelRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]; + } +} diff --git a/app/Http/Requests/App/Media/DuplicateMediaRequest.php b/app/Http/Requests/App/Media/DuplicateMediaRequest.php new file mode 100644 index 00000000..58c40c26 --- /dev/null +++ b/app/Http/Requests/App/Media/DuplicateMediaRequest.php @@ -0,0 +1,25 @@ + ['required', 'array', 'max:50'], + 'targets.*.model' => ['required', 'string', Rule::in(['postPlatform', 'workspace', 'user'])], + 'targets.*.model_id' => ['required', 'uuid'], + ]; + } +} diff --git a/app/Http/Requests/StoreChunkedMediaRequest.php b/app/Http/Requests/App/Media/StoreChunkedMediaRequest.php similarity index 67% rename from app/Http/Requests/StoreChunkedMediaRequest.php rename to app/Http/Requests/App/Media/StoreChunkedMediaRequest.php index 3c4f0f2d..57d6deee 100644 --- a/app/Http/Requests/StoreChunkedMediaRequest.php +++ b/app/Http/Requests/App/Media/StoreChunkedMediaRequest.php @@ -1,6 +1,8 @@ ['required', 'string', 'regex:'.self::CONTENT_RANGE_PATTERN], 'model' => ['required', 'string', Rule::in($this->allowedModels)], - 'model_id' => ['required', 'string'], + 'model_id' => ['required', 'uuid'], 'collection' => ['sometimes', 'string', 'max:255'], - 'file_name' => ['required', 'string', 'max:255'], + 'file_name' => [ + 'required', + 'string', + 'max:255', + 'regex:/\.(jpe?g|png|gif|webp|mp4)$/i', + ], + ]; + } + + public function messages(): array + { + return [ + 'file_name.regex' => 'File type not supported. Allowed: JPEG, PNG, GIF, WebP, MP4.', + ]; + } + + public function after(): array + { + return [ + function ($validator) { + if ($validator->errors()->has('content_range')) { + return; + } + + if ($this->totalSize() > 1073741824) { // 1GB + $validator->errors()->add('content_range', 'File size exceeds the maximum allowed (1GB).'); + } + }, ]; } @@ -74,7 +103,7 @@ public function progress(): int public function chunkIdentifier(): string { - return md5($this->input('file_name').$this->totalSize()); + return md5($this->user()->id.$this->input('file_name').$this->totalSize()); } /** diff --git a/app/Http/Requests/StoreMediaRequest.php b/app/Http/Requests/App/Media/StoreMediaRequest.php similarity index 71% rename from app/Http/Requests/StoreMediaRequest.php rename to app/Http/Requests/App/Media/StoreMediaRequest.php index dba91163..67de64bf 100644 --- a/app/Http/Requests/StoreMediaRequest.php +++ b/app/Http/Requests/App/Media/StoreMediaRequest.php @@ -1,6 +1,8 @@ isVideoUpload() ? 1048576 : 10240; // Videos: 1GB, Images: 10MB + return [ 'media' => [ 'required', 'file', - 'max:512000', // 500MB - 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm,application/pdf', + "max:{$maxSize}", + 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4', ], 'model' => [ 'required', @@ -34,7 +38,7 @@ public function rules(): array ], 'model_id' => [ 'required', - 'string', + 'uuid', ], 'collection' => [ 'sometimes', @@ -48,11 +52,16 @@ public function messages(): array { return [ 'media.required' => 'The file is required.', - 'media.max' => 'The file must be at most 500MB.', + 'media.max' => $this->isVideoUpload() ? 'Videos must be at most 1GB.' : 'Images must be at most 10MB.', 'media.mimetypes' => 'File type not supported.', 'model.required' => 'The model is required.', 'model.in' => 'Invalid model type.', 'model_id.required' => 'The model ID is required.', ]; } + + private function isVideoUpload(): bool + { + return str_starts_with($this->file('media')?->getMimeType() ?? '', 'video/'); + } } diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php new file mode 100644 index 00000000..b440cb40 --- /dev/null +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -0,0 +1,55 @@ + ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])], + 'synced' => ['required', 'boolean'], + 'scheduled_at' => [ + 'sometimes', + 'nullable', + 'date', + Rule::when( + $this->input('status') === Status::Scheduled->value, + ['after:now'] + ), + ], + 'platforms' => ['required', 'array'], + 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))], + 'platforms.*.content' => ['nullable', 'string', 'max:63206'], + 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], + 'platforms.*.meta' => ['nullable', 'array'], + 'label_ids' => ['sometimes', 'array'], + 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], + ]; + } + + public function messages(): array + { + return [ + 'status.required' => 'The post status is required.', + 'status.in' => 'Invalid post status.', + 'synced.required' => 'The synced field is required.', + 'platforms.required' => 'At least one platform is required.', + 'platforms.*.content_type.required' => 'The content type is required for each platform.', + 'platforms.*.content_type.in' => 'Invalid content type.', + 'scheduled_at.after' => 'The scheduled date must be in the future.', + ]; + } +} diff --git a/app/Http/Requests/Settings/PasswordUpdateRequest.php b/app/Http/Requests/App/Settings/PasswordUpdateRequest.php similarity index 89% rename from app/Http/Requests/Settings/PasswordUpdateRequest.php rename to app/Http/Requests/App/Settings/PasswordUpdateRequest.php index 0e5ff2f4..960ecf97 100644 --- a/app/Http/Requests/Settings/PasswordUpdateRequest.php +++ b/app/Http/Requests/App/Settings/PasswordUpdateRequest.php @@ -1,6 +1,8 @@ ['sometimes', 'string'], - 'scheduled_at' => ['sometimes', 'nullable', 'string'], - 'platforms' => ['sometimes', 'array'], - 'platforms.*.id' => ['required', 'uuid', 'exists:post_platforms,id'], - 'platforms.*.content' => ['nullable', 'string', 'max:5000'], - 'label_ids' => ['sometimes', 'array'], - 'label_ids.*' => ['uuid', 'exists:workspace_labels,id'], - ]; - } - - public function messages(): array - { - return [ - 'scheduled_at.after' => 'The scheduled date must be in the future.', - ]; - } -} diff --git a/app/Http/Resources/Api/ApiKeyResource.php b/app/Http/Resources/Api/ApiKeyResource.php new file mode 100644 index 00000000..e574c2a9 --- /dev/null +++ b/app/Http/Resources/Api/ApiKeyResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'key_hint' => $this->key_hint, + 'status' => $this->status, + 'last_used_at' => $this->last_used_at?->format('Y-m-d H:i:s'), + 'expires_at' => $this->expires_at?->format('Y-m-d H:i:s'), + 'created_at' => $this->created_at->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/app/Http/Resources/Api/HashtagResource.php b/app/Http/Resources/Api/HashtagResource.php new file mode 100644 index 00000000..85ade0c5 --- /dev/null +++ b/app/Http/Resources/Api/HashtagResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'hashtags' => $this->hashtags, + 'created_at' => $this->created_at->format('Y-m-d H:i:s'), + 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/app/Http/Resources/Api/LabelResource.php b/app/Http/Resources/Api/LabelResource.php new file mode 100644 index 00000000..b35a6eb5 --- /dev/null +++ b/app/Http/Resources/Api/LabelResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'color' => $this->color, + 'created_at' => $this->created_at->format('Y-m-d H:i:s'), + 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/app/Http/Resources/Api/PostPlatformResource.php b/app/Http/Resources/Api/PostPlatformResource.php new file mode 100644 index 00000000..b8939cab --- /dev/null +++ b/app/Http/Resources/Api/PostPlatformResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'platform' => $this->platform, + 'content' => $this->content, + 'content_type' => $this->content_type, + 'status' => $this->status, + 'enabled' => $this->enabled, + 'social_account' => new SocialAccountResource($this->whenLoaded('socialAccount')), + ]; + } +} diff --git a/app/Http/Resources/Api/PostResource.php b/app/Http/Resources/Api/PostResource.php new file mode 100644 index 00000000..34063b40 --- /dev/null +++ b/app/Http/Resources/Api/PostResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'status' => $this->status, + 'synced' => $this->synced, + 'scheduled_at' => $this->scheduled_at?->format('Y-m-d H:i:s'), + 'published_at' => $this->published_at?->format('Y-m-d H:i:s'), + 'platforms' => PostPlatformResource::collection($this->whenLoaded('postPlatforms')), + 'labels' => LabelResource::collection($this->whenLoaded('labels')), + 'created_at' => $this->created_at->format('Y-m-d H:i:s'), + 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/app/Http/Resources/Api/SocialAccountResource.php b/app/Http/Resources/Api/SocialAccountResource.php new file mode 100644 index 00000000..c3d32dcb --- /dev/null +++ b/app/Http/Resources/Api/SocialAccountResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'platform' => $this->platform, + 'display_name' => $this->display_name, + 'username' => $this->username, + 'is_active' => $this->is_active, + 'status' => $this->status, + ]; + } +} diff --git a/app/Http/Resources/Api/WorkspaceResource.php b/app/Http/Resources/Api/WorkspaceResource.php new file mode 100644 index 00000000..b8a26f9f --- /dev/null +++ b/app/Http/Resources/Api/WorkspaceResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'timezone' => $this->timezone, + 'created_at' => $this->created_at->format('Y-m-d H:i:s'), + 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/app/Http/Resources/App/HandleInertiaRequests/AuthUserResource.php b/app/Http/Resources/App/HandleInertiaRequests/AuthUserResource.php new file mode 100644 index 00000000..ca20b7d9 --- /dev/null +++ b/app/Http/Resources/App/HandleInertiaRequests/AuthUserResource.php @@ -0,0 +1,28 @@ + + */ + public static function make(User $user): array + { + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'has_photo' => $user->has_photo, + 'photo_url' => $user->photo_url, + 'email_verified_at' => $user->email_verified_at?->toIso8601String(), + 'current_workspace_id' => $user->current_workspace_id, + 'created_at' => $user->created_at->toIso8601String(), + 'updated_at' => $user->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/App/HandleInertiaRequests/AuthWorkspaceResource.php b/app/Http/Resources/App/HandleInertiaRequests/AuthWorkspaceResource.php new file mode 100644 index 00000000..f18fc527 --- /dev/null +++ b/app/Http/Resources/App/HandleInertiaRequests/AuthWorkspaceResource.php @@ -0,0 +1,47 @@ + + */ + public static function make(Workspace $workspace, ?User $user = null): array + { + return [ + 'id' => $workspace->id, + 'name' => $workspace->name, + 'timezone' => $workspace->timezone, + 'logo_url' => $workspace->logo_url, + 'created_at' => $workspace->created_at->toIso8601String(), + 'role' => $user ? self::resolveRole($workspace, $user) : null, + ]; + } + + /** + * @return array + */ + public static function summary(Workspace $workspace): array + { + return [ + 'id' => $workspace->id, + 'name' => $workspace->name, + 'logo_url' => $workspace->logo_url, + ]; + } + + private static function resolveRole(Workspace $workspace, User $user): ?string + { + return $workspace->members() + ->where('users.id', $user->id) + ->first() + ?->pivot + ?->role; + } +} diff --git a/app/Jobs/PublishPost.php b/app/Jobs/PublishPost.php index 8bb4e6c4..792a5368 100644 --- a/app/Jobs/PublishPost.php +++ b/app/Jobs/PublishPost.php @@ -1,15 +1,20 @@ $this->post->id, + 'error' => $exception?->getMessage(), + ]); + + $this->post->markAsFailed(); + } } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 5834671e..69ee3f47 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -1,12 +1,23 @@ onQueue($postPlatform->platform->queue()); + } public function handle(): void { - if ($this->postPlatform->socialAccount->isDisconnected()) { + // Idempotency: skip if already published (prevents duplicate posts on retry) + $this->postPlatform->refresh(); + + if ($this->postPlatform->status === PostPlatformStatus::Published) { + return; + } + + if (! $this->postPlatform->socialAccount->is_active) { + $this->postPlatform->markAsFailed(__('posts.errors.account_inactive')); + $this->updatePostStatus(); + $this->broadcastStatus(); + + return; + } + + if ($this->postPlatform->socialAccount->status === Status::Disconnected) { $this->postPlatform->markAsFailed(__('posts.errors.account_disconnected')); $this->updatePostStatus(); $this->broadcastStatus(); @@ -41,32 +70,89 @@ public function handle(): void return; } + $requiredScopes = $this->postPlatform->platform->requiredPublishScopes(); + $accountScopes = $this->postPlatform->socialAccount->scopes ?? []; + + if (! empty($requiredScopes)) { + $missingScopes = array_diff($requiredScopes, $accountScopes); + + if (! empty($missingScopes)) { + $this->postPlatform->markAsFailed( + 'Missing permissions: '.implode(', ', $missingScopes).'. Please reconnect your account.', + ['category' => 'permission', 'missing_scopes' => $missingScopes, 'failed_at' => now()->toIso8601String()] + ); + $this->updatePostStatus(); + $this->broadcastStatus(); + + return; + } + } + $this->postPlatform->markAsPublishing(); $this->broadcastStatus(); - try { - $publisher = $this->getPublisher(); - $result = $publisher->publish($this->postPlatform); + $maxAttempts = 2; // Original attempt + 1 retry after token refresh - $this->postPlatform->markAsPublished($result['id'], $result['url'] ?? null); - } catch (TokenExpiredException $e) { - Log::error('Token expired while publishing to social platform', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'error' => $e->getMessage(), - 'platform_error_code' => $e->platformErrorCode, - ]); + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + $publisher = $this->getPublisher(); + $result = $publisher->publish($this->postPlatform); + $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + break; + } catch (TokenExpiredException $e) { + if ($attempt < $maxAttempts) { + try { + $this->refreshAccountToken(); - $this->postPlatform->markAsFailed($e->getMessage()); - $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); - } catch (\Throwable $e) { - Log::error('Failed to publish to social platform', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'error' => $e->getMessage(), - ]); + continue; + } catch (\Throwable $refreshError) { + Log::error('Token refresh failed during publish retry', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $refreshError->getMessage(), + ]); + } + } - $this->postPlatform->markAsFailed($e->getMessage()); + // All attempts exhausted or refresh failed + Log::error('Token expired while publishing to social platform', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + 'platform_error_code' => $e->platformErrorCode, + ]); + + $this->postPlatform->markAsFailed($e->getMessage(), [ + 'category' => 'token_expired', + 'platform_error_code' => $e->platformErrorCode, + 'failed_at' => now()->toIso8601String(), + ]); + $this->postPlatform->socialAccount->markAsTokenExpired($e->getMessage()); + break; + } catch (SocialPublishException $e) { + Log::error('Social publish failed: '.$e->userMessage); + $this->postPlatform->markAsFailed($e->userMessage, [ + 'category' => $e->category->value, + 'platform_error_code' => $e->platformErrorCode, + 'failed_at' => now()->toIso8601String(), + 'content_length' => mb_strlen($this->postPlatform->content ?? ''), + 'media_count' => $this->postPlatform->media->count(), + ]); + break; + } catch (\Throwable $e) { + Log::error('Failed to publish to social platform', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + ]); + $this->postPlatform->markAsFailed($e->getMessage(), [ + 'category' => 'unknown', + 'failed_at' => now()->toIso8601String(), + 'content_length' => mb_strlen($this->postPlatform->content ?? ''), + 'media_count' => $this->postPlatform->media->count(), + ]); + break; + } } // Always check and update post status after each platform finishes @@ -76,6 +162,14 @@ public function handle(): void $this->broadcastStatus(); } + private function refreshAccountToken(): void + { + $account = $this->postPlatform->socialAccount; + + // Delegate to ConnectionVerifier which already has per-platform refresh logic + app(ConnectionVerifier::class)->verify($account); + } + private function broadcastStatus(): void { PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); @@ -104,8 +198,8 @@ private function updatePostStatus(): void $enabledPlatforms = $post->postPlatforms->where('enabled', true); $total = $enabledPlatforms->count(); - $publishedCount = $enabledPlatforms->where('status', 'published')->count(); - $failedCount = $enabledPlatforms->where('status', 'failed')->count(); + $publishedCount = $enabledPlatforms->where('status', PostPlatformStatus::Published)->count(); + $failedCount = $enabledPlatforms->where('status', PostPlatformStatus::Failed)->count(); $finishedCount = $publishedCount + $failedCount; // Only update post status when all platforms have finished @@ -115,10 +209,89 @@ private function updatePostStatus(): void if ($publishedCount === $total) { $post->markAsPublished(); + $this->notifySuccess($post); } elseif ($publishedCount > 0) { $post->markAsPartiallyPublished(); + $this->notifyFailure($post); } else { $post->markAsFailed(); + $this->notifyFailure($post); } } + + private function notifySuccess(Post $post): void + { + $owner = $post->workspace->owner; + + if (! $owner) { + return; + } + + $publishedPlatforms = $post->postPlatforms() + ->with('socialAccount') + ->where('enabled', true) + ->get() + ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Published) + ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') + ->implode(', '); + + SendNotification::dispatch( + user: $owner, + workspaceId: $post->workspace_id, + type: Type::PostPublished, + channel: Channel::Both, + title: 'Post published successfully', + body: $publishedPlatforms, + data: ['post_id' => $post->id], + mailable: new PostPublished($post), + ); + } + + public function failed(?\Throwable $exception): void + { + Log::error('PublishToSocialPlatform job failed permanently', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $exception?->getMessage(), + ]); + + $this->postPlatform->refresh(); + + if ($this->postPlatform->status !== PostPlatformStatus::Published) { + $this->postPlatform->markAsFailed($exception?->getMessage() ?? 'Unknown error', [ + 'category' => 'job_failed', + 'failed_at' => now()->toIso8601String(), + ]); + $this->updatePostStatus(); + $this->broadcastStatus(); + } + } + + private function notifyFailure(Post $post): void + { + $owner = $post->workspace->owner; + + if (! $owner) { + return; + } + + $failedPlatforms = $post->postPlatforms() + ->with('socialAccount') + ->where('enabled', true) + ->get() + ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Failed) + ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') + ->implode(', '); + + SendNotification::dispatch( + user: $owner, + workspaceId: $post->workspace_id, + type: Type::PostFailed, + channel: Channel::Both, + title: 'Post failed to publish', + body: "Failed on: {$failedPlatforms}", + data: ['post_id' => $post->id], + mailable: new PostPublishFailed($post), + ); + } } diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php new file mode 100644 index 00000000..2162c604 --- /dev/null +++ b/app/Jobs/RefreshSocialToken.php @@ -0,0 +1,33 @@ +verify($this->account); + } catch (\Throwable $e) { + Log::warning('Proactive token refresh failed', [ + 'account_id' => $this->account->id, + 'platform' => $this->account->platform->value, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/SendNotification.php b/app/Jobs/SendNotification.php new file mode 100644 index 00000000..46944eed --- /dev/null +++ b/app/Jobs/SendNotification.php @@ -0,0 +1,68 @@ +|null $data + */ + public function __construct( + public User $user, + public string $workspaceId, + public Type $type, + public Channel $channel, + public string $title, + public string $body, + public ?array $data = null, + public ?Mailable $mailable = null, + ) {} + + public function handle(): void + { + // Save in-app notification + if ($this->channel !== Channel::Email) { + Notification::create([ + 'user_id' => $this->user->id, + 'workspace_id' => $this->workspaceId, + 'type' => $this->type, + 'channel' => $this->channel, + 'title' => $this->title, + 'body' => $this->body, + 'data' => $this->data, + ]); + } + + // Send email (respects user preferences) + if ($this->mailable && $this->channel !== Channel::InApp && $this->user->wantsEmailFor($this->type)) { + Mail::to($this->user)->send($this->mailable); + } + } + + public function failed(\Throwable $exception): void + { + Log::error('SendNotification job failed', [ + 'user_id' => $this->user->id, + 'type' => $this->type->value, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/SendPostHogEvent.php b/app/Jobs/SendPostHogEvent.php new file mode 100644 index 00000000..59b880dd --- /dev/null +++ b/app/Jobs/SendPostHogEvent.php @@ -0,0 +1,49 @@ +}> $calls + */ + public function __construct( + public array $calls, + ) { + $this->onQueue('posthog'); + } + + public function handle(): void + { + if (! config('services.posthog.api_key')) { + return; + } + + foreach ($this->calls as $call) { + match (data_get($call, 'method')) { + 'capture' => PostHog::capture(data_get($call, 'payload')), + 'identify' => PostHog::identify(data_get($call, 'payload')), + 'groupIdentify' => PostHog::groupIdentify(data_get($call, 'payload')), + default => Log::warning('SendPostHogEvent: unknown method', ['method' => data_get($call, 'method')]), + }; + } + + PostHog::flush(); + } +} diff --git a/app/Jobs/VerifyWorkspaceConnections.php b/app/Jobs/VerifyWorkspaceConnections.php index 287e5211..6188c1de 100644 --- a/app/Jobs/VerifyWorkspaceConnections.php +++ b/app/Jobs/VerifyWorkspaceConnections.php @@ -1,7 +1,11 @@ workspace->socialAccounts() - ->where('status', Status::Connected) + $accounts = $this->workspace->socialAccounts() + ->with('workspace.owner') + ->whereIn('status', [Status::Connected, Status::TokenExpired]) ->get(); - if ($connectedAccounts->isEmpty()) { + if ($accounts->isEmpty()) { return; } - Log::info('Verifying workspace connections', [ - 'workspace_id' => $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'account_count' => $connectedAccounts->count(), - ]); - $disconnectedAccounts = collect(); - foreach ($connectedAccounts as $account) { + foreach ($accounts as $account) { if ($this->verifyAccount($verifier, $account)) { + // If was TokenExpired but now verified OK, mark as connected again + if ($account->status === Status::TokenExpired) { + $account->markAsConnected(); + } + continue; } @@ -60,11 +63,6 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco try { $verifier->verify($account); - Log::info('Social account connection verified', [ - 'account_id' => $account->id, - 'platform' => $account->platform->value, - ]); - return true; } catch (TokenExpiredException $e) { Log::warning('Social account connection is invalid', [ @@ -73,11 +71,18 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco 'error' => $e->getMessage(), ]); - $account->update([ - 'status' => Status::Disconnected, - 'error_message' => $e->getMessage(), - 'disconnected_at' => now(), - ]); + if ($account->status === Status::TokenExpired) { + // Second failure — escalate to Disconnected (no individual notification, + // the batch notification from notifyOwner() handles it) + $account->update([ + 'status' => Status::Disconnected, + 'error_message' => $e->getMessage(), + 'disconnected_at' => now(), + ]); + } else { + // First failure — mark as TokenExpired (softer state) + $account->markAsTokenExpired($e->getMessage()); + } return false; } catch (\Exception $e) { @@ -87,6 +92,7 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco 'error' => $e->getMessage(), ]); + // Unknown error — don't mark as disconnected, retry next time return true; } } @@ -96,12 +102,25 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco */ private function notifyOwner(Collection $disconnectedAccounts): void { - Log::info('Sending workspace disconnection notification', [ - 'workspace_id' => $this->workspace->id, - 'disconnected_count' => $disconnectedAccounts->count(), - ]); + $owner = $this->workspace->owner; - Mail::to($this->workspace->owner) - ->send(new WorkspaceConnectionsDisconnected($this->workspace, $disconnectedAccounts)); + if (! $owner) { + return; + } + + $accountNames = $disconnectedAccounts + ->map(fn ($account) => $account->platform->label().' (@'.($account->username ?? $account->display_name).')') + ->implode(', '); + + SendNotification::dispatch( + user: $owner, + workspaceId: $this->workspace->id, + type: Type::AccountDisconnected, + channel: Channel::Both, + title: $disconnectedAccounts->count().' '.($disconnectedAccounts->count() === 1 ? 'account' : 'accounts').' disconnected', + body: $accountNames, + data: ['workspace_id' => $this->workspace->id], + mailable: new WorkspaceConnectionsDisconnected($this->workspace, $disconnectedAccounts), + ); } } diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 673c4772..82ea79cc 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -4,6 +4,7 @@ namespace App\Listeners; +use App\Enums\User\Setup; use App\Events\SubscriptionCreated; use App\Models\User; use Illuminate\Support\Facades\Log; @@ -17,8 +18,8 @@ class StripeEventListener public function handle(WebhookReceived $event): void { try { - $type = $event->payload['type'] ?? null; - $stripeCustomerId = $event->payload['data']['object']['customer'] ?? null; + $type = data_get($event->payload, 'type'); + $stripeCustomerId = data_get($event->payload, 'data.object.customer'); if (! $stripeCustomerId) { return; @@ -46,6 +47,10 @@ public function handle(WebhookReceived $event): void protected function handleSubscriptionCreated(User $user, array $payload): void { + if ($user->setup === Setup::Subscription) { + $user->update(['setup' => Setup::Completed]); + } + SubscriptionCreated::dispatch($user); } diff --git a/app/Mail/AccountDisconnected.php b/app/Mail/AccountDisconnected.php index 6c3fa747..3c063521 100644 --- a/app/Mail/AccountDisconnected.php +++ b/app/Mail/AccountDisconnected.php @@ -1,5 +1,7 @@ $platformName, 'accountName' => $accountName, 'workspaceName' => $workspaceName, - 'url' => route('accounts'), + 'url' => route('app.accounts'), ], ); } diff --git a/app/Mail/PostPublishFailed.php b/app/Mail/PostPublishFailed.php new file mode 100644 index 00000000..2d422d10 --- /dev/null +++ b/app/Mail/PostPublishFailed.php @@ -0,0 +1,61 @@ +post->workspace->name}", + ); + } + + public function content(): Content + { + $failedPlatforms = $this->post->postPlatforms() + ->with('socialAccount') + ->where('enabled', true) + ->get() + ->filter(fn ($pp) => $pp->status === Status::Failed) + ->map(fn ($pp) => [ + 'name' => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', data_get($pp, 'socialAccount.display_name', '')).')', + 'error' => $pp->error_message, + ]) + ->values() + ->all(); + + return new Content( + view: 'mail.post-publish-failed', + with: [ + 'title' => 'Your post failed to publish', + 'previewText' => 'One or more platforms failed to publish your post.', + 'body' => "Your scheduled post in the {$this->post->workspace->name} workspace failed to publish on one or more platforms.", + 'failedPlatforms' => $failedPlatforms, + 'url' => route('app.posts.edit', $this->post), + ], + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/PostPublished.php b/app/Mail/PostPublished.php new file mode 100644 index 00000000..f6719619 --- /dev/null +++ b/app/Mail/PostPublished.php @@ -0,0 +1,61 @@ +post->workspace->name}", + ); + } + + public function content(): Content + { + $publishedPlatforms = $this->post->postPlatforms() + ->with('socialAccount') + ->where('enabled', true) + ->get() + ->filter(fn ($pp) => $pp->status === Status::Published) + ->map(fn ($pp) => [ + 'name' => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')', + 'url' => $pp->platform_url, + ]) + ->values() + ->all(); + + return new Content( + view: 'mail.post-published', + with: [ + 'title' => 'Your post was published', + 'previewText' => 'Your post has been published successfully.', + 'body' => "Your post in the {$this->post->workspace->name} workspace has been published successfully.", + 'publishedPlatforms' => $publishedPlatforms, + 'url' => route('app.posts.edit', $this->post), + ], + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/WorkspaceConnectionsDisconnected.php b/app/Mail/WorkspaceConnectionsDisconnected.php index 5f764b79..ebc88985 100644 --- a/app/Mail/WorkspaceConnectionsDisconnected.php +++ b/app/Mail/WorkspaceConnectionsDisconnected.php @@ -1,5 +1,7 @@ locale = $this->workspace->owner?->language?->code ?? 'en'; + $this->locale = config('app.locale'); } public function envelope(): Envelope @@ -61,7 +63,7 @@ public function content(): Content 'buttonText' => __('mail.workspace_connections_disconnected.button', [], $locale), 'workspace' => $this->workspace, 'disconnectedAccounts' => $this->disconnectedAccounts, - 'url' => route('accounts'), + 'url' => route('app.accounts'), ], ); } diff --git a/app/Mail/WorkspaceInvite.php b/app/Mail/WorkspaceInvite.php index e666f0dc..f3764a30 100644 --- a/app/Mail/WorkspaceInvite.php +++ b/app/Mail/WorkspaceInvite.php @@ -1,5 +1,7 @@ "You've been invited to join {$this->invite->workspace->name}", 'previewText' => "You've been invited to join {$this->invite->workspace->name}", 'invite' => $this->invite, - 'url' => route('invites.show', $this->invite->id), + 'url' => route('app.invites.show', $this->invite), ], ); } diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php new file mode 100644 index 00000000..4e11b6bf --- /dev/null +++ b/app/Mcp/Servers/TryPostServer.php @@ -0,0 +1,72 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'expires_at' => ['nullable', 'date', 'after:now'], + ]); + + $result = CreateApiKey::execute($request->user()->currentWorkspace, $validated); + + return Response::structured([ + ...data_get($result, 'token')->toArray(), + 'token' => data_get($result, 'plain_token'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->required()->description('The API key name.'), + 'expires_at' => $schema->string()->description('Optional expiration date.'), + ]; + } +} diff --git a/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php b/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php new file mode 100644 index 00000000..7b4ac155 --- /dev/null +++ b/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php @@ -0,0 +1,39 @@ +user()->current_workspace_id) + ->find(data_get($request->validate(['api_key_id' => ['required', 'string']]), 'api_key_id')); + + if (! $apiToken) { + return Response::error('API key not found.'); + } + + DeleteApiKey::execute($apiToken); + + return Response::structured(['deleted' => true]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'api_key_id' => $schema->string()->required()->description('The API key ID to delete.'), + ]; + } +} diff --git a/app/Mcp/Tools/ApiKey/ListApiKeysTool.php b/app/Mcp/Tools/ApiKey/ListApiKeysTool.php new file mode 100644 index 00000000..6ff34d85 --- /dev/null +++ b/app/Mcp/Tools/ApiKey/ListApiKeysTool.php @@ -0,0 +1,24 @@ +user()->currentWorkspace->apiTokens()->latest()->get(); + + return Response::structured($tokens->toArray()); + } +} diff --git a/app/Mcp/Tools/Hashtag/CreateHashtagTool.php b/app/Mcp/Tools/Hashtag/CreateHashtagTool.php new file mode 100644 index 00000000..1aa36ba4 --- /dev/null +++ b/app/Mcp/Tools/Hashtag/CreateHashtagTool.php @@ -0,0 +1,37 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'hashtags' => ['required', 'string'], + ]); + + $hashtag = CreateHashtag::execute($request->user()->currentWorkspace, $validated); + + return Response::structured($hashtag->toArray()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->required()->description('The hashtag group name.'), + 'hashtags' => $schema->string()->required()->description('The hashtags string (e.g. "#tech #ai #startup").'), + ]; + } +} diff --git a/app/Mcp/Tools/Hashtag/DeleteHashtagTool.php b/app/Mcp/Tools/Hashtag/DeleteHashtagTool.php new file mode 100644 index 00000000..215f193f --- /dev/null +++ b/app/Mcp/Tools/Hashtag/DeleteHashtagTool.php @@ -0,0 +1,39 @@ +user()->current_workspace_id) + ->find(data_get($request->validate(['hashtag_id' => ['required', 'string']]), 'hashtag_id')); + + if (! $hashtag) { + return Response::error('Hashtag not found.'); + } + + DeleteHashtag::execute($hashtag); + + return Response::structured(['deleted' => true]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'hashtag_id' => $schema->string()->required()->description('The hashtag group ID to delete.'), + ]; + } +} diff --git a/app/Mcp/Tools/Hashtag/ListHashtagsTool.php b/app/Mcp/Tools/Hashtag/ListHashtagsTool.php new file mode 100644 index 00000000..2a830593 --- /dev/null +++ b/app/Mcp/Tools/Hashtag/ListHashtagsTool.php @@ -0,0 +1,24 @@ +user()->currentWorkspace->hashtags()->latest()->get(); + + return Response::structured($hashtags->toArray()); + } +} diff --git a/app/Mcp/Tools/Hashtag/UpdateHashtagTool.php b/app/Mcp/Tools/Hashtag/UpdateHashtagTool.php new file mode 100644 index 00000000..d49ec1e2 --- /dev/null +++ b/app/Mcp/Tools/Hashtag/UpdateHashtagTool.php @@ -0,0 +1,47 @@ +validate([ + 'hashtag_id' => ['required', 'string'], + 'name' => ['required', 'string', 'max:255'], + 'hashtags' => ['required', 'string'], + ]); + + $hashtag = WorkspaceHashtag::where('workspace_id', $request->user()->current_workspace_id) + ->find(data_get($validated, 'hashtag_id')); + + if (! $hashtag) { + return Response::error('Hashtag not found.'); + } + + $hashtag = UpdateHashtag::execute($hashtag, $validated); + + return Response::structured($hashtag->toArray()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'hashtag_id' => $schema->string()->required()->description('The hashtag group ID.'), + 'name' => $schema->string()->required()->description('The new name.'), + 'hashtags' => $schema->string()->required()->description('The new hashtags string.'), + ]; + } +} diff --git a/app/Mcp/Tools/Label/CreateLabelTool.php b/app/Mcp/Tools/Label/CreateLabelTool.php new file mode 100644 index 00000000..05d42828 --- /dev/null +++ b/app/Mcp/Tools/Label/CreateLabelTool.php @@ -0,0 +1,37 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]); + + $label = CreateLabel::execute($request->user()->currentWorkspace, $validated); + + return Response::structured($label->toArray()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->required()->description('The label name.'), + 'color' => $schema->string()->required()->description('Hex color code (e.g. #FF5733).'), + ]; + } +} diff --git a/app/Mcp/Tools/Label/DeleteLabelTool.php b/app/Mcp/Tools/Label/DeleteLabelTool.php new file mode 100644 index 00000000..43ab3298 --- /dev/null +++ b/app/Mcp/Tools/Label/DeleteLabelTool.php @@ -0,0 +1,39 @@ +user()->current_workspace_id) + ->find(data_get($request->validate(['label_id' => ['required', 'string']]), 'label_id')); + + if (! $label) { + return Response::error('Label not found.'); + } + + DeleteLabel::execute($label); + + return Response::structured(['deleted' => true]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'label_id' => $schema->string()->required()->description('The label ID to delete.'), + ]; + } +} diff --git a/app/Mcp/Tools/Label/ListLabelsTool.php b/app/Mcp/Tools/Label/ListLabelsTool.php new file mode 100644 index 00000000..f0ccb8cc --- /dev/null +++ b/app/Mcp/Tools/Label/ListLabelsTool.php @@ -0,0 +1,24 @@ +user()->currentWorkspace->labels()->latest()->get(); + + return Response::structured($labels->toArray()); + } +} diff --git a/app/Mcp/Tools/Label/UpdateLabelTool.php b/app/Mcp/Tools/Label/UpdateLabelTool.php new file mode 100644 index 00000000..589c613d --- /dev/null +++ b/app/Mcp/Tools/Label/UpdateLabelTool.php @@ -0,0 +1,47 @@ +validate([ + 'label_id' => ['required', 'string'], + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]); + + $label = WorkspaceLabel::where('workspace_id', $request->user()->current_workspace_id) + ->find(data_get($validated, 'label_id')); + + if (! $label) { + return Response::error('Label not found.'); + } + + $label = UpdateLabel::execute($label, $validated); + + return Response::structured($label->toArray()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'label_id' => $schema->string()->required()->description('The label ID.'), + 'name' => $schema->string()->required()->description('The new name.'), + 'color' => $schema->string()->required()->description('Hex color code (e.g. #FF5733).'), + ]; + } +} diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php new file mode 100644 index 00000000..fbc00113 --- /dev/null +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -0,0 +1,34 @@ +user()->currentWorkspace; + $validated = $request->validate(['date' => ['nullable', 'string']]); + $post = CreatePost::execute($workspace, $request->user(), $validated); + $post->load(['postPlatforms.socialAccount']); + + return Response::structured($post->toArray()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'date' => $schema->string()->description('The scheduled date (Y-m-d). Defaults to today.'), + ]; + } +} diff --git a/app/Mcp/Tools/Post/DeletePostTool.php b/app/Mcp/Tools/Post/DeletePostTool.php new file mode 100644 index 00000000..94532135 --- /dev/null +++ b/app/Mcp/Tools/Post/DeletePostTool.php @@ -0,0 +1,39 @@ +user()->current_workspace_id) + ->find(data_get($request->validate(['post_id' => ['required', 'string']]), 'post_id')); + + if (! $post) { + return Response::error('Post not found.'); + } + + DeletePost::execute($post); + + return Response::structured(['deleted' => true]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The post ID to delete.'), + ]; + } +} diff --git a/app/Mcp/Tools/Post/GetPostTool.php b/app/Mcp/Tools/Post/GetPostTool.php new file mode 100644 index 00000000..65b1fd26 --- /dev/null +++ b/app/Mcp/Tools/Post/GetPostTool.php @@ -0,0 +1,39 @@ +user()->current_workspace_id) + ->with(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels']) + ->find(data_get($request->validate(['post_id' => ['required', 'string']]), 'post_id')); + + if (! $post) { + return Response::error('Post not found.'); + } + + return Response::structured($post->toArray()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The post ID to retrieve.'), + ]; + } +} diff --git a/app/Mcp/Tools/Post/ListPostsTool.php b/app/Mcp/Tools/Post/ListPostsTool.php new file mode 100644 index 00000000..fa4a9f3b --- /dev/null +++ b/app/Mcp/Tools/Post/ListPostsTool.php @@ -0,0 +1,28 @@ +user()->currentWorkspace + ->posts() + ->with(['postPlatforms.socialAccount', 'labels']) + ->latest('scheduled_at') + ->paginate(50); + + return Response::structured($posts->toArray()); + } +} diff --git a/app/Mcp/Tools/SocialAccount/ListSocialAccountsTool.php b/app/Mcp/Tools/SocialAccount/ListSocialAccountsTool.php new file mode 100644 index 00000000..04888de3 --- /dev/null +++ b/app/Mcp/Tools/SocialAccount/ListSocialAccountsTool.php @@ -0,0 +1,35 @@ +user()->currentWorkspace + ->socialAccounts() + ->orderBy('platform') + ->get() + ->map(fn ($account) => [ + 'id' => $account->id, + 'platform' => $account->platform->value, + 'display_name' => $account->display_name, + 'username' => $account->username, + 'is_active' => $account->is_active, + 'status' => $account->status->value, + ]); + + return Response::structured($accounts->toArray()); + } +} diff --git a/app/Mcp/Tools/SocialAccount/ToggleSocialAccountTool.php b/app/Mcp/Tools/SocialAccount/ToggleSocialAccountTool.php new file mode 100644 index 00000000..02420bbd --- /dev/null +++ b/app/Mcp/Tools/SocialAccount/ToggleSocialAccountTool.php @@ -0,0 +1,52 @@ +validate([ + 'account_id' => ['required', 'string', 'uuid'], + ]); + + $workspace = $request->user()->currentWorkspace; + $account = SocialAccount::where('workspace_id', $workspace->id) + ->where('id', data_get($validated, 'account_id')) + ->first(); + + if (! $account) { + return Response::error('Account not found.'); + } + + ToggleSocialAccount::execute($account); + + return Response::structured([ + 'id' => $account->id, + 'platform' => $account->platform->value, + 'display_name' => $account->display_name, + 'username' => $account->username, + 'is_active' => $account->is_active, + 'status' => $account->status->value, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'account_id' => $schema->string()->required()->description('The UUID of the social account to toggle.'), + ]; + } +} diff --git a/app/Mcp/Tools/Workspace/GetWorkspaceTool.php b/app/Mcp/Tools/Workspace/GetWorkspaceTool.php new file mode 100644 index 00000000..994d5b67 --- /dev/null +++ b/app/Mcp/Tools/Workspace/GetWorkspaceTool.php @@ -0,0 +1,22 @@ +user()->currentWorkspace->toArray()); + } +} diff --git a/app/Models/ApiToken.php b/app/Models/ApiToken.php new file mode 100644 index 00000000..d1838ddb --- /dev/null +++ b/app/Models/ApiToken.php @@ -0,0 +1,71 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + protected function keyHint(): Attribute + { + return Attribute::make( + get: fn () => 'tp_'.substr($this->token_lookup, 0, 8).'...', + ); + } + + protected function status(): Attribute + { + return Attribute::make( + get: function () { + if ($this->expires_at === null) { + return Status::Active->value; + } + + return now()->greaterThan($this->expires_at) ? Status::Expired->value : Status::Active->value; + } + ); + } +} diff --git a/app/Models/Language.php b/app/Models/Language.php deleted file mode 100644 index 44a38a8b..00000000 --- a/app/Models/Language.php +++ /dev/null @@ -1,23 +0,0 @@ -hasMany(User::class); - } -} diff --git a/app/Models/Media.php b/app/Models/Media.php index a397147a..93667ada 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -1,8 +1,11 @@ */ + /** @use HasFactory */ use HasFactory, HasUuids; protected $table = 'medias'; @@ -55,6 +58,28 @@ protected function url(): Attribute ); } + public function isVideo(): bool + { + if ($this->mime_type) { + return str_starts_with($this->mime_type, 'video/'); + } + + $extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION)); + + return in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v']); + } + + public function isImage(): bool + { + if ($this->mime_type) { + return str_starts_with($this->mime_type, 'image/'); + } + + $extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION)); + + return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']); + } + public function getTemporaryUrl(int $expirationMinutes = 60): string { return Storage::temporaryUrl( diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 00000000..5083e34d --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,78 @@ + */ + use HasFactory, HasUuids; + + /** + * @var list + */ + protected $fillable = [ + 'user_id', + 'workspace_id', + 'type', + 'channel', + 'title', + 'body', + 'data', + 'read_at', + 'archived_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => Type::class, + 'channel' => Channel::class, + 'data' => 'array', + 'read_at' => 'datetime', + 'archived_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function markAsRead(): void + { + $this->update(['read_at' => now()]); + } + + public function archive(): void + { + $this->update(['archived_at' => now()]); + } + + public function isRead(): bool + { + return $this->read_at !== null; + } + + public function isArchived(): bool + { + return $this->archived_at !== null; + } +} diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php new file mode 100644 index 00000000..9accc52a --- /dev/null +++ b/app/Models/NotificationPreference.php @@ -0,0 +1,35 @@ + 'boolean', + 'post_failed' => 'boolean', + 'account_disconnected' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index bcf89158..338a2478 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -1,8 +1,11 @@ */ + /** @use HasFactory */ use HasFactory, HasUuids; protected $fillable = [ diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 95ccee0a..8c7a26ee 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -1,10 +1,14 @@ */ + /** @use HasFactory */ use HasFactory, HasMedia, HasUuids; protected $fillable = [ @@ -26,6 +30,7 @@ class PostPlatform extends Model 'platform_post_id', 'platform_url', 'error_message', + 'error_context', 'published_at', 'meta', ]; @@ -36,8 +41,10 @@ protected function casts(): array 'enabled' => 'boolean', 'platform' => SocialPlatform::class, 'content_type' => ContentType::class, + 'status' => Status::class, 'published_at' => 'datetime', 'meta' => 'array', + 'error_context' => 'array', ]; } @@ -53,24 +60,25 @@ public function socialAccount(): BelongsTo public function markAsPublishing(): void { - $this->update(['status' => 'publishing']); + $this->update(['status' => Status::Publishing]); } public function markAsPublished(string $platformPostId, ?string $platformUrl = null): void { $this->update([ - 'status' => 'published', + 'status' => Status::Published, 'platform_post_id' => $platformPostId, 'platform_url' => $platformUrl, 'published_at' => now(), ]); } - public function markAsFailed(string $errorMessage): void + public function markAsFailed(string $errorMessage, ?array $errorContext = null): void { $this->update([ - 'status' => 'failed', + 'status' => Status::Failed, 'error_message' => $errorMessage, + 'error_context' => $errorContext, ]); } } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 2e0b8af7..9a2ccfc9 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -1,10 +1,17 @@ */ + /** @use HasFactory */ use HasFactory, HasUuids; protected $fillable = [ @@ -33,6 +39,7 @@ class SocialAccount extends Model 'scopes', 'meta', 'status', + 'is_active', 'error_message', 'disconnected_at', ]; @@ -47,6 +54,7 @@ protected function casts(): array return [ 'platform' => SocialPlatform::class, 'status' => Status::class, + 'is_active' => 'boolean', 'access_token' => 'encrypted', 'refresh_token' => 'encrypted', 'token_expires_at' => 'datetime', @@ -102,8 +110,20 @@ public function markAsDisconnected(string $errorMessage): void 'disconnected_at' => now(), ]); - if ($wasConnected) { - Mail::to($this->workspace->owner)->send(new AccountDisconnected($this)); + if ($wasConnected && $this->workspace->owner) { + $platformName = $this->platform->label(); + $accountName = $this->username ?? $this->display_name; + + SendNotification::dispatch( + user: $this->workspace->owner, + workspaceId: $this->workspace_id, + type: Type::AccountDisconnected, + channel: Channel::Both, + title: "{$platformName} account disconnected", + body: "@{$accountName} needs to be reconnected", + data: ['social_account_id' => $this->id], + mailable: new AccountDisconnected($this), + ); } } finally { $lock->release(); @@ -111,6 +131,15 @@ public function markAsDisconnected(string $errorMessage): void } } + public function markAsTokenExpired(string $errorMessage): void + { + $this->update([ + 'status' => Status::TokenExpired, + 'error_message' => $errorMessage, + 'disconnected_at' => $this->disconnected_at ?? now(), + ]); + } + public function markAsConnected(): void { $this->update([ @@ -124,4 +153,9 @@ public function isDisconnected(): bool { return $this->status === Status::Disconnected || $this->status === Status::TokenExpired; } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true)->orderBy('platform'); + } } diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index 13d9c9b9..1ad6f8f8 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -1,5 +1,7 @@ workspaces()->wherePivot('role', 'owner')->count(); + return $this->workspaces()->wherePivot('role', Role::Owner->value)->count(); } /** @@ -64,7 +68,7 @@ public function canCreateWorkspace(): bool return $this->ownedWorkspacesCount() === 0; } - $subscription = $this->subscription('default'); + $subscription = $this->subscription(User::SUBSCRIPTION_NAME); return $subscription && $this->ownedWorkspacesCount() < $subscription->quantity; } @@ -80,7 +84,7 @@ public function incrementWorkspaceQuantity(): void } if ($this->hasActiveSubscription()) { - $this->subscription('default')->incrementQuantity(); + $this->subscription(User::SUBSCRIPTION_NAME)->incrementQuantity(); } } @@ -95,7 +99,7 @@ public function decrementWorkspaceQuantity(): void } if ($this->hasActiveSubscription()) { - $subscription = $this->subscription('default'); + $subscription = $this->subscription(User::SUBSCRIPTION_NAME); if ($subscription->quantity > 1) { $subscription->decrementQuantity(); @@ -117,7 +121,7 @@ public function syncWorkspaceQuantity(): void $count = $this->ownedWorkspacesCount(); if ($count > 0) { - $this->subscription('default')->updateQuantity($count); + $this->subscription(User::SUBSCRIPTION_NAME)->updateQuantity($count); } } } diff --git a/app/Models/User.php b/app/Models/User.php index 0fbd5e72..4b9c00e5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -1,24 +1,31 @@ */ + /** @use HasFactory */ use Billable, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable; + public const SUBSCRIPTION_NAME = 'default'; + /** * The attributes that are mass assignable. * @@ -31,7 +38,6 @@ class User extends Authenticatable implements MustVerifyEmail 'setup', 'persona', 'current_workspace_id', - 'language_id', 'email_verified_at', ]; @@ -47,19 +53,19 @@ class User extends Authenticatable implements MustVerifyEmail 'remember_token', ]; - protected $appends = ['avatar']; + protected $appends = [ + 'has_photo', + 'photo_url', + ]; - /** - * @return array{url: string, media_id: string|null} - */ - public function getAvatarAttribute(): array + public function getHasPhotoAttribute(): bool { - $media = $this->getFirstMedia('avatar'); + return $this->getFirstMedia('avatar') !== null; + } - return [ - 'url' => $media?->url ?? $this->getFallbackAvatarUrl($this->name), - 'media_id' => $media?->id, - ]; + public function getPhotoUrlAttribute(): ?string + { + return $this->getFirstMediaUrl('avatar'); } /** @@ -79,11 +85,32 @@ protected function casts(): array } /** - * Get the user's language. + * @return HasMany */ - public function language(): BelongsTo + public function notifications(): HasMany { - return $this->belongsTo(Language::class); + return $this->hasMany(Notification::class); + } + + public function notificationPreference(): HasOne + { + return $this->hasOne(NotificationPreference::class); + } + + public function wantsEmailFor(NotificationType $type): bool + { + $preference = $this->notificationPreference; + + if (! $preference) { + return true; // Default: all enabled + } + + return match ($type) { + NotificationType::PostPublished => $preference->post_published, + NotificationType::PostFailed, NotificationType::PostPartiallyPublished => $preference->post_failed, + NotificationType::AccountDisconnected => $preference->account_disconnected, + default => true, + }; } /** @@ -96,7 +123,7 @@ public function hasActiveSubscription(): bool return true; } - return $this->subscribed('default'); + return $this->subscribed(self::SUBSCRIPTION_NAME); } /** diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 93e75476..ab8a924c 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -1,8 +1,11 @@ */ + /** @use HasFactory */ use HasFactory, HasMedia, HasUuids; protected $fillable = [ @@ -21,19 +24,16 @@ class Workspace extends Model 'timezone', ]; - protected $appends = ['logo']; + protected $appends = ['has_logo', 'logo_url']; - /** - * @return array{url: string, media_id: string|null} - */ - public function getLogoAttribute(): array + public function getHasLogoAttribute(): bool { - $media = $this->getFirstMedia('logo'); + return $this->getFirstMedia('logo') !== null; + } - return [ - 'url' => $media?->url ?? $this->getFallbackAvatarUrl($this->name), - 'media_id' => $media?->id, - ]; + public function getLogoUrlAttribute(): ?string + { + return $this->getFirstMediaUrl('logo') ?: null; } public function owner(): BelongsTo @@ -63,11 +63,6 @@ public function invites(): HasMany return $this->hasMany(WorkspaceInvite::class); } - public function pendingInvites(): HasMany - { - return $this->invites()->pending(); - } - public function hashtags(): HasMany { return $this->hasMany(WorkspaceHashtag::class); @@ -78,6 +73,11 @@ public function labels(): HasMany return $this->hasMany(WorkspaceLabel::class); } + public function apiTokens(): HasMany + { + return $this->hasMany(ApiToken::class); + } + public function hasMember(User $user): bool { return $this->user_id === $user->id || $this->members()->where('user_id', $user->id)->exists(); diff --git a/app/Models/WorkspaceHashtag.php b/app/Models/WorkspaceHashtag.php index 269129d1..01643d73 100644 --- a/app/Models/WorkspaceHashtag.php +++ b/app/Models/WorkspaceHashtag.php @@ -1,7 +1,10 @@ */ + /** @use HasFactory */ use HasFactory, HasUuids, SoftDeletes; protected $fillable = [ diff --git a/app/Models/WorkspaceInvite.php b/app/Models/WorkspaceInvite.php index 435149ee..ecf29360 100644 --- a/app/Models/WorkspaceInvite.php +++ b/app/Models/WorkspaceInvite.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\UserWorkspace\Role; +use Database\Factories\WorkspaceInviteFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ class WorkspaceInvite extends Model { - /** @use HasFactory<\Database\Factories\WorkspaceInviteFactory> */ + /** @use HasFactory */ use HasFactory, HasUuids; /** diff --git a/app/Models/WorkspaceLabel.php b/app/Models/WorkspaceLabel.php index 1a74ea88..d3d7db5f 100644 --- a/app/Models/WorkspaceLabel.php +++ b/app/Models/WorkspaceLabel.php @@ -1,7 +1,10 @@ */ + /** @use HasFactory */ use HasFactory, HasUuids, SoftDeletes; protected $fillable = [ diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 0085c4da..89ef7d7d 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -1,8 +1,10 @@ isOwner($user, $workspace) || $workspace->members->contains($user); + return $this->isMember($user, $workspace); } public function create(User $user): bool @@ -25,45 +27,48 @@ public function create(User $user): bool public function update(User $user, Workspace $workspace): bool { - return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace); + return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]); } public function delete(User $user, Workspace $workspace): bool { - return $this->isOwner($user, $workspace); + return $this->hasRole($user, $workspace, [Role::Owner]); } public function restore(User $user, Workspace $workspace): bool { - return $this->isOwner($user, $workspace); + return $this->hasRole($user, $workspace, [Role::Owner]); } public function forceDelete(User $user, Workspace $workspace): bool { - return $this->isOwner($user, $workspace); + return $this->hasRole($user, $workspace, [Role::Owner]); } public function manageTeam(User $user, Workspace $workspace): bool { - return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace); + return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]); } public function manageAccounts(User $user, Workspace $workspace): bool { - return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace); + return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]); } public function createPost(User $user, Workspace $workspace): bool { - return $this->isOwner($user, $workspace) || $workspace->members->contains($user); + return $this->isMember($user, $workspace); } - private function isOwner(User $user, Workspace $workspace): bool + public function manageBilling(User $user, Workspace $workspace): bool { - return $user->id === $workspace->user_id; + return $this->hasRole($user, $workspace, [Role::Owner]); } - private function isAdmin(User $user, Workspace $workspace): bool + /** + * @param Role[] $roles + */ + private function hasRole(User $user, Workspace $workspace, array $roles): bool { $member = $workspace->members()->where('user_id', $user->id)->first(); @@ -71,6 +76,11 @@ private function isAdmin(User $user, Workspace $workspace): bool return false; } - return $member->pivot->role === WorkspaceRole::Admin->value; + return in_array(Role::tryFrom($member->pivot->role), $roles); + } + + private function isMember(User $user, Workspace $workspace): bool + { + return $workspace->members()->where('user_id', $user->id)->exists(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b5ec5c07..92a9faca 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -1,10 +1,13 @@ app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) { + $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class); + $this->app->register(TelescopeServiceProvider::class); + } } /** @@ -56,6 +68,8 @@ public function boot(): void { $this->configureDefaults(); $this->configureMorphMap(); + $this->configurePostHog(); + $this->configureRateLimiting(); $this->configureSocialite(); $this->configureStripeWebhooks(); @@ -66,8 +80,9 @@ public function boot(): void protected function configureMorphMap(): void { Relation::enforceMorphMap([ - 'language' => Language::class, 'media' => Media::class, + 'notification' => Notification::class, + 'notificationPreference' => NotificationPreference::class, 'post' => Post::class, 'postPlatform' => PostPlatform::class, 'socialAccount' => SocialAccount::class, @@ -81,6 +96,28 @@ protected function configureMorphMap(): void ]); } + protected function configurePostHog(): void + { + $apiKey = config('services.posthog.api_key'); + + if ($apiKey) { + PostHog::init($apiKey, [ + 'host' => config('services.posthog.host'), + ]); + } + } + + protected function configureRateLimiting(): void + { + RateLimiter::for('api', function (Request $request) { + if ($this->app->environment('local')) { + return Limit::none(); + } + + return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip()); + }); + } + protected function configureStripeWebhooks(): void { Event::listen(WebhookReceived::class, StripeEventListener::class); @@ -88,6 +125,13 @@ protected function configureStripeWebhooks(): void protected function configureSocialite(): void { + // Google Auth (login/signup) - separate from YouTube OAuth + Socialite::extend('google-auth', function ($app) { + $config = $app['config']['services.google-auth']; + + return Socialite::buildProvider(GoogleProvider::class, $config); + }); + // Instagram Business Login Socialite::extend('instagram', function ($app) { $config = $app['config']['services.instagram']; @@ -108,6 +152,7 @@ protected function configureDefaults(): void // Disable wrapping of JSON resources JsonResource::withoutWrapping(); + Model::shouldBeStrict(! $this->app->isProduction()); DB::prohibitDestructiveCommands( app()->isProduction(), diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index c7d739b7..a0361957 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -1,5 +1,7 @@ hideSensitiveRequestDetails(); + + $isLocal = $this->app->environment('local'); + + Telescope::filter(function (IncomingEntry $entry) use ($isLocal) { + return $isLocal || + $entry->isReportableException() || + $entry->isFailedRequest() || + $entry->isFailedJob() || + $entry->isScheduledTask() || + $entry->hasMonitoredTag(); + }); + } + + /** + * Prevent sensitive request details from being logged by Telescope. + */ + protected function hideSensitiveRequestDetails(): void + { + if ($this->app->environment('local')) { + return; + } + + Telescope::hideRequestParameters(['_token']); + + Telescope::hideRequestHeaders([ + 'cookie', + 'x-csrf-token', + 'x-xsrf-token', + ]); + } + + /** + * Register the Telescope gate. + * + * This gate determines who can access Telescope in non-local environments. + */ + protected function gate(): void + { + Gate::define('viewTelescope', function (User $user) { + return in_array($user->email, [ + // + ]); + }); + } +} diff --git a/app/Rules/Timezone.php b/app/Rules/Timezone.php index 90301db0..2432498f 100644 --- a/app/Rules/Timezone.php +++ b/app/Rules/Timezone.php @@ -6,11 +6,12 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Translation\PotentiallyTranslatedString; class Timezone implements ValidationRule { /** - * @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @param Closure(string, ?string=): PotentiallyTranslatedString $fail */ public function validate(string $attribute, mixed $value, Closure $fail): void { diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php new file mode 100644 index 00000000..c5f0d1c3 --- /dev/null +++ b/app/Services/Media/MediaOptimizer.php @@ -0,0 +1,143 @@ +manager = new ImageManager(Driver::class); + } + + /** + * Optimize an image for a specific platform. + * Returns path to optimized temp file (caller must clean up). + */ + public function optimizeImage(string $filePath, Platform $platform): string + { + $config = $this->getImageConfig($platform); + + // Check image dimensions to prevent GD memory overflow + $imageInfo = @getimagesize($filePath); + if ($imageInfo) { + $width = $imageInfo[0]; + $height = $imageInfo[1]; + $channels = $imageInfo['channels'] ?? 4; + $estimatedMemory = $width * $height * $channels * 1.5; // 1.5x safety margin + + // If image would use more than 256MB of RAM, skip optimization and return as-is + if ($estimatedMemory > 256 * 1024 * 1024) { + Log::warning('MediaOptimizer: Image too large for GD processing', [ + 'width' => $width, + 'height' => $height, + 'estimated_memory' => $estimatedMemory, + 'platform' => $platform->value, + ]); + + // Copy original file to temp location and return + $tempFile = tempnam(sys_get_temp_dir(), 'media_opt_'); + copy($filePath, $tempFile); + + return $tempFile; + } + } + + $image = $this->manager->decodePath($filePath); + + $maxWidth = data_get($config, 'max_width'); + $maxSize = data_get($config, 'max_size'); + $format = data_get($config, 'format'); + $quality = data_get($config, 'quality'); + + // Resize if needed (maintain aspect ratio, never upscale) + if ($maxWidth && $image->width() > $maxWidth) { + $image->scaleDown(width: $maxWidth); + } + + // Encode to target format + $tempFile = tempnam(sys_get_temp_dir(), 'media_opt_'); + $encoded = $image->encodeUsingMediaType($format, quality: $quality); + file_put_contents($tempFile, (string) $encoded); + + // Reduce quality iteratively if file still too large + while (filesize($tempFile) > $maxSize && $quality > 30) { + $quality -= 10; + $encoded = $image->encodeUsingMediaType($format, quality: $quality); + file_put_contents($tempFile, (string) $encoded); + } + + return $tempFile; + } + + /** + * @return array{max_width: int, max_size: int, format: string, quality: int} + */ + private function getImageConfig(Platform $platform): array + { + return match ($platform) { + Platform::Instagram, Platform::Threads => [ + 'max_width' => 1440, + 'max_size' => 8 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::Facebook => [ + 'max_width' => 2048, + 'max_size' => 4 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::X => [ + 'max_width' => 2048, + 'max_size' => 5 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::TikTok => [ + 'max_width' => 1080, + 'max_size' => 20 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 95, + ], + Platform::LinkedIn, Platform::LinkedInPage => [ + 'max_width' => 2048, + 'max_size' => 10 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::Pinterest => [ + 'max_width' => 1000, + 'max_size' => 20 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::Bluesky => [ + 'max_width' => 2048, + 'max_size' => 976 * 1024, + 'format' => 'image/jpeg', + 'quality' => 85, + ], + Platform::Mastodon => [ + 'max_width' => 2048, + 'max_size' => 10 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::YouTube => [ + 'max_width' => 1920, + 'max_size' => 2 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 90, + ], + }; + } +} diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php new file mode 100644 index 00000000..60d0dcc2 --- /dev/null +++ b/app/Services/PostHogService.php @@ -0,0 +1,64 @@ + $properties + */ + public function capture(string $distinctId, string $event, array $properties = []): void + { + $this->dispatch('capture', [ + 'distinctId' => $distinctId, + 'event' => $event, + 'properties' => $properties, + ]); + } + + /** + * @param array $properties + */ + public function identify(string $distinctId, array $properties = []): void + { + $this->dispatch('identify', [ + 'distinctId' => $distinctId, + 'properties' => $properties, + ]); + } + + /** + * @param array $properties + */ + public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void + { + $this->dispatch('groupIdentify', [ + 'groupType' => $groupType, + 'groupKey' => $groupKey, + 'properties' => $properties, + ]); + } + + /** + * @param array $payload + */ + private function dispatch(string $method, array $payload): void + { + if (! config('services.posthog.api_key')) { + return; + } + + try { + SendPostHogEvent::dispatch([ + ['method' => $method, 'payload' => $payload], + ]); + } catch (\Throwable $e) { + Log::warning('PostHogService: failed to dispatch event', ['method' => $method, 'error' => $e->getMessage()]); + } + } +} diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 923fc1dc..28c5f95e 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -1,24 +1,36 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; $service = $account->meta['service'] ?? 'https://bsky.social'; // Refresh token if needed if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } @@ -29,7 +41,7 @@ public function publish(PostPlatform $postPlatform): array if ($medias->count() > 0) { $images = []; foreach ($medias->take(4) as $media) { - if (str_starts_with($media->mime_type, 'image/')) { + if ($media->isImage()) { $blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type); if ($blob) { $images[] = [ @@ -49,7 +61,7 @@ public function publish(PostPlatform $postPlatform): array } // Parse facets (links, mentions, hashtags) from text - $text = $postPlatform->content ?? ''; + $text = $content ?? ''; $facets = $this->parseFacets($text); // Create post record @@ -67,13 +79,7 @@ public function publish(PostPlatform $postPlatform): array $record['facets'] = $facets; } - Log::info('Bluesky publishing post', [ - 'user_id' => $account->platform_user_id, - 'has_embed' => $embed !== null, - 'facet_count' => count($facets), - ]); - - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post("{$service}/xrpc/com.atproto.repo.createRecord", [ 'repo' => $account->platform_user_id, 'collection' => 'app.bsky.feed.post', @@ -83,7 +89,7 @@ public function publish(PostPlatform $postPlatform): array if ($response->failed()) { Log::error('Bluesky post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); @@ -92,14 +98,9 @@ public function publish(PostPlatform $postPlatform): array $data = $response->json(); // Extract post ID from URI (at://did/app.bsky.feed.post/xxx) - $uri = $data['uri']; + $uri = data_get($data, 'uri'); $postId = basename($uri); - Log::info('Bluesky post created successfully', [ - 'uri' => $uri, - 'post_id' => $postId, - ]); - return [ 'id' => $postId, 'url' => $this->buildPostUrl($account->username, $postId), @@ -108,33 +109,47 @@ public function publish(PostPlatform $postPlatform): array private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array { - try { - $imageContent = file_get_contents($url); + $tempFile = tempnam(sys_get_temp_dir(), 'bsky_blob_'); - if ($imageContent === false) { - Log::error('Bluesky failed to read image', ['url' => $url]); + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + $fileSize = filesize($tempFile); + + if ($fileSize === false || $fileSize === 0) { + Log::error('Bluesky failed to download media', ['url' => $url]); return null; } - // Bluesky has 1MB limit for images - if (strlen($imageContent) > 1000000) { - Log::warning('Bluesky image exceeds 1MB limit', [ - 'size' => strlen($imageContent), - 'url' => $url, - ]); - // TODO: Resize image if needed + // Optimize images for Bluesky's 1MB limit + if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky); + @unlink($tempFile); + $tempFile = $optimizedPath; + $mimeType = 'image/jpeg'; } - $response = Http::withToken($account->access_token) + $stream = fopen($tempFile, 'r'); + + $response = $this->socialHttp()->withToken($account->access_token) ->withHeaders(['Content-Type' => $mimeType]) - ->withBody($imageContent, $mimeType) + ->withBody($stream, $mimeType) ->post("{$service}/xrpc/com.atproto.repo.uploadBlob"); + if (is_resource($stream)) { + fclose($stream); + } + if ($response->failed()) { Log::error('Bluesky blob upload failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); return null; @@ -148,6 +163,8 @@ private function uploadBlob(SocialAccount $account, string $service, string $url ]); return null; + } finally { + @unlink($tempFile); } } @@ -255,28 +272,23 @@ public function refreshToken(SocialAccount $account): void { $service = $account->meta['service'] ?? 'https://bsky.social'; - Log::info('Bluesky refreshing token', ['user_id' => $account->platform_user_id]); - // Try refresh first - $response = Http::withToken($account->refresh_token) + $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['accessJwt'], - 'refresh_token' => $data['refreshJwt'], + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), 'token_expires_at' => now()->addHours(2), ]); - Log::info('Bluesky token refreshed via refresh token'); - return; } Log::warning('Bluesky refresh token failed, trying re-authentication', [ 'status' => $response->status(), - 'body' => $response->body(), ]); // If refresh fails, re-authenticate with stored credentials @@ -293,13 +305,11 @@ public function refreshToken(SocialAccount $account): void if ($response->successful()) { $data = $response->json(); $account->update([ - 'access_token' => $data['accessJwt'], - 'refresh_token' => $data['refreshJwt'], + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), 'token_expires_at' => now()->addHours(2), ]); - Log::info('Bluesky token refreshed via re-authentication'); - return; } } catch (\Exception $e) { @@ -312,16 +322,8 @@ public function refreshToken(SocialAccount $account): void throw new TokenExpiredException('Bluesky session expired'); } - private function handleApiError(Response $response): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $error = $body['error'] ?? 'Unknown error'; - $message = $body['message'] ?? $response->body(); - - if ($error === 'ExpiredToken' || $error === 'InvalidToken') { - throw new TokenExpiredException("Bluesky: {$message}"); - } - - throw new \Exception("Bluesky API error: {$message}"); + throw BlueskyPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php new file mode 100644 index 00000000..f914040b --- /dev/null +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -0,0 +1,74 @@ +platform->maxContentLength(); + $contentLength = mb_strlen($postPlatform->content ?? ''); + + if ($contentLength > $maxLength) { + throw new \Exception( + "Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)." + ); + } + } + + 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( + times: 3, + sleepMilliseconds: 5000, + when: fn ($exception, $request) => $exception->response?->status() === 429, + throw: false, + )->timeout(120); + } + + 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 + ); + } +} diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 16daa60b..50cbca7b 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -1,10 +1,13 @@ platform) { - Platform::LinkedIn, Platform::LinkedInPage => $this->refreshLinkedInToken($account), - Platform::X => $this->refreshXToken($account), - Platform::Bluesky => $this->refreshBlueskyToken($account), - Platform::YouTube => $this->refreshYouTubeToken($account), - Platform::TikTok => $this->refreshTikTokToken($account), - Platform::Pinterest => $this->refreshPinterestToken($account), - Platform::Threads => $this->refreshThreadsToken($account), - // Facebook, Instagram use long-lived tokens without refresh mechanism - // Mastodon tokens don't expire - default => null, - }; + $lock = Cache::lock("token_refresh:{$account->id}", 30); + + if (! $lock->get()) { + // Another process is already refreshing this token + $account->refresh(); + + return; + } + + try { + match ($account->platform) { + Platform::LinkedIn, Platform::LinkedInPage => $this->refreshLinkedInToken($account), + Platform::X => $this->refreshXToken($account), + Platform::Bluesky => $this->refreshBlueskyToken($account), + Platform::YouTube => $this->refreshYouTubeToken($account), + Platform::TikTok => $this->refreshTikTokToken($account), + Platform::Pinterest => $this->refreshPinterestToken($account), + Platform::Threads => $this->refreshThreadsToken($account), + Platform::Instagram => $this->refreshInstagramToken($account), + // Facebook uses page tokens that don't expire + // Mastodon tokens don't expire + default => null, + }; + } finally { + $lock->release(); + } } private function refreshLinkedInToken(SocialAccount $account): void @@ -72,16 +89,16 @@ private function refreshLinkedInToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh LinkedIn token'); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + '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, ]); $account->refresh(); @@ -104,16 +121,16 @@ private function refreshXToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: X token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh X token'); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200), + '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)), ]); $account->refresh(); @@ -130,8 +147,8 @@ private function refreshBlueskyToken(SocialAccount $account): void if ($response->successful()) { $data = $response->json(); $account->update([ - 'access_token' => $data['accessJwt'], - 'refresh_token' => $data['refreshJwt'], + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), 'token_expires_at' => now()->addHours(2), ]); @@ -154,8 +171,8 @@ private function refreshBlueskyToken(SocialAccount $account): void if ($response->successful()) { $data = $response->json(); $account->update([ - 'access_token' => $data['accessJwt'], - 'refresh_token' => $data['refreshJwt'], + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), 'token_expires_at' => now()->addHours(2), ]); @@ -187,15 +204,15 @@ private function refreshYouTubeToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh YouTube token'); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + 'access_token' => data_get($data, 'access_token'), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, ]); $account->refresh(); @@ -215,16 +232,16 @@ private function refreshTikTokToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh TikTok token'); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + '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, ]); $account->refresh(); @@ -247,16 +264,16 @@ private function refreshPinterestToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $response->body()]); + Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]); throw new TokenExpiredException('Failed to refresh Pinterest token'); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + '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, ]); $account->refresh(); @@ -271,15 +288,41 @@ private function refreshThreadsToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $response->body()]); + 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'); $account->update([ - 'access_token' => $data['access_token'], - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + 'access_token' => $newToken, + 'refresh_token' => $newToken, + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } + + private function refreshInstagramToken(SocialAccount $account): void + { + $response = Http::get('https://graph.instagram.com/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'); + + $account->update([ + 'access_token' => $newToken, + 'refresh_token' => $newToken, + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, ]); $account->refresh(); @@ -473,4 +516,23 @@ 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 + ); + } } diff --git a/app/Services/Social/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php new file mode 100644 index 00000000..f5f73e50 --- /dev/null +++ b/app/Services/Social/ContentSanitizer.php @@ -0,0 +1,103 @@ + $this->convertBoldAndStrip($content), + Platform::Mastodon => $this->stripUnsafeHtml($content), + default => $this->stripHtml($content), + }; + } + + private function stripHtml(string $content): string + { + // Convert

tags to newlines + $content = preg_replace('/]*>/i', '', $content); + $content = str_replace('

', "\n", $content); + + // Convert
to newlines + $content = preg_replace('//i', "\n", $content); + + // Convert list items to dash prefix + $content = preg_replace('/]*>/i', '- ', $content); + $content = str_replace('', "\n", $content); + + // Strip remaining HTML tags + $content = strip_tags($content); + + // Decode HTML entities + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Clean up excessive newlines (max 2 consecutive) + $content = preg_replace("/\n{3,}/", "\n\n", $content); + + return trim($content); + } + + private function stripUnsafeHtml(string $content): string + { + // Mastodon accepts a subset of HTML: p, strong, em, a, br, span + $content = strip_tags($content, ['p', 'strong', 'em', 'b', 'i', 'a', 'br', 'span']); + + // Decode HTML entities that aren't part of allowed tags + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim($content); + } + + private function convertBoldAndStrip(string $content): string + { + // Convert / to Unicode bold characters for LinkedIn + $content = preg_replace_callback( + '/<(?:strong|b)>(.*?)<\/(?:strong|b)>/si', + fn ($matches) => $this->toUnicodeBold($matches[1]), + $content + ); + + // Convert to Unicode underline + $content = preg_replace_callback( + '/(.*?)<\/u>/si', + fn ($matches) => $this->toUnicodeUnderline($matches[1]), + $content + ); + + return $this->stripHtml($content); + } + + private function toUnicodeBold(string $text): string + { + $map = [ + 'a' => '𝗮', 'b' => '𝗯', 'c' => '𝗰', 'd' => '𝗱', 'e' => '𝗲', + 'f' => '𝗳', 'g' => '𝗴', 'h' => '𝗵', 'i' => '𝗶', 'j' => '𝗷', + 'k' => '𝗸', 'l' => '𝗹', 'm' => '𝗺', 'n' => '𝗻', 'o' => '𝗼', + 'p' => '𝗽', 'q' => '𝗾', 'r' => '𝗿', 's' => '𝘀', 't' => '𝘁', + 'u' => '𝘂', 'v' => '𝘃', 'w' => '𝘄', 'x' => '𝘅', 'y' => '𝘆', 'z' => '𝘇', + 'A' => '𝗔', 'B' => '𝗕', 'C' => '𝗖', 'D' => '𝗗', 'E' => '𝗘', + 'F' => '𝗙', 'G' => '𝗚', 'H' => '𝗛', 'I' => '𝗜', 'J' => '𝗝', + 'K' => '𝗞', 'L' => '𝗟', 'M' => '𝗠', 'N' => '𝗡', 'O' => '𝗢', + 'P' => '𝗣', 'Q' => '𝗤', 'R' => '𝗥', 'S' => '𝗦', 'T' => '𝗧', + 'U' => '𝗨', 'V' => '𝗩', 'W' => '𝗪', 'X' => '𝗫', 'Y' => '𝗬', 'Z' => '𝗭', + '0' => '𝟬', '1' => '𝟭', '2' => '𝟮', '3' => '𝟯', '4' => '𝟰', + '5' => '𝟱', '6' => '𝟲', '7' => '𝟳', '8' => '𝟴', '9' => '𝟵', + ]; + + return strtr($text, $map); + } + + private function toUnicodeUnderline(string $text): string + { + // Unicode combining underline character + return implode('', array_map( + fn ($char) => $char."\u{0332}", + mb_str_split($text) + )); + } +} diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 3af810f1..6efe0213 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -1,38 +1,28 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; $pageId = $account->platform_user_id; $accessToken = $account->access_token; @@ -41,9 +31,9 @@ public function publish(PostPlatform $postPlatform): array $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $postPlatform->content, $media->first()), + ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()), ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), - ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $postPlatform->content, $media), + ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media), default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"), }; } @@ -60,8 +50,8 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); - $isImage = str_starts_with($firstMedia->mime_type, 'image/'); + $isVideo = $firstMedia->isVideo(); + $isImage = $firstMedia->isImage(); if ($isVideo) { return $this->publishVideoPost($pageId, $accessToken, $content, $firstMedia); @@ -81,9 +71,7 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte private function publishTextPost(string $pageId, string $accessToken, string $content): array { - Log::info('Facebook publishing text post', ['page_id' => $pageId]); - - $response = Http::post("{$this->baseUrl}/{$pageId}/feed", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", [ 'message' => $content, 'access_token' => $accessToken, ]); @@ -91,13 +79,13 @@ private function publishTextPost(string $pageId, string $accessToken, string $co if ($response->failed()) { Log::error('Facebook text post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } $data = $response->json(); - $postId = $data['id']; + $postId = data_get($data, 'id'); return [ 'id' => $postId, @@ -107,9 +95,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array { - Log::info('Facebook publishing single image post', ['page_id' => $pageId]); - - $response = Http::post("{$this->baseUrl}/{$pageId}/photos", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'message' => $content, 'url' => $media->url, 'access_token' => $accessToken, @@ -118,13 +104,13 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st if ($response->failed()) { Log::error('Facebook single image post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } $data = $response->json(); - $postId = $data['post_id'] ?? $data['id']; + $postId = data_get($data, 'post_id', data_get($data, 'id')); return [ 'id' => $postId, @@ -134,20 +120,15 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection): array { - Log::info('Facebook publishing multi-image post', [ - 'page_id' => $pageId, - 'image_count' => $mediaCollection->count(), - ]); - // Upload each image as unpublished $attachedMedia = []; foreach ($mediaCollection as $media) { - if (! str_starts_with($media->mime_type, 'image/')) { + if (! $media->isImage()) { continue; } - $uploadResponse = Http::post("{$this->baseUrl}/{$pageId}/photos", [ + $uploadResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'url' => $media->url, 'published' => 'false', 'access_token' => $accessToken, @@ -155,7 +136,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str if ($uploadResponse->failed()) { Log::error('Facebook image upload failed', [ - 'body' => $uploadResponse->body(), + 'body' => $this->redactResponseBody($uploadResponse->body()), ]); continue; @@ -179,18 +160,18 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str $postData["attached_media[{$index}]"] = json_encode($media); } - $response = Http::post("{$this->baseUrl}/{$pageId}/feed", $postData); + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData); if ($response->failed()) { Log::error('Facebook multi-image post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } $data = $response->json(); - $postId = $data['id']; + $postId = data_get($data, 'id'); return [ 'id' => $postId, @@ -200,10 +181,8 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array { - Log::info('Facebook publishing video post', ['page_id' => $pageId]); - // Use resumable upload for videos - $response = Http::post("{$this->baseUrl}/{$pageId}/videos", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", [ 'description' => $content, 'file_url' => $media->url, 'access_token' => $accessToken, @@ -212,13 +191,13 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ if ($response->failed()) { Log::error('Facebook video post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } $data = $response->json(); - $videoId = $data['id']; + $videoId = data_get($data, 'id'); return [ 'id' => $videoId, @@ -228,10 +207,8 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array { - Log::info('Facebook publishing reel', ['page_id' => $pageId]); - // Upload video as reel - $response = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); @@ -239,30 +216,28 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte if ($response->failed()) { Log::error('Facebook reel upload start failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } $data = $response->json(); - $videoId = $data['video_id']; + $videoId = data_get($data, 'video_id'); - // Upload the video file - $uploadResponse = Http::post("{$this->baseUrl}/{$videoId}", [ + // Upload the video file (Facebook accepts URL in video_file_chunk) + $uploadResponse = $this->socialHttp()->post("{$this->baseUrl}/{$videoId}", [ 'upload_phase' => 'transfer', 'video_file_chunk' => $media->url, 'access_token' => $accessToken, ]); if ($uploadResponse->failed()) { - Log::error('Facebook reel upload transfer failed', [ - 'body' => $uploadResponse->body(), - ]); - $this->handleApiError($uploadResponse, 'Facebook API error'); + Log::error('Facebook reel upload transfer failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); } // Finish and publish the reel - $finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [ + $finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ 'upload_phase' => 'finish', 'video_id' => $videoId, 'video_state' => 'PUBLISHED', @@ -272,9 +247,9 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte if ($finishResponse->failed()) { Log::error('Facebook reel finish failed', [ - 'body' => $finishResponse->body(), + 'body' => $this->redactResponseBody($finishResponse->body()), ]); - $this->handleApiError($finishResponse, 'Facebook API error'); + $this->handleApiError($finishResponse); } $finishData = $finishResponse->json(); @@ -288,39 +263,46 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte private function publishStory(string $pageId, string $accessToken, $media): array { - Log::info('Facebook publishing story', ['page_id' => $pageId]); - - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); if ($isVideo) { // Video story - $response = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); if ($response->failed()) { - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } - $videoId = $response->json()['video_id']; + $videoId = $response->json()['video_id'] ?? null; - // Transfer the video - Http::post("{$this->baseUrl}/{$videoId}", [ + if (! $videoId) { + throw new \Exception('Facebook story upload failed: no video ID returned'); + } + + // Transfer the video (Facebook accepts URL in video_file_chunk) + $transferResponse = $this->socialHttp()->post("{$this->baseUrl}/{$videoId}", [ 'upload_phase' => 'transfer', 'video_file_chunk' => $media->url, 'access_token' => $accessToken, ]); + if ($transferResponse->failed()) { + Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]); + $this->handleApiError($transferResponse); + } + // Finish the story - $finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [ + $finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'finish', 'video_id' => $videoId, 'access_token' => $accessToken, ]); if ($finishResponse->failed()) { - $this->handleApiError($finishResponse, 'Facebook API error'); + $this->handleApiError($finishResponse); } $storyId = $finishResponse->json()['post_id'] ?? $videoId; @@ -332,16 +314,16 @@ private function publishStory(string $pageId, string $accessToken, $media): arra } // Image story - $response = Http::post("{$this->baseUrl}/{$pageId}/photo_stories", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [ 'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media), 'access_token' => $accessToken, ]); if ($response->failed()) { Log::error('Facebook photo story failed', [ - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } $storyId = $response->json()['post_id'] ?? $response->json()['id']; @@ -354,39 +336,21 @@ private function publishStory(string $pageId, string $accessToken, $media): arra private function uploadUnpublishedPhoto(string $pageId, string $accessToken, $media): string { - $response = Http::post("{$this->baseUrl}/{$pageId}/photos", [ + $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'url' => $media->url, 'published' => 'false', 'access_token' => $accessToken, ]); if ($response->failed()) { - $this->handleApiError($response, 'Facebook API error'); + $this->handleApiError($response); } return $response->json()['id']; } - private function handleApiError(Response $response, string $context): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $error = $body['error'] ?? []; - $errorCode = $error['code'] ?? null; - $errorSubcode = $error['error_subcode'] ?? null; - $errorType = $error['type'] ?? null; - $message = $error['message'] ?? $response->body(); - - $isTokenError = $errorType === 'OAuthException' - || in_array($errorCode, self::TOKEN_ERROR_CODES) - || in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES); - - if ($isTokenError) { - throw new TokenExpiredException( - "{$context}: {$message}", - $errorCode ? (string) $errorCode : null - ); - } - - throw new \Exception("{$context}: {$message}"); + throw FacebookPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 63202ffa..06cc4004 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -1,42 +1,41 @@ validateContentLength($postPlatform); + $account = $postPlatform->socialAccount; + + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + $account->refresh(); + } + $instagramId = $account->platform_user_id; $accessToken = $account->access_token; + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $media = $postPlatform->media; if ($media->isEmpty()) { @@ -47,37 +46,43 @@ public function publish(PostPlatform $postPlatform): array $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia), + ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $content, $firstMedia), ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia), - ContentType::InstagramFeed => $media->count() > 1 - ? $this->publishCarousel($instagramId, $accessToken, $postPlatform->content, $media) - : $this->publishSingleImage($instagramId, $accessToken, $postPlatform->content, $firstMedia), + ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $content, $media), default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"), }; } + private function publishFeed(string $instagramId, string $accessToken, ?string $content, $media): array + { + if ($media->count() > 1) { + return $this->publishCarousel($instagramId, $accessToken, $content, $media); + } + + $firstMedia = $media->first(); + + if ($firstMedia->isVideo()) { + return $this->publishReel($instagramId, $accessToken, $content, $firstMedia); + } + + return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia); + } + private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array { - Log::info('Instagram publishing single image', ['instagram_id' => $instagramId, 'image_url' => $media->url]); - // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ 'image_url' => $media->url, 'caption' => $content, 'access_token' => $accessToken, ]); - Log::info('Instagram container response', [ - 'status' => $containerResponse->status(), - 'body' => $containerResponse->json(), - ]); - if ($containerResponse->failed()) { Log::error('Instagram container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); - $this->handleApiError($containerResponse, 'Instagram API error'); + $this->handleApiError($containerResponse); } $containerId = $containerResponse->json()['id'] ?? null; @@ -95,10 +100,8 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array { - Log::info('Instagram publishing reel', ['instagram_id' => $instagramId]); - // Step 1: Create container for video/reel - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ 'video_url' => $media->url, 'caption' => $content, 'media_type' => 'REELS', @@ -108,9 +111,9 @@ private function publishReel(string $instagramId, string $accessToken, ?string $ if ($containerResponse->failed()) { Log::error('Instagram reel container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); - $this->handleApiError($containerResponse, 'Instagram API error'); + $this->handleApiError($containerResponse); } $containerId = $containerResponse->json()['id'] ?? null; @@ -128,9 +131,7 @@ private function publishReel(string $instagramId, string $accessToken, ?string $ private function publishStory(string $instagramId, string $accessToken, $media): array { - Log::info('Instagram publishing story', ['instagram_id' => $instagramId]); - - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); $params = [ 'media_type' => 'STORIES', @@ -144,14 +145,14 @@ private function publishStory(string $instagramId, string $accessToken, $media): } // Step 1: Create story container - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params); + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); if ($containerResponse->failed()) { Log::error('Instagram story container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); - $this->handleApiError($containerResponse, 'Instagram API error'); + $this->handleApiError($containerResponse); } $containerId = $containerResponse->json()['id'] ?? null; @@ -169,16 +170,11 @@ private function publishStory(string $instagramId, string $accessToken, $media): private function publishCarousel(string $instagramId, string $accessToken, ?string $content, $mediaCollection): array { - Log::info('Instagram publishing carousel', [ - 'instagram_id' => $instagramId, - 'media_count' => $mediaCollection->count(), - ]); - // Step 1: Create containers for each media item $childContainers = []; foreach ($mediaCollection as $media) { - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); $params = [ 'is_carousel_item' => 'true', @@ -192,17 +188,23 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $params['image_url'] = $media->url; } - $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params); + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); if ($containerResponse->failed()) { Log::error('Instagram carousel item creation failed', [ - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); continue; } - $childId = $containerResponse->json()['id']; + $childId = $containerResponse->json()['id'] ?? null; + + if (! $childId) { + Log::error('Instagram carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]); + + continue; + } // Wait for video processing if needed if ($isVideo) { @@ -217,7 +219,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri } // Step 2: Create carousel container - $carouselResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [ + $carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ 'media_type' => 'CAROUSEL', 'caption' => $content, 'children' => implode(',', $childContainers), @@ -226,9 +228,9 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri if ($carouselResponse->failed()) { Log::error('Instagram carousel container creation failed', [ - 'body' => $carouselResponse->body(), + 'body' => $this->redactResponseBody($carouselResponse->body()), ]); - $this->handleApiError($carouselResponse, 'Instagram API error'); + $this->handleApiError($carouselResponse); } $carouselId = $carouselResponse->json()['id'] ?? null; @@ -246,7 +248,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri private function publishContainer(string $instagramId, string $accessToken, string $containerId): array { - $publishResponse = Http::post("{$this->baseUrl}/{$instagramId}/media_publish", [ + $publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media_publish", [ 'creation_id' => $containerId, 'access_token' => $accessToken, ]); @@ -254,23 +256,25 @@ private function publishContainer(string $instagramId, string $accessToken, stri if ($publishResponse->failed()) { Log::error('Instagram publish failed', [ 'status' => $publishResponse->status(), - 'body' => $publishResponse->body(), + 'body' => $this->redactResponseBody($publishResponse->body()), ]); - $this->handleApiError($publishResponse, 'Instagram publish error'); + $this->handleApiError($publishResponse); } - $mediaId = $publishResponse->json()['id']; + $mediaId = $publishResponse->json()['id'] ?? null; + + if (! $mediaId) { + throw new \Exception('Instagram publish failed: no media ID returned'); + } // Get permalink - $permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [ + $permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [ 'fields' => 'permalink', 'access_token' => $accessToken, ]); $permalink = $permalinkResponse->json()['permalink'] ?? null; - Log::info('Instagram publish success', ['media_id' => $mediaId, 'permalink' => $permalink]); - return [ 'id' => $mediaId, 'url' => $permalink, @@ -280,7 +284,7 @@ private function publishContainer(string $instagramId, string $accessToken, stri private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void { for ($i = 0; $i < $maxAttempts; $i++) { - $statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [ + $statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [ 'fields' => 'status_code', 'access_token' => $accessToken, ]); @@ -293,8 +297,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken $status = $statusResponse->json()['status_code'] ?? 'UNKNOWN'; - Log::info('Instagram media processing status', ['status' => $status, 'attempt' => $i]); - if ($status === 'FINISHED') { return; } @@ -309,26 +311,32 @@ private function waitForMediaProcessing(string $containerId, string $accessToken Log::warning('Instagram media processing timeout, proceeding anyway'); } - private function handleApiError(Response $response, string $context): void + private function refreshToken(SocialAccount $account): void { - $body = $response->json() ?? []; - $error = $body['error'] ?? []; - $errorCode = $error['code'] ?? null; - $errorSubcode = $error['error_subcode'] ?? null; - $errorType = $error['type'] ?? null; - $message = $error['message'] ?? $response->body(); + $response = Http::get('https://graph.instagram.com/refresh_access_token', [ + 'grant_type' => 'ig_refresh_token', + 'access_token' => $account->access_token, + ]); - $isTokenError = $errorType === 'OAuthException' - || in_array($errorCode, self::TOKEN_ERROR_CODES) - || in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES); + if ($response->failed()) { + Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - if ($isTokenError) { - throw new TokenExpiredException( - "{$context}: {$message}", - $errorCode ? (string) $errorCode : null - ); + throw new TokenExpiredException('Failed to refresh Instagram token'); } - throw new \Exception("{$context}: {$message}"); + $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); } } diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 3860fab7..a5e95bd7 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -1,11 +1,17 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $this->account = $postPlatform->socialAccount; $this->hasRetried = false; if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { - $this->refreshToken($this->account); + $this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account)); $this->account->refresh(); } @@ -57,16 +58,16 @@ public function publish(PostPlatform $postPlatform): array try { return match ($contentType) { - ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), - ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account), + ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account), default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), }; } catch (TokenExpiredException $e) { - return $this->retryWithRefresh($postPlatform, $e); + return $this->retryWithRefresh($postPlatform, $content, $e); } } - private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array + private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array { if ($this->hasRetried) { throw $originalException; @@ -74,10 +75,6 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept $this->hasRetried = true; - Log::info('LinkedIn Page token rejected, attempting refresh and retry', [ - 'account_id' => $this->account->id, - ]); - try { $this->refreshToken($this->account); $this->account->refresh(); @@ -88,8 +85,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), - ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account), + ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account), default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), }; } catch (\Throwable $e) { @@ -128,17 +125,15 @@ private function publishPost(string $organizationUrn, ?string $content, $media, } } - Log::info('LinkedIn Page creating post', ['payload' => $payload]); - $response = $this->getHttpClient() ->post("{$this->baseUrl}/rest/posts", $payload); if ($response->failed()) { Log::error('LinkedIn Page post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'LinkedIn Page API error'); + $this->handleApiError($response); } $postId = $response->header('x-restli-id'); @@ -157,16 +152,11 @@ private function publishPost(string $organizationUrn, ?string $content, $media, private function publishCarousel(string $organizationUrn, ?string $content, $mediaCollection, $account): array { - Log::info('LinkedIn Page publishing carousel', [ - 'owner' => $organizationUrn, - 'media_count' => $mediaCollection->count(), - ]); - // Upload images and build carousel items $carouselItems = []; foreach ($mediaCollection as $media) { - if (! str_starts_with($media->mime_type, 'image/')) { + if (! $media->isImage()) { continue; } @@ -201,17 +191,15 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med ], ]; - Log::info('LinkedIn Page creating carousel post', ['payload' => $payload]); - $response = $this->getHttpClient() ->post("{$this->baseUrl}/rest/posts", $payload); if ($response->failed()) { Log::error('LinkedIn Page carousel post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'LinkedIn Page API error'); + $this->handleApiError($response); } $postId = $response->header('x-restli-id'); @@ -230,13 +218,12 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'X-Restli-Protocol-Version' => '2.0.0', 'LinkedIn-Version' => $this->apiVersion, 'Content-Type' => 'application/json', - ]) - ->timeout(300); + ]); } private function uploadMedia($mediaItem, string $ownerUrn): ?string @@ -258,8 +245,6 @@ private function uploadMedia($mediaItem, string $ownerUrn): ?string private function uploadImage($mediaItem, string $ownerUrn): ?string { - Log::info('LinkedIn Page initializing image upload', ['owner' => $ownerUrn]); - // Step 1: Initialize upload $initResponse = $this->getHttpClient() ->post("{$this->baseUrl}/rest/images?action=initializeUpload", [ @@ -269,8 +254,8 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string ]); if ($initResponse->failed()) { - Log::error('LinkedIn Page image init failed', ['body' => $initResponse->body()]); - $this->handleApiError($initResponse, 'Failed to initialize LinkedIn Page image upload'); + Log::error('LinkedIn Page image init failed', ['body' => $this->redactResponseBody($initResponse->body())]); + $this->handleApiError($initResponse); } $initData = $initResponse->json(); @@ -281,37 +266,68 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string throw new \Exception('LinkedIn Page image upload init missing uploadUrl or image URN'); } - Log::info('LinkedIn Page image init success', ['imageUrn' => $imageUrn]); + // Step 2: Download and optimize image + $tempFile = tempnam(sys_get_temp_dir(), 'lip_image_'); - // Step 2: Upload binary data - $imageContent = file_get_contents($mediaItem->url); + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->withBody($imageContent, 'application/octet-stream') - ->put($uploadUrl); + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } - if ($uploadResponse->failed()) { - Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]); - $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn Page image'); + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedInPage); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $stream = fopen($tempFile, 'r'); + + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->withBody($stream, 'application/octet-stream') + ->put($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($uploadResponse->failed()) { + Log::error('LinkedIn Page image upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); + } + + return $imageUrn; + } finally { + @unlink($tempFile); } - - Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]); - - return $imageUrn; } private function uploadVideo($mediaItem, string $ownerUrn): ?string { - $videoContent = file_get_contents($mediaItem->url); - $fileSize = strlen($videoContent); + $tempFile = tempnam(sys_get_temp_dir(), 'lip_video_'); - Log::info('LinkedIn Page initializing video upload', [ - 'owner' => $ownerUrn, - 'fileSize' => $fileSize, - ]); + try { + return $this->doUploadVideo($tempFile, $mediaItem, $ownerUrn); + } finally { + @unlink($tempFile); + } + } + + private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ?string + { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + $fileSize = (int) filesize($tempFile); // Step 1: Initialize upload $initResponse = $this->getHttpClient() @@ -325,8 +341,8 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string ]); if ($initResponse->failed()) { - Log::error('LinkedIn Page video init failed', ['body' => $initResponse->body()]); - $this->handleApiError($initResponse, 'Failed to initialize LinkedIn Page video upload'); + Log::error('LinkedIn Page video init failed', ['body' => $this->redactResponseBody($initResponse->body())]); + $this->handleApiError($initResponse); } $initData = $initResponse->json(); @@ -338,55 +354,47 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string throw new \Exception('LinkedIn Page video upload init missing video URN or upload instructions'); } - Log::info('LinkedIn Page video init success', [ - 'videoUrn' => $videoUrn, - 'chunks' => count($uploadInstructions), - ]); - // Step 2: Upload chunks $uploadedPartIds = []; + $handle = fopen($tempFile, 'r'); - foreach ($uploadInstructions as $index => $instruction) { - $uploadUrl = $instruction['uploadUrl']; - $firstByte = $instruction['firstByte']; - $lastByte = $instruction['lastByte']; + try { + foreach ($uploadInstructions as $index => $instruction) { + $uploadUrl = data_get($instruction, 'uploadUrl'); + $firstByte = data_get($instruction, 'firstByte'); + $lastByte = data_get($instruction, 'lastByte'); - $chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1); + $chunkLength = $lastByte - $firstByte + 1; + fseek($handle, $firstByte); + $chunkData = fread($handle, $chunkLength); - Log::info('LinkedIn Page uploading video chunk', [ - 'index' => $index, - 'firstByte' => $firstByte, - 'lastByte' => $lastByte, - 'chunkSize' => strlen($chunkData), - ]); + $chunkResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->timeout(600) + ->withBody($chunkData, 'application/octet-stream') + ->put($uploadUrl); - $chunkResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->timeout(600) - ->withBody($chunkData, 'application/octet-stream') - ->put($uploadUrl); + if ($chunkResponse->failed()) { + Log::error('LinkedIn Page video chunk upload failed', [ + 'index' => $index, + 'body' => $this->redactResponseBody($chunkResponse->body()), + ]); + $this->handleApiError($chunkResponse); + } + + $etag = $chunkResponse->header('etag'); + if ($etag) { + $uploadedPartIds[] = $etag; + } - if ($chunkResponse->failed()) { - Log::error('LinkedIn Page video chunk upload failed', [ - 'index' => $index, - 'body' => $chunkResponse->body(), - ]); - $this->handleApiError($chunkResponse, 'Failed to upload LinkedIn Page video chunk'); } - - $etag = $chunkResponse->header('etag'); - if ($etag) { - $uploadedPartIds[] = $etag; - } - - Log::info('LinkedIn Page video chunk uploaded', ['index' => $index, 'etag' => $etag]); + } finally { + fclose($handle); } // Step 3: Finalize upload - Log::info('LinkedIn Page finalizing video upload', ['videoUrn' => $videoUrn]); - $finalizeResponse = $this->getHttpClient() ->post("{$this->baseUrl}/rest/videos?action=finalizeUpload", [ 'finalizeUploadRequest' => [ @@ -397,12 +405,10 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string ]); if ($finalizeResponse->failed()) { - Log::error('LinkedIn Page video finalize failed', ['body' => $finalizeResponse->body()]); - $this->handleApiError($finalizeResponse, 'Failed to finalize LinkedIn Page video upload'); + Log::error('LinkedIn Page video finalize failed', ['body' => $this->redactResponseBody($finalizeResponse->body())]); + $this->handleApiError($finalizeResponse); } - Log::info('LinkedIn Page video upload finalized', ['videoUrn' => $videoUrn]); - // Step 4: Wait for processing $this->waitForVideoProcessing($videoUrn); @@ -425,9 +431,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) } $data = $response->json(); - $status = $data['status'] ?? 'UNKNOWN'; - - Log::info('LinkedIn Page video processing status', ['status' => $status, 'attempt' => $i]); + $status = data_get($data, 'status', 'UNKNOWN'); if ($status === 'AVAILABLE') { return; @@ -457,34 +461,23 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - $this->handleApiError($response, 'Failed to refresh LinkedIn Page token'); + $this->handleApiError($response); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + '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, string $context): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $errorCode = $body['code'] ?? null; - $message = $body['message'] ?? $response->body(); - - if ($response->status() === 401 || in_array($errorCode, self::TOKEN_ERROR_CODES)) { - throw new TokenExpiredException( - "{$context}: {$message}", - $errorCode - ); - } - - throw new \Exception("{$context}: {$message}"); + throw LinkedInPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 2eb4255b..5ae0ec75 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -1,11 +1,17 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $this->account = $postPlatform->socialAccount; $this->hasRetried = false; if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { - $this->refreshToken($this->account); + $this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account)); $this->account->refresh(); } @@ -51,16 +52,16 @@ public function publish(PostPlatform $postPlatform): array try { return match ($contentType) { - ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), - ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), + ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media), + ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media), default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), }; } catch (TokenExpiredException $e) { - return $this->retryWithRefresh($postPlatform, $e); + return $this->retryWithRefresh($postPlatform, $content, $e); } } - private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array + private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array { if ($this->hasRetried) { throw $originalException; @@ -68,10 +69,6 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept $this->hasRetried = true; - Log::info('LinkedIn token rejected, attempting refresh and retry', [ - 'account_id' => $this->account->id, - ]); - try { $this->refreshToken($this->account); $this->account->refresh(); @@ -81,8 +78,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), - ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), + ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media), + ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media), default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), }; } catch (\Throwable $e) { @@ -121,17 +118,15 @@ private function publishPost(string $personUrn, ?string $content, $media): array } } - Log::info('LinkedIn creating post', ['payload' => $payload]); - $response = $this->getHttpClient() ->post("{$this->baseUrl}/rest/posts", $payload); if ($response->failed()) { Log::error('LinkedIn post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'LinkedIn API error'); + $this->handleApiError($response); } $postId = $response->header('x-restli-id'); @@ -144,16 +139,11 @@ private function publishPost(string $personUrn, ?string $content, $media): array private function publishCarousel(string $personUrn, ?string $content, $mediaCollection): array { - Log::info('LinkedIn publishing carousel', [ - 'owner' => $personUrn, - 'media_count' => $mediaCollection->count(), - ]); - // Upload images and build carousel items $carouselItems = []; foreach ($mediaCollection as $media) { - if (! str_starts_with($media->mime_type, 'image/')) { + if (! $media->isImage()) { continue; } @@ -188,17 +178,15 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl ], ]; - Log::info('LinkedIn creating carousel post', ['payload' => $payload]); - $response = $this->getHttpClient() ->post("{$this->baseUrl}/rest/posts", $payload); if ($response->failed()) { Log::error('LinkedIn carousel post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'LinkedIn API error'); + $this->handleApiError($response); } $postId = $response->header('x-restli-id'); @@ -211,13 +199,12 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'X-Restli-Protocol-Version' => '2.0.0', 'LinkedIn-Version' => $this->apiVersion, 'Content-Type' => 'application/json', - ]) - ->timeout(300); + ]); } private function uploadMedia($mediaItem, string $ownerUrn): ?string @@ -239,8 +226,6 @@ private function uploadMedia($mediaItem, string $ownerUrn): ?string private function uploadImage($mediaItem, string $ownerUrn): ?string { - Log::info('LinkedIn initializing image upload', ['owner' => $ownerUrn]); - // Step 1: Initialize upload $initResponse = $this->getHttpClient() ->post("{$this->baseUrl}/rest/images?action=initializeUpload", [ @@ -250,8 +235,8 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string ]); if ($initResponse->failed()) { - Log::error('LinkedIn image init failed', ['body' => $initResponse->body()]); - $this->handleApiError($initResponse, 'Failed to initialize LinkedIn image upload'); + Log::error('LinkedIn image init failed', ['body' => $this->redactResponseBody($initResponse->body())]); + $this->handleApiError($initResponse); } $initData = $initResponse->json(); @@ -262,37 +247,68 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string throw new \Exception('LinkedIn image upload init missing uploadUrl or image URN'); } - Log::info('LinkedIn image init success', ['imageUrn' => $imageUrn]); + // Step 2: Download and optimize image + $tempFile = tempnam(sys_get_temp_dir(), 'li_image_'); - // Step 2: Upload binary data - $imageContent = file_get_contents($mediaItem->url); + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->withBody($imageContent, 'application/octet-stream') - ->put($uploadUrl); + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } - if ($uploadResponse->failed()) { - Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]); - $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn image'); + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedIn); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $stream = fopen($tempFile, 'r'); + + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->withBody($stream, 'application/octet-stream') + ->put($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($uploadResponse->failed()) { + Log::error('LinkedIn image upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); + } + + return $imageUrn; + } finally { + @unlink($tempFile); } - - Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]); - - return $imageUrn; } private function uploadVideo($mediaItem, string $ownerUrn): ?string { - $videoContent = file_get_contents($mediaItem->url); - $fileSize = strlen($videoContent); + $tempFile = tempnam(sys_get_temp_dir(), 'li_video_'); - Log::info('LinkedIn initializing video upload', [ - 'owner' => $ownerUrn, - 'fileSize' => $fileSize, - ]); + try { + return $this->doUploadVideo($tempFile, $mediaItem, $ownerUrn); + } finally { + @unlink($tempFile); + } + } + + private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ?string + { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + $fileSize = (int) filesize($tempFile); // Step 1: Initialize upload $initResponse = $this->getHttpClient() @@ -306,8 +322,8 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string ]); if ($initResponse->failed()) { - Log::error('LinkedIn video init failed', ['body' => $initResponse->body()]); - $this->handleApiError($initResponse, 'Failed to initialize LinkedIn video upload'); + Log::error('LinkedIn video init failed', ['body' => $this->redactResponseBody($initResponse->body())]); + $this->handleApiError($initResponse); } $initData = $initResponse->json(); @@ -319,55 +335,47 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string throw new \Exception('LinkedIn video upload init missing video URN or upload instructions'); } - Log::info('LinkedIn video init success', [ - 'videoUrn' => $videoUrn, - 'chunks' => count($uploadInstructions), - ]); - // Step 2: Upload chunks $uploadedPartIds = []; + $handle = fopen($tempFile, 'r'); - foreach ($uploadInstructions as $index => $instruction) { - $uploadUrl = $instruction['uploadUrl']; - $firstByte = $instruction['firstByte']; - $lastByte = $instruction['lastByte']; + try { + foreach ($uploadInstructions as $index => $instruction) { + $uploadUrl = data_get($instruction, 'uploadUrl'); + $firstByte = data_get($instruction, 'firstByte'); + $lastByte = data_get($instruction, 'lastByte'); - $chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1); + $chunkLength = $lastByte - $firstByte + 1; + fseek($handle, $firstByte); + $chunkData = fread($handle, $chunkLength); - Log::info('LinkedIn uploading video chunk', [ - 'index' => $index, - 'firstByte' => $firstByte, - 'lastByte' => $lastByte, - 'chunkSize' => strlen($chunkData), - ]); + $chunkResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->timeout(600) + ->withBody($chunkData, 'application/octet-stream') + ->put($uploadUrl); - $chunkResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->timeout(600) - ->withBody($chunkData, 'application/octet-stream') - ->put($uploadUrl); + if ($chunkResponse->failed()) { + Log::error('LinkedIn video chunk upload failed', [ + 'index' => $index, + 'body' => $this->redactResponseBody($chunkResponse->body()), + ]); + $this->handleApiError($chunkResponse); + } + + $etag = $chunkResponse->header('etag'); + if ($etag) { + $uploadedPartIds[] = $etag; + } - if ($chunkResponse->failed()) { - Log::error('LinkedIn video chunk upload failed', [ - 'index' => $index, - 'body' => $chunkResponse->body(), - ]); - $this->handleApiError($chunkResponse, 'Failed to upload LinkedIn video chunk'); } - - $etag = $chunkResponse->header('etag'); - if ($etag) { - $uploadedPartIds[] = $etag; - } - - Log::info('LinkedIn video chunk uploaded', ['index' => $index, 'etag' => $etag]); + } finally { + fclose($handle); } // Step 3: Finalize upload - Log::info('LinkedIn finalizing video upload', ['videoUrn' => $videoUrn]); - $finalizeResponse = $this->getHttpClient() ->post("{$this->baseUrl}/rest/videos?action=finalizeUpload", [ 'finalizeUploadRequest' => [ @@ -378,12 +386,10 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string ]); if ($finalizeResponse->failed()) { - Log::error('LinkedIn video finalize failed', ['body' => $finalizeResponse->body()]); - $this->handleApiError($finalizeResponse, 'Failed to finalize LinkedIn video upload'); + Log::error('LinkedIn video finalize failed', ['body' => $this->redactResponseBody($finalizeResponse->body())]); + $this->handleApiError($finalizeResponse); } - Log::info('LinkedIn video upload finalized', ['videoUrn' => $videoUrn]); - // Step 4: Wait for processing $this->waitForVideoProcessing($videoUrn); @@ -406,9 +412,7 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) } $data = $response->json(); - $status = $data['status'] ?? 'UNKNOWN'; - - Log::info('LinkedIn video processing status', ['status' => $status, 'attempt' => $i]); + $status = data_get($data, 'status', 'UNKNOWN'); if ($status === 'AVAILABLE') { return; @@ -438,34 +442,23 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - $this->handleApiError($response, 'Failed to refresh LinkedIn token'); + $this->handleApiError($response); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + '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, string $context): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $errorCode = $body['code'] ?? null; - $message = $body['message'] ?? $response->body(); - - if ($response->status() === 401 || in_array($errorCode, self::TOKEN_ERROR_CODES)) { - throw new TokenExpiredException( - "{$context}: {$message}", - $errorCode - ); - } - - throw new \Exception("{$context}: {$message}"); + throw LinkedInPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/LinkedInTokenSynchronizer.php b/app/Services/Social/LinkedInTokenSynchronizer.php index 94bccba3..5a20de65 100644 --- a/app/Services/Social/LinkedInTokenSynchronizer.php +++ b/app/Services/Social/LinkedInTokenSynchronizer.php @@ -1,11 +1,12 @@ $sourceAccount->id, - 'source_platform' => $sourceAccount->platform->value, - 'target_id' => $targetAccount->id, - 'target_platform' => $targetAccount->platform->value, - ]); - $targetAccount->update([ 'access_token' => $sourceAccount->access_token, 'refresh_token' => $sourceAccount->refresh_token, diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 20383cc8..c6e076d6 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -1,18 +1,29 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; $instance = $account->meta['instance'] ?? 'https://mastodon.social'; @@ -21,7 +32,7 @@ public function publish(PostPlatform $postPlatform): array // Upload media first (max 4) foreach ($medias->take(4) as $media) { - $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->filename); + $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename); if ($mediaId) { $mediaIds[] = $mediaId; } @@ -29,7 +40,7 @@ public function publish(PostPlatform $postPlatform): array // Create status $payload = [ - 'status' => $postPlatform->content ?? '', + 'status' => $content ?? '', 'visibility' => 'public', ]; @@ -37,60 +48,70 @@ public function publish(PostPlatform $postPlatform): array $payload['media_ids'] = $mediaIds; } - Log::info('Mastodon publishing status', [ - 'instance' => $instance, - 'user_id' => $account->platform_user_id, - 'has_media' => count($mediaIds) > 0, - ]); - - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post("{$instance}/api/v1/statuses", $payload); if ($response->failed()) { Log::error('Mastodon post failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } $data = $response->json(); - Log::info('Mastodon post created', [ - 'id' => $data['id'], - 'url' => $data['url'], - ]); - return [ - 'id' => $data['id'], - 'url' => $data['url'], + 'id' => data_get($data, 'id'), + 'url' => data_get($data, 'url'), ]; } private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string { + $tempFile = tempnam(sys_get_temp_dir(), 'masto_media_'); + try { - $fileContent = file_get_contents($url); - if ($fileContent === false) { - Log::error('Mastodon failed to read media', ['url' => $url]); + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + if (filesize($tempFile) === 0) { + Log::error('Mastodon failed to download media', ['url' => $url]); return null; } - // Determine filename from URL if not provided + // Optimize images (skip GIFs) + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Mastodon); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + $name = $filename ?? basename(parse_url($url, PHP_URL_PATH)); if (empty($name)) { $name = 'media'; } - $response = Http::withToken($account->access_token) - ->attach('file', $fileContent, $name) + $stream = fopen($tempFile, 'r'); + + $response = $this->socialHttp()->withToken($account->access_token) + ->attach('file', $stream, $name) ->post("{$instance}/api/v1/media"); + if (is_resource($stream)) { + fclose($stream); + } + if ($response->failed()) { Log::error('Mastodon media upload failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); return null; @@ -98,9 +119,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u $data = $response->json(); - Log::info('Mastodon media uploaded', ['id' => $data['id']]); - - return $data['id']; + return data_get($data, 'id'); } catch (\Exception $e) { Log::error('Mastodon media upload error', [ 'error' => $e->getMessage(), @@ -108,18 +127,13 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u ]); return null; + } finally { + @unlink($tempFile); } } - private function handleApiError(Response $response): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $error = $body['error'] ?? $response->body(); - - if ($response->status() === 401 || $response->status() === 403) { - throw new TokenExpiredException("Mastodon: {$error}"); - } - - throw new \Exception("Mastodon API error: {$error}"); + throw MastodonPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index df345a85..6e3af4d4 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -1,45 +1,48 @@ validateContentLength($postPlatform); + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + return match ($postPlatform->content_type) { - ContentType::PinterestPin => $this->publishImagePin($postPlatform), - ContentType::PinterestVideoPin => $this->publishVideoPin($postPlatform), - ContentType::PinterestCarousel => $this->publishCarousel($postPlatform), + ContentType::PinterestPin => $this->publishImagePin($postPlatform, $content), + ContentType::PinterestVideoPin => $this->publishVideoPin($postPlatform, $content), + ContentType::PinterestCarousel => $this->publishCarousel($postPlatform, $content), default => throw new \Exception("Unsupported content type: {$postPlatform->content_type->value}"), }; } - private function publishImagePin(PostPlatform $postPlatform): array + private function publishImagePin(PostPlatform $postPlatform, ?string $content): array { $account = $postPlatform->socialAccount; $media = $postPlatform->media->first(); @@ -48,64 +51,80 @@ private function publishImagePin(PostPlatform $postPlatform): array throw new \Exception('Pinterest requires at least one image'); } - $boardId = $postPlatform->meta['board_id'] ?? $account->meta['default_board_id'] ?? null; + $boardId = data_get($postPlatform->meta, 'board_id') ?? data_get($account->meta, 'default_board_id') ?? null; if (! $boardId) { throw new \Exception('Pinterest board_id is required'); } - Log::info('Pinterest publishing image pin', [ - 'user_id' => $account->platform_user_id, - 'board_id' => $boardId, - 'image_url' => $media->url, - ]); + // Download and optimize image + $tempFile = tempnam(sys_get_temp_dir(), 'pin_image_'); + + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Pinterest); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $imageBase64 = base64_encode(file_get_contents($tempFile)); + } finally { + @unlink($tempFile); + } $payload = [ 'board_id' => $boardId, 'media_source' => [ - 'source_type' => 'image_url', - 'url' => $media->url, + 'source_type' => 'image_base64', + 'content_type' => 'image/jpeg', + 'data' => $imageBase64, ], ]; - if ($postPlatform->content) { - $payload['description'] = $postPlatform->content; + if ($content) { + $payload['description'] = $content; } - if (! empty($postPlatform->meta['title'])) { - $payload['title'] = substr($postPlatform->meta['title'], 0, 100); + if (! empty(data_get($postPlatform->meta, 'title'))) { + $payload['title'] = substr(data_get($postPlatform->meta, 'title'), 0, 100); } - if (! empty($postPlatform->meta['link'])) { - $payload['link'] = $postPlatform->meta['link']; + if (! empty(data_get($postPlatform->meta, 'link'))) { + $payload['link'] = data_get($postPlatform->meta, 'link'); } - if (! empty($postPlatform->meta['alt_text'])) { - $payload['alt_text'] = substr($postPlatform->meta['alt_text'], 0, 500); + if (! empty(data_get($postPlatform->meta, 'alt_text'))) { + $payload['alt_text'] = substr(data_get($postPlatform->meta, 'alt_text'), 0, 500); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/pins', $payload); if ($response->failed()) { Log::error('Pinterest pin creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Pinterest API error'); + $this->handleApiError($response); } $data = $response->json(); - Log::info('Pinterest pin created successfully', ['pin_id' => $data['id']]); - return [ - 'id' => $data['id'], - 'url' => "https://pinterest.com/pin/{$data['id']}", + 'id' => data_get($data, 'id'), + 'url' => 'https://pinterest.com/pin/'.data_get($data, 'id'), ]; } - private function publishVideoPin(PostPlatform $postPlatform): array + private function publishVideoPin(PostPlatform $postPlatform, ?string $content): array { $account = $postPlatform->socialAccount; $media = $postPlatform->media->first(); @@ -114,19 +133,14 @@ private function publishVideoPin(PostPlatform $postPlatform): array throw new \Exception('Pinterest requires a video'); } - $boardId = $postPlatform->meta['board_id'] ?? $account->meta['default_board_id'] ?? null; + $boardId = data_get($postPlatform->meta, 'board_id') ?? data_get($account->meta, 'default_board_id') ?? null; if (! $boardId) { throw new \Exception('Pinterest board_id is required'); } - Log::info('Pinterest publishing video pin', [ - 'user_id' => $account->platform_user_id, - 'board_id' => $boardId, - ]); - // Step 1: Register media upload - $registerResponse = Http::withToken($account->access_token) + $registerResponse = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/media', [ 'media_type' => 'video', ]); @@ -134,15 +148,17 @@ private function publishVideoPin(PostPlatform $postPlatform): array if ($registerResponse->failed()) { Log::error('Pinterest media registration failed', [ 'status' => $registerResponse->status(), - 'body' => $registerResponse->body(), + 'body' => $this->redactResponseBody($registerResponse->body()), ]); - $this->handleApiError($registerResponse, 'Pinterest media registration error'); + $this->handleApiError($registerResponse); } $registerData = $registerResponse->json(); - $mediaId = $registerData['media_id']; + $mediaId = $registerData['media_id'] ?? null; - Log::info('Pinterest media registered', ['media_id' => $mediaId]); + if (! $mediaId) { + throw new \Exception('Pinterest media registration failed: no media ID returned'); + } // Step 2: Upload video to S3 $uploadParams = $registerData['upload_parameters'] ?? []; @@ -158,31 +174,43 @@ private function publishVideoPin(PostPlatform $postPlatform): array $multipart[] = ['name' => $key, 'contents' => $value]; } - // Get video content - $videoContent = file_get_contents($media->url); - if ($videoContent === false) { - throw new \Exception('Failed to read video file'); + // Download video to temp file (memory-safe) + $tempFile = tempnam(sys_get_temp_dir(), 'pin_video_'); + $videoStream = null; + + try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + $videoStream = fopen($tempFile, 'r'); + + if ($videoStream === false) { + throw new \Exception('Failed to read video file'); + } + + $multipart[] = [ + 'name' => 'file', + 'contents' => $videoStream, + 'filename' => basename($media->url), + ]; + + $uploadResponse = Http::asMultipart() + ->timeout(600) + ->post($uploadUrl, $multipart); + + if ($uploadResponse->failed()) { + $this->handleApiError($uploadResponse); + } + } finally { + if ($videoStream !== null && is_resource($videoStream)) { + fclose($videoStream); + } + @unlink($tempFile); } - $multipart[] = [ - 'name' => 'file', - 'contents' => $videoContent, - 'filename' => basename($media->url), - ]; - - $uploadResponse = Http::asMultipart() - ->post($uploadUrl, $multipart); - - if ($uploadResponse->failed()) { - Log::error('Pinterest video upload failed', [ - 'status' => $uploadResponse->status(), - 'body' => $uploadResponse->body(), - ]); - throw new \Exception('Pinterest video upload failed'); - } - - Log::info('Pinterest video uploaded'); - // Step 3: Wait for processing $this->waitForMediaProcessing($account, $mediaId); @@ -195,44 +223,42 @@ private function publishVideoPin(PostPlatform $postPlatform): array ], ]; - if ($postPlatform->content) { - $payload['description'] = $postPlatform->content; + if ($content) { + $payload['description'] = $content; } - if (! empty($postPlatform->meta['title'])) { - $payload['title'] = substr($postPlatform->meta['title'], 0, 100); + if (! empty(data_get($postPlatform->meta, 'title'))) { + $payload['title'] = substr(data_get($postPlatform->meta, 'title'), 0, 100); } - if (! empty($postPlatform->meta['link'])) { - $payload['link'] = $postPlatform->meta['link']; + if (! empty(data_get($postPlatform->meta, 'link'))) { + $payload['link'] = data_get($postPlatform->meta, 'link'); } - if (! empty($postPlatform->meta['cover_image_url'])) { - $payload['media_source']['cover_image_url'] = $postPlatform->meta['cover_image_url']; + if (! empty(data_get($postPlatform->meta, 'cover_image_url'))) { + $payload['media_source']['cover_image_url'] = data_get($postPlatform->meta, 'cover_image_url'); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/pins', $payload); if ($response->failed()) { Log::error('Pinterest video pin creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Pinterest API error'); + $this->handleApiError($response); } $data = $response->json(); - Log::info('Pinterest video pin created successfully', ['pin_id' => $data['id']]); - return [ - 'id' => $data['id'], - 'url' => "https://pinterest.com/pin/{$data['id']}", + 'id' => data_get($data, 'id'), + 'url' => 'https://pinterest.com/pin/'.data_get($data, 'id'), ]; } - private function publishCarousel(PostPlatform $postPlatform): array + private function publishCarousel(PostPlatform $postPlatform, ?string $content): array { $account = $postPlatform->socialAccount; $medias = $postPlatform->media; @@ -241,18 +267,12 @@ private function publishCarousel(PostPlatform $postPlatform): array throw new \Exception('Pinterest carousel requires 2-5 images'); } - $boardId = $postPlatform->meta['board_id'] ?? $account->meta['default_board_id'] ?? null; + $boardId = data_get($postPlatform->meta, 'board_id') ?? data_get($account->meta, 'default_board_id') ?? null; if (! $boardId) { throw new \Exception('Pinterest board_id is required'); } - Log::info('Pinterest publishing carousel', [ - 'user_id' => $account->platform_user_id, - 'board_id' => $boardId, - 'image_count' => $medias->count(), - ]); - $items = $medias->map(fn ($media) => [ 'url' => $media->url, ])->toArray(); @@ -265,50 +285,48 @@ private function publishCarousel(PostPlatform $postPlatform): array ], ]; - if ($postPlatform->content) { - $payload['description'] = $postPlatform->content; + if ($content) { + $payload['description'] = $content; } - if (! empty($postPlatform->meta['title'])) { - $payload['title'] = substr($postPlatform->meta['title'], 0, 100); + if (! empty(data_get($postPlatform->meta, 'title'))) { + $payload['title'] = substr(data_get($postPlatform->meta, 'title'), 0, 100); } - if (! empty($postPlatform->meta['link'])) { - $payload['link'] = $postPlatform->meta['link']; + if (! empty(data_get($postPlatform->meta, 'link'))) { + $payload['link'] = data_get($postPlatform->meta, 'link'); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->post(self::API_BASE.'/pins', $payload); if ($response->failed()) { Log::error('Pinterest carousel creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Pinterest API error'); + $this->handleApiError($response); } $data = $response->json(); - Log::info('Pinterest carousel created successfully', ['pin_id' => $data['id']]); - return [ - 'id' => $data['id'], - 'url' => "https://pinterest.com/pin/{$data['id']}", + 'id' => data_get($data, 'id'), + 'url' => 'https://pinterest.com/pin/'.data_get($data, 'id'), ]; } private function waitForMediaProcessing(SocialAccount $account, string $mediaId, int $maxAttempts = 30): void { for ($i = 0; $i < $maxAttempts; $i++) { - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->get(self::API_BASE."/media/{$mediaId}"); if ($response->failed()) { Log::warning('Pinterest media status check failed', [ 'media_id' => $mediaId, 'attempt' => $i, - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); sleep(3); @@ -316,20 +334,14 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, } $data = $response->json(); - $status = $data['status'] ?? 'unknown'; - - Log::info('Pinterest media processing status', [ - 'media_id' => $mediaId, - 'status' => $status, - 'attempt' => $i, - ]); + $status = data_get($data, 'status', 'unknown'); if ($status === 'succeeded') { return; } if ($status === 'failed') { - $failureCode = $data['failure_code'] ?? 'unknown'; + $failureCode = data_get($data, 'failure_code', 'unknown'); throw new \Exception("Pinterest media processing failed: {$failureCode}"); } @@ -339,10 +351,8 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, throw new \Exception("Pinterest media processing timeout after {$maxAttempts} attempts"); } - public function refreshToken(SocialAccount $account): void + private function refreshToken(SocialAccount $account): void { - Log::info('Pinterest refreshing token', ['user_id' => $account->platform_user_id]); - $response = Http::asForm() ->withBasicAuth( config('services.pinterest.client_id'), @@ -354,19 +364,17 @@ public function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('Pinterest token refresh failed', ['body' => $response->body()]); - $this->handleApiError($response, 'Failed to refresh Pinterest token'); + Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + $this->handleApiError($response); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : now()->addDays(30), + '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), ]); - - Log::info('Pinterest token refreshed successfully'); } /** @@ -375,39 +383,25 @@ public function refreshToken(SocialAccount $account): void public function getBoards(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } - $response = Http::withToken($account->access_token) + $response = $this->socialHttp()->withToken($account->access_token) ->get(self::API_BASE.'/boards', [ 'page_size' => 100, ]); if ($response->failed()) { - Log::error('Pinterest get boards failed', ['body' => $response->body()]); - $this->handleApiError($response, 'Pinterest API error'); + Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]); + $this->handleApiError($response); } return $response->json()['items'] ?? []; } - private function handleApiError(Response $response, string $context): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $code = $body['code'] ?? $response->status(); - $message = $body['message'] ?? $response->body(); - - $isTokenError = $response->status() === 401 - || in_array($code, self::TOKEN_ERROR_CODES); - - if ($isTokenError) { - throw new TokenExpiredException( - "{$context}: {$message}", - is_int($code) ? (string) $code : null - ); - } - - throw new \Exception("{$context}: {$message}"); + throw PinterestPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 967377e8..a9744ce2 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -1,42 +1,33 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } @@ -47,35 +38,33 @@ public function publish(PostPlatform $postPlatform): array // Text only post if ($media->isEmpty()) { - if (empty($postPlatform->content)) { + if (empty($content)) { throw new \Exception('Threads text posts require content. Please add text to your post.'); } - return $this->publishTextPost($userId, $accessToken, $postPlatform->content); + return $this->publishTextPost($userId, $accessToken, $content); } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); + $isVideo = $firstMedia->isVideo(); // Single media if ($media->count() === 1) { if ($isVideo) { - return $this->publishVideoPost($userId, $accessToken, $postPlatform->content, $firstMedia); + return $this->publishVideoPost($userId, $accessToken, $content, $firstMedia); } - return $this->publishImagePost($userId, $accessToken, $postPlatform->content, $firstMedia); + return $this->publishImagePost($userId, $accessToken, $content, $firstMedia); } // Multiple media - carousel - return $this->publishCarousel($userId, $accessToken, $postPlatform->content, $media); + return $this->publishCarousel($userId, $accessToken, $content, $media); } private function publishTextPost(string $userId, string $accessToken, string $content): array { - Log::info('Threads publishing text post', ['user_id' => $userId]); - // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'TEXT', 'text' => $content, 'access_token' => $accessToken, @@ -84,12 +73,16 @@ private function publishTextPost(string $userId, string $accessToken, string $co if ($containerResponse->failed()) { Log::error('Threads container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); - $this->handleApiError($containerResponse, 'Threads API error'); + $this->handleApiError($containerResponse); } - $containerId = $containerResponse->json()['id']; + $containerId = $containerResponse->json()['id'] ?? null; + + if (! $containerId) { + throw new \Exception('Threads text container creation failed: no container ID returned'); + } // Step 2: Publish return $this->publishContainer($userId, $accessToken, $containerId); @@ -97,10 +90,8 @@ private function publishTextPost(string $userId, string $accessToken, string $co private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array { - Log::info('Threads publishing image post', ['user_id' => $userId, 'image_url' => $media->url]); - // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'IMAGE', 'image_url' => $media->url, 'text' => $content, @@ -110,14 +101,16 @@ private function publishImagePost(string $userId, string $accessToken, ?string $ if ($containerResponse->failed()) { Log::error('Threads image container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); - $this->handleApiError($containerResponse, 'Threads API error'); + $this->handleApiError($containerResponse); } - $containerId = $containerResponse->json()['id']; + $containerId = $containerResponse->json()['id'] ?? null; - Log::info('Threads image container created', ['container_id' => $containerId]); + if (! $containerId) { + throw new \Exception('Threads image container creation failed: no container ID returned'); + } // Step 2: Wait for image processing $this->waitForMediaProcessing($containerId, $accessToken); @@ -128,10 +121,8 @@ private function publishImagePost(string $userId, string $accessToken, ?string $ private function publishVideoPost(string $userId, string $accessToken, ?string $content, $media): array { - Log::info('Threads publishing video post', ['user_id' => $userId]); - // Step 1: Create container - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'VIDEO', 'video_url' => $media->url, 'text' => $content, @@ -141,12 +132,16 @@ private function publishVideoPost(string $userId, string $accessToken, ?string $ if ($containerResponse->failed()) { Log::error('Threads video container creation failed', [ 'status' => $containerResponse->status(), - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); - $this->handleApiError($containerResponse, 'Threads API error'); + $this->handleApiError($containerResponse); } - $containerId = $containerResponse->json()['id']; + $containerId = $containerResponse->json()['id'] ?? null; + + if (! $containerId) { + throw new \Exception('Threads video container creation failed: no container ID returned'); + } // Wait for video processing $this->waitForMediaProcessing($containerId, $accessToken); @@ -157,16 +152,11 @@ private function publishVideoPost(string $userId, string $accessToken, ?string $ private function publishCarousel(string $userId, string $accessToken, ?string $content, $mediaCollection): array { - Log::info('Threads publishing carousel', [ - 'user_id' => $userId, - 'media_count' => $mediaCollection->count(), - ]); - // Step 1: Create containers for each media item $childContainers = []; foreach ($mediaCollection as $media) { - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); $params = [ 'is_carousel_item' => 'true', @@ -181,17 +171,23 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c $params['image_url'] = $media->url; } - $containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", $params); + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params); if ($containerResponse->failed()) { Log::error('Threads carousel item creation failed', [ - 'body' => $containerResponse->body(), + 'body' => $this->redactResponseBody($containerResponse->body()), ]); continue; } - $childId = $containerResponse->json()['id']; + $childId = $containerResponse->json()['id'] ?? null; + + if (! $childId) { + Log::error('Threads carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]); + + continue; + } // Wait for media processing (both images and videos) $this->waitForMediaProcessing($childId, $accessToken); @@ -204,7 +200,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c } // Step 2: Create carousel container - $carouselResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [ + $carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [ 'media_type' => 'CAROUSEL', 'text' => $content, 'children' => implode(',', $childContainers), @@ -213,12 +209,16 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c if ($carouselResponse->failed()) { Log::error('Threads carousel container creation failed', [ - 'body' => $carouselResponse->body(), + 'body' => $this->redactResponseBody($carouselResponse->body()), ]); - $this->handleApiError($carouselResponse, 'Threads API error'); + $this->handleApiError($carouselResponse); } - $carouselId = $carouselResponse->json()['id']; + $carouselId = $carouselResponse->json()['id'] ?? null; + + if (! $carouselId) { + throw new \Exception('Threads carousel container creation failed: no container ID returned'); + } // Step 3: Publish carousel return $this->publishContainer($userId, $accessToken, $carouselId); @@ -226,7 +226,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c private function publishContainer(string $userId, string $accessToken, string $containerId): array { - $publishResponse = Http::post("{$this->baseUrl}/{$userId}/threads_publish", [ + $publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads_publish", [ 'creation_id' => $containerId, 'access_token' => $accessToken, ]); @@ -234,23 +234,25 @@ private function publishContainer(string $userId, string $accessToken, string $c if ($publishResponse->failed()) { Log::error('Threads publish failed', [ 'status' => $publishResponse->status(), - 'body' => $publishResponse->body(), + 'body' => $this->redactResponseBody($publishResponse->body()), ]); - $this->handleApiError($publishResponse, 'Threads publish error'); + $this->handleApiError($publishResponse); } - $mediaId = $publishResponse->json()['id']; + $mediaId = $publishResponse->json()['id'] ?? null; + + if (! $mediaId) { + throw new \Exception('Threads publish failed: no media ID returned'); + } // Get permalink - $permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [ + $permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [ 'fields' => 'permalink', 'access_token' => $accessToken, ]); $permalink = $permalinkResponse->json()['permalink'] ?? null; - Log::info('Threads publish success', ['media_id' => $mediaId, 'permalink' => $permalink]); - return [ 'id' => $mediaId, 'url' => $permalink, @@ -260,7 +262,7 @@ private function publishContainer(string $userId, string $accessToken, string $c private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void { for ($i = 0; $i < $maxAttempts; $i++) { - $statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [ + $statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [ 'fields' => 'status,error_message', 'access_token' => $accessToken, ]); @@ -269,7 +271,7 @@ private function waitForMediaProcessing(string $containerId, string $accessToken Log::warning('Threads status check failed', [ 'container_id' => $containerId, 'attempt' => $i, - 'body' => $statusResponse->body(), + 'body' => $this->redactResponseBody($statusResponse->body()), ]); sleep(3); @@ -277,21 +279,14 @@ private function waitForMediaProcessing(string $containerId, string $accessToken } $data = $statusResponse->json(); - $status = $data['status'] ?? 'UNKNOWN'; - - Log::info('Threads media processing status', [ - 'container_id' => $containerId, - 'status' => $status, - 'attempt' => $i, - 'response' => $data, - ]); + $status = data_get($data, 'status', 'UNKNOWN'); if ($status === 'FINISHED') { return; } if ($status === 'ERROR') { - $errorMessage = $data['error_message'] ?? 'Unknown error'; + $errorMessage = data_get($data, 'error_message', 'Unknown error'); throw new \Exception('Threads media processing failed: '.$errorMessage); } @@ -311,40 +306,23 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('Threads token refresh failed', ['body' => $response->body()]); - $this->handleApiError($response, 'Failed to refresh Threads token'); + Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + $this->handleApiError($response); } $data = $response->json(); - $account->update([ - 'access_token' => $data['access_token'], - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, - ]); + $newToken = data_get($data, 'access_token'); - Log::info('Threads token refreshed successfully'); + $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, string $context): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $error = $body['error'] ?? []; - $errorCode = $error['code'] ?? null; - $errorSubcode = $error['error_subcode'] ?? null; - $errorType = $error['type'] ?? null; - $message = $error['message'] ?? $response->body(); - - $isTokenError = $errorType === 'OAuthException' - || in_array($errorCode, self::TOKEN_ERROR_CODES) - || in_array($errorSubcode, self::TOKEN_ERROR_SUBCODES); - - if ($isTokenError) { - throw new TokenExpiredException( - "{$context}: {$message}", - $errorCode ? (string) $errorCode : null - ); - } - - throw new \Exception("{$context}: {$message}"); + throw ThreadsPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 9db2c492..3de19220 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -1,10 +1,14 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } @@ -51,15 +44,15 @@ public function publish(PostPlatform $postPlatform): array } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); - $isImage = str_starts_with($firstMedia->mime_type, 'image/'); + $isVideo = $firstMedia->isVideo(); + $isImage = $firstMedia->isImage(); if ($isVideo) { - return $this->publishVideo($postPlatform, $firstMedia); + return $this->publishVideo($postPlatform, $firstMedia, $content); } if ($isImage) { - return $this->publishPhotos($postPlatform, $media); + return $this->publishPhotos($postPlatform, $media, $content); } throw new \Exception('TikTok only supports video or image content.'); @@ -67,26 +60,52 @@ public function publish(PostPlatform $postPlatform): array private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'Content-Type' => 'application/json; charset=UTF-8', - ]) - ->timeout(120); + ]); } - private function publishVideo(PostPlatform $postPlatform, $media): array + private function queryCreatorInfo(): array { - Log::info('TikTok publishing video', [ - 'video_url' => $media->url, - 'media_full_url' => $media->full_url ?? $media->url, - 'content' => $postPlatform->content, - ]); + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/creator_info/query/"); + + if ($response->failed()) { + Log::warning('TikTok creator_info query failed', ['body' => $this->redactResponseBody($response->body())]); + + return ['privacy_level' => 'SELF_ONLY']; + } + + $data = data_get($response->json(), 'data', []); + $privacyOptions = data_get($data, 'privacy_level_options', ['SELF_ONLY']); + + // Prefer PUBLIC_TO_EVERYONE > MUTUAL_FOLLOW_FRIENDS > FOLLOWER_OF_CREATOR > SELF_ONLY + $preferred = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']; + + $privacyLevel = 'SELF_ONLY'; + foreach ($preferred as $level) { + if (in_array($level, $privacyOptions)) { + $privacyLevel = $level; + break; + } + } + + return [ + 'privacy_level' => $privacyLevel, + 'max_video_post_duration_sec' => data_get($data, 'max_video_post_duration_sec'), + ]; + } + + private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array + { + $creatorInfo = $this->queryCreatorInfo(); $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ 'post_info' => [ - 'title' => $postPlatform->content ?? '', - 'privacy_level' => 'SELF_ONLY', + 'title' => $content ?? '', + 'privacy_level' => data_get($creatorInfo, 'privacy_level'), 'disable_duet' => false, 'disable_comment' => false, 'disable_stitch' => false, @@ -100,23 +119,21 @@ private function publishVideo(PostPlatform $postPlatform, $media): array if ($response->failed()) { Log::error('TikTok video publish failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'TikTok API error'); + $this->handleApiError($response); } $data = $response->json(); - Log::info('TikTok video init response', ['data' => $data]); - - $publishId = $data['data']['publish_id'] ?? null; + $publishId = data_get($data, 'data.publish_id'); if (! $publishId) { throw new \Exception('TikTok did not return a publish_id'); } // Wait for processing and get final status - $finalStatus = $this->waitForPublishStatus($publishId); + $this->waitForPublishStatus($publishId); return [ 'id' => $publishId, @@ -124,10 +141,10 @@ private function publishVideo(PostPlatform $postPlatform, $media): array ]; } - private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): array + private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array { $photoUrls = $mediaCollection - ->filter(fn ($m) => str_starts_with($m->mime_type, 'image/')) + ->filter(fn ($m) => $m->isImage()) ->map(fn ($m) => $m->url) ->values() ->toArray(); @@ -136,17 +153,13 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar throw new \Exception('No valid images found for TikTok photo post'); } - Log::info('TikTok publishing photos', [ - 'photo_urls' => $photoUrls, - 'photo_count' => count($photoUrls), - 'content' => $postPlatform->content, - ]); + $creatorInfo = $this->queryCreatorInfo(); $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/content/init/", [ 'post_info' => [ - 'title' => $postPlatform->content ?? '', - 'privacy_level' => 'SELF_ONLY', + 'title' => $content ?? '', + 'privacy_level' => data_get($creatorInfo, 'privacy_level'), 'disable_comment' => false, ], 'source_info' => [ @@ -161,23 +174,21 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar if ($response->failed()) { Log::error('TikTok photo publish failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'TikTok API error'); + $this->handleApiError($response); } $data = $response->json(); - Log::info('TikTok photo init response', ['data' => $data]); - - $publishId = $data['data']['publish_id'] ?? null; + $publishId = data_get($data, 'data.publish_id'); if (! $publishId) { throw new \Exception('TikTok did not return a publish_id'); } // Wait for processing and get final status - $finalStatus = $this->waitForPublishStatus($publishId); + $this->waitForPublishStatus($publishId); return [ 'id' => $publishId, @@ -198,28 +209,22 @@ private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): if ($response->failed()) { Log::warning('TikTok status check failed', [ 'attempt' => $i, - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); continue; } $data = $response->json(); - $status = $data['data']['status'] ?? 'UNKNOWN'; - - Log::info('TikTok publish status', [ - 'status' => $status, - 'attempt' => $i, - 'data' => $data, - ]); + $status = data_get($data, 'data.status', 'UNKNOWN'); if ($status === 'PUBLISH_COMPLETE') { - return $data['data'] ?? []; + return data_get($data, 'data', []); } if (in_array($status, ['FAILED', 'PUBLISH_FAILED'])) { - $errorCode = $data['data']['fail_reason'] ?? 'Unknown error'; - throw new \Exception("TikTok publish failed: {$errorCode}"); + $failReason = data_get($data, 'data.fail_reason', 'Unknown error'); + throw TikTokPublishException::fromFailReason($failReason, json_encode($data)); } // PROCESSING_UPLOAD, PROCESSING_DOWNLOAD, SENDING_TO_USER_INBOX - continue waiting @@ -255,40 +260,22 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('TikTok token refresh failed', ['body' => $response->body()]); - $this->handleApiError($response, 'Failed to refresh TikTok token'); + Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); + $this->handleApiError($response); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + '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, ]); - Log::info('TikTok token refreshed successfully'); } - private function handleApiError(Response $response, string $context): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $error = $body['error'] ?? []; - $errorCode = $error['code'] ?? $body['error']['code'] ?? null; - $errorMessage = $error['message'] ?? $body['error']['message'] ?? $response->body(); - - // TikTok can return error codes as strings or numeric codes - $isTokenError = in_array($errorCode, self::TOKEN_ERROR_CODES) - || in_array((int) $errorCode, self::TOKEN_ERROR_NUMERIC_CODES) - || $response->status() === 401; - - if ($isTokenError) { - throw new TokenExpiredException( - "{$context}: {$errorMessage}", - is_string($errorCode) ? $errorCode : (string) $errorCode - ); - } - - throw new \Exception("{$context}: {$errorMessage}"); + throw TikTokPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 025dc888..ec6113d5 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -1,10 +1,16 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; // Refresh token if expired or expiring soon if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } @@ -39,8 +42,8 @@ public function publish(PostPlatform $postPlatform): array $data = []; - if (! empty($postPlatform->content)) { - $data['text'] = $postPlatform->content; + if (! empty($content)) { + $data['text'] = $content; } $mediaIds = []; @@ -48,17 +51,10 @@ public function publish(PostPlatform $postPlatform): array if ($media->isNotEmpty()) { foreach ($media as $mediaItem) { - Log::info('Uploading media to X', [ - 'url' => $mediaItem->url, - 'mime_type' => $mediaItem->mime_type, - ]); - $uploadedMedia = $this->uploadMedia($mediaItem); - Log::info('X media upload response', ['response' => $uploadedMedia]); - // v2 API returns data.id, v1 returns media_id - $mediaId = $uploadedMedia['data']['id'] ?? $uploadedMedia['media_id'] ?? null; + $mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id')); if ($mediaId) { $mediaIds[] = $mediaId; } @@ -71,21 +67,19 @@ public function publish(PostPlatform $postPlatform): array ]; } - if (empty($data['text']) && empty($mediaIds)) { + if (empty($content) && empty($mediaIds)) { throw new \Exception('X posts require either text or media. Please add content to your post.'); } - Log::info('Posting tweet', ['data' => $data]); - $response = $this->getHttpClient() ->post("{$this->baseUrl}/2/tweets", $data); if ($response->failed()) { Log::error('X post creation failed', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'X API error'); + $this->handleApiError($response); } $responseData = $response->json(); @@ -99,46 +93,54 @@ public function publish(PostPlatform $postPlatform): array private function getHttpClient(): PendingRequest { - return Http::withToken($this->accessToken) + return $this->socialHttp()->withToken($this->accessToken) ->withHeaders([ 'Content-Type' => 'application/json', 'Accept' => 'application/json', - ]) - ->timeout(360); + ]); } private function uploadMedia($mediaItem): ?array { - $mediaContent = file_get_contents($mediaItem->url); $mimeType = $mediaItem->mime_type; - $fileSize = strlen($mediaContent); - // Create temp file + // Download to temp file (memory-safe) $tempFile = tempnam(sys_get_temp_dir(), 'x_media_'); - file_put_contents($tempFile, $mediaContent); try { + $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + } + + // Optimize images (skip GIFs — they need special handling) + if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X); + @unlink($tempFile); + $tempFile = $optimizedPath; + $mimeType = 'image/jpeg'; + } + + $fileSize = filesize($tempFile); $mediaCategory = $this->getMediaCategory($mimeType, $fileSize); $isVideo = str_starts_with($mimeType, 'video/'); $isGif = $mimeType === 'image/gif'; - // Use chunked upload for: - // - Videos (always) - // - GIFs (need async processing) - // - Files > 5MB (API limit for simple upload) $useChunkedUpload = $isVideo || $isGif || $fileSize > 5 * 1024 * 1024; if ($useChunkedUpload) { - return $this->chunkedUpload($mediaContent, $mimeType, $mediaCategory); + return $this->chunkedUpload($tempFile, $fileSize, $mimeType, $mediaCategory); } // Simple upload for small images - $response = Http::withToken($this->accessToken) + $response = $this->socialHttp()->withToken($this->accessToken) ->timeout(360) ->attach( 'media', - file_get_contents($tempFile), + fopen($tempFile, 'r'), basename($tempFile), ['Content-Type' => $mimeType] ); @@ -153,14 +155,13 @@ private function uploadMedia($mediaItem): ?array if ($response->failed()) { Log::error('X media upload error', [ 'status' => $response->status(), - 'body' => $response->body(), + 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response, 'Failed to upload media'); + $this->handleApiError($response); } $responseData = $response->json(); - // v2 API returns data.id $mediaId = $responseData['data']['id'] ?? $responseData['media_id'] ?? null; if ($isGif && $mediaId) { @@ -169,24 +170,14 @@ private function uploadMedia($mediaItem): ?array return $responseData; } finally { - if (file_exists($tempFile)) { - unlink($tempFile); - } + @unlink($tempFile); } } - private function chunkedUpload(string $mediaContent, string $mimeType, string $mediaCategory): array + private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array { - $totalBytes = strlen($mediaContent); - - Log::info('X chunked upload INIT', [ - 'total_bytes' => $totalBytes, - 'media_type' => $mimeType, - 'media_category' => $mediaCategory, - ]); - - // INIT - Use dedicated initialize endpoint - $initResponse = Http::withToken($this->accessToken) + // INIT + $initResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(60) ->post("{$this->baseUrl}/2/media/upload/initialize", [ 'media_type' => $mimeType, @@ -197,9 +188,9 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m if ($initResponse->failed()) { Log::error('X chunked upload INIT error', [ 'status' => $initResponse->status(), - 'body' => $initResponse->body(), + 'body' => $this->redactResponseBody($initResponse->body()), ]); - $this->handleApiError($initResponse, 'Failed to initialize chunked upload'); + $this->handleApiError($initResponse); } $initData = $initResponse->json(); @@ -209,50 +200,52 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m throw new \Exception('No media_id returned from INIT'); } - Log::info('X chunked upload INIT success', ['media_id' => $mediaId]); + // APPEND - Read from temp file in 5MB chunks (memory-safe) + $chunkSize = 5 * 1024 * 1024; + $handle = fopen($tempFile, 'r'); + $index = 0; - // APPEND - Upload in 1MB chunks (API limit) - $chunkSize = 1 * 1024 * 1024; - $chunks = str_split($mediaContent, $chunkSize); + try { + while (! feof($handle)) { + $chunk = fread($handle, $chunkSize); - foreach ($chunks as $index => $chunk) { - Log::info('X chunked upload APPEND', [ - 'media_id' => $mediaId, - 'segment' => $index, - 'chunk_size' => strlen($chunk), - ]); + if ($chunk === '' || $chunk === false) { + break; + } - // APPEND uses the new v2 endpoint with media_id in URL - $appendResponse = Http::withToken($this->accessToken) - ->timeout(300) - ->attach('media', $chunk, 'chunk'.$index) - ->post("{$this->baseUrl}/2/media/upload/{$mediaId}/append", [ - 'segment_index' => $index, - ]); + $appendResponse = $this->socialHttp()->withToken($this->accessToken) + ->timeout(300) + ->attach('media', $chunk, 'chunk'.$index) + ->post("{$this->baseUrl}/2/media/upload/{$mediaId}/append", [ + 'segment_index' => $index, + ]); - if ($appendResponse->failed()) { - Log::error('X chunked upload APPEND error', [ - 'status' => $appendResponse->status(), - 'body' => $appendResponse->body(), - 'segment' => $index, - ]); - $this->handleApiError($appendResponse, 'Failed to append chunk'); + if ($appendResponse->failed()) { + Log::error('X chunked upload APPEND error', [ + 'status' => $appendResponse->status(), + 'body' => $this->redactResponseBody($appendResponse->body()), + 'segment' => $index, + ]); + $this->handleApiError($appendResponse); + } + + $index++; } + } finally { + fclose($handle); } // FINALIZE - Use the new v2 endpoint - Log::info('X chunked upload FINALIZE', ['media_id' => $mediaId]); - - $finalizeResponse = Http::withToken($this->accessToken) + $finalizeResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(60) ->post("{$this->baseUrl}/2/media/upload/{$mediaId}/finalize"); if ($finalizeResponse->failed()) { Log::error('X chunked upload FINALIZE error', [ 'status' => $finalizeResponse->status(), - 'body' => $finalizeResponse->body(), + 'body' => $this->redactResponseBody($finalizeResponse->body()), ]); - $this->handleApiError($finalizeResponse, 'Failed to finalize chunked upload'); + $this->handleApiError($finalizeResponse); } $finalizeData = $finalizeResponse->json(); @@ -294,7 +287,7 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool ->get("{$this->baseUrl}/2/media/{$mediaId}"); if ($response->failed()) { - Log::error('X media status check error: '.$response->body()); + Log::error('X media status check error', ['body' => $this->redactResponseBody($response->body())]); sleep(3); continue; @@ -342,31 +335,20 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - $this->handleApiError($response, 'Failed to refresh X token'); + $this->handleApiError($response); } $data = $response->json(); $account->update([ - 'access_token' => $data['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200), + '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, string $context): void + private function handleApiError(Response $response): never { - $body = $response->json() ?? []; - $errorTitle = $body['title'] ?? null; - $message = $body['detail'] ?? $response->body(); - - if ($response->status() === 401 || in_array($errorTitle, self::TOKEN_ERROR_TITLES)) { - throw new TokenExpiredException( - "{$context}: {$message}", - $errorTitle - ); - } - - throw new \Exception("{$context}: {$message}"); + throw XPublishException::fromApiResponse($response); } } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index b0f98458..1cb271e6 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -1,49 +1,42 @@ validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); + $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); $account->refresh(); } - $this->accessToken = $account->access_token; - $media = $postPlatform->media; if ($media->isEmpty()) { @@ -51,121 +44,154 @@ public function publish(PostPlatform $postPlatform): array } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); - if (! $isVideo) { + if (! $firstMedia->isVideo()) { throw new \Exception('YouTube Shorts only supports video content.'); } - return $this->publishShort($postPlatform, $firstMedia); + return $this->publishShort($postPlatform, $firstMedia, $account, $content); } - private function getHttpClient(): PendingRequest + private function createGoogleClient(SocialAccount $account): GoogleClient { - return Http::withToken($this->accessToken) - ->timeout(600); + $client = new GoogleClient; + $client->setClientId(config('services.google.client_id')); + $client->setClientSecret(config('services.google.client_secret')); + + $remainingSeconds = $account->token_expires_at + ? max(0, (int) now()->diffInSeconds($account->token_expires_at, false)) + : 3600; + + $tokenData = [ + 'access_token' => $account->access_token, + 'created' => time(), + 'expires_in' => $remainingSeconds, + ]; + + if ($account->refresh_token) { + $tokenData['refresh_token'] = $account->refresh_token; + } + + $client->setAccessToken($tokenData); + + return $client; } - private function publishShort(PostPlatform $postPlatform, $media): array + private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account, ?string $content): array { - if (empty($postPlatform->content)) { + if (empty($content)) { throw new \Exception('YouTube Shorts require a title. Please add text to your post.'); } - $title = $this->buildTitle($postPlatform->content); - $description = $postPlatform->content; + $title = $this->buildTitle($content); + $description = $content; - Log::info('YouTube Shorts publishing video', [ - 'video_url' => $media->url, - 'title' => $title, - ]); + $tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_'); + $handle = null; - // Step 1: Get video content - $videoContent = file_get_contents($media->url); - $fileSize = strlen($videoContent); + try { + // Download video to temp file (memory-safe) + $downloadResponse = Http::withOptions(['sink' => $tempFile]) + ->timeout(600) + ->get($media->url); - Log::info('YouTube video file size', ['size' => $fileSize]); + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download video for YouTube upload: HTTP '.$downloadResponse->status()); + } - // Step 2: Initialize resumable upload - $initResponse = $this->getHttpClient() - ->withHeaders([ - 'Content-Type' => 'application/json; charset=UTF-8', - 'X-Upload-Content-Length' => $fileSize, - 'X-Upload-Content-Type' => $media->mime_type, - ]) - ->post("{$this->baseUrl}/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", [ - 'snippet' => [ - 'title' => $title, - 'description' => $description, - 'categoryId' => '22', // People & Blogs - ], - 'status' => [ - 'privacyStatus' => 'public', - 'selfDeclaredMadeForKids' => false, - ], + $fileSize = filesize($tempFile); + + if ($fileSize === false || $fileSize < 1024) { + throw new \Exception('Downloaded video is too small or empty ('.$fileSize.' bytes), aborting upload'); + } + + // Set up Google Client with deferred mode for resumable upload + $client = $this->createGoogleClient($account); + $client->setDefer(true); + + $youtube = new YouTube($client); + + // Build video metadata + $snippet = new VideoSnippet; + $snippet->setTitle($title); + $snippet->setDescription($description); + $snippet->setCategoryId('22'); + + $status = new VideoStatus; + $status->setPrivacyStatus('public'); + $status->setSelfDeclaredMadeForKids(false); + + $video = new Video; + $video->setSnippet($snippet); + $video->setStatus($status); + + // Initialize resumable upload request + $insertRequest = $youtube->videos->insert('snippet,status', $video); + + $mediaUpload = new Google_Http_MediaFileUpload( + $client, + $insertRequest, + $media->mime_type ?: 'video/mp4', + null, + true, + self::CHUNK_SIZE + ); + $mediaUpload->setFileSize($fileSize); + + // Upload in chunks (memory-safe for large files) + $uploadStatus = false; + $handle = fopen($tempFile, 'r'); + + if ($handle === false) { + throw new \Exception('Failed to open temp file for YouTube upload'); + } + + while (! $uploadStatus && ! feof($handle)) { + $chunk = fread($handle, self::CHUNK_SIZE); + $uploadStatus = $mediaUpload->nextChunk($chunk); + } + + fclose($handle); + $handle = null; + + $client->setDefer(false); + + if (! $uploadStatus instanceof Video) { + throw new \Exception('YouTube upload failed: no video object returned'); + } + + $videoId = $uploadStatus->getId(); + + return [ + 'id' => $videoId, + 'url' => "https://www.youtube.com/shorts/{$videoId}", + ]; + } catch (\Google\Service\Exception $e) { + $this->handleGoogleError($e); + } catch (\Throwable $e) { + Log::error('YouTube upload failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]); - if ($initResponse->failed()) { - Log::error('YouTube upload init failed', [ - 'status' => $initResponse->status(), - 'body' => $initResponse->body(), - ]); - $this->handleApiError($initResponse, 'YouTube API error'); + throw $e; + } finally { + if ($handle !== null && is_resource($handle)) { + fclose($handle); + } + + @unlink($tempFile); } - - $uploadUrl = $initResponse->header('Location'); - - if (! $uploadUrl) { - throw new \Exception('YouTube did not return an upload URL'); - } - - Log::info('YouTube upload initialized', ['uploadUrl' => $uploadUrl]); - - // Step 3: Upload the video content - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => $media->mime_type, - 'Content-Length' => $fileSize, - ]) - ->timeout(600) - ->withBody($videoContent, $media->mime_type) - ->put($uploadUrl); - - if ($uploadResponse->failed()) { - Log::error('YouTube video upload failed', [ - 'status' => $uploadResponse->status(), - 'body' => $uploadResponse->body(), - ]); - $this->handleApiError($uploadResponse, 'YouTube upload error'); - } - - $data = $uploadResponse->json(); - - Log::info('YouTube upload response', ['data' => $data]); - - $videoId = $data['id'] ?? null; - - if (! $videoId) { - throw new \Exception('YouTube did not return a video ID'); - } - - return [ - 'id' => $videoId, - 'url' => "https://www.youtube.com/shorts/{$videoId}", - ]; } private function buildTitle(string $content): string { - // YouTube title max is 100 characters - // For Shorts, add #Shorts hashtag to help YouTube classify it $maxLength = 100; $shortsTag = ' #Shorts'; $availableLength = $maxLength - strlen($shortsTag); - // Get first line or first sentence as title - $title = strtok($content, "\n"); - $title = strtok($title, '.'); + $firstLine = explode("\n", $content)[0]; + $title = explode('.', $firstLine)[0]; if (strlen($title) > $availableLength) { $title = substr($title, 0, $availableLength - 3).'...'; @@ -188,51 +214,23 @@ private function refreshToken(SocialAccount $account): void ]); if ($response->failed()) { - Log::error('YouTube token refresh failed', ['body' => $response->body()]); - $this->handleApiError($response, 'Failed to refresh YouTube token'); + 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['access_token'], - 'refresh_token' => $data['refresh_token'] ?? $account->refresh_token, - 'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null, + '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, ]); - Log::info('YouTube token refreshed successfully'); } - private function handleApiError(Response $response, string $context): void + private function handleGoogleError(\Google\Service\Exception $e): never { - $body = $response->json() ?? []; - - // Google OAuth error format - $errorCode = $body['error'] ?? null; - $errorDescription = $body['error_description'] ?? null; - - // YouTube API error format - $error = $body['error'] ?? []; - if (is_array($error)) { - $errors = $error['errors'] ?? []; - $reason = $errors[0]['reason'] ?? null; - $message = $error['message'] ?? $errorDescription ?? $response->body(); - } else { - $reason = null; - $message = $errorDescription ?? $response->body(); - } - - $isTokenError = $response->status() === 401 - || in_array($errorCode, self::TOKEN_ERROR_CODES) - || in_array($reason, self::TOKEN_ERROR_REASONS); - - if ($isTokenError) { - throw new TokenExpiredException( - "{$context}: {$message}", - is_string($errorCode) ? $errorCode : $reason - ); - } - - throw new \Exception("{$context}: {$message}"); + throw YouTubePublishException::fromGoogleException($e); } } diff --git a/app/Socialite/InstagramProvider.php b/app/Socialite/InstagramProvider.php index 63b42ec2..bdbd9967 100644 --- a/app/Socialite/InstagramProvider.php +++ b/app/Socialite/InstagramProvider.php @@ -1,5 +1,7 @@ [ 'grant_type' => 'ig_exchange_token', 'client_secret' => $this->clientSecret, - 'access_token' => $data['access_token'], + 'access_token' => data_get($data, 'access_token'), ], ]); diff --git a/app/Socialite/LinkedInPageExtendSocialite.php b/app/Socialite/LinkedInPageExtendSocialite.php index 5cffe538..e5439f48 100644 --- a/app/Socialite/LinkedInPageExtendSocialite.php +++ b/app/Socialite/LinkedInPageExtendSocialite.php @@ -1,5 +1,7 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + apiPrefix: '', commands: __DIR__.'/../routes/console.php', channels: __DIR__.'/../routes/channels.php', health: '/up', - then: function () { - Route::middleware('web') - ->group(base_path('routes/auth.php')); - }, ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); + $middleware->trustProxies(at: '*'); + + $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'locale']); $middleware->web(append: [ + SetLocale::class, HandleAppearance::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, @@ -31,8 +37,31 @@ $middleware->alias([ 'subscribed' => EnsureSubscribed::class, + 'api.auth' => AuthenticateApiToken::class, + 'mcp.auth' => AuthenticateMcpToken::class, ]); + + $middleware->preventRequestForgery(except: [ + 'stripe/*', + ]); + + $middleware->prependToPriorityList( + ThrottleRequests::class, + AuthenticateApiToken::class, + ); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->renderable(function (TooManyRequestsHttpException $e, Request $request) { + if ($request->expectsJson()) { + $retryAfter = $e->getHeaders()['Retry-After'] ?? null; + $message = $retryAfter + ? "Rate limit exceeded. Please retry after {$retryAfter} seconds." + : 'Rate limit exceeded. Please try again later.'; + + return response()->json([ + 'name' => 'rate_limit_exceeded', + 'message' => $message, + ], 429)->withHeaders($e->getHeaders()); + } + }); })->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 4e3b4407..2345aa62 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,6 +1,9 @@ =1.2.3", + "squizlabs/php_codesniffer": "^4.0", + "symfony/filesystem": "^6.3||^7.3", + "symfony/process": "^6.0||^7.0", + "webmozart/assert": "^1.11||^2.0" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Auth Library for PHP", + "homepage": "https://github.com/google/google-auth-library-php", + "keywords": [ + "Authentication", + "google", + "oauth2" + ], + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest", + "issues": "https://github.com/googleapis/google-auth-library-php/issues", + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.50.1" + }, + "time": "2026-03-18T20:03:29+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -1518,6 +1696,216 @@ }, "time": "2026-03-25T21:07:46+00:00" }, + { + "name": "intervention/gif", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "d856f59205aec768059d837148d755c079cdb94a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/d856f59205aec768059d837148d755c079cdb94a", + "reference": "d856f59205aec768059d837148d755c079cdb94a", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/5.0.0" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-03-21T05:08:17+00:00" + }, + { + "name": "intervention/image", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "66865f150576ab36e72d1096e8e108c072d40fff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/66865f150576ab36e72d1096e8e108c072d40fff", + "reference": "66865f150576ab36e72d1096e8e108c072d40fff", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^5", + "php": "^8.3" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" + } + ], + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/4.0.0" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-03-28T07:02:39+00:00" + }, + { + "name": "laravel/ai", + "version": "v0.4.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/ai.git", + "reference": "91441b6ae5bc995f21bb3043744860bd3043e78c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/ai/zipball/91441b6ae5bc995f21bb3043744860bd3043e78c", + "reference": "91441b6ae5bc995f21bb3043744860bd3043e78c", + "shasum": "" + }, + "require": { + "illuminate/console": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/filesystem": "^12.0|^13.0", + "illuminate/json-schema": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "laravel/prompts": "^0.3.6", + "laravel/serializable-closure": "^2.0", + "php": "^8.3", + "prism-php/prism": "^0.99.0" + }, + "require-dev": { + "laravel/pint": "^1.26", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^10.6|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Ai\\AiServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php" + ], + "psr-4": { + "Laravel\\Ai\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The official AI SDK for Laravel.", + "homepage": "https://github.com/laravel/ai", + "keywords": [ + "ai", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/ai/issues", + "source": "https://github.com/laravel/ai" + }, + "time": "2026-03-27T18:24:41+00:00" + }, { "name": "laravel/boost", "version": "v2.4.1", @@ -4468,6 +4856,59 @@ }, "time": "2026-01-25T14:56:51+00:00" }, + { + "name": "posthog/posthog-php", + "version": "4.1.1", + "source": { + "type": "git", + "url": "https://github.com/PostHog/posthog-php.git", + "reference": "6fe1346ea8b178a9b2c79777bc980892f0486cbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PostHog/posthog-php/zipball/6fe1346ea8b178a9b2c79777bc980892f0486cbb", + "reference": "6fe1346ea8b178a9b2c79777bc980892f0486cbb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.2", + "symfony/clock": "^6.2|^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "bin": [ + "bin/posthog" + ], + "type": "library", + "autoload": { + "psr-4": { + "PostHog\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PostHog ", + "homepage": "https://posthog.com/" + } + ], + "description": "PostHog PHP Library", + "homepage": "https://github.com/PostHog/posthog-php", + "keywords": [ + "posthog" + ], + "support": { + "issues": "https://github.com/PostHog/posthog-php/issues", + "source": "https://github.com/PostHog/posthog-php/tree/4.1.1" + }, + "time": "2026-03-30T12:02:24+00:00" + }, { "name": "predis/predis", "version": "v3.4.2", @@ -4531,6 +4972,134 @@ ], "time": "2026-03-09T20:33:04+00:00" }, + { + "name": "prism-php/prism", + "version": "v0.99.22", + "source": { + "type": "git", + "url": "https://github.com/prism-php/prism.git", + "reference": "989f67567aef69c613eae6e932d615fb96e2f5d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/prism-php/prism/zipball/989f67567aef69c613eae6e932d615fb96e2f5d7", + "reference": "989f67567aef69c613eae6e932d615fb96e2f5d7", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "laravel/framework": "^11.0|^12.0|^13.0", + "php": "^8.2" + }, + "require-dev": { + "brianium/paratest": "^7.8.4", + "laravel/mcp": "^0.6.0", + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-arch": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpdoc-parser": "^2.0", + "phpstan/phpstan": "2.1.34", + "phpstan/phpstan-deprecation-rules": "^2.0", + "projektgopher/whisky": "^0.7.0", + "rector/rector": "2.3.3", + "spatie/laravel-ray": "^1.39", + "symplify/rule-doc-generator-contracts": "^11.2" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PrismServer": "Prism\\Prism\\Facades\\PrismServer" + }, + "providers": [ + "Prism\\Prism\\PrismServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Prism\\Prism\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "TJ Miller", + "email": "hello@echolabs.dev" + } + ], + "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications.", + "support": { + "issues": "https://github.com/prism-php/prism/issues", + "source": "https://github.com/prism-php/prism/tree/v0.99.22" + }, + "funding": [ + { + "url": "https://github.com/sixlive", + "type": "github" + } + ], + "time": "2026-03-12T17:55:23+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -9984,6 +10553,75 @@ }, "time": "2026-03-23T15:56:34+00:00" }, + { + "name": "laravel/telescope", + "version": "v5.19.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/telescope.git", + "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b", + "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", + "php": "^8.0", + "symfony/console": "^5.3|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "ext-gd": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "laravel/octane": "^1.4|^2.0", + "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Telescope\\TelescopeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Telescope\\": "src/", + "Laravel\\Telescope\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mohamed Said", + "email": "mohamed@laravel.com" + } + ], + "description": "An elegant debug assistant for the Laravel framework.", + "keywords": [ + "debugging", + "laravel", + "monitoring" + ], + "support": { + "issues": "https://github.com/laravel/telescope/issues", + "source": "https://github.com/laravel/telescope/tree/v5.19.0" + }, + "time": "2026-03-24T18:37:14+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", diff --git a/config/ai.php b/config/ai.php new file mode 100644 index 00000000..fd9c922f --- /dev/null +++ b/config/ai.php @@ -0,0 +1,132 @@ + 'openai', + 'default_for_images' => 'gemini', + 'default_for_audio' => 'openai', + 'default_for_transcription' => 'openai', + 'default_for_embeddings' => 'openai', + 'default_for_reranking' => 'cohere', + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | Below you may configure caching strategies for AI related operations + | such as embedding generation. You are free to adjust these values + | based on your application's available caching stores and needs. + | + */ + + 'caching' => [ + 'embeddings' => [ + 'cache' => false, + 'store' => env('CACHE_STORE', 'database'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | AI Providers + |-------------------------------------------------------------------------- + | + | Below are each of your AI providers defined for this application. Each + | represents an AI provider and API key combination which can be used + | to perform tasks like text, image, and audio creation via agents. + | + */ + + 'providers' => [ + 'anthropic' => [ + 'driver' => 'anthropic', + 'key' => env('ANTHROPIC_API_KEY'), + ], + + 'azure' => [ + 'driver' => 'azure', + 'key' => env('AZURE_OPENAI_API_KEY'), + 'url' => env('AZURE_OPENAI_URL'), + 'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-10-21'), + 'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'), + 'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'), + ], + + 'cohere' => [ + 'driver' => 'cohere', + 'key' => env('COHERE_API_KEY'), + ], + + 'deepseek' => [ + 'driver' => 'deepseek', + 'key' => env('DEEPSEEK_API_KEY'), + ], + + 'eleven' => [ + 'driver' => 'eleven', + 'key' => env('ELEVENLABS_API_KEY'), + ], + + 'gemini' => [ + 'driver' => 'gemini', + 'key' => env('GEMINI_API_KEY'), + ], + + 'groq' => [ + 'driver' => 'groq', + 'key' => env('GROQ_API_KEY'), + ], + + 'jina' => [ + 'driver' => 'jina', + 'key' => env('JINA_API_KEY'), + ], + + 'mistral' => [ + 'driver' => 'mistral', + 'key' => env('MISTRAL_API_KEY'), + ], + + 'ollama' => [ + 'driver' => 'ollama', + 'key' => env('OLLAMA_API_KEY', ''), + 'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), + ], + + 'openai' => [ + 'driver' => 'openai', + 'key' => env('OPENAI_API_KEY'), + 'url' => env('OPENAI_URL', 'https://api.openai.com/v1'), + ], + + 'openrouter' => [ + 'driver' => 'openrouter', + 'key' => env('OPENROUTER_API_KEY'), + ], + + 'voyageai' => [ + 'driver' => 'voyageai', + 'key' => env('VOYAGEAI_API_KEY'), + ], + + 'xai' => [ + 'driver' => 'xai', + 'key' => env('XAI_API_KEY'), + ], + ], + +]; diff --git a/config/app.php b/config/app.php index 423eed59..997df9a8 100644 --- a/config/app.php +++ b/config/app.php @@ -1,5 +1,7 @@ env('APP_NAME', 'Laravel'), + 'name' => env('APP_NAME', 'TryPost.it'), /* |-------------------------------------------------------------------------- @@ -52,7 +54,7 @@ | */ - 'url' => env('APP_URL', 'http://localhost'), + 'url' => env('APP_URL', 'https://trypost.it'), /* |-------------------------------------------------------------------------- @@ -84,6 +86,18 @@ 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + /* + |-------------------------------------------------------------------------- + | Application Pagination + |-------------------------------------------------------------------------- + | + | This is default pagination value for the application. + | + */ + 'pagination' => [ + 'default' => 25, + ], + /* |-------------------------------------------------------------------------- | Encryption Key diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..ffe7b034 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,5 +1,9 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/broadcasting.php b/config/broadcasting.php index ebc3fb9c..221915a6 100644 --- a/config/broadcasting.php +++ b/config/broadcasting.php @@ -1,5 +1,7 @@ true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -79,7 +82,7 @@ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/filesystems.php b/config/filesystems.php index 9003fd23..8c1fd705 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -1,5 +1,7 @@ 0, 'memory' => 128, 'tries' => 1, - 'timeout' => 60, + 'timeout' => 630, + 'nice' => 0, + ], + + 'social-publishing' => [ + 'connection' => 'redis', + 'queue' => Platform::allQueues(), + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'timeout' => 630, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, 'nice' => 0, ], ], @@ -231,12 +249,22 @@ 'balanceMaxShift' => 1, 'balanceCooldown' => 3, ], + + 'social-publishing' => [ + 'maxProcesses' => 10, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], ], 'local' => [ 'supervisor-1' => [ 'maxProcesses' => 3, ], + + 'social-publishing' => [ + 'maxProcesses' => 3, + ], ], ], ]; diff --git a/config/inertia.php b/config/inertia.php index d7213b06..4da733d8 100644 --- a/config/inertia.php +++ b/config/inertia.php @@ -1,5 +1,7 @@ [ + 'en' => 'English', + 'es' => 'Español', + 'pt-BR' => 'Português', + ], + + /* + |-------------------------------------------------------------------------- + | Default Language + |-------------------------------------------------------------------------- + | + | The default locale code assigned to new users. + | + */ + + 'default' => 'en', + +]; diff --git a/config/logging.php b/config/logging.php index 9e998a49..aa0d9feb 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,5 +1,7 @@ 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 660), 'block_for' => null, 'after_commit' => false, ], diff --git a/config/reverb.php b/config/reverb.php index d7fc88e0..4806f3d0 100644 --- a/config/reverb.php +++ b/config/reverb.php @@ -1,5 +1,7 @@ env('GOOGLE_CLIENT_REDIRECT'), ], + // Google OAuth (used for login/signup) + 'google-auth' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_AUTH_CALLBACK'), + ], + // Facebook Pages 'facebook' => [ 'client_id' => env('FACEBOOK_CLIENT_ID'), @@ -95,4 +104,13 @@ 'redirect' => env('PINTEREST_CLIENT_REDIRECT'), ], + 'gtm' => [ + 'id' => env('GTM_ID'), + ], + + 'posthog' => [ + 'api_key' => env('POSTHOG_API_KEY'), + 'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'), + ], + ]; diff --git a/config/session.php b/config/session.php index f5744827..d0c30db8 100644 --- a/config/session.php +++ b/config/session.php @@ -1,6 +1,6 @@ env( - 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' - ), + 'cookie' => env('SESSION_COOKIE', 'trypost-session'), /* |-------------------------------------------------------------------------- diff --git a/config/telescope.php b/config/telescope.php new file mode 100644 index 00000000..b45689b3 --- /dev/null +++ b/config/telescope.php @@ -0,0 +1,214 @@ + env('TELESCOPE_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Telescope Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Telescope will be accessible from. If the + | setting is null, Telescope will reside under the same domain as the + | application. Otherwise, this value will be used as the subdomain. + | + */ + + 'domain' => env('TELESCOPE_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Telescope Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Telescope will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('TELESCOPE_PATH', 'debug'), + + /* + |-------------------------------------------------------------------------- + | Telescope Storage Driver + |-------------------------------------------------------------------------- + | + | This configuration options determines the storage driver that will + | be used to store Telescope's data. In addition, you may set any + | custom options as needed by the particular driver you choose. + | + */ + + 'driver' => env('TELESCOPE_DRIVER', 'database'), + + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + 'chunk' => 1000, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Queue + |-------------------------------------------------------------------------- + | + | This configuration options determines the queue connection and queue + | which will be used to process ProcessPendingUpdate jobs. This can + | be changed if you would prefer to use a non-default connection. + | + */ + + 'queue' => [ + 'connection' => env('TELESCOPE_QUEUE_CONNECTION'), + 'queue' => env('TELESCOPE_QUEUE'), + 'delay' => env('TELESCOPE_QUEUE_DELAY', 10), + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will be assigned to every Telescope route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => [ + 'web', + Authorize::class, + ], + + /* + |-------------------------------------------------------------------------- + | Allowed / Ignored Paths & Commands + |-------------------------------------------------------------------------- + | + | The following array lists the URI paths and Artisan commands that will + | not be watched by Telescope. In addition to this list, some Laravel + | commands, like migrations and queue commands, are always ignored. + | + */ + + 'only_paths' => [ + // 'api/*' + ], + + 'ignore_paths' => [ + 'livewire*', + 'nova-api*', + 'pulse*', + '_boost*', + '.well-known*', + ], + + 'ignore_commands' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Watchers + |-------------------------------------------------------------------------- + | + | The following array lists the "watchers" that will be registered with + | Telescope. The watchers gather the application's profile data when + | a request or task is executed. Feel free to customize this list. + | + */ + + 'watchers' => [ + Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true), + + Watchers\CacheWatcher::class => [ + 'enabled' => env('TELESCOPE_CACHE_WATCHER', true), + 'hidden' => [], + 'ignore' => [], + ], + + Watchers\ClientRequestWatcher::class => [ + 'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), + 'ignore_hosts' => [], + ], + + Watchers\CommandWatcher::class => [ + 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true), + 'ignore' => [], + ], + + Watchers\DumpWatcher::class => [ + 'enabled' => env('TELESCOPE_DUMP_WATCHER', true), + 'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false), + ], + + Watchers\EventWatcher::class => [ + 'enabled' => env('TELESCOPE_EVENT_WATCHER', true), + 'ignore' => [], + ], + + Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true), + + Watchers\GateWatcher::class => [ + 'enabled' => env('TELESCOPE_GATE_WATCHER', true), + 'ignore_abilities' => [], + 'ignore_packages' => true, + 'ignore_paths' => [], + ], + + Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true), + + Watchers\LogWatcher::class => [ + 'enabled' => env('TELESCOPE_LOG_WATCHER', true), + 'level' => 'error', + ], + + Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), + + Watchers\ModelWatcher::class => [ + 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), + 'events' => ['eloquent.*'], + 'hydrations' => true, + ], + + Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true), + + Watchers\QueryWatcher::class => [ + 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), + 'ignore_packages' => true, + 'ignore_paths' => [], + 'slow' => 100, + ], + + Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true), + + Watchers\RequestWatcher::class => [ + 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), + 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), + 'ignore_http_methods' => [], + 'ignore_status_codes' => [], + ], + + Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true), + Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true), + ], +]; diff --git a/config/trypost.php b/config/trypost.php index 95a9f76d..f860264f 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -1,5 +1,7 @@ + */ +class ApiTokenFactory extends Factory +{ + /** + * The plain token generated during creation. + */ + public static ?string $lastPlainToken = null; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $plainToken = 'tp_'.Str::random(48); + static::$lastPlainToken = $plainToken; + + return [ + 'workspace_id' => Workspace::factory(), + 'name' => fake()->words(2, true), + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + 'last_used_at' => null, + 'expires_at' => null, + ]; + } + + /** + * Mark the token as expired. + */ + public function expired(): static + { + return $this->state(fn () => [ + 'expires_at' => now()->subDay(), + ]); + } +} diff --git a/database/factories/LanguageFactory.php b/database/factories/LanguageFactory.php deleted file mode 100644 index 318bbe9b..00000000 --- a/database/factories/LanguageFactory.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -class LanguageFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => 'English', - 'code' => 'en', - ]; - } -} diff --git a/database/factories/MediaFactory.php b/database/factories/MediaFactory.php index 94c14315..5c62ba5f 100644 --- a/database/factories/MediaFactory.php +++ b/database/factories/MediaFactory.php @@ -1,5 +1,7 @@ + * @extends Factory */ class MediaFactory extends Factory { diff --git a/database/factories/NotificationFactory.php b/database/factories/NotificationFactory.php new file mode 100644 index 00000000..95a16756 --- /dev/null +++ b/database/factories/NotificationFactory.php @@ -0,0 +1,37 @@ + + */ +class NotificationFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'workspace_id' => Workspace::factory(), + 'type' => fake()->randomElement(Type::cases()), + 'channel' => Channel::InApp, + 'title' => fake()->sentence(4), + 'body' => fake()->sentence(10), + 'data' => null, + 'read_at' => null, + ]; + } + + public function read(): static + { + return $this->state(fn () => ['read_at' => now()]); + } +} diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php index c5188ec0..594b044a 100644 --- a/database/factories/PostFactory.php +++ b/database/factories/PostFactory.php @@ -1,14 +1,17 @@ + * @extends Factory */ class PostFactory extends Factory { diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index 95557b2c..ed831fef 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -1,15 +1,19 @@ + * @extends Factory */ class PostPlatformFactory extends Factory { @@ -27,7 +31,7 @@ public function definition(): array 'platform' => Platform::LinkedIn, 'content' => $this->faker->paragraph(), 'content_type' => ContentType::LinkedInPost, - 'status' => 'pending', + 'status' => Status::Pending, 'meta' => [], ]; } @@ -42,7 +46,7 @@ public function disabled(): static public function published(): static { return $this->state(fn (array $attributes) => [ - 'status' => 'published', + 'status' => Status::Published, 'platform_post_id' => $this->faker->uuid(), 'platform_url' => $this->faker->url(), 'published_at' => now(), @@ -52,7 +56,7 @@ public function published(): static public function failed(): static { return $this->state(fn (array $attributes) => [ - 'status' => 'failed', + 'status' => Status::Failed, 'error_message' => 'Failed to publish', ]); } diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index b0d2e20a..c98f924f 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -1,14 +1,17 @@ + * @extends Factory */ class SocialAccountFactory extends Factory { @@ -38,6 +41,7 @@ public function linkedin(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::LinkedIn, + 'scopes' => Platform::LinkedIn->requiredPublishScopes(), ]); } @@ -45,6 +49,7 @@ public function linkedinPage(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::LinkedInPage, + 'scopes' => Platform::LinkedInPage->requiredPublishScopes(), ]); } @@ -52,6 +57,7 @@ public function x(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::X, + 'scopes' => Platform::X->requiredPublishScopes(), ]); } @@ -59,6 +65,7 @@ public function tiktok(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::TikTok, + 'scopes' => Platform::TikTok->requiredPublishScopes(), ]); } @@ -66,6 +73,7 @@ public function youtube(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::YouTube, + 'scopes' => Platform::YouTube->requiredPublishScopes(), ]); } @@ -73,6 +81,7 @@ public function facebook(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Facebook, + 'scopes' => Platform::Facebook->requiredPublishScopes(), ]); } @@ -80,6 +89,7 @@ public function instagram(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Instagram, + 'scopes' => Platform::Instagram->requiredPublishScopes(), ]); } @@ -87,6 +97,7 @@ public function threads(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Threads, + 'scopes' => Platform::Threads->requiredPublishScopes(), ]); } @@ -94,6 +105,7 @@ public function pinterest(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Pinterest, + 'scopes' => Platform::Pinterest->requiredPublishScopes(), ]); } @@ -101,6 +113,7 @@ public function bluesky(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Bluesky, + 'scopes' => Platform::Bluesky->requiredPublishScopes(), 'token_expires_at' => now()->addHours(2), 'meta' => [ 'service' => 'https://bsky.social', @@ -114,6 +127,7 @@ public function mastodon(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Mastodon, + 'scopes' => Platform::Mastodon->requiredPublishScopes(), 'token_expires_at' => null, // Mastodon tokens don't expire 'meta' => [ 'instance' => 'https://mastodon.social', diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index cba2d87b..7c5fff22 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -1,14 +1,16 @@ + * @extends Factory */ class UserFactory extends Factory { @@ -30,7 +32,11 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), - 'language_id' => Language::factory(), + 'current_workspace_id' => null, + 'stripe_id' => null, + 'pm_type' => null, + 'pm_last_four' => null, + 'trial_ends_at' => null, 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php index 6de65ad6..c698e81e 100644 --- a/database/factories/WorkspaceFactory.php +++ b/database/factories/WorkspaceFactory.php @@ -1,12 +1,15 @@ + * @extends Factory */ class WorkspaceFactory extends Factory { diff --git a/database/factories/WorkspaceHashtagFactory.php b/database/factories/WorkspaceHashtagFactory.php index 93de23f8..3ec9782d 100644 --- a/database/factories/WorkspaceHashtagFactory.php +++ b/database/factories/WorkspaceHashtagFactory.php @@ -1,12 +1,15 @@ + * @extends Factory */ class WorkspaceHashtagFactory extends Factory { diff --git a/database/factories/WorkspaceInviteFactory.php b/database/factories/WorkspaceInviteFactory.php index 5a810e94..5e2e45bf 100644 --- a/database/factories/WorkspaceInviteFactory.php +++ b/database/factories/WorkspaceInviteFactory.php @@ -1,13 +1,16 @@ + * @extends Factory */ class WorkspaceInviteFactory extends Factory { diff --git a/database/factories/WorkspaceLabelFactory.php b/database/factories/WorkspaceLabelFactory.php index 064251fc..7e8baeae 100644 --- a/database/factories/WorkspaceLabelFactory.php +++ b/database/factories/WorkspaceLabelFactory.php @@ -1,12 +1,15 @@ + * @extends Factory */ class WorkspaceLabelFactory extends Factory { diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 03dc3353..9f9b61a5 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -1,5 +1,7 @@ uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('token_lookup', 16)->unique(); + $table->string('token_hash', 255); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_tokens'); + } +}; diff --git a/database/migrations/2026_03_29_232520_create_agent_conversations_table.php b/database/migrations/2026_03_29_232520_create_agent_conversations_table.php new file mode 100644 index 00000000..4fe11658 --- /dev/null +++ b/database/migrations/2026_03_29_232520_create_agent_conversations_table.php @@ -0,0 +1,52 @@ +uuid('id')->primary(); + $table->foreignId('user_id')->nullable(); + $table->string('title'); + $table->timestamps(); + + $table->index(['user_id', 'updated_at']); + }); + + Schema::create('agent_conversation_messages', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('conversation_id', 36)->index(); + $table->foreignId('user_id')->nullable(); + $table->string('agent'); + $table->string('role', 25); + $table->text('content'); + $table->text('attachments'); + $table->text('tool_calls'); + $table->text('tool_results'); + $table->text('usage'); + $table->text('meta'); + $table->timestamps(); + + $table->index(['conversation_id', 'user_id', 'updated_at'], 'conversation_index'); + $table->index(['user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('agent_conversations'); + Schema::dropIfExists('agent_conversation_messages'); + } +}; diff --git a/database/migrations/2026_03_29_233024_create_telescope_entries_table.php b/database/migrations/2026_03_29_233024_create_telescope_entries_table.php new file mode 100644 index 00000000..972f583f --- /dev/null +++ b/database/migrations/2026_03_29_233024_create_telescope_entries_table.php @@ -0,0 +1,72 @@ +getConnection()); + + $schema->create('telescope_entries', function (Blueprint $table) { + $table->bigIncrements('sequence'); + $table->uuid('uuid'); + $table->uuid('batch_id'); + $table->string('family_hash')->nullable(); + $table->boolean('should_display_on_index')->default(true); + $table->string('type', 20); + $table->longText('content'); + $table->dateTime('created_at')->nullable(); + + $table->unique('uuid'); + $table->index('batch_id'); + $table->index('family_hash'); + $table->index('created_at'); + $table->index(['type', 'should_display_on_index']); + }); + + $schema->create('telescope_entries_tags', function (Blueprint $table) { + $table->uuid('entry_uuid'); + $table->string('tag'); + + $table->primary(['entry_uuid', 'tag']); + $table->index('tag'); + + $table->foreign('entry_uuid') + ->references('uuid') + ->on('telescope_entries') + ->cascadeOnDelete(); + }); + + $schema->create('telescope_monitoring', function (Blueprint $table) { + $table->string('tag')->primary(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $schema = Schema::connection($this->getConnection()); + + $schema->dropIfExists('telescope_entries_tags'); + $schema->dropIfExists('telescope_entries'); + $schema->dropIfExists('telescope_monitoring'); + } +}; diff --git a/database/migrations/2026_03_30_024008_replace_language_id_with_locale_on_users.php b/database/migrations/2026_03_30_024008_replace_language_id_with_locale_on_users.php new file mode 100644 index 00000000..74b763a9 --- /dev/null +++ b/database/migrations/2026_03_30_024008_replace_language_id_with_locale_on_users.php @@ -0,0 +1,53 @@ +string('locale', 10)->default('en')->after('current_workspace_id'); + }); + + // Migrate existing language_id to locale code + DB::statement(' + UPDATE users + SET locale = COALESCE( + (SELECT code FROM languages WHERE languages.id = users.language_id), + \'en\' + ) + '); + + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['language_id']); + $table->dropColumn('language_id'); + }); + + Schema::dropIfExists('languages'); + } + + public function down(): void + { + Schema::create('languages', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('name'); + $table->string('code'); + $table->timestamps(); + }); + + Schema::table('users', function (Blueprint $table) { + $table->uuid('language_id')->nullable()->after('current_workspace_id'); + $table->foreign('language_id')->references('id')->on('languages')->nullOnDelete(); + }); + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('locale'); + }); + } +}; diff --git a/database/migrations/2026_03_30_115716_drop_locale_from_users.php b/database/migrations/2026_03_30_115716_drop_locale_from_users.php new file mode 100644 index 00000000..29542842 --- /dev/null +++ b/database/migrations/2026_03_30_115716_drop_locale_from_users.php @@ -0,0 +1,24 @@ +dropColumn('locale'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('locale', 10)->default('en')->after('current_workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_03_30_120000_add_indexes_to_post_platforms.php b/database/migrations/2026_03_30_120000_add_indexes_to_post_platforms.php new file mode 100644 index 00000000..db4e5449 --- /dev/null +++ b/database/migrations/2026_03_30_120000_add_indexes_to_post_platforms.php @@ -0,0 +1,26 @@ +index(['post_id', 'enabled']); + $table->index('social_account_id'); + }); + } + + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table) { + $table->dropIndex(['post_id', 'enabled']); + $table->dropIndex(['social_account_id']); + }); + } +}; diff --git a/database/migrations/2026_03_30_191151_create_notifications_table.php b/database/migrations/2026_03_30_191151_create_notifications_table.php new file mode 100644 index 00000000..c88ef05a --- /dev/null +++ b/database/migrations/2026_03_30_191151_create_notifications_table.php @@ -0,0 +1,36 @@ +uuid('id')->primary(); + $table->uuid('user_id'); + $table->uuid('workspace_id'); + $table->string('type'); + $table->string('channel'); + $table->string('title'); + $table->text('body'); + $table->json('data')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete(); + $table->index(['user_id', 'workspace_id', 'read_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_03_30_202513_create_notification_preferences_table.php b/database/migrations/2026_03_30_202513_create_notification_preferences_table.php new file mode 100644 index 00000000..cd11c197 --- /dev/null +++ b/database/migrations/2026_03_30_202513_create_notification_preferences_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->uuid('user_id'); + $table->boolean('post_published')->default(true); + $table->boolean('post_failed')->default(true); + $table->boolean('account_disconnected')->default(true); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_preferences'); + } +}; diff --git a/database/migrations/2026_03_30_215118_add_is_active_to_social_accounts.php b/database/migrations/2026_03_30_215118_add_is_active_to_social_accounts.php new file mode 100644 index 00000000..6a2a890c --- /dev/null +++ b/database/migrations/2026_03_30_215118_add_is_active_to_social_accounts.php @@ -0,0 +1,24 @@ +boolean('is_active')->default(true)->after('status'); + }); + } + + public function down(): void + { + Schema::table('social_accounts', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + } +}; diff --git a/database/migrations/2026_03_30_235524_make_password_nullable_in_users_table.php b/database/migrations/2026_03_30_235524_make_password_nullable_in_users_table.php new file mode 100644 index 00000000..e1155e86 --- /dev/null +++ b/database/migrations/2026_03_30_235524_make_password_nullable_in_users_table.php @@ -0,0 +1,24 @@ +string('password')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('password')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_04_01_140435_add_error_context_to_post_platforms_table.php b/database/migrations/2026_04_01_140435_add_error_context_to_post_platforms_table.php new file mode 100644 index 00000000..2bbd6e59 --- /dev/null +++ b/database/migrations/2026_04_01_140435_add_error_context_to_post_platforms_table.php @@ -0,0 +1,22 @@ +json('error_context')->nullable()->after('error_message'); + }); + } + + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table) { + $table->dropColumn('error_context'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 563e208b..a19a78b1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,5 +1,7 @@ call([ - LanguageSeeder::class, - ]); + // } } diff --git a/database/seeders/LanguageSeeder.php b/database/seeders/LanguageSeeder.php deleted file mode 100644 index d1902ad2..00000000 --- a/database/seeders/LanguageSeeder.php +++ /dev/null @@ -1,18 +0,0 @@ - 'English', 'code' => 'en']); - Language::create(['name' => 'Português', 'code' => 'pt-br']); - } -} diff --git a/docs/superpowers/plans/2026-03-31-image-resize.md b/docs/superpowers/plans/2026-03-31-image-resize.md new file mode 100644 index 00000000..a553aef3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-image-resize.md @@ -0,0 +1,281 @@ +# Image Resize Per Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically optimize images before publishing to each social platform — resize, convert format, and reduce quality to meet each platform's limits. + +**Architecture:** A `MediaOptimizer` service with per-platform config (max width, max size, format, quality). Each publisher calls `optimizeImage()` before uploading. Uses a quality reduction loop to guarantee file size compliance. + +**Tech Stack:** Laravel 13, PHP 8.4, Intervention Image v4, Pest 4 + +**Spec:** `docs/superpowers/specs/2026-03-31-image-resize-design.md` + +--- + +### Task 1: Install Intervention Image + +**Files:** +- Modify: `composer.json` + +- [ ] **Step 1: Install the package** + +```bash +composer require intervention/image +``` + +- [ ] **Step 2: Verify installation** + +```bash +php artisan tinker --execute "echo Intervention\Image\ImageManager::class;" +``` + +Expected: `Intervention\Image\ImageManager` + +- [ ] **Step 3: Commit** + +```bash +git add composer.json composer.lock +git commit -m "chore: install intervention/image v4" +``` + +--- + +### Task 2: Create MediaOptimizer service with tests + +**Files:** +- Create: `app/Services/Media/MediaOptimizer.php` +- Test: `tests/Unit/Services/Media/MediaOptimizerTest.php` + +- [ ] **Step 1: Write failing tests** + +Create `tests/Unit/Services/Media/MediaOptimizerTest.php` with tests: + +1. `it optimizes image for instagram (converts to jpeg, max 1440px width)` + - Create a 2000px wide PNG test image using Intervention + - Optimize for Instagram + - Assert output is JPEG, width <= 1440, file size <= 8MB + +2. `it optimizes image for bluesky (under 1MB)` + - Create a large JPEG test image + - Optimize for Bluesky + - Assert output file size < 1MB (976KB) + +3. `it reduces quality to meet size limit` + - Create a high-quality large image + - Optimize for Bluesky (976KB limit) + - Assert output fits within limit + +4. `it does not upscale small images` + - Create a 500px wide image + - Optimize for Instagram (max 1440px) + - Assert width stays 500px (not upscaled) + +5. `it returns original if already within limits` + - Create a small JPEG under all limits + - Optimize for Facebook + - Assert output exists and is valid JPEG + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +php artisan test --compact --filter=MediaOptimizer +``` + +- [ ] **Step 3: Implement MediaOptimizer** + +Create `app/Services/Media/MediaOptimizer.php` with: +- `optimizeImage(string $filePath, Platform $platform): string` — returns path to optimized temp file +- `getImageConfig(Platform $platform): array` — returns config per platform from spec +- Quality reduction loop: if file exceeds max_size, reduce quality by 10 until it fits or quality reaches 30 + +Use the code from the spec. Use `ImageManager::gd()` as the driver. + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +php artisan test --compact --filter=MediaOptimizer +``` + +- [ ] **Step 5: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Services/Media/MediaOptimizer.php tests/Unit/Services/Media/MediaOptimizerTest.php +git commit -m "feat: add MediaOptimizer service with per-platform image optimization" +``` + +--- + +### Task 3: Integrate MediaOptimizer into BlueskyPublisher + +**Files:** +- Modify: `app/Services/Social/BlueskyPublisher.php` + +Bluesky is the most critical — hard 1MB limit. + +- [ ] **Step 1: Update uploadBlob method** + +In `BlueskyPublisher::uploadBlob()`, after downloading to temp file and before uploading: + +```php +// If it's an image, optimize for Bluesky +if (str_starts_with($mimeType, 'image/')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky); + @unlink($tempFile); + $tempFile = $optimizedPath; + $mimeType = 'image/jpeg'; // MediaOptimizer converts to JPEG +} +``` + +Remove the existing "Bluesky has 1MB limit" warning log — the optimizer handles it now. + +- [ ] **Step 2: Run tests** + +```bash +php artisan test --compact --filter=Bluesky +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat: BlueskyPublisher uses MediaOptimizer for 1MB image limit" +``` + +--- + +### Task 4: Integrate MediaOptimizer into X, LinkedIn, LinkedInPage publishers + +**Files:** +- Modify: `app/Services/Social/XPublisher.php` +- Modify: `app/Services/Social/LinkedInPublisher.php` +- Modify: `app/Services/Social/LinkedInPagePublisher.php` + +These publishers upload images directly (not via URL pull). + +- [ ] **Step 1: Update XPublisher::uploadMedia** + +In the `uploadMedia` method, after downloading to temp file and before upload, optimize images: + +```php +if (str_starts_with($mimeType, 'image/') && !str_starts_with($mimeType, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X); + @unlink($tempFile); + $tempFile = $optimizedPath; + $mimeType = 'image/jpeg'; + $fileSize = filesize($tempFile); +} +``` + +Note: Skip GIFs — they need special handling (animated). + +- [ ] **Step 2: Update LinkedInPublisher::uploadImage** + +In the `uploadImage` method, optimize before uploading: + +```php +$optimizer = app(MediaOptimizer::class); +$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedIn); +``` + +- [ ] **Step 3: Update LinkedInPagePublisher::uploadImage** + +Same as LinkedIn but with `Platform::LinkedInPage`. + +- [ ] **Step 4: Run tests** + +```bash +php artisan test --compact +``` + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat: X, LinkedIn, LinkedInPage publishers use MediaOptimizer for images" +``` + +--- + +### Task 5: Integrate MediaOptimizer into Mastodon and Pinterest publishers + +**Files:** +- Modify: `app/Services/Social/MastodonPublisher.php` +- Modify: `app/Services/Social/PinterestPublisher.php` + +- [ ] **Step 1: Update MastodonPublisher::uploadMedia** + +After downloading to temp file, optimize images before upload: + +```php +if (str_starts_with($mimeType, 'image/') && !str_starts_with($mimeType, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Mastodon); + @unlink($tempFile); + $tempFile = $optimizedPath; +} +``` + +Note: Mastodon `uploadMedia` currently doesn't detect mime type from the media model. Need to pass it through or detect from temp file. + +- [ ] **Step 2: Update PinterestPublisher** + +Pinterest image pins upload images. Optimize before the multipart upload. + +- [ ] **Step 3: Run tests** + +```bash +php artisan test --compact +``` + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat: Mastodon, Pinterest publishers use MediaOptimizer for images" +``` + +--- + +### Task 6: Skip optimization for URL-pull platforms + +**Files:** None (verification only) + +Instagram, Facebook, Threads, and TikTok use URL pull — their APIs download media from our CDN. These platforms handle resize on their side. No changes needed. + +- [ ] **Step 1: Verify URL-pull platforms don't need optimization** + +Verify that these publishers pass `$media->url` directly to the API (not uploading binary): +- `InstagramPublisher` — uses `image_url` / `video_url` params +- `FacebookPublisher` — uses `url` / `file_url` params +- `ThreadsPublisher` — uses `image_url` / `video_url` params +- `TikTokPublisher` — uses `PULL_FROM_URL` source + +No code changes needed. Just verify and document. + +- [ ] **Step 2: Commit verification note** + +No commit needed — just verification. + +--- + +### Task 7: Final verification + +- [ ] **Step 1: Run full test suite** + +```bash +php artisan test --compact +``` + +All tests must pass. + +- [ ] **Step 2: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 3: Final commit and push** + +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-31-social-error-mapping.md b/docs/superpowers/plans/2026-03-31-social-error-mapping.md new file mode 100644 index 00000000..82192a74 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-social-error-mapping.md @@ -0,0 +1,368 @@ +# Social Error Mapping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace generic exceptions in all social publishers with platform-specific exceptions that give users clear error messages and provide structured context for Nightwatch. + +**Architecture:** Abstract base `SocialPublishException` with Laravel's native `context()` method, per-platform subclasses that parse API responses via `fromApiResponse()`, and an `ErrorCategory` enum. Token errors stay as `TokenExpiredException`. The `PublishToSocialPlatform` job catches `SocialPublishException` separately. + +**Tech Stack:** Laravel 13, PHP 8.4, Pest 4 + +**Spec:** `docs/superpowers/specs/2026-03-31-social-error-mapping-design.md` + +--- + +### Task 1: Create ErrorCategory enum and SocialPublishException base class + +**Files:** +- Create: `app/Exceptions/Social/ErrorCategory.php` +- Create: `app/Exceptions/Social/SocialPublishException.php` +- Test: `tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php` + +- [ ] **Step 1: Create ErrorCategory enum** + +Create `app/Exceptions/Social/ErrorCategory.php` with cases: MediaFormat, RateLimit, Permission, ContentPolicy, ServerError, Unknown. + +- [ ] **Step 2: Create SocialPublishException base class** + +Create `app/Exceptions/Social/SocialPublishException.php` with constructor taking `$userMessage`, `$category`, `$platformErrorCode`, `$rawResponse`. Implement `context()` method. Declare abstract `fromApiResponse()` and `platform()`. + +- [ ] **Step 3: Write test for base class context()** + +Create test verifying `context()` returns correct array with platform, category, error code, message, and raw response. + +- [ ] **Step 4: Run tests** + +Run: `php artisan test --compact --filter=SocialPublishException` + +- [ ] **Step 5: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Exceptions/Social/ tests/Unit/Exceptions/Social/ +git commit -m "feat: add ErrorCategory enum and SocialPublishException base class" +``` + +--- + +### Task 2: Create InstagramPublishException + +**Files:** +- Create: `app/Exceptions/Social/InstagramPublishException.php` +- Test: `tests/Unit/Exceptions/Social/InstagramPublishExceptionTest.php` + +- [ ] **Step 1: Write failing tests** + +Test that: +- Subcode 2207026 maps to "Unsupported video format" with MediaFormat category +- Subcode 2207042 maps to RateLimit category +- Subcode 2207050 maps to Permission category +- OAuthException type throws TokenExpiredException +- Unknown subcode falls through to error_user_msg +- Unknown subcode without error_user_msg falls through to error.message + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Implement InstagramPublishException** + +Use the code from the spec — match on `error_subcode` (int), handle all 25 Instagram error codes. Check token errors first. Fallback to `error_user_msg` then `error.message`. + +- [ ] **Step 4: Run tests to verify they pass** + +- [ ] **Step 5: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Exceptions/Social/InstagramPublishException.php tests/Unit/Exceptions/Social/InstagramPublishExceptionTest.php +git commit -m "feat: add InstagramPublishException with 25 error codes" +``` + +--- + +### Task 3: Create TikTokPublishException + +**Files:** +- Create: `app/Exceptions/Social/TikTokPublishException.php` +- Test: `tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php` + +- [ ] **Step 1: Write failing tests** + +Test HTTP errors (access_token_invalid → TokenExpiredException, rate_limit_exceeded → RateLimit, file_format_check_failed → MediaFormat) and fail_reason errors (spam_risk_too_many_posts → RateLimit, video_pull_failed → ServerError). + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Implement TikTokPublishException** + +Two parsing paths: HTTP response errors match on `error.code` string, publish status errors match on `fail_reason` string. Add static method `fromFailReason(string $failReason, ?string $rawResponse)` for publish status failures. + +- [ ] **Step 4: Run tests to verify they pass** + +- [ ] **Step 5: Run Pint and commit** + +```bash +git commit -m "feat: add TikTokPublishException with HTTP and fail_reason errors" +``` + +--- + +### Task 4: Create YouTubePublishException + +**Files:** +- Create: `app/Exceptions/Social/YouTubePublishException.php` +- Test: `tests/Unit/Exceptions/Social/YouTubePublishExceptionTest.php` + +- [ ] **Step 1: Write failing tests** + +Test: invalidTitle → ContentPolicy, uploadLimitExceeded → RateLimit, forbidden → Permission, HTTP 401 → TokenExpiredException. + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Implement YouTubePublishException** + +Parse `Google\Service\Exception` — match on `getErrors()[0]['reason']` string. Handle HTTP 401 as TokenExpiredException. + +- [ ] **Step 4: Run tests to verify they pass** + +- [ ] **Step 5: Run Pint and commit** + +```bash +git commit -m "feat: add YouTubePublishException with 15 error reasons" +``` + +--- + +### Task 5: Create FacebookPublishException + +**Files:** +- Create: `app/Exceptions/Social/FacebookPublishException.php` +- Test: `tests/Unit/Exceptions/Social/FacebookPublishExceptionTest.php` + +- [ ] **Step 1: Write failing tests** + +Test: code 1363031 → MediaFormat, code 190 → TokenExpiredException, code 4 → RateLimit, code 1363042 → Permission. + +- [ ] **Step 2: Implement and test** + +Match on `error.code` (int). Token errors checked first by OAuthException type or code 190 + subcodes 458-467. Map all 30 error codes from spec. + +- [ ] **Step 3: Run Pint and commit** + +```bash +git commit -m "feat: add FacebookPublishException with 30 error codes" +``` + +--- + +### Task 6: Create remaining 6 platform exceptions + +**Files:** +- Create: `app/Exceptions/Social/LinkedInPublishException.php` +- Create: `app/Exceptions/Social/XPublishException.php` +- Create: `app/Exceptions/Social/ThreadsPublishException.php` +- Create: `app/Exceptions/Social/PinterestPublishException.php` +- Create: `app/Exceptions/Social/BlueskyPublishException.php` +- Create: `app/Exceptions/Social/MastodonPublishException.php` +- Test: `tests/Unit/Exceptions/Social/` (one test file per exception) + +- [ ] **Step 1: Create LinkedInPublishException with tests** + +Match on HTTP status + body text. 5 error mappings from spec. + +- [ ] **Step 2: Create XPublishException with tests** + +Match on Problem `type` suffix + HTTP status. 10 error mappings from spec. + +- [ ] **Step 3: Create ThreadsPublishException with tests** + +Same Graph API format as Instagram. Match on error.type + error.code. + +- [ ] **Step 4: Create PinterestPublishException with tests** + +Match on HTTP status + processing status. 6 error mappings. + +- [ ] **Step 5: Create BlueskyPublishException with tests** + +Match on AT Protocol error strings. 6 error mappings. + +- [ ] **Step 6: Create MastodonPublishException with tests** + +Match on HTTP status + error message text. 7 error mappings. + +- [ ] **Step 7: Run full test suite and commit** + +```bash +php artisan test --compact +git commit -m "feat: add error mapping for LinkedIn, X, Threads, Pinterest, Bluesky, Mastodon" +``` + +--- + +### Task 7: Update PublishToSocialPlatform job + +**Files:** +- Modify: `app/Jobs/PublishToSocialPlatform.php` +- Test: `tests/Feature/Jobs/PublishToSocialPlatformTest.php` + +- [ ] **Step 1: Write failing test** + +Test that when a publisher throws `SocialPublishException`, the job saves `$e->userMessage` to `error_message` (not the raw API response). + +- [ ] **Step 2: Add SocialPublishException catch block** + +Between the `TokenExpiredException` catch and the `\Throwable` catch, add: + +```php +} catch (SocialPublishException $e) { + Log::error('Social publish failed: ' . $e->userMessage); + $this->postPlatform->markAsFailed($e->userMessage); +} +``` + +- [ ] **Step 3: Run tests to verify pass** + +- [ ] **Step 4: Run Pint and commit** + +```bash +git commit -m "feat: PublishToSocialPlatform catches SocialPublishException" +``` + +--- + +### Task 8: Replace handleApiError in InstagramPublisher + +**Files:** +- Modify: `app/Services/Social/InstagramPublisher.php` +- Test: `tests/Feature/Jobs/PublishToSocialPlatformTest.php` (existing Instagram tests) + +- [ ] **Step 1: Replace handleApiError method** + +Replace the existing `handleApiError` with: + +```php +private function handleApiError(Response $response): never +{ + throw InstagramPublishException::fromApiResponse($response); +} +``` + +Remove the `TOKEN_ERROR_CODES` and `TOKEN_ERROR_SUBCODES` constants (now handled inside the exception). + +- [ ] **Step 2: Run tests** + +Run: `php artisan test --compact --filter=PublishToSocialPlatform` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "refactor: InstagramPublisher uses InstagramPublishException" +``` + +--- + +### Task 9: Replace handleApiError in TikTok, YouTube, Facebook publishers + +**Files:** +- Modify: `app/Services/Social/TikTokPublisher.php` +- Modify: `app/Services/Social/YouTubePublisher.php` +- Modify: `app/Services/Social/FacebookPublisher.php` + +- [ ] **Step 1: Update TikTokPublisher** + +Replace `handleApiError` with `TikTokPublishException::fromApiResponse()`. Also update the `waitForPublishStatus` method to use `TikTokPublishException::fromFailReason()` when status is FAILED. + +- [ ] **Step 2: Update YouTubePublisher** + +Replace `handleGoogleError` with `YouTubePublishException::fromGoogleException()`. This takes a `Google\Service\Exception` instead of an HTTP response. + +- [ ] **Step 3: Update FacebookPublisher** + +Replace `handleApiError` with `FacebookPublishException::fromApiResponse()`. Remove TOKEN_ERROR constants. + +- [ ] **Step 4: Run tests** + +Run: `php artisan test --compact` + +- [ ] **Step 5: Commit** + +```bash +git commit -m "refactor: TikTok, YouTube, Facebook publishers use platform exceptions" +``` + +--- + +### Task 10: Replace handleApiError in remaining 6 publishers + +**Files:** +- Modify: `app/Services/Social/LinkedInPublisher.php` +- Modify: `app/Services/Social/LinkedInPagePublisher.php` +- Modify: `app/Services/Social/XPublisher.php` +- Modify: `app/Services/Social/ThreadsPublisher.php` +- Modify: `app/Services/Social/PinterestPublisher.php` +- Modify: `app/Services/Social/BlueskyPublisher.php` +- Modify: `app/Services/Social/MastodonPublisher.php` + +- [ ] **Step 1: Update LinkedInPublisher and LinkedInPagePublisher** + +Both share the same error format. Replace `handleApiError` with `LinkedInPublishException::fromApiResponse()`. + +- [ ] **Step 2: Update XPublisher** + +Replace `handleApiError` with `XPublishException::fromApiResponse()`. + +- [ ] **Step 3: Update ThreadsPublisher** + +Replace `handleApiError` with `ThreadsPublishException::fromApiResponse()`. Remove TOKEN constants. + +- [ ] **Step 4: Update PinterestPublisher** + +Replace `handleApiError` with `PinterestPublishException::fromApiResponse()`. + +- [ ] **Step 5: Update BlueskyPublisher** + +Bluesky error handling is scattered (inline checks). Consolidate into `BlueskyPublishException::fromApiResponse()`. + +- [ ] **Step 6: Update MastodonPublisher** + +Replace `handleApiError` with `MastodonPublishException::fromApiResponse()`. + +- [ ] **Step 7: Run full test suite** + +Run: `php artisan test --compact` + +- [ ] **Step 8: Commit** + +```bash +git commit -m "refactor: all publishers use platform-specific exceptions" +``` + +--- + +### Task 11: Final verification + +- [ ] **Step 1: Run full test suite** + +```bash +php artisan test --compact +``` + +All 917+ tests must pass. + +- [ ] **Step 2: Verify no remaining generic exceptions in publishers** + +```bash +grep -rn "throw new \\\\Exception" app/Services/Social/ | grep -v "TokenExpiredException\|SocialPublishException" +``` + +Should return only legitimate non-API exceptions (e.g., "requires media", "only supports video"). + +- [ ] **Step 3: Run Pint** + +```bash +vendor/bin/pint --dirty --format agent +``` + +- [ ] **Step 4: Final commit and push** + +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-04-01-publishing-engine-improvements.md b/docs/superpowers/plans/2026-04-01-publishing-engine-improvements.md new file mode 100644 index 00000000..b8d12443 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-publishing-engine-improvements.md @@ -0,0 +1,470 @@ +# Publishing Engine Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve publishing reliability with rate limit retry, inline token refresh, per-platform concurrency control, and proactive token refresh. + +**Architecture:** A shared `HasSocialHttpClient` trait for rate limit retry, a `TokenRefresher` service for centralized refresh logic, Horizon per-platform queues for concurrency, and a scheduled command for proactive refresh. + +**Tech Stack:** Laravel 13, PHP 8.4, Horizon, Redis, Pest 4 + +**Spec:** `docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md` + +**Scope:** Tasks 1-4 are for implementation now. Tasks 5-6 are documented for future sprints. + +--- + +## NOW — Implement + +### Task 1: Rate limit retry (429 handling) + +**Files:** +- Create: `app/Services/Social/Concerns/HasSocialHttpClient.php` +- Modify: All 11 publishers to use the trait +- Test: `tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php` + +- [ ] **Step 1: Write failing test for the trait** + +Create test that verifies: +- HTTP 429 response triggers automatic retry (up to 3 times) +- After 3 retries, the exception is thrown +- Non-429 errors are not retried +- Successful response after retry is returned normally + +```php +test('socialHttp retries on 429 responses', function () { + Http::fake([ + 'api.example.com/*' => Http::sequence() + ->push(['error' => 'rate_limit'], 429) + ->push(['data' => 'success'], 200), + ]); + + $client = new class { use HasSocialHttpClient; }; + $response = $client->socialHttp()->get('https://api.example.com/test'); + + expect($response->status())->toBe(200); + Http::assertSentCount(2); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +- [ ] **Step 3: Create HasSocialHttpClient trait** + +```php + $exception->response?->status() === 429, + throw: false, + )->timeout(120); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +- [ ] **Step 5: Integrate into all publishers** + +Replace `Http::withToken(...)` calls with `$this->socialHttp()->withToken(...)` in each publisher. The trait adds rate limit retry to all API calls automatically. + +For each publisher: +1. Add `use HasSocialHttpClient;` to the class +2. Replace direct `Http::` calls that hit platform APIs with `$this->socialHttp()->` +3. Keep `Http::withOptions(['sink' => ...])` for downloads (those don't need retry) + +Publishers to update: +- InstagramPublisher (uses `Http::post`, `Http::get`) +- FacebookPublisher (uses `Http::post`) +- TikTokPublisher (has `getHttpClient()` method — update it to use trait) +- YouTubePublisher (uses Google SDK — skip, SDK has its own retry) +- LinkedInPublisher (has `getHttpClient()` method — update it) +- LinkedInPagePublisher (has `getHttpClient()` method — update it) +- XPublisher (uses `Http::withToken`) +- ThreadsPublisher (uses `Http::post`, `Http::get`) +- PinterestPublisher (uses `Http::withToken`) +- BlueskyPublisher (uses `Http::withToken`) +- MastodonPublisher (uses `Http::withToken`) + +- [ ] **Step 6: Run all publisher tests** + +```bash +php artisan test --compact --filter="Unit.*Publisher" +``` + +- [ ] **Step 7: Commit** + +```bash +git commit -m "feat: add rate limit retry (429) to all publishers via HasSocialHttpClient trait" +``` + +--- + +### Task 2: Token refresh inline during publishing + +**Files:** +- Create: `app/Services/Social/TokenRefresher.php` +- Modify: `app/Jobs/PublishToSocialPlatform.php` +- Test: `tests/Unit/Services/Social/TokenRefresherTest.php` +- Test: `tests/Feature/Jobs/PublishToSocialPlatformTest.php` (add inline refresh test) + +- [ ] **Step 1: Create TokenRefresher service** + +Extract the refresh logic from `ConnectionVerifier::refreshTokenIfNeeded` into a standalone service that all publishers and the job can use: + +```php +platform) { + Platform::LinkedIn, Platform::LinkedInPage => $this->refreshLinkedIn($account), + Platform::X => $this->refreshX($account), + Platform::YouTube => $this->refreshYouTube($account), + Platform::TikTok => $this->refreshTikTok($account), + Platform::Pinterest => $this->refreshPinterest($account), + Platform::Threads => $this->refreshThreads($account), + Platform::Instagram => $this->refreshInstagram($account), + Platform::Bluesky => $this->refreshBluesky($account), + default => throw new TokenExpiredException('Token refresh not supported for ' . $account->platform->value), + }; + + $account->refresh(); + } + + // ... private methods extracted from ConnectionVerifier +} +``` + +- [ ] **Step 2: Write test for TokenRefresher** + +Test that each platform refresh works (mock HTTP calls). + +- [ ] **Step 3: Update PublishToSocialPlatform job with inline retry** + +Replace the current try/catch with a retry loop: + +```php +$maxAttempts = 2; + +for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + $publisher = $this->getPublisher(); + $result = $publisher->publish($this->postPlatform); + $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); + break; + } catch (TokenExpiredException $e) { + if ($attempt < $maxAttempts) { + try { + app(TokenRefresher::class)->refresh($this->postPlatform->socialAccount); + continue; + } catch (\Throwable $refreshError) { + // Refresh failed — fall through to disconnect + } + } + + Log::error('Token expired while publishing', [...]); + $this->postPlatform->markAsFailed($e->getMessage()); + $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); + break; + } catch (SocialPublishException $e) { + Log::error('Social publish failed: ' . $e->userMessage); + $this->postPlatform->markAsFailed($e->userMessage); + break; + } catch (\Throwable $e) { + Log::error('Unexpected publish error', [...]); + $this->postPlatform->markAsFailed($e->getMessage()); + break; + } +} +``` + +- [ ] **Step 4: Write test for inline token refresh** + +Test that when publish throws `TokenExpiredException`, the job refreshes the token and retries. On second failure, it disconnects. + +- [ ] **Step 5: Update ConnectionVerifier to use TokenRefresher** + +Replace duplicated refresh logic in `ConnectionVerifier` with calls to `TokenRefresher`. + +- [ ] **Step 6: Remove duplicated refresh methods from publishers** + +Each publisher currently has its own `refreshToken()` method. After `TokenRefresher` exists, publishers should delegate to it. However, this is a larger refactor — for now, keep the publisher refresh methods and just add the inline retry in the job. + +- [ ] **Step 7: Run tests and commit** + +```bash +php artisan test --compact --filter="PublishToSocialPlatform|TokenRefresher" +git commit -m "feat: inline token refresh retry during publishing" +``` + +--- + +### Task 3: Per-platform concurrency control via Horizon queues + +**Files:** +- Modify: `config/horizon.php` +- Modify: `app/Jobs/PublishToSocialPlatform.php` + +- [ ] **Step 1: Add per-platform queues to Horizon config** + +In `config/horizon.php`, add supervisor blocks for each platform: + +```php +'environments' => [ + 'production' => [ + 'social-default' => [ + 'connection' => 'redis', + 'queue' => [ + 'social-instagram', + 'social-facebook', + 'social-tiktok', + 'social-youtube', + 'social-linkedin', + 'social-linkedin-page', + 'social-x', + 'social-threads', + 'social-pinterest', + 'social-bluesky', + 'social-mastodon', + ], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'timeout' => 630, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, + 'nice' => 0, + ], + ], + 'local' => [ + 'social-default' => [ + 'connection' => 'redis', + 'queue' => [ + 'social-instagram', + 'social-facebook', + 'social-tiktok', + 'social-youtube', + 'social-linkedin', + 'social-linkedin-page', + 'social-x', + 'social-threads', + 'social-pinterest', + 'social-bluesky', + 'social-mastodon', + ], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 1, + 'timeout' => 630, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, + 'nice' => 0, + ], + ], +], +``` + +- [ ] **Step 2: Update PublishToSocialPlatform to dispatch to platform queue** + +```php +public function __construct(public PostPlatform $postPlatform) +{ + $this->onQueue('social-' . $postPlatform->platform->value); +} +``` + +- [ ] **Step 3: Run tests and commit** + +```bash +php artisan test --compact --filter="PublishToSocialPlatform" +git commit -m "feat: per-platform Horizon queues for concurrency control" +``` + +--- + +### Task 4: Proactive token refresh + +**Files:** +- Create: `app/Console/Commands/RefreshExpiringTokens.php` +- Create: `app/Jobs/RefreshSocialToken.php` +- Modify: `routes/console.php` +- Test: `tests/Feature/Commands/RefreshExpiringTokensTest.php` + +- [ ] **Step 1: Create RefreshSocialToken job** + +```php +refresh($this->account); + } catch (\Throwable $e) { + Log::warning('Proactive token refresh failed', [ + 'account_id' => $this->account->id, + 'platform' => $this->account->platform->value, + 'error' => $e->getMessage(), + ]); + } + } +} +``` + +- [ ] **Step 2: Create RefreshExpiringTokens command** + +```php +where('status', Status::Connected) + ->whereNotNull('token_expires_at') + ->where('token_expires_at', '<=', now()->addHours(2)) + ->where('token_expires_at', '>', now()) + ->chunk(50, fn ($accounts) => $accounts->each( + fn ($account) => RefreshSocialToken::dispatch($account) + )); + } +} +``` + +- [ ] **Step 3: Schedule the command** + +In `routes/console.php`: +```php +Schedule::command(RefreshExpiringTokens::class)->hourly(); +``` + +- [ ] **Step 4: Write tests** + +Test that the command dispatches jobs for accounts with tokens expiring in 2 hours, and does NOT dispatch for tokens expiring in 5 hours or already expired. + +- [ ] **Step 5: Run tests and commit** + +```bash +php artisan test --compact --filter="RefreshExpiring" +git commit -m "feat: proactive token refresh for tokens expiring within 2 hours" +``` + +--- + +## FUTURE — Plan Only (Not Implementing Now) + +### Task 5: Webhooks post-publish + +**Scope:** Full webhook system for post lifecycle events. + +**Data model:** +- `webhooks` table: id, workspace_id, url, events (json array), secret (encrypted), is_active, created_at, updated_at +- Events: `post.published`, `post.failed`, `post.partially_published`, `account.disconnected` + +**Architecture:** +- `Webhook` model with `workspace` relationship +- `SendWebhook` job — dispatched after status change, signs payload with HMAC-SHA256, retries 3x with exponential backoff +- Webhook management CRUD (controller, form requests, Vue components) +- Webhook delivery logs table for debugging + +**Integration points:** +- `PublishToSocialPlatform` job — dispatch `SendWebhook` after markAsPublished/markAsFailed +- `SocialAccount::markAsDisconnected` — dispatch `SendWebhook` for account.disconnected + +**Estimated effort:** 2-3 days (backend + frontend + tests) + +--- + +### Task 6: Threads / Comments support + +**Scope:** Support posting a main post + sequential comments/replies as a thread. + +**Data model changes:** +- Add `parent_id` (nullable, self-referencing FK) to `post_platforms` +- Add `delay_seconds` (int, default 0) to `post_platforms` +- Add `thread_position` (int) to `post_platforms` + +**Publisher changes:** +- Add `comment(string $postId, string $content, ?array $media): array` method to each publisher that supports it: + - Instagram: `POST /{media-id}/comments` + - X/Twitter: `POST /2/tweets` with `reply.in_reply_to_tweet_id` + - Facebook: `POST /{post-id}/comments` + - LinkedIn: `POST /rest/socialActions/{post-urn}/comments` + - Threads: `POST /{user-id}/threads` with `reply_to_id` + +**Job changes:** +- `PublishToSocialPlatform` publishes main post first +- Then iterates over child posts (ordered by thread_position) +- Waits `delay_seconds` between each +- Each child calls `publisher->comment()` with the parent's platform_post_id + +**Frontend changes:** +- Thread builder UI in post editor +- Drag-to-reorder thread items +- Per-item content and media +- Delay configuration between items + +**Estimated effort:** 1-2 weeks (data model + backend + frontend + tests) diff --git a/docs/superpowers/specs/2026-03-31-image-resize-design.md b/docs/superpowers/specs/2026-03-31-image-resize-design.md new file mode 100644 index 00000000..66a8604c --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-image-resize-design.md @@ -0,0 +1,196 @@ +# Image & Media Resize Per Platform + +## Problem + +Each social platform has different limits for image size, resolution, format, and aspect ratio. Currently we upload media as-is — if it exceeds a platform's limits, the API rejects it. We need to automatically resize/convert images before publishing. + +## Solution + +Install `intervention/image` and create a `MediaOptimizer` service that processes images per platform before upload. Each platform has a configuration defining its limits, and the optimizer ensures media meets them. + +## Library + +[Intervention Image v4](https://image.intervention.io/v4) — PHP image handling library supporting GD and Imagick drivers. + +## Platform Media Specifications (from official docs) + +### Images + +| Platform | Max Size | Formats | Max Resolution | Aspect Ratio | Source | +|---|---|---|---|---|---| +| **Instagram** | 8 MB | JPEG only | 1440px width, min 320px | 4:5 to 1.91:1 | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| **Facebook** | 4 MB (PNG: 1 MB rec.) | JPEG, PNG, BMP, GIF, TIFF | Auto-resized | No limit | [Official](https://developers.facebook.com/docs/graph-api/reference/page/photos/) | +| **X/Twitter** | 5 MB | JPG, PNG, GIF, WEBP | No hard limit | No limit | [Official](https://docs.x.com/x-api/media/quickstart/best-practices) | +| **TikTok** | 20 MB | JPEG, WebP | 1080px max | No limit | [Official](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | +| **YouTube** | N/A | N/A | N/A | N/A | Video only | +| **LinkedIn** | < 36M pixels | JPG, GIF, PNG | < 36,152,320 pixels total | No limit | [Official](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api) | +| **Threads** | 8 MB | JPEG only | Same as Instagram | Same as Instagram | Same API as Instagram | +| **Pinterest** | 20 MB (desktop), 32 MB (app) | PNG, JPEG | 1000x1500 recommended | 2:3 recommended | [Official](https://help.pinterest.com/en/business/article/pinterest-product-specs) | +| **Bluesky** | 1 MB | Any | No hard limit | No limit | [Official](https://docs.bsky.app/docs/advanced-guides/posts) | +| **Mastodon** | Instance-dependent (~10 MB) | JPG, PNG, GIF, WebP | No hard limit | No limit | [Official](https://docs.joinmastodon.org/methods/statuses/) | + +### Videos + +| Platform | Max Size | Formats | Codec | Max Resolution | Duration | Aspect Ratio | Source | +|---|---|---|---|---|---|---|---| +| **Instagram Feed** | 100 MB | MP4, MOV | H.264/HEVC | 1920px | 3s-60min | 4:5 to 1.91:1 | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| **Instagram Reel** | 300 MB | MP4, MOV | H.264/HEVC | 1920px | 3s-15min | 9:16 rec. | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| **Instagram Story** | 100 MB | MP4, MOV | H.264/HEVC | 1920px | 3-60s | 9:16 rec. | [Official](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | +| **Facebook** | 2 GB | MP4 | H.264 | No limit | 1s-40min | No limit | [Official](https://developers.facebook.com/docs/video-api/reference/error-codes/) | +| **X/Twitter** | 512 MB | MP4 | H.264 High | 1280x1024 | 0.5-140s | 1:3 to 3:1 | [Official](https://docs.x.com/x-api/media/quickstart/best-practices) | +| **TikTok** | 4 GB | MP4, WebM, MOV | H.264/H.265/VP8/VP9 | 4096px | Up to 10min | No limit | [Official](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | +| **YouTube** | 128 GB | MP4, MOV, AVI, WebM+ | H.264 rec. | No limit | Up to 12h | No limit | [Official](https://developers.google.com/youtube/v3/docs/videos/insert) | +| **LinkedIn** | 500 MB | MP4 | H.264 | No limit | 3s-30min | No limit | [Official](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api) | +| **Pinterest** | 2 GB | MP4, MOV, M4V | H.264/H.265 | No limit | 4s-15min | 1:2 to 1.91:1 | [Official](https://help.pinterest.com/en/business/article/pinterest-product-specs) | +| **Bluesky** | 50 MB | MP4 | H.264 | 1920px | Up to 60s | No limit | [Official](https://docs.bsky.app/docs/advanced-guides/posts) | +| **Mastodon** | Instance-dependent (~40 MB) | MP4, WebM | H.264/VP9 | No limit | No limit | No limit | [Official](https://docs.joinmastodon.org/methods/statuses/) | + +## Architecture + +### MediaOptimizer Service + +```php +manager = ImageManager::gd(); // or ::imagick() + } + + /** + * Optimize an image for a specific platform. + * Returns path to optimized temp file (caller must clean up). + */ + public function optimizeImage(string $filePath, Platform $platform): string + { + $config = $this->getImageConfig($platform); + $image = $this->manager->read($filePath); + + // Resize if needed (maintain aspect ratio) + if ($config['max_width'] && $image->width() > $config['max_width']) { + $image->scaleDown(width: $config['max_width']); + } + + // Convert format if needed + $tempFile = tempnam(sys_get_temp_dir(), 'media_opt_'); + $encoded = $image->encodeByMediaType($config['format'], quality: $config['quality']); + file_put_contents($tempFile, $encoded); + + // Check file size, reduce quality if still too large + while (filesize($tempFile) > $config['max_size'] && $config['quality'] > 30) { + $config['quality'] -= 10; + $encoded = $image->encodeByMediaType($config['format'], quality: $config['quality']); + file_put_contents($tempFile, $encoded); + } + + return $tempFile; + } + + private function getImageConfig(Platform $platform): array + { + return match ($platform) { + Platform::Instagram, Platform::Threads => [ + 'max_width' => 1440, + 'max_size' => 8 * 1024 * 1024, // 8 MB + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::Facebook => [ + 'max_width' => 2048, + 'max_size' => 4 * 1024 * 1024, // 4 MB + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::X => [ + 'max_width' => 2048, + 'max_size' => 5 * 1024 * 1024, // 5 MB + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::TikTok => [ + 'max_width' => 1080, + 'max_size' => 20 * 1024 * 1024, // 20 MB + 'format' => 'image/jpeg', + 'quality' => 95, + ], + Platform::LinkedIn, Platform::LinkedInPage => [ + 'max_width' => 2048, + 'max_size' => 10 * 1024 * 1024, // 10 MB (practical limit) + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::Pinterest => [ + 'max_width' => 1000, + 'max_size' => 20 * 1024 * 1024, // 20 MB + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::Bluesky => [ + 'max_width' => 2048, + 'max_size' => 976 * 1024, // ~976 KB (under 1 MB with margin) + 'format' => 'image/jpeg', + 'quality' => 85, + ], + Platform::Mastodon => [ + 'max_width' => 2048, + 'max_size' => 10 * 1024 * 1024, // 10 MB + 'format' => 'image/jpeg', + 'quality' => 90, + ], + Platform::YouTube => [ + 'max_width' => 1920, + 'max_size' => 2 * 1024 * 1024, // 2 MB (thumbnails only) + 'format' => 'image/jpeg', + 'quality' => 90, + ], + }; + } +} +``` + +### Integration with Publishers + +Each publisher calls `MediaOptimizer::optimizeImage()` before uploading images: + +```php +// In publisher (e.g., BlueskyPublisher): +$optimizer = app(MediaOptimizer::class); +$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky); + +try { + // upload $optimizedPath +} finally { + @unlink($optimizedPath); +} +``` + +### What we DON'T do (video transcoding) + +Video transcoding (converting codecs, changing resolution) requires FFmpeg and is computationally expensive. For now: +- We validate video format/size before upload +- We let the platform API reject incompatible videos with clear error messages (from the error mapping spec) +- Video transcoding is a future feature if needed + +## Testing + +- Unit tests for `MediaOptimizer` with sample images of different sizes/formats +- Verify resize maintains aspect ratio +- Verify quality reduction loop stops at threshold +- Verify format conversion (PNG → JPEG) +- Verify Bluesky always produces < 1 MB output + +## Files Changed + +- Install: `intervention/image` v4 via composer +- Create: `app/Services/Media/MediaOptimizer.php` +- Modify: Publishers that upload images directly (Bluesky, X, LinkedIn, LinkedInPage, Mastodon, Pinterest) +- Create: `tests/Unit/Services/Media/MediaOptimizerTest.php` diff --git a/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md b/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md new file mode 100644 index 00000000..215ff9f4 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-social-error-mapping-design.md @@ -0,0 +1,553 @@ +# Social Platform Error Mapping + +## Problem + +All publishers throw generic `\Exception` or `TokenExpiredException` with raw API error messages. Users see cryptic strings like `"Instagram API error: Only photo or video can be accepted as media type."` instead of actionable messages. Debugging requires reading raw logs. + +## Solution + +Create per-platform exception classes inside `app/Exceptions/Social/` that parse API responses and return clear user-facing messages, a categorized error type, and structured context for Nightwatch via Laravel's native `context()` method. + +## Architecture + +### Directory Structure + +``` +app/Exceptions/ + TokenExpiredException.php (existing, unchanged) + Social/ + SocialPublishException.php (abstract base) + ErrorCategory.php (enum) + InstagramPublishException.php + TikTokPublishException.php + YouTubePublishException.php + FacebookPublishException.php + LinkedInPublishException.php + XPublishException.php + ThreadsPublishException.php + PinterestPublishException.php + BlueskyPublishException.php + MastodonPublishException.php +``` + +### ErrorCategory Enum + +```php + + */ + public function context(): array + { + return [ + 'platform' => static::platform(), + 'category' => $this->category->value, + 'platform_error_code' => $this->platformErrorCode, + 'user_message' => $this->userMessage, + 'raw_response' => $this->rawResponse, + ]; + } + + /** + * Parse an API response and return a platform-specific exception. + */ + abstract public static function fromApiResponse(mixed $response): static; + + /** + * Platform identifier for logging. + */ + abstract protected static function platform(): string; +} +``` + +### Per-Platform Exception (Example: Instagram) + +`fromApiResponse` receives the Laravel HTTP response, checks for token errors first, then matches on `error_subcode` (the reliable identifier per Instagram's official error docs). When the API includes `error_user_msg`, we prefer that over our own message since it's localized by Meta. + +Reference: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/ + +```php +json() ?? []; + $error = data_get($json, 'error', []); + $errorCode = data_get($error, 'code'); + $errorSubcode = data_get($error, 'error_subcode'); + $errorType = data_get($error, 'type'); + $errorUserMsg = data_get($error, 'error_user_msg'); + + // Token errors throw TokenExpiredException + if ($errorType === 'OAuthException' || $errorCode === 190) { + throw new TokenExpiredException( + data_get($error, 'message', 'Instagram token expired'), + (string) $errorCode, + ); + } + + // Match on error_subcode (official Instagram error identifier) + [$message, $category] = match ($errorSubcode) { + // Media format errors + 2207026 => ['Unsupported video format. Please upload MP4 or MOV.', ErrorCategory::MediaFormat], + 2207005 => ['Unsupported image format.', ErrorCategory::MediaFormat], + 2207004 => ['Image is too large (max 8MB).', ErrorCategory::MediaFormat], + 2207009 => ['Aspect ratio not supported (must be between 4:5 and 1.91:1).', ErrorCategory::MediaFormat], + 2207057 => ['Thumbnail offset is outside the video duration.', ErrorCategory::MediaFormat], + 2207023 => ['Unknown media type.', ErrorCategory::MediaFormat], + + // Upload/processing errors + 2207003 => ['Media download timed out. Please try again.', ErrorCategory::ServerError], + 2207020 => ['Media has expired. Please upload again.', ErrorCategory::ServerError], + 2207032 => ['Failed to create media. Please try again.', ErrorCategory::ServerError], + 2207053 => ['Unknown upload error. Please try again.', ErrorCategory::ServerError], + 2207052 => ['Could not fetch media from URL. Please try again.', ErrorCategory::ServerError], + 2207006 => ['Media not found. Please upload again.', ErrorCategory::ServerError], + 2207008 => ['Media container expired. Please try again in a few minutes.', ErrorCategory::ServerError], + 2207027 => ['Media is not ready for publishing. Please wait and try again.', ErrorCategory::ServerError], + 2207001 => ['Instagram server error. Please try again.', ErrorCategory::ServerError], + + // Content validation + 2207010 => ['Caption is too long (max 2,200 characters, 30 hashtags, 20 @mentions).', ErrorCategory::ContentPolicy], + 2207028 => ['Carousel needs between 2 and 10 photos/videos.', ErrorCategory::ContentPolicy], + 2207051 => ['Instagram restricted this action to protect the community.', ErrorCategory::ContentPolicy], + + // Product tagging + 2207035 => ['Product tag positions are not supported for videos.', ErrorCategory::ContentPolicy], + 2207036 => ['Product tag positions are required for photos.', ErrorCategory::ContentPolicy], + 2207037 => ['Invalid product tag. The product may be deleted or not permitted.', ErrorCategory::ContentPolicy], + 2207040 => ['Too many tags (max 20).', ErrorCategory::ContentPolicy], + + // Rate limits + 2207042 => ['Daily publishing limit reached. Please try again tomorrow.', ErrorCategory::RateLimit], + + // Permissions + 2207050 => ['Instagram account is restricted or inactive. Please check the Instagram app.', ErrorCategory::Permission], + 2207081 => ["This account doesn't support Trial Reels.", ErrorCategory::Permission], + + // Fall through — use Instagram's own error_user_msg if available + default => [null, ErrorCategory::Unknown], + }; + + // Prefer Instagram's own user-facing message when we don't have a mapping + $message ??= $errorUserMsg ?? data_get($error, 'message', 'Instagram publishing failed'); + + return new static( + $message, + $category, + $errorSubcode ? (string) $errorSubcode : (string) $errorCode, + $response->body(), + ); + } +} +``` + +### Error Maps Per Platform + +Sources: Official API documentation + Postiz error mappings. + +--- + +#### Instagram (25 errors) + +Source: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/ + +Match on `error_subcode` (int). Use `error_user_msg` as fallback when available. + +| Subcode | Message | Category | +|---|---|---| +| 2207026 | Unsupported video format. Please upload MP4 or MOV. | MediaFormat | +| 2207005 | Unsupported image format. | MediaFormat | +| 2207004 | Image is too large (max 8MB). | MediaFormat | +| 2207009 | Aspect ratio not supported (must be between 4:5 and 1.91:1). | MediaFormat | +| 2207057 | Thumbnail offset is outside the video duration. | MediaFormat | +| 2207023 | Unknown media type. | MediaFormat | +| 2207003 | Media download timed out. Please try again. | ServerError | +| 2207020 | Media has expired. Please upload again. | ServerError | +| 2207032 | Failed to create media. Please try again. | ServerError | +| 2207053 | Unknown upload error. Please try again. | ServerError | +| 2207052 | Could not fetch media from URL. Please try again. | ServerError | +| 2207006 | Media not found. Please upload again. | ServerError | +| 2207008 | Media container expired. Please try again in a few minutes. | ServerError | +| 2207027 | Media is not ready for publishing. Please wait and try again. | ServerError | +| 2207001 | Instagram server error. Please try again. | ServerError | +| 2207010 | Caption is too long (max 2,200 characters, 30 hashtags, 20 @mentions). | ContentPolicy | +| 2207028 | Carousel needs between 2 and 10 photos/videos. | ContentPolicy | +| 2207051 | Instagram restricted this action to protect the community. | ContentPolicy | +| 2207035 | Product tag positions are not supported for videos. | ContentPolicy | +| 2207036 | Product tag positions are required for photos. | ContentPolicy | +| 2207037 | Invalid product tag. The product may be deleted or not permitted. | ContentPolicy | +| 2207040 | Too many tags (max 20). | ContentPolicy | +| 2207042 | Daily publishing limit reached. Please try again tomorrow. | RateLimit | +| 2207050 | Instagram account is restricted or inactive. Please check the Instagram app. | Permission | +| 2207081 | This account doesn't support Trial Reels. | Permission | + +Token errors: `OAuthException` type or code `190` → `TokenExpiredException`. + +--- + +#### TikTok (20 errors) + +Sources: https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status/ and https://developers.tiktok.com/doc/tiktok-api-v2-error-handling + +Two types of errors: HTTP response errors (match on `error` string) and publish status fail reasons (match on `fail_reason` string). + +**HTTP errors:** + +| Error Code | Message | Category | +|---|---|---| +| `access_token_invalid` | Access token is invalid or expired. Please reconnect. | TokenExpiredException | +| `scope_not_authorized` | Missing required permissions. Please reconnect with all scopes. | Permission | +| `scope_permission_missed` | Additional permissions required. Please reconnect. | Permission | +| `rate_limit_exceeded` | TikTok rate limit exceeded. Please try again later. | RateLimit | +| `invalid_file_upload` | File does not meet API specifications. | MediaFormat | +| `invalid_params` | Invalid request parameters. | MediaFormat | +| `internal_error` | TikTok server error. Please try again later. | ServerError | + +**Publish fail reasons:** + +| Fail Reason | Message | Category | +|---|---|---| +| `file_format_check_failed` | Unsupported media format. | MediaFormat | +| `duration_check_failed` | Video duration is not within allowed limits. | MediaFormat | +| `frame_rate_check_failed` | Video frame rate is not supported. | MediaFormat | +| `picture_size_check_failed` | Image dimensions exceed limits. | MediaFormat | +| `video_pull_failed` | Failed to download video from URL. | ServerError | +| `photo_pull_failed` | Failed to download photo from URL. | ServerError | +| `publish_cancelled` | Publishing was cancelled. | ContentPolicy | +| `auth_removed` | App access was revoked during processing. | Permission | +| `spam_risk_too_many_posts` | Daily posting limit reached. Try again tomorrow. | RateLimit | +| `spam_risk_user_banned_from_posting` | Account is banned from posting. | ContentPolicy | +| `spam_risk_text` | TikTok detected spam in the description. | ContentPolicy | +| `spam_risk` | Publishing request flagged as high-risk. | ContentPolicy | +| `internal` | TikTok server error. Please try again. | ServerError | + +Additional HTTP errors from Postiz: +- `reached_active_user_cap` → RateLimit: Daily active user quota reached. +- `unaudited_client_can_only_post_to_private_accounts` → Permission: App not approved for public posting. +- `url_ownership_unverified` → Permission: Domain ownership not verified. +- `privacy_level_option_mismatch` → Permission: Privacy level not available for this account. +- `app_version_check_failed` → Permission: TikTok app update required. + +--- + +#### YouTube (15 errors) + +Source: https://developers.google.com/youtube/v3/docs/videos/insert + +Match on `reason` field in `Google\Service\Exception::getErrors()[0]['reason']`. + +| Reason | Message | Category | +|---|---|---| +| `invalidTitle` | Video title is invalid or empty. | ContentPolicy | +| `invalidDescription` | Video description is invalid. | ContentPolicy | +| `invalidTags` | Video tags are invalid. | ContentPolicy | +| `invalidCategoryId` | Video category is invalid. | ContentPolicy | +| `invalidVideoMetadata` | Video metadata is invalid. Title and category are required. | ContentPolicy | +| `invalidPublishAt` | Scheduled publishing time is invalid. | ContentPolicy | +| `invalidFilename` | Video filename is invalid. | MediaFormat | +| `invalidRecordingDetails` | Recording details are invalid. | ContentPolicy | +| `invalidVideoGameRating` | Video game rating is invalid. | ContentPolicy | +| `mediaBodyRequired` | Video file is missing from the request. | MediaFormat | +| `uploadLimitExceeded` | Daily upload limit reached. Try again tomorrow. | RateLimit | +| `forbidden` | You don't have permission to upload to this channel. | Permission | +| `forbiddenLicenseSetting` | Invalid video license setting. | Permission | +| `forbiddenPrivacySetting` | Invalid video privacy setting. | Permission | +| `failedPrecondition` | Thumbnail too large or account not verified. | MediaFormat | + +Token errors: HTTP 401, `Unauthorized`, `UNAUTHENTICATED`, `invalid_grant` → `TokenExpiredException`. + +--- + +#### Facebook (30 errors) + +Sources: https://developers.facebook.com/docs/video-api/reference/error-codes/ and https://developers.facebook.com/docs/graph-api/guides/error-handling/ + +Match on `error.code` (int). Token errors checked first by `error.type === 'OAuthException'` or code `190`. + +**Token errors → TokenExpiredException:** +- Code `190` — Token expired +- Subcode `458` — App not installed +- Subcode `459` — User checkpointed +- Subcode `460` — Password changed +- Subcode `463` — Session expired +- Subcode `464` — Unconfirmed user +- Subcode `467` — Invalid token + +**Video upload errors (Session init):** + +| Code | Message | Category | +|---|---|---| +| 6000 | Problem with file. Try with another file. | MediaFormat | +| 1363042 | No permission to upload video here. | Permission | +| 1363023 | Video exceeds 2GB maximum size. | MediaFormat | +| 1363022 | Video below 1KB minimum size. | MediaFormat | + +**Video upload errors (Upload phase):** + +| Code | Message | Category | +|---|---|---| +| 1363030 | Upload timed out. Please try again. | ServerError | +| 1363019 | Problem uploading video. Please try again. | ServerError | +| 1363031 | Unsupported file format. | MediaFormat | +| 1363032 | File is not a valid video. | MediaFormat | +| 1363024 | Unsupported video format. | MediaFormat | +| 1363025 | Video is too short (minimum 1 second). | MediaFormat | +| 1363026 | Video is too long (maximum 40 minutes). | MediaFormat | +| 1363033 | Upload interrupted. Please try again. | ServerError | +| 1363037 | Invalid upload offset. | ServerError | +| 1363020 | No video file selected. | MediaFormat | +| 1363045 | Upload size mismatch. | ServerError | +| 1363041 | Upload session expired. Please try again. | ServerError | +| 1363021 | Problem during video upload. Please try again. | ServerError | +| 1363005 | No permission to edit this video. | Permission | + +**Reel/Story specific:** + +| Code | Message | Category | +|---|---|---| +| 1363047 | Reel encoding issue. Please try a different video. | MediaFormat | +| 1609008 | Video format not supported for Reels. | MediaFormat | +| 1609010 | Reel encoding requirements not met. | MediaFormat | +| 1366046 | Reels require a video. | ContentPolicy | +| 2061006 | Video is too short for this format. | MediaFormat | + +**General:** + +| Code | Message | Category | +|---|---|---| +| 1390008 | Caption is too long. | ContentPolicy | +| 1346003 | Thumbnail is incompatible. | ContentPolicy | +| 1349125 | Rate limit exceeded. Try again later. | RateLimit | +| 4 | Too many API calls. Please try again later. | RateLimit | +| 17 | User call limit reached. | RateLimit | +| 506 | Duplicate post detected. Please modify content. | ContentPolicy | + +--- + +#### X/Twitter (10 errors) + +Source: https://docs.x.com/x-api/fundamentals/response-codes-and-errors + +Match on Problem `type` suffix and HTTP status code. X uses RFC 7807 Problem Details. + +| Error Type / Status | Message | Category | +|---|---|---| +| `unsupported-authentication` / 401 | Authentication method not supported. Please reconnect. | TokenExpiredException | +| HTTP 401 | Access token is invalid or expired. | TokenExpiredException | +| `usage-capped` / 429 | Usage limit exceeded. Please try again later. | RateLimit | +| `rate-limit-exceeded` / 429 | Rate limit exceeded. Please try again later. | RateLimit | +| `invalid-request` / 400 | Invalid request. Check your post content. | ContentPolicy | +| `client-forbidden` / 403 | App not enrolled or lacks required access. | Permission | +| `not-authorized-for-resource` / 403 | Not authorized for this resource. | Permission | +| `resource-not-found` / 404 | Resource not found. | ContentPolicy | +| `The Tweet contains an invalid URL` | Post contains an invalid URL. | ContentPolicy | +| `video longer than 2 minutes` | Video exceeds the 2-minute limit for this account. | MediaFormat | +| HTTP 500/502/503/504 | X server error. Please try again later. | ServerError | + +--- + +#### LinkedIn (5 errors) + +Source: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api + +LinkedIn uses generic HTTP status codes. Match on HTTP status and response body text. + +| Status / Text | Message | Category | +|---|---|---| +| HTTP 401 | LinkedIn access token expired. Please reconnect. | TokenExpiredException | +| HTTP 403 | Not authorized to post to this account. | Permission | +| HTTP 422 | Invalid post data. Please check your content. | ContentPolicy | +| `Unable to obtain activity` | LinkedIn server error. Please try again. | ServerError | +| `resource is forbidden` | Access to this resource is forbidden. | Permission | + +--- + +#### Threads (8 errors) + +Source: Threads uses the same Graph API error format as Instagram. + +Match on `error.type` and `error.code`. Token errors: `OAuthException` or code `190`. + +| Code / Text | Message | Category | +|---|---|---| +| `OAuthException` / 190 | Threads token expired. Please reconnect. | TokenExpiredException | +| HTTP 400 + `text can't be blank` | Post text is required. | ContentPolicy | +| HTTP 400 + media processing error | Media processing failed. Please try again. | ServerError | +| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | +| HTTP 500 | Threads server error. Please try again. | ServerError | + +--- + +#### Pinterest (6 errors) + +Source: https://developers.pinterest.com/docs/api/v5/ + +Match on HTTP status code and processing status. + +| Status / Text | Message | Category | +|---|---|---| +| HTTP 401 | Pinterest token expired. Please reconnect. | TokenExpiredException | +| HTTP 403 | Not authorized to create pins on this board. | Permission | +| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | +| Processing status `failed` | Media processing failed. Please try a different file. | MediaFormat | +| HTTP 400 + board error | Invalid board. Please select a valid board. | ContentPolicy | +| HTTP 500 | Pinterest server error. Please try again. | ServerError | + +--- + +#### Bluesky (6 errors) + +Source: https://docs.bsky.app/docs/advanced-guides/posts + +Match on AT Protocol error strings and HTTP status. + +| Error / Status | Message | Category | +|---|---|---| +| `ExpiredToken` | Bluesky session expired. Please reconnect. | TokenExpiredException | +| `InvalidToken` | Bluesky token is invalid. Please reconnect. | TokenExpiredException | +| Blob size > 1MB | Image exceeds Bluesky's 1MB limit. | MediaFormat | +| HTTP 400 + `InvalidRequest` | Invalid post data. | ContentPolicy | +| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | +| HTTP 500/502 | Bluesky server error. Please try again. | ServerError | + +--- + +#### Mastodon (7 errors) + +Source: https://docs.joinmastodon.org/methods/statuses/ + +Match on HTTP status code and error message text. + +| Status / Text | Message | Category | +|---|---|---| +| HTTP 401 | Mastodon token is invalid. Please reconnect. | TokenExpiredException | +| HTTP 403 | This action is not allowed. | Permission | +| HTTP 422 + `Text can't be blank` | Post text is required when no media is attached. | ContentPolicy | +| HTTP 422 + media error | Media validation failed. | MediaFormat | +| HTTP 413 | File is too large for this Mastodon instance. | MediaFormat | +| HTTP 429 | Rate limit exceeded. Please try again later. | RateLimit | +| HTTP 503 | Mastodon server error. Please try again. | ServerError | + +## Integration with Publishers + +Each publisher's `handleApiError` method is replaced with the platform exception: + +```php +// Before (every publisher): +private function handleApiError(Response $response, string $context): void +{ + $body = $response->json() ?? []; + $error = $body['error'] ?? []; + // ... manual token check ... + throw new \Exception("{$context}: {$message}"); +} + +// After: +private function handleApiError(Response $response): never +{ + // fromApiResponse handles token errors internally + // (throws TokenExpiredException for token issues) + throw InstagramPublishException::fromApiResponse($response); +} +``` + +## Integration with PublishToSocialPlatform Job + +```php +try { + $result = $publisher->publish($this->postPlatform); + $this->postPlatform->markAsPublished(...); +} catch (TokenExpiredException $e) { + Log::error('Token expired while publishing', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + ]); + $this->postPlatform->markAsFailed($e->getMessage()); + $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); +} catch (SocialPublishException $e) { + // context() is automatically included in the log by Laravel + Log::error('Social publish failed: ' . $e->userMessage); + $this->postPlatform->markAsFailed($e->userMessage); +} catch (\Throwable $e) { + Log::error('Unexpected publish error', [ + 'post_platform_id' => $this->postPlatform->id, + 'error' => $e->getMessage(), + ]); + $this->postPlatform->markAsFailed($e->getMessage()); +} +``` + +The `$e->userMessage` goes to `error_message` in the database (shown to user). The `context()` method automatically provides platform, category, error code, and raw response to Nightwatch/logs. + +## Testing + +Each platform exception gets a test file that verifies: +- Known error codes map to correct user messages and categories +- Token errors correctly throw `TokenExpiredException` (not `SocialPublishException`) +- Unknown errors fall through to generic message with `ErrorCategory::Unknown` + +## Files Changed + +- Create: `app/Exceptions/Social/ErrorCategory.php` +- Create: `app/Exceptions/Social/SocialPublishException.php` +- Create: 10 platform exception files (`InstagramPublishException.php`, etc.) +- Modify: 10 publisher files (replace `handleApiError` with platform exception) +- Modify: `app/Jobs/PublishToSocialPlatform.php` (add `SocialPublishException` catch) +- Create: 10 test files for platform exceptions diff --git a/docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md b/docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md new file mode 100644 index 00000000..8a487dfa --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-publishing-engine-improvements-design.md @@ -0,0 +1,257 @@ +# Publishing Engine Improvements + +Based on comparative analysis of Postiz's publishing engine vs ours. + +## 1. Rate Limit Retry (429 handling) + +### Problem + +When a platform API returns 429 (Too Many Requests), our publishers throw an exception and the post fails. The user has to manually retry. Postiz retries automatically with a 5-second delay, up to 3 times. + +### Solution + +Add a `retry()` middleware to all HTTP calls that hit social platform APIs. Laravel's HTTP client supports `retry()` natively. + +```php +// Before: +$response = Http::withToken($token)->post($url, $data); + +// After: +$response = Http::withToken($token) + ->retry(3, 5000, fn ($e, $request) => $e->response?->status() === 429) + ->post($url, $data); +``` + +### Implementation + +Create a trait `HasSocialHttpClient` that all publishers use: + +```php +trait HasSocialHttpClient +{ + protected function socialHttp(): PendingRequest + { + return Http::retry( + times: 3, + sleepMilliseconds: 5000, + when: fn ($exception, $request) => $exception->response?->status() === 429, + throw: false, + ); + } +} +``` + +Each publisher replaces `Http::withToken(...)` calls with `$this->socialHttp()->withToken(...)`. + +### Files Changed + +- Create: `app/Services/Social/Concerns/HasSocialHttpClient.php` +- Modify: All 11 publishers to use the trait + +--- + +## 2. Token Refresh Inline During Publishing + +### Problem + +We refresh tokens **before** publishing, but if the token expires **during** a long upload (e.g., 244MB YouTube video), the publish fails. Postiz retries up to 5 times with inline token refresh between attempts. + +### Solution + +Wrap the publish call in the `PublishToSocialPlatform` job with a retry loop that catches `TokenExpiredException`, refreshes the token, and retries: + +```php +// In PublishToSocialPlatform::handle() +$maxAttempts = 2; // 1 retry after token refresh + +for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + $result = $publisher->publish($this->postPlatform); + $this->postPlatform->markAsPublished(...); + break; + } catch (TokenExpiredException $e) { + if ($attempt < $maxAttempts) { + $this->refreshTokenAndRetry($e); + continue; + } + // Final attempt failed — disconnect account + $this->postPlatform->markAsFailed($e->getMessage()); + $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); + } +} +``` + +The `refreshTokenAndRetry` method calls the platform-specific refresh (same logic as `ConnectionVerifier::refreshTokenIfNeeded`). + +### Files Changed + +- Modify: `app/Jobs/PublishToSocialPlatform.php` +- Extract: Token refresh logic from `ConnectionVerifier` into a reusable `TokenRefresher` service + +--- + +## 3. Concurrency Control Per Platform + +### Problem + +If 50 posts are scheduled for the same time, all 50 jobs hit Instagram's API simultaneously, causing rate limits and failures. Postiz uses per-platform task queues with `maxConcurrentJob`. + +### Solution + +Use Horizon's queue configuration to create per-platform queues with max processes: + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'social-instagram' => [ + 'connection' => 'redis', + 'queue' => ['social-instagram'], + 'maxProcesses' => 2, + 'timeout' => 630, + ], + 'social-tiktok' => [ + 'connection' => 'redis', + 'queue' => ['social-tiktok'], + 'maxProcesses' => 2, + 'timeout' => 630, + ], + // ... per platform + ], +], +``` + +The `PublishToSocialPlatform` job dispatches to the platform-specific queue: + +```php +public function __construct(public PostPlatform $postPlatform) +{ + $this->onQueue('social-' . $postPlatform->platform->value); +} +``` + +### Platform Concurrency Limits (from Postiz) + +| Platform | Max Concurrent | Our Queue maxProcesses | +|---|---|---| +| Instagram | 400 | 3 | +| TikTok | 300 | 2 | +| YouTube | 200 | 1 | +| Facebook | default | 3 | +| LinkedIn | default | 2 | +| X/Twitter | default | 2 | +| Threads | default | 2 | +| Pinterest | default | 2 | +| Bluesky | default | 2 | +| Mastodon | default | 2 | + +### Files Changed + +- Modify: `config/horizon.php` — add per-platform queues +- Modify: `app/Jobs/PublishToSocialPlatform.php` — dispatch to platform queue +- Modify: `app/Jobs/PublishPost.php` — pass platform info when dispatching + +--- + +## 4. Webhooks Post-Publish + +### Problem + +Users building integrations (Zapier, Make, custom CRM) can't programmatically know when a post is published. Postiz fires webhooks after each successful publish. + +### Solution + +Add a `Webhook` model and fire webhooks after post status changes. This is a larger feature that deserves its own spec. + +### High-Level Design + +- `webhooks` table: `id, workspace_id, url, events (json), secret, is_active` +- Events: `post.published`, `post.failed`, `account.disconnected` +- Fire webhook in `PublishToSocialPlatform` job after status update +- Sign payload with HMAC-SHA256 using the webhook secret +- Async dispatch via a `SendWebhook` job +- Retry 3x with exponential backoff + +### Files Changed + +- Create: Migration, Model, Controller, FormRequest for Webhooks CRUD +- Create: `app/Jobs/SendWebhook.php` +- Modify: `app/Jobs/PublishToSocialPlatform.php` — dispatch webhook after publish +- Create: Frontend components for webhook management + +--- + +## 5. Threads / Comments Support + +### Problem + +Postiz supports posting a main post + sequential comments (Twitter threads, Instagram first comment). We only post single posts. + +### Solution + +This requires significant data model changes: + +- A `Post` can have ordered child `Post` records (thread items) +- The publisher publishes the first post, then iterates over children posting each as a reply/comment +- Each platform's comment API is different (Twitter reply_to, Instagram comment endpoint, etc.) + +### High-Level Design + +- Add `parent_post_platform_id` to `post_platforms` table +- Add `delay_seconds` column for delayed comments +- Extend each publisher with a `comment()` method (like Postiz) +- The job publishes main post → waits for delay → publishes each comment + +This is the largest feature. Deserves its own dedicated spec + plan. + +### Files Changed + +- Migration: Add columns to `post_platforms` +- Modify: All publishers to add `comment()` method +- Modify: Frontend to support thread/comment creation UI +- Modify: `PublishToSocialPlatform` job to handle sequential publishing + +--- + +## 6. Proactive Token Refresh + +### Problem + +Currently we only refresh tokens reactively (when publishing) and via daily `CheckSocialConnections`. Postiz runs a dedicated workflow per integration that sleeps until token expiry and proactively refreshes. + +### Solution + +Create a scheduled command that runs every hour and refreshes tokens expiring in the next 2 hours: + +```php +// app/Console/Commands/RefreshExpiringTokens.php +SocialAccount::query() + ->where('status', Status::Connected) + ->whereNotNull('token_expires_at') + ->where('token_expires_at', '<=', now()->addHours(2)) + ->where('token_expires_at', '>', now()) + ->chunk(50, function ($accounts) { + foreach ($accounts as $account) { + RefreshSocialToken::dispatch($account); + } + }); +``` + +### Files Changed + +- Create: `app/Console/Commands/RefreshExpiringTokens.php` +- Create: `app/Jobs/RefreshSocialToken.php` +- Modify: `routes/console.php` — schedule hourly + +--- + +## Priority Order + +| # | Feature | Impact | Effort | When | +|---|---|---|---|---| +| 1 | Rate limit retry (429) | High | Low | This sprint | +| 2 | Token refresh inline | High | Medium | This sprint | +| 3 | Concurrency control | Medium | Medium | This sprint | +| 6 | Proactive token refresh | Medium | Low | This sprint | +| 4 | Webhooks | Medium | High | Next sprint | +| 5 | Threads/Comments | High | Very High | Future | diff --git a/docs/superpowers/specs/2026-04-01-publishing-hardening-design.md b/docs/superpowers/specs/2026-04-01-publishing-hardening-design.md new file mode 100644 index 00000000..a3bc73e9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-publishing-hardening-design.md @@ -0,0 +1,298 @@ +# Publishing Hardening — Best Practices from Postiz + +Improvements to the existing publishing flow. No new features — just making what we have more robust. + +## 1. Content Sanitization Before Publishing + +### Problem + +We send raw content to platform APIs. If the user pastes HTML from the editor or has formatting tags, they get sent as-is. Each platform has different rules: +- Instagram, TikTok, Pinterest, Bluesky: plain text only +- LinkedIn: supports bold/italic via Unicode characters +- X: plain text only +- Facebook, Threads: plain text only +- Mastodon: supports some HTML +- YouTube: plain text titles + +### Solution + +Create a `ContentSanitizer` service that strips/converts content per platform: + +```php +class ContentSanitizer +{ + public function sanitize(string $content, Platform $platform): string + { + return match ($platform) { + Platform::LinkedIn, Platform::LinkedInPage => $this->convertToUnicodeBold($this->stripHtml($content)), + Platform::Mastodon => $this->stripUnsafeHtml($content), + default => $this->stripHtml($content), + }; + } + + private function stripHtml(string $content): string + { + // Remove HTML tags, decode entities (& → &,   → space, etc.) + } + + private function convertToUnicodeBold(string $content): string + { + // Convert text to Unicode bold characters (𝗯𝗼𝗹𝗱) + // Convert text to Unicode underline (t̲e̲x̲t̲) + } + + private function stripUnsafeHtml(string $content): string + { + // Allow only safe tags:

, , , ,
+ // Strip everything else + } +} +``` + +Each publisher calls `$this->sanitizeContent($content, $platform)` before sending to the API. + +### Files Changed + +- Create: `app/Services/Social/ContentSanitizer.php` +- Modify: All 11 publishers — sanitize content before API call +- Test: `tests/Unit/Services/Social/ContentSanitizerTest.php` + +--- + +## 2. Backend Content Length Validation Before Publishing + +### Problem + +We validate content length in the frontend only. If the frontend has a bug or someone uses the API directly, content that exceeds platform limits goes through and fails with a cryptic API error. + +### Solution + +Validate content length in each publisher's `publish()` method before making any API calls: + +```php +// In each publisher, at the start of publish(): +$maxLength = $postPlatform->platform->maxContentLength(); + +if ($postPlatform->content && mb_strlen($postPlatform->content) > $maxLength) { + throw new \Exception("Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters."); +} +``` + +Better: extract to the `HasSocialHttpClient` trait as a shared method: + +```php +protected function validateContentLength(PostPlatform $postPlatform): void +{ + $maxLength = $postPlatform->platform->maxContentLength(); + + if ($postPlatform->content && mb_strlen($postPlatform->content) > $maxLength) { + throw new \Exception( + "Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters." + ); + } +} +``` + +### Files Changed + +- Modify: `app/Services/Social/Concerns/HasSocialHttpClient.php` — add `validateContentLength()` +- Modify: All 11 publishers — call `$this->validateContentLength($postPlatform)` at start of `publish()` +- Test: Add tests for content length validation + +--- + +## 3. Scope Verification Before Publishing + +### Problem + +We save scopes during OAuth connection but never verify them again. If the user revokes a permission (e.g., removes `instagram_business_content_publish` from the app), we only find out when the post fails with a confusing error. + +### Solution + +Add a `requiredScopes()` method to the `Platform` enum and verify before publishing: + +```php +// In Platform enum: +public function requiredPublishScopes(): array +{ + return match ($this) { + self::Instagram => ['instagram_business_content_publish'], + self::Facebook => ['pages_manage_posts'], + self::TikTok => ['video.publish'], + self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'], + self::LinkedIn, self::LinkedInPage => ['w_member_social'], + self::X => ['tweet.write'], + self::Threads => ['threads_basic', 'threads_content_publish'], + self::Pinterest => ['pins:write'], + self::Bluesky => [], // no scopes, uses app password + self::Mastodon => ['write:statuses'], + }; +} +``` + +Check in `PublishToSocialPlatform` before calling the publisher: + +```php +$requiredScopes = $this->postPlatform->platform->requiredPublishScopes(); +$accountScopes = $this->postPlatform->socialAccount->scopes ?? []; + +$missingScopes = array_diff($requiredScopes, $accountScopes); + +if (! empty($missingScopes)) { + $this->postPlatform->markAsFailed( + 'Missing permissions: ' . implode(', ', $missingScopes) . '. Please reconnect your account.' + ); + $this->postPlatform->socialAccount->markAsDisconnected('Missing required scopes'); + return; +} +``` + +### Files Changed + +- Modify: `app/Enums/SocialAccount/Platform.php` — add `requiredPublishScopes()` +- Modify: `app/Jobs/PublishToSocialPlatform.php` — add scope check before publish +- Test: Add test for missing scopes scenario + +--- + +## 4. "Refresh Needed" State (separate from "Disconnected") + +### Problem + +When a token refresh fails, we immediately mark the account as "disconnected". This is too aggressive — the account isn't disconnected, it just needs a new token. The user sees "disconnected" and thinks something is broken, when they just need to re-authenticate. + +Postiz has three separate states: `connected`, `refreshNeeded`, `disabled`. + +### Solution + +Add a `TokenExpired` status to the SocialAccount status enum (we already have it but don't use it consistently): + +```php +enum Status: string +{ + case Connected = 'connected'; + case Disconnected = 'disconnected'; + case TokenExpired = 'token_expired'; // exists but underused +} +``` + +When token refresh fails during publishing: +1. First failure → mark as `TokenExpired` (not `Disconnected`) +2. User gets notification: "Your Instagram token expired, please reconnect" +3. Daily `CheckSocialConnections` → if still `TokenExpired`, try refresh again +4. If still failing after daily check → then mark as `Disconnected` + +### Files Changed + +- Modify: `app/Jobs/PublishToSocialPlatform.php` — use `TokenExpired` instead of `Disconnected` on first failure +- Modify: `app/Jobs/VerifyWorkspaceConnections.php` — handle `TokenExpired` state +- Modify: Frontend — show different UI for `TokenExpired` vs `Disconnected` +- Test: Add tests for state transitions + +--- + +## 5. Error Context Saved in Database + +### Problem + +When a post fails, we save only `error_message` (the user-facing string). We have no record of what content/media was sent, what the API returned, etc. Debugging requires checking logs. + +### Solution + +Save structured error context in the existing `meta` JSON column on `post_platforms`: + +```php +// In PublishToSocialPlatform when marking as failed: +$this->postPlatform->update([ + 'status' => Status::Failed, + 'error_message' => $e->userMessage, + 'meta' => array_merge($this->postPlatform->meta ?? [], [ + 'error_context' => [ + 'category' => $e->category->value, + 'platform_error_code' => $e->platformErrorCode, + 'failed_at' => now()->toIso8601String(), + 'content_length' => mb_strlen($this->postPlatform->content ?? ''), + 'media_count' => $this->postPlatform->media->count(), + ], + ]), +]); +``` + +No raw API response in the database — that stays in logs only. Just enough context to understand what happened. + +### Files Changed + +- Modify: `app/Jobs/PublishToSocialPlatform.php` — save error context to meta +- Modify: `app/Models/PostPlatform.php` — add helper `markAsFailedWithContext()` +- Frontend: Show error context in post detail view (optional) + +--- + +## 6. Stuck Post Recovery + +### Problem + +If a post gets stuck in `publishing` status (job crashed, worker died, etc.), it stays there forever. The user sees "Publishing..." indefinitely. + +Postiz has `searchForMissingThreeHoursPosts()` that finds stuck posts and re-dispatches. + +### Solution + +Create a scheduled command that runs every 30 minutes: + +```php +class RecoverStuckPosts extends Command +{ + protected $signature = 'social:recover-stuck-posts'; + + public function handle(): void + { + // Find posts stuck in "publishing" for more than 1 hour + Post::query() + ->where('status', PostStatus::Publishing) + ->where('updated_at', '<=', now()->subHour()) + ->each(function (Post $post) { + // Check if any platform is still actively processing + $activeJobs = $post->postPlatforms() + ->where('enabled', true) + ->where('status', PostPlatformStatus::Publishing) + ->count(); + + if ($activeJobs === 0) { + // All platforms finished but post status wasn't updated (race condition) + $this->recalculatePostStatus($post); + } else { + // Mark stuck platforms as failed + $post->postPlatforms() + ->where('status', PostPlatformStatus::Publishing) + ->where('updated_at', '<=', now()->subHour()) + ->update([ + 'status' => PostPlatformStatus::Failed, + 'error_message' => 'Publishing timed out. Please try again.', + ]); + + $this->recalculatePostStatus($post); + } + }); + } +} +``` + +### Files Changed + +- Create: `app/Console/Commands/RecoverStuckPosts.php` +- Modify: `routes/console.php` — schedule every 30 minutes +- Test: `tests/Feature/Commands/RecoverStuckPostsTest.php` + +--- + +## Priority + +| # | Improvement | Impact | Effort | +|---|---|---|---| +| 1 | Content sanitization | High — prevents HTML in posts | Medium | +| 2 | Backend content length validation | High — prevents cryptic API errors | Low | +| 3 | Scope verification | Medium — early error detection | Low | +| 5 | Error context in database | Medium — easier debugging | Low | +| 6 | Stuck post recovery | High — prevents stuck UI | Low | +| 4 | Refresh needed state | Medium — better UX | Medium | diff --git a/lang/en/accounts.php b/lang/en/accounts.php index f7c3e30d..f8833106 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -62,6 +62,8 @@ 'connected' => 'Account connected successfully!', 'session_expired' => 'Session expired. Please try again.', 'workspace_not_found' => 'Workspace not found.', + 'activated' => 'Account activated!', + 'deactivated' => 'Account deactivated!', 'already_connected' => 'This platform is already connected.', 'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.', ], diff --git a/lang/en/auth.php b/lang/en/auth.php index be4ac939..d83b0b51 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -22,6 +22,45 @@ 'welcome_trial' => 'Welcome to TryPost! Your trial has started.', ], + 'legal' => 'By continuing, you agree to our
Terms of Service and Privacy Policy.', + + 'slides' => [ + 'calendar' => [ + 'title' => 'Visual Calendar', + 'description' => 'Plan and schedule your content with an intuitive drag-and-drop calendar across all your social accounts.', + ], + 'scheduling' => [ + 'title' => 'Smart Scheduling', + 'description' => 'Schedule posts across LinkedIn, X, Instagram, TikTok, YouTube, and more — all from one place.', + ], + 'media' => [ + 'title' => 'Rich Media', + 'description' => 'Publish images, carousels, stories, and reels. Each platform gets the right format automatically.', + ], + 'video' => [ + 'title' => 'Video Publishing', + 'description' => 'Upload videos once and publish to TikTok, YouTube Shorts, Instagram Reels, and Facebook Reels.', + ], + 'team' => [ + 'title' => 'Team Workspaces', + 'description' => 'Invite your team, assign roles, and manage multiple brands from separate workspaces.', + ], + 'hashtags' => [ + 'title' => 'Hashtag Groups', + 'description' => 'Save hashtag collections and add them to posts with one click. Organize with labels and filters.', + ], + ], + + 'or_continue_with' => 'Or continue with', + 'google_login' => 'Log in with Google', + 'google_signup' => 'Sign up with Google', + + 'signup_success' => [ + 'page_title' => 'Welcome', + 'title' => 'Setting up your account', + 'description' => 'This usually takes just a few seconds...', + ], + 'login' => [ 'title' => 'Log in to your account', 'description' => 'Enter your email and password below to log in', @@ -43,6 +82,8 @@ 'name_placeholder' => 'Full name', 'email' => 'Email address', 'password' => 'Password', + 'show_password' => 'Show password', + 'hide_password' => 'Hide password', 'submit' => 'Create account', 'has_account' => 'Already have an account?', 'log_in' => 'Log in', diff --git a/lang/en/billing.php b/lang/en/billing.php index 33448842..b8fe14f1 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -4,6 +4,23 @@ 'title' => 'Subscription', 'description' => 'Manage your subscription and payment method', + 'subscribe' => [ + 'page_title' => 'Start your free trial', + 'title' => 'Start your free trial', + 'description' => ':days days free to explore all features.', + 'start_trial' => 'Start :days-day free trial', + 'cancel_anytime' => 'Cancel anytime. No questions asked.', + 'switch_workspace' => 'Switch workspace', + 'features' => [ + 'calendar' => 'Visual drag-and-drop calendar', + 'scheduling' => 'Unlimited post scheduling', + 'media' => 'Images, carousels & stories', + 'video' => 'Video publishing across platforms', + 'team' => 'Team collaboration & workspaces', + 'hashtags' => 'Hashtag groups & labels', + ], + ], + 'trial' => [ 'title' => 'Trial period active', 'description' => 'Your trial ends on :date. After that, your subscription will be charged automatically.', @@ -11,6 +28,7 @@ 'subscription' => [ 'title' => 'Your Subscription', + 'status' => 'Status', 'workspaces' => 'Workspaces', 'quantity' => 'Subscription quantity', 'expires' => 'Expires :date', @@ -25,6 +43,17 @@ 'paid' => 'Paid', ], + 'processing' => [ + 'page_title' => 'Processing...', + 'title' => 'Processing your subscription', + 'description' => 'Please wait while we set up your account. This will only take a moment.', + 'success_title' => 'You\'re all set!', + 'success_description' => 'Your subscription is active. Redirecting you to your workspaces...', + 'cancelled_title' => 'Checkout cancelled', + 'cancelled_description' => 'Your checkout was cancelled. No charges were made.', + 'retry' => 'Try again', + ], + 'status' => [ 'active' => 'Active', 'canceled' => 'Canceled', diff --git a/lang/en/common.php b/lang/en/common.php new file mode 100644 index 00000000..ebc3320f --- /dev/null +++ b/lang/en/common.php @@ -0,0 +1,31 @@ + [ + 'cannot_be_undone' => 'This cannot be undone.', + 'type' => 'Type', + 'to_confirm' => 'to confirm.', + 'copy_to_clipboard' => 'Copy to clipboard', + ], + + 'photo_upload' => [ + 'upload' => 'Upload', + 'uploading' => 'Uploading...', + 'remove' => 'Remove photo', + 'hint' => 'Recommended: square image, max 2 MB.', + ], + + 'timezone' => [ + 'select' => 'Select timezone', + 'search' => 'Search timezone...', + 'empty' => 'No timezone found', + ], + + 'date_picker' => [ + 'select' => 'Select date', + ], + + 'cancel' => 'Cancel', +]; diff --git a/lang/en/hashtags.php b/lang/en/hashtags.php index db94aa98..ed35835f 100644 --- a/lang/en/hashtags.php +++ b/lang/en/hashtags.php @@ -3,6 +3,7 @@ return [ 'title' => 'Hashtags', 'description' => 'Create hashtag groups to quickly add to your posts', + 'search' => 'Search hashtags...', 'new_group' => 'New Group', 'no_groups_yet' => 'No hashtag groups yet', 'no_groups_description' => 'Create hashtag groups to quickly add popular hashtags to your posts', diff --git a/lang/en/labels.php b/lang/en/labels.php index 3d1fe5d2..005d034e 100644 --- a/lang/en/labels.php +++ b/lang/en/labels.php @@ -3,6 +3,7 @@ return [ 'title' => 'Labels', 'description' => 'Create labels to organize and categorize your posts', + 'search' => 'Search labels...', 'new_label' => 'New Label', 'no_labels_yet' => 'No labels yet', 'create_first_label' => 'Create your first label', diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php new file mode 100644 index 00000000..aa095997 --- /dev/null +++ b/lang/en/onboarding.php @@ -0,0 +1,48 @@ + [ + 'page_title' => 'Welcome - Tell us about yourself', + 'title' => 'Tell us about yourself', + 'description' => 'Help us personalize your experience', + 'submit' => 'Continue', + ], + + 'personas' => [ + 'founder' => [ + 'label' => 'Founder', + 'description' => 'Building a startup or new venture', + ], + 'creator' => [ + 'label' => 'Creator', + 'description' => 'Content creator or influencer', + ], + 'agency' => [ + 'label' => 'Agency', + 'description' => 'Marketing or social media agency', + ], + 'enterprise' => [ + 'label' => 'Enterprise', + 'description' => 'Large company or corporation', + ], + 'small_business' => [ + 'label' => 'Small Business', + 'description' => 'Small to medium business', + ], + 'personal' => [ + 'label' => 'Personal', + 'description' => 'Personal brand or hobby', + ], + ], + + 'connect' => [ + 'page_title' => 'Connect your accounts', + 'title' => 'Connect your accounts', + 'description' => 'Connect at least one social network to get started', + 'submit' => 'Continue', + 'error' => 'Something went wrong. Please try again.', + 'go_back' => 'Go Back', + ], +]; diff --git a/lang/en/posts.php b/lang/en/posts.php index ededd206..6b07d3ed 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -2,6 +2,7 @@ return [ 'title' => 'Posts', + 'search' => 'Search posts...', 'all_posts' => 'All Posts', 'new_post' => 'New Post', 'no_posts' => 'No posts found', @@ -35,6 +36,7 @@ ], 'status' => [ + 'pending' => 'Pending', 'draft' => 'Draft', 'scheduled' => 'Scheduled', 'publishing' => 'Publishing', @@ -113,6 +115,7 @@ 'videos_not_supported' => 'Videos not supported', 'max_images' => 'Max :count images', 'requires_media' => 'Requires media', + 'requires_content' => 'Text content is required', 'exceeded' => ':count exceeded', 'does_not_support_images' => ':platform does not support images', 'supports_up_to_images' => ':platform supports up to :count images', @@ -223,5 +226,6 @@ 'errors' => [ 'account_disconnected' => 'Social account is disconnected', + 'account_inactive' => 'Social account is deactivated', ], ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 5ee87855..5e7283eb 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -9,11 +9,27 @@ 'password' => 'Password', 'workspace' => 'Workspace', 'members' => 'Members', + 'notifications' => 'Notifications', 'billing' => 'Billing', ], + 'notifications' => [ + 'title' => 'Notification preferences', + 'heading' => 'Email notifications', + 'description' => 'Choose which email notifications you want to receive', + 'post_published' => 'Post published', + 'post_published_description' => 'Receive an email when your post is published successfully', + 'post_failed' => 'Post failed', + 'post_failed_description' => 'Receive an email when your post fails to publish', + 'account_disconnected' => 'Account disconnected', + 'account_disconnected_description' => 'Receive an email when a social account is disconnected', + 'save' => 'Save preferences', + ], + 'profile' => [ 'title' => 'Profile settings', + 'photo_heading' => 'Profile photo', + 'photo_description' => 'Upload a profile photo', 'heading' => 'Profile information', 'description' => 'Update your name and email address', 'avatar' => 'Avatar', @@ -56,14 +72,16 @@ 'workspace' => [ 'title' => 'Workspace settings', - 'heading' => 'Workspace settings', - 'description' => 'Update your workspace name, logo, and timezone', - 'logo' => 'Logo', + 'logo_heading' => 'Workspace logo', + 'logo_description' => 'Upload a logo for your workspace', + 'heading' => 'Workspace name', + 'description' => 'Update your workspace name and timezone', + 'members_heading' => 'Members', + 'members_description' => 'Manage workspace members and invitations', 'name' => 'Name', 'name_placeholder' => 'My Workspace', 'timezone' => 'Timezone', 'save' => 'Save', - 'saved' => 'Saved.', ], 'members' => [ @@ -71,6 +89,11 @@ 'heading' => 'Team members', 'description' => 'Manage members and invites for this workspace', + 'cancel' => 'Cancel', + 'remove' => 'Remove', + 'make_admin' => 'Make admin', + 'make_member' => 'Make member', + 'invite' => [ 'title' => 'Invite Member', 'description' => 'Send an email invite to add collaborators', @@ -79,7 +102,6 @@ 'role' => 'Role', 'role_placeholder' => 'Select a role', 'submit' => 'Send Invite', - 'cancel_confirm' => 'Are you sure you want to cancel this invite?', ], 'pending' => [ @@ -92,7 +114,18 @@ 'title' => 'Members', 'description' => 'People with access to this workspace', 'empty' => 'No members besides the owner', - 'remove_confirm' => 'Are you sure you want to remove this member?', + ], + + 'remove_modal' => [ + 'title' => 'Remove member', + 'description' => 'Are you sure you want to remove this member from the workspace? They will lose access to all workspace resources.', + 'action' => 'Remove member', + ], + + 'cancel_invite_modal' => [ + 'title' => 'Cancel invitation', + 'description' => 'Are you sure you want to cancel this invitation?', + 'action' => 'Cancel invitation', ], 'roles' => [ @@ -105,6 +138,7 @@ 'invite_sent' => 'Invite sent successfully!', 'invite_deleted' => 'Invite deleted.', 'member_removed' => 'Member removed successfully.', + 'role_updated' => 'Member role updated.', 'wrong_email' => 'This invite is for a different email address.', 'already_member' => 'You are already a member of this workspace.', 'invite_accepted' => 'Welcome! You are now a member of the workspace.', @@ -117,5 +151,56 @@ 'language_updated' => 'Language updated successfully!', 'password_updated' => 'Password updated successfully!', 'workspace_updated' => 'Settings updated successfully!', + 'photo_updated' => 'Photo updated successfully!', + 'photo_deleted' => 'Photo removed successfully!', + 'logo_updated' => 'Logo uploaded successfully!', + 'logo_deleted' => 'Logo removed successfully!', + 'notifications_updated' => 'Notification preferences updated!', + ], + + 'api_keys' => [ + 'title' => 'API Keys', + 'page_title' => 'API Keys', + 'heading' => 'API Keys', + 'description' => 'Manage API keys for programmatic access to your workspace.', + 'create' => 'Create API Key', + 'copy' => 'Copy', + 'new_token_message' => 'Your new API key has been created. Copy it now — you won\'t be able to see it again.', + 'table' => [ + 'name' => 'Name', + 'key' => 'Key', + 'status' => 'Status', + 'expires' => 'Expires', + 'last_used' => 'Last Used', + 'never' => 'Never', + ], + 'actions' => [ + 'copy_id' => 'Copy API Key ID', + 'copy_id_success' => 'API Key ID copied to clipboard', + 'delete' => 'Delete', + ], + 'empty' => [ + 'title' => 'No API keys yet', + 'description' => 'Create an API key to access your workspace programmatically.', + ], + 'delete_modal' => [ + 'title' => 'Delete API key', + 'description' => 'Are you sure you want to delete this API key? Any applications using this key will lose access immediately.', + 'action' => 'Delete API key', + ], + 'create_dialog' => [ + 'title' => 'Create API Key', + 'description' => 'Create a new API key for programmatic access to your workspace.', + 'name' => 'Name', + 'name_placeholder' => 'e.g. Production API Key', + 'expires' => 'Expiration date (optional)', + 'expires_placeholder' => 'No expiration', + 'submit' => 'Create', + 'cancel' => 'Cancel', + ], + 'flash' => [ + 'created' => 'API key created successfully!', + 'deleted' => 'API key deleted successfully!', + ], ], ]; diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index c6a14199..a6294db5 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -1,10 +1,12 @@ 'Workspaces', 'select_workspace' => 'Select workspace', 'create_workspace' => 'Create workspace', 'create_post' => 'Create post', 'profile' => 'Profile', + 'account' => 'Account', 'log_out' => 'Log out', 'workspace' => 'Workspace: :name', @@ -39,10 +41,16 @@ 'settings' => 'Settings', ], + 'notifications' => 'Notifications', + 'mark_all_read' => 'Mark all as read', + 'mark_as_read' => 'Mark as read', + 'archive_all' => 'Archive all', + 'no_notifications' => 'No notifications', + 'support' => [ 'discord' => 'Discord', 'share_feedback' => 'Share feedback', 'last_updates' => 'Last Updates', - 'docs' => 'Docs', + 'docs' => 'Documentation', ], ]; diff --git a/lang/en/workspaces.php b/lang/en/workspaces.php new file mode 100644 index 00000000..23f16154 --- /dev/null +++ b/lang/en/workspaces.php @@ -0,0 +1,20 @@ + 'Workspaces', + 'select_title' => 'Your workspaces', + 'select_description' => 'Select a workspace to continue', + 'current' => 'Current', + 'connections' => ':count connections', + 'posts' => ':count posts', + + 'create' => [ + 'title' => 'Create a workspace', + 'description' => 'Give your workspace a name to get started', + 'name' => 'Workspace name', + 'name_placeholder' => 'My Workspace', + 'submit' => 'Create workspace', + ], +]; diff --git a/lang/es/accounts.php b/lang/es/accounts.php new file mode 100644 index 00000000..73ad9815 --- /dev/null +++ b/lang/es/accounts.php @@ -0,0 +1,70 @@ + 'Conexiones', + 'page_title' => 'Cuentas conectadas', + 'description' => 'Conecta tus redes sociales para programar y publicar posts', + + 'not_connected' => 'No conectado', + 'connect' => 'Conectar', + 'connection_lost' => 'Conexión perdida', + 'reconnect_account' => 'Reconectar cuenta', + 'view_profile' => 'Ver perfil', + 'disconnect' => 'Desconectar', + + 'disconnect_modal' => [ + 'title' => 'Desconectar cuenta', + 'description' => '¿Estás seguro de que deseas desconectar esta cuenta? Puedes volver a conectarla en cualquier momento.', + 'confirm' => 'Desconectar', + 'cancel' => 'Cancelar', + ], + + 'bluesky' => [ + 'title' => 'Conectar Bluesky', + 'description' => 'Introduce tus credenciales para conectar', + 'email' => 'Correo electrónico', + 'email_placeholder' => 'tuusuario.bsky.social', + 'app_password' => 'Contraseña de app', + 'app_password_placeholder' => 'xxxx-xxxx-xxxx-xxxx', + 'app_password_hint' => 'Usa una Contraseña de App por seguridad. Crea una en bsky.app/settings.', + 'submit' => 'Conectar Bluesky', + 'submitting' => 'Conectando...', + ], + + 'mastodon' => [ + 'title' => 'Conectar Mastodon', + 'description' => 'Introduce tu instancia de Mastodon', + 'instance_url' => 'URL de la instancia', + 'instance_placeholder' => 'https://mastodon.social', + 'instance_hint' => 'Introduce la URL de tu instancia de Mastodon (ej: mastodon.social, techhub.social)', + 'submit' => 'Continuar con Mastodon', + 'submitting' => 'Conectando...', + ], + + 'facebook' => [ + 'title' => 'Seleccionar página de Facebook', + 'description' => 'Elige qué página deseas conectar', + 'no_pages' => 'No se encontraron páginas', + 'no_pages_description' => 'No eres administrador de ninguna página de Facebook.', + 'page_label' => 'Página de Facebook', + ], + + 'linkedin' => [ + 'title' => 'Seleccionar página de LinkedIn', + 'description' => 'Elige qué página deseas conectar', + 'no_pages' => 'No se encontraron páginas', + 'no_pages_description' => 'No eres administrador de ninguna página de LinkedIn.', + 'page_label' => 'Página de LinkedIn', + ], + + 'flash' => [ + 'disconnected' => '¡Cuenta desconectada correctamente!', + 'connected' => '¡Cuenta conectada correctamente!', + 'session_expired' => 'Sesión expirada. Inténtalo de nuevo.', + 'workspace_not_found' => 'Workspace no encontrado.', + 'activated' => '¡Cuenta activada!', + 'deactivated' => '¡Cuenta desactivada!', + 'already_connected' => 'Esta plataforma ya está conectada.', + 'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.', + ], +]; diff --git a/lang/es/auth.php b/lang/es/auth.php new file mode 100644 index 00000000..5a64fb13 --- /dev/null +++ b/lang/es/auth.php @@ -0,0 +1,123 @@ + 'Estas credenciales no coinciden con nuestros registros.', + 'password' => 'La contraseña proporcionada es incorrecta.', + 'throttle' => 'Demasiados intentos de inicio de sesión. Inténtalo de nuevo en :seconds segundos.', + + 'flash' => [ + 'welcome' => '¡Bienvenido a TryPost!', + 'welcome_trial' => '¡Bienvenido a TryPost! Tu prueba ha comenzado.', + ], + + 'legal' => 'Al continuar, aceptas nuestros Términos de Servicio y Política de Privacidad.', + + 'slides' => [ + 'calendar' => [ + 'title' => 'Calendario Visual', + 'description' => 'Planifica y programa tu contenido con un calendario intuitivo de arrastrar y soltar en todas tus cuentas sociales.', + ], + 'scheduling' => [ + 'title' => 'Programación Inteligente', + 'description' => 'Programa posts en LinkedIn, X, Instagram, TikTok, YouTube y más — todo desde un solo lugar.', + ], + 'media' => [ + 'title' => 'Contenido Multimedia', + 'description' => 'Publica imágenes, carruseles, historias y reels. Cada plataforma recibe el formato correcto automáticamente.', + ], + 'video' => [ + 'title' => 'Publicación de Video', + 'description' => 'Sube videos una vez y publícalos en TikTok, YouTube Shorts, Instagram Reels y Facebook Reels.', + ], + 'team' => [ + 'title' => 'Workspaces en Equipo', + 'description' => 'Invita a tu equipo, asigna roles y gestiona múltiples marcas en workspaces separados.', + ], + 'hashtags' => [ + 'title' => 'Grupos de Hashtags', + 'description' => 'Guarda colecciones de hashtags y agrégalos a tus posts con un clic. Organiza con etiquetas y filtros.', + ], + ], + + 'or_continue_with' => 'O continuar con', + 'google_login' => 'Iniciar sesión con Google', + 'google_signup' => 'Registrarse con Google', + + 'signup_success' => [ + 'page_title' => 'Bienvenido', + 'title' => 'Configurando tu cuenta', + 'description' => 'Esto suele tardar solo unos segundos...', + ], + + 'login' => [ + 'title' => 'Inicia sesión en tu cuenta', + 'description' => 'Introduce tu correo y contraseña para iniciar sesión', + 'page_title' => 'Iniciar sesión', + 'email' => 'Correo electrónico', + 'password' => 'Contraseña', + 'forgot_password' => '¿Olvidaste tu contraseña?', + 'remember_me' => 'Recuérdame', + 'submit' => 'Iniciar sesión', + 'no_account' => '¿No tienes una cuenta?', + 'sign_up' => 'Regístrate', + ], + + 'register' => [ + 'title' => 'Crear una cuenta', + 'description' => 'Introduce tus datos para crear tu cuenta', + 'page_title' => 'Registro', + 'name' => 'Nombre', + 'name_placeholder' => 'Nombre completo', + 'email' => 'Correo electrónico', + 'password' => 'Contraseña', + 'show_password' => 'Mostrar contraseña', + 'hide_password' => 'Ocultar contraseña', + 'submit' => 'Crear cuenta', + 'has_account' => '¿Ya tienes una cuenta?', + 'log_in' => 'Iniciar sesión', + ], + + 'forgot_password' => [ + 'title' => 'Olvidé mi contraseña', + 'description' => 'Introduce tu correo para recibir un enlace de restablecimiento', + 'page_title' => 'Olvidé mi contraseña', + 'email' => 'Correo electrónico', + 'submit' => 'Enviar enlace de restablecimiento', + 'return_to' => 'O vuelve a', + 'log_in' => 'iniciar sesión', + ], + + 'reset_password' => [ + 'title' => 'Restablecer contraseña', + 'description' => 'Introduce tu nueva contraseña', + 'page_title' => 'Restablecer contraseña', + 'email' => 'Correo electrónico', + 'password' => 'Contraseña', + 'confirm_password' => 'Confirmar contraseña', + 'confirm_placeholder' => 'Confirmar contraseña', + 'submit' => 'Restablecer contraseña', + ], + + 'verify_email' => [ + 'title' => 'Verificar correo', + 'description' => 'Verifica tu correo electrónico haciendo clic en el enlace que acabamos de enviarte.', + 'page_title' => 'Verificación de correo', + 'link_sent' => 'Se ha enviado un nuevo enlace de verificación al correo electrónico proporcionado durante el registro.', + 'resend' => 'Reenviar correo de verificación', + 'log_out' => 'Cerrar sesión', + ], + + 'accept_invite' => [ + 'page_title' => 'Aceptar invitación', + 'title' => '¡Has sido invitado!', + 'description' => 'Has sido invitado a unirte al workspace :workspace.', + 'workspace' => 'Workspace', + 'your_role' => 'Tu rol', + 'email' => 'Correo electrónico', + 'accept' => 'Aceptar invitación', + 'decline' => 'Rechazar invitación', + 'login_prompt' => 'Inicia sesión o crea una cuenta para aceptar esta invitación.', + 'log_in' => 'Iniciar sesión', + 'create_account' => 'Crear cuenta', + ], +]; diff --git a/lang/es/billing.php b/lang/es/billing.php new file mode 100644 index 00000000..7ef94dd4 --- /dev/null +++ b/lang/es/billing.php @@ -0,0 +1,66 @@ + 'Suscripción', + 'description' => 'Administra tu suscripción y método de pago', + + 'subscribe' => [ + 'page_title' => 'Comienza tu prueba gratuita', + 'title' => 'Comienza tu prueba gratuita', + 'description' => ':days días gratis para explorar todas las funciones.', + 'start_trial' => 'Comenzar prueba de :days días', + 'cancel_anytime' => 'Cancela en cualquier momento. Sin preguntas.', + 'switch_workspace' => 'Cambiar workspace', + 'features' => [ + 'calendar' => 'Calendario visual con arrastrar y soltar', + 'scheduling' => 'Programación ilimitada de posts', + 'media' => 'Imágenes, carruseles e historias', + 'video' => 'Publicación de videos en todas las plataformas', + 'team' => 'Colaboración en equipo y workspaces', + 'hashtags' => 'Grupos de hashtags y etiquetas', + ], + ], + + 'trial' => [ + 'title' => 'Periodo de prueba activo', + 'description' => 'Tu prueba termina el :date. Después, tu suscripción se cobrará automáticamente.', + ], + + 'subscription' => [ + 'title' => 'Tu suscripción', + 'status' => 'Estado', + 'workspaces' => 'Workspaces', + 'quantity' => 'Cantidad de suscripción', + 'expires' => 'Expira :date', + 'canceled_on' => 'Tu suscripción se cancelará el :date', + 'manage' => 'Administrar en Stripe', + ], + + 'invoices' => [ + 'title' => 'Facturas', + 'description' => 'Historial de pagos', + 'empty' => 'No se encontraron facturas', + 'paid' => 'Pagado', + ], + + 'processing' => [ + 'page_title' => 'Procesando...', + 'title' => 'Procesando tu suscripción', + 'description' => 'Espera mientras configuramos tu cuenta. Solo tomará un momento.', + 'success_title' => '¡Todo listo!', + 'success_description' => 'Tu suscripción está activa. Redirigiendo a tus workspaces...', + 'cancelled_title' => 'Pago cancelado', + 'cancelled_description' => 'Tu pago fue cancelado. No se realizaron cargos.', + 'retry' => 'Intentar de nuevo', + ], + + 'status' => [ + 'active' => 'Activa', + 'canceled' => 'Cancelada', + 'incomplete' => 'Incompleta', + 'incomplete_expired' => 'Expirada', + 'past_due' => 'Vencida', + 'trialing' => 'Prueba', + 'unpaid' => 'Sin pagar', + ], +]; diff --git a/lang/es/calendar.php b/lang/es/calendar.php new file mode 100644 index 00000000..7d0e5d8e --- /dev/null +++ b/lang/es/calendar.php @@ -0,0 +1,12 @@ + 'Calendario', + 'today' => 'Hoy', + 'day' => 'Día', + 'week' => 'Semana', + 'month' => 'Mes', + 'new_post' => 'Nuevo post', + 'no_content' => 'Sin contenido', + 'more' => '+:count más', +]; diff --git a/lang/es/common.php b/lang/es/common.php new file mode 100644 index 00000000..f80b12c0 --- /dev/null +++ b/lang/es/common.php @@ -0,0 +1,31 @@ + [ + 'cannot_be_undone' => 'Esta acción no se puede deshacer.', + 'type' => 'Escribe', + 'to_confirm' => 'para confirmar.', + 'copy_to_clipboard' => 'Copiar al portapapeles', + ], + + 'photo_upload' => [ + 'upload' => 'Subir', + 'uploading' => 'Subiendo...', + 'remove' => 'Eliminar foto', + 'hint' => 'Recomendado: imagen cuadrada, máximo 2 MB.', + ], + + 'timezone' => [ + 'select' => 'Seleccionar zona horaria', + 'search' => 'Buscar zona horaria...', + 'empty' => 'No se encontró zona horaria', + ], + + 'date_picker' => [ + 'select' => 'Seleccionar fecha', + ], + + 'cancel' => 'Cancelar', +]; diff --git a/lang/es/hashtags.php b/lang/es/hashtags.php new file mode 100644 index 00000000..6af89bfd --- /dev/null +++ b/lang/es/hashtags.php @@ -0,0 +1,49 @@ + 'Hashtags', + 'description' => 'Crea grupos de hashtags para agregarlos rápidamente a tus posts', + 'search' => 'Buscar hashtags...', + 'new_group' => 'Nuevo grupo', + 'no_groups_yet' => 'Aún no hay grupos de hashtags', + 'no_groups_description' => 'Crea grupos de hashtags para agregar rápidamente hashtags populares a tus posts', + 'create_first_group' => 'Crea tu primer grupo', + 'hashtags_count' => ':count hashtags', + + 'create' => [ + 'title' => 'Crear grupo de hashtags', + 'description' => 'Dale un nombre a tu grupo y agrega hashtags separados por espacios o comas', + 'name' => 'Nombre del grupo', + 'name_placeholder' => 'ej: Marketing, Viajes, Comida', + 'hashtags' => 'Hashtags', + 'hashtags_placeholder' => '#marketing #redessociales #negocios #crecimiento', + 'hashtags_hint' => 'Ingresa hashtags separados por espacios o comas. Incluye el símbolo #.', + 'submit' => 'Crear grupo', + 'submitting' => 'Creando...', + ], + + 'edit' => [ + 'title' => 'Editar grupo de hashtags', + 'description' => 'Actualiza el nombre y los hashtags de este grupo', + 'name' => 'Nombre del grupo', + 'name_placeholder' => 'ej: Marketing, Viajes, Comida', + 'hashtags' => 'Hashtags', + 'hashtags_placeholder' => '#marketing #redessociales #negocios #crecimiento', + 'hashtags_hint' => 'Ingresa hashtags separados por espacios o comas. Incluye el símbolo #.', + 'submit' => 'Guardar cambios', + 'submitting' => 'Guardando...', + ], + + 'delete' => [ + 'title' => 'Eliminar grupo de hashtags', + 'description' => '¿Estás seguro de que deseas eliminar este grupo de hashtags? Esta acción no se puede deshacer.', + 'confirm' => 'Eliminar', + 'cancel' => 'Cancelar', + ], + + 'flash' => [ + 'created' => '¡Grupo de hashtags creado correctamente!', + 'updated' => '¡Grupo de hashtags actualizado correctamente!', + 'deleted' => '¡Grupo de hashtags eliminado correctamente!', + ], +]; diff --git a/lang/es/labels.php b/lang/es/labels.php new file mode 100644 index 00000000..3cc4ab64 --- /dev/null +++ b/lang/es/labels.php @@ -0,0 +1,43 @@ + 'Etiquetas', + 'description' => 'Crea etiquetas para organizar y categorizar tus posts', + 'search' => 'Buscar etiquetas...', + 'new_label' => 'Nueva etiqueta', + 'no_labels_yet' => 'Aún no hay etiquetas', + 'create_first_label' => 'Crea tu primera etiqueta', + + 'create' => [ + 'title' => 'Crear etiqueta', + 'description' => 'Dale un nombre y elige un color para tu etiqueta', + 'name' => 'Nombre', + 'name_placeholder' => 'Nombre de la etiqueta...', + 'color' => 'Color', + 'submit' => 'Crear etiqueta', + 'submitting' => 'Creando...', + ], + + 'edit' => [ + 'title' => 'Editar etiqueta', + 'description' => 'Actualiza el nombre y el color de esta etiqueta', + 'name' => 'Nombre', + 'name_placeholder' => 'Nombre de la etiqueta...', + 'color' => 'Color', + 'submit' => 'Guardar cambios', + 'submitting' => 'Guardando...', + ], + + 'delete' => [ + 'title' => 'Eliminar etiqueta', + 'description' => '¿Estás seguro de que deseas eliminar esta etiqueta? Esta acción no se puede deshacer.', + 'confirm' => 'Eliminar', + 'cancel' => 'Cancelar', + ], + + 'flash' => [ + 'created' => '¡Etiqueta creada correctamente!', + 'updated' => '¡Etiqueta actualizada correctamente!', + 'deleted' => '¡Etiqueta eliminada correctamente!', + ], +]; diff --git a/lang/es/mail.php b/lang/es/mail.php new file mode 100644 index 00000000..c99ce05a --- /dev/null +++ b/lang/es/mail.php @@ -0,0 +1,15 @@ + [ + 'subject' => '{1} :count cuenta necesita ser reconectada en :workspace|[2,*] :count cuentas necesitan ser reconectadas en :workspace', + 'title' => 'Cuentas necesitan reconexión', + 'intro' => 'Las siguientes cuentas sociales en tu workspace :workspace se han desconectado y necesitan ser reconectadas:', + 'reasons_title' => 'Esto puede haber ocurrido porque:', + 'reason_expired' => 'Los tokens de acceso expiraron', + 'reason_revoked' => 'Revocaste el acceso a TryPost en la plataforma', + 'reason_changed' => 'La plataforma cambió sus requisitos de autenticación', + 'reconnect_cta' => 'Reconecta estas cuentas para seguir programando y publicando posts.', + 'button' => 'Reconectar cuentas', + ], +]; diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php new file mode 100644 index 00000000..a3795f59 --- /dev/null +++ b/lang/es/onboarding.php @@ -0,0 +1,48 @@ + [ + 'page_title' => 'Bienvenido - Cuéntanos sobre ti', + 'title' => 'Cuéntanos sobre ti', + 'description' => 'Ayúdanos a personalizar tu experiencia', + 'submit' => 'Continuar', + ], + + 'personas' => [ + 'founder' => [ + 'label' => 'Fundador', + 'description' => 'Construyendo una startup o nuevo negocio', + ], + 'creator' => [ + 'label' => 'Creador de Contenido', + 'description' => 'Creador de contenido o influencer', + ], + 'agency' => [ + 'label' => 'Agencia', + 'description' => 'Agencia de marketing o redes sociales', + ], + 'enterprise' => [ + 'label' => 'Empresa', + 'description' => 'Gran empresa o corporación', + ], + 'small_business' => [ + 'label' => 'Pequeño Negocio', + 'description' => 'Pequeña o mediana empresa', + ], + 'personal' => [ + 'label' => 'Personal', + 'description' => 'Marca personal o hobby', + ], + ], + + 'connect' => [ + 'page_title' => 'Conecta tus cuentas', + 'title' => 'Conecta tus cuentas', + 'description' => 'Conecta al menos una red social para comenzar', + 'submit' => 'Continuar', + 'error' => 'Algo salió mal. Inténtalo de nuevo.', + 'go_back' => 'Volver', + ], +]; diff --git a/lang/es/pagination.php b/lang/es/pagination.php new file mode 100644 index 00000000..1bed3bd0 --- /dev/null +++ b/lang/es/pagination.php @@ -0,0 +1,6 @@ + '« Anterior', + 'next' => 'Siguiente »', +]; diff --git a/lang/es/passwords.php b/lang/es/passwords.php new file mode 100644 index 00000000..66292cb3 --- /dev/null +++ b/lang/es/passwords.php @@ -0,0 +1,9 @@ + 'Tu contraseña ha sido restablecida.', + 'sent' => 'Te hemos enviado un enlace para restablecer tu contraseña.', + 'throttled' => 'Espera antes de intentarlo de nuevo.', + 'token' => 'Este token de restablecimiento de contraseña no es válido.', + 'user' => 'No encontramos un usuario con ese correo electrónico.', +]; diff --git a/lang/es/posts.php b/lang/es/posts.php new file mode 100644 index 00000000..20a3dddf --- /dev/null +++ b/lang/es/posts.php @@ -0,0 +1,231 @@ + 'Posts', + 'search' => 'Buscar posts...', + 'all_posts' => 'Todos los posts', + 'new_post' => 'Nuevo post', + 'no_posts' => 'No se encontraron posts', + 'start_creating' => 'Empieza creando tu primer post.', + 'manage_posts' => 'Administra todos tus posts', + 'delete_confirm' => '¿Estás seguro de que deseas eliminar este post?', + 'by' => 'por', + + 'actions' => [ + 'view' => 'Ver post', + 'delete' => 'Eliminar post', + ], + + 'form' => [ + 'post_type' => 'Tipo de post', + 'board' => 'Tablero', + 'select_board' => 'Seleccionar tablero', + 'search_board' => 'Buscar tablero...', + 'no_board_found' => 'No se encontró tablero', + 'media' => 'Multimedia', + 'min' => 'Min', + 'uploading' => 'Subiendo...', + 'drop_to_upload' => 'Suelta para subir', + 'drag_and_drop' => 'Arrastra y suelta o haz clic para subir', + 'photos_and_videos' => 'Fotos y videos', + 'photos_only' => 'Solo fotos', + 'videos_only' => 'Solo videos', + 'drag_to_reorder' => 'Arrastra para reordenar', + 'caption' => 'Descripción', + 'write_caption' => 'Escribe tu descripción...', + ], + + 'status' => [ + 'pending' => 'Pendiente', + 'draft' => 'Borrador', + 'scheduled' => 'Programado', + 'publishing' => 'Publicando', + 'published' => 'Publicado', + 'partially_published' => 'Parcialmente publicado', + 'failed' => 'Fallido', + ], + + 'descriptions' => [ + 'draft' => 'Posts esperando ser programados', + 'scheduled' => 'Posts programados para publicar', + 'published' => 'Posts ya publicados', + ], + + 'edit' => [ + 'title' => 'Editar post', + 'view_title' => 'Ver post', + 'manage_platforms' => 'Administrar plataformas', + 'sync' => 'Sincronizar', + 'labels' => 'Etiquetas', + 'hashtags' => 'Hashtags', + 'schedule' => 'Programar', + 'publish' => 'Publicar', + 'delete' => 'Eliminar', + 'settings' => 'Configuración', + 'schedule_for' => 'Programar para', + 'saving' => 'Guardando...', + 'saved' => 'Guardado', + 'scheduled_at' => 'Programado:', + 'published_at' => 'Publicado:', + 'media' => 'Multimedia', + 'caption' => 'Descripción', + 'no_caption' => 'Sin descripción', + 'no_content' => 'Sin contenido', + + 'empty_state' => [ + 'title' => 'No hay plataformas seleccionadas', + 'description' => 'Selecciona al menos una plataforma para crear tu post', + ], + + 'delete_modal' => [ + 'title' => 'Eliminar post', + 'description' => '¿Estás seguro de que deseas eliminar este post? Esta acción no se puede deshacer.', + 'action' => 'Eliminar', + 'cancel' => 'Cancelar', + ], + + 'sync_enable' => [ + 'title' => '¿Activar sincronización?', + 'description' => 'Todas las plataformas compartirán el mismo contenido. Las ediciones personalizadas realizadas en plataformas individuales serán reemplazadas con el contenido actual.', + 'cancel' => 'Cancelar', + 'action' => 'Activar sincronización', + ], + + 'sync_disable' => [ + 'title' => '¿Desactivar sincronización?', + 'description' => 'Cada plataforma mantendrá su contenido actual, pero las ediciones futuras solo se aplicarán a la plataforma que estés editando.', + 'customize_note' => 'Podrás personalizar el contenido de cada plataforma individualmente.', + 'cancel' => 'Cancelar', + 'action' => 'Desactivar sincronización', + ], + + 'platforms_dialog' => [ + 'title' => 'Seleccionar plataformas', + 'description' => 'Elige en qué plataformas publicar este post.', + ], + + 'hashtags_modal' => [ + 'search' => 'Buscar hashtags...', + 'no_results' => 'No se encontraron hashtags.', + ], + + 'validation' => [ + 'select_board' => 'Selecciona un tablero', + 'images_not_supported' => 'Imágenes no soportadas', + 'videos_not_supported' => 'Videos no soportados', + 'max_images' => 'Máximo :count imágenes', + 'requires_media' => 'Requiere multimedia', + 'requires_content' => 'Se requiere texto', + 'exceeded' => ':count excedido', + 'does_not_support_images' => ':platform no soporta imágenes', + 'supports_up_to_images' => ':platform soporta hasta :count imágenes', + 'does_not_support_videos' => ':platform no soporta videos', + ], + ], + + 'content_types' => [ + 'instagram_feed' => [ + 'label' => 'Post del feed', + 'description' => 'Aparece en tu feed y perfil', + ], + 'instagram_reel' => [ + 'label' => 'Reel', + 'description' => 'Video corto de hasta 90 segundos', + ], + 'instagram_story' => [ + 'label' => 'Historia', + 'description' => 'Desaparece después de 24 horas', + ], + 'linkedin_post' => [ + 'label' => 'Post', + 'description' => 'Post estándar con texto y multimedia', + ], + 'linkedin_carousel' => [ + 'label' => 'Carrusel', + 'description' => 'Imágenes deslizables', + ], + 'linkedin_page_post' => [ + 'label' => 'Post', + 'description' => 'Post estándar con texto y multimedia', + ], + 'linkedin_page_carousel' => [ + 'label' => 'Carrusel', + 'description' => 'Imágenes deslizables', + ], + 'facebook_post' => [ + 'label' => 'Post', + 'description' => 'Post estándar en tu página', + ], + 'facebook_reel' => [ + 'label' => 'Reel', + 'description' => 'Video corto de hasta 90 segundos', + ], + 'facebook_story' => [ + 'label' => 'Historia', + 'description' => 'Desaparece después de 24 horas', + ], + 'tiktok_video' => [ + 'label' => 'Video', + 'description' => 'Contenido de video corto', + ], + 'youtube_short' => [ + 'label' => 'Short', + 'description' => 'Video vertical de hasta 60 segundos', + ], + 'x_post' => [ + 'label' => 'Post', + 'description' => 'Tweet con texto y multimedia', + ], + 'threads_post' => [ + 'label' => 'Post', + 'description' => 'Post de texto con multimedia opcional', + ], + 'pinterest_pin' => [ + 'label' => 'Pin', + 'description' => 'Pin de imagen con enlace', + ], + 'pinterest_video_pin' => [ + 'label' => 'Pin de video', + 'description' => 'Contenido de video', + ], + 'pinterest_carousel' => [ + 'label' => 'Carrusel', + 'description' => '2-5 imágenes', + ], + 'bluesky_post' => [ + 'label' => 'Post', + 'description' => 'Post de texto con imágenes opcionales', + ], + 'mastodon_post' => [ + 'label' => 'Post', + 'description' => 'Post de texto con multimedia opcional', + ], + ], + + 'platforms' => [ + 'linkedin' => 'LinkedIn', + 'linkedin-page' => 'Página de LinkedIn', + 'x' => 'X', + 'tiktok' => 'TikTok', + 'youtube' => 'YouTube Shorts', + 'facebook' => 'Página de Facebook', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'pinterest' => 'Pinterest', + 'bluesky' => 'Bluesky', + 'mastodon' => 'Mastodon', + ], + + 'flash' => [ + 'scheduled' => '¡Post programado correctamente!', + 'publishing' => '¡El post se está publicando!', + 'deleted' => '¡Post eliminado correctamente!', + 'cannot_edit_published' => 'Los posts publicados no se pueden editar.', + 'connect_first' => 'Conecta al menos una red social antes de crear un post.', + ], + + 'errors' => [ + 'account_disconnected' => 'Cuenta social desconectada', + 'account_inactive' => 'Cuenta social desactivada', + ], +]; diff --git a/lang/es/settings.php b/lang/es/settings.php new file mode 100644 index 00000000..486d40ec --- /dev/null +++ b/lang/es/settings.php @@ -0,0 +1,206 @@ + 'Configuración', + 'description' => 'Administra tu perfil y configuración de la cuenta', + + 'nav' => [ + 'profile' => 'Perfil', + 'password' => 'Contraseña', + 'workspace' => 'Workspace', + 'members' => 'Miembros', + 'notifications' => 'Notificaciones', + 'billing' => 'Facturación', + ], + + 'notifications' => [ + 'title' => 'Preferencias de notificaciones', + 'heading' => 'Notificaciones por correo', + 'description' => 'Elige qué notificaciones por correo deseas recibir', + 'post_published' => 'Post publicado', + 'post_published_description' => 'Recibir un correo cuando tu post se publique correctamente', + 'post_failed' => 'Post fallido', + 'post_failed_description' => 'Recibir un correo cuando tu post falle al publicar', + 'account_disconnected' => 'Cuenta desconectada', + 'account_disconnected_description' => 'Recibir un correo cuando una cuenta social se desconecte', + 'save' => 'Guardar preferencias', + ], + + 'profile' => [ + 'title' => 'Configuración del perfil', + 'photo_heading' => 'Foto de perfil', + 'photo_description' => 'Sube una foto de perfil', + 'heading' => 'Información del perfil', + 'description' => 'Actualiza tu nombre y correo electrónico', + 'avatar' => 'Avatar', + 'name' => 'Nombre', + 'name_placeholder' => 'Nombre completo', + 'email' => 'Correo electrónico', + 'email_placeholder' => 'Correo electrónico', + 'email_unverified' => 'Tu correo electrónico no ha sido verificado.', + 'resend_verification' => 'Haz clic aquí para reenviar el correo de verificación.', + 'verification_sent' => 'Se ha enviado un nuevo enlace de verificación a tu correo electrónico.', + 'save' => 'Guardar', + ], + + 'password' => [ + 'title' => 'Configuración de contraseña', + 'heading' => 'Actualizar contraseña', + 'description' => 'Asegúrate de que tu cuenta use una contraseña larga y aleatoria para mantenerte seguro', + 'current_password' => 'Contraseña actual', + 'current_password_placeholder' => 'Contraseña actual', + 'new_password' => 'Nueva contraseña', + 'new_password_placeholder' => 'Nueva contraseña', + 'confirm_password' => 'Confirmar contraseña', + 'confirm_password_placeholder' => 'Confirmar contraseña', + 'save' => 'Guardar contraseña', + ], + + 'delete_account' => [ + 'heading' => 'Eliminar cuenta', + 'description' => 'Elimina tu cuenta y todos sus recursos', + 'warning' => 'Advertencia', + 'warning_message' => 'Procede con precaución, esta acción no se puede deshacer.', + 'button' => 'Eliminar cuenta', + 'modal_title' => '¿Estás seguro de que deseas eliminar tu cuenta?', + 'modal_description' => 'Una vez eliminada tu cuenta, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar que deseas eliminar permanentemente tu cuenta.', + 'password' => 'Contraseña', + 'password_placeholder' => 'Contraseña', + 'cancel' => 'Cancelar', + 'confirm' => 'Eliminar cuenta', + ], + + 'workspace' => [ + 'title' => 'Configuración del workspace', + 'logo_heading' => 'Logo del workspace', + 'logo_description' => 'Sube un logo para tu workspace', + 'heading' => 'Nombre del workspace', + 'description' => 'Actualiza el nombre y zona horaria del workspace', + 'members_heading' => 'Miembros', + 'members_description' => 'Administra miembros e invitaciones del workspace', + 'name' => 'Nombre', + 'name_placeholder' => 'Mi Workspace', + 'timezone' => 'Zona horaria', + 'save' => 'Guardar', + ], + + 'members' => [ + 'title' => 'Miembros', + 'heading' => 'Miembros del equipo', + 'description' => 'Administra miembros e invitaciones de este workspace', + + 'cancel' => 'Cancelar', + 'remove' => 'Eliminar', + 'make_admin' => 'Hacer administrador', + 'make_member' => 'Hacer miembro', + + 'invite' => [ + 'title' => 'Invitar miembro', + 'description' => 'Envía una invitación por correo para agregar colaboradores', + 'email' => 'Correo electrónico', + 'email_placeholder' => 'colaborador@email.com', + 'role' => 'Rol', + 'role_placeholder' => 'Selecciona un rol', + 'submit' => 'Enviar invitación', + ], + + 'pending' => [ + 'title' => 'Invitaciones pendientes', + 'description' => 'Invitaciones en espera de aceptación', + 'empty' => 'No hay invitaciones pendientes', + ], + + 'list' => [ + 'title' => 'Miembros', + 'description' => 'Personas con acceso a este workspace', + 'empty' => 'No hay miembros además del propietario', + ], + + 'remove_modal' => [ + 'title' => 'Eliminar miembro', + 'description' => '¿Estás seguro de que deseas eliminar a este miembro del workspace? Perderá acceso a todos los recursos del workspace.', + 'action' => 'Eliminar miembro', + ], + + 'cancel_invite_modal' => [ + 'title' => 'Cancelar invitación', + 'description' => '¿Estás seguro de que deseas cancelar esta invitación?', + 'action' => 'Cancelar invitación', + ], + + 'roles' => [ + 'owner' => 'Propietario', + 'admin' => 'Administrador', + 'member' => 'Miembro', + ], + + 'flash' => [ + 'invite_sent' => '¡Invitación enviada correctamente!', + 'invite_deleted' => 'Invitación eliminada.', + 'member_removed' => '¡Miembro eliminado correctamente!', + 'role_updated' => 'Rol del miembro actualizado.', + 'wrong_email' => 'Esta invitación es para otro correo electrónico.', + 'already_member' => 'Ya eres miembro de este workspace.', + 'invite_accepted' => '¡Bienvenido! Ahora eres miembro del workspace.', + 'invite_declined' => 'Invitación rechazada.', + ], + ], + + 'flash' => [ + 'profile_updated' => '¡Perfil actualizado correctamente!', + 'language_updated' => '¡Idioma actualizado correctamente!', + 'password_updated' => '¡Contraseña actualizada correctamente!', + 'workspace_updated' => '¡Configuración actualizada correctamente!', + 'photo_updated' => '¡Foto actualizada correctamente!', + 'photo_deleted' => '¡Foto eliminada correctamente!', + 'logo_updated' => '¡Logo subido correctamente!', + 'logo_deleted' => '¡Logo eliminado correctamente!', + 'notifications_updated' => '¡Preferencias de notificaciones actualizadas!', + ], + + 'api_keys' => [ + 'title' => 'Claves API', + 'page_title' => 'Claves API', + 'heading' => 'Claves API', + 'description' => 'Administra claves API para acceso programático a tu workspace.', + 'create' => 'Crear clave API', + 'copy' => 'Copiar', + 'new_token_message' => 'Tu nueva clave API ha sido creada. Cópiala ahora — no podrás verla de nuevo.', + 'table' => [ + 'name' => 'Nombre', + 'key' => 'Clave', + 'status' => 'Estado', + 'expires' => 'Expira', + 'last_used' => 'Último uso', + 'never' => 'Nunca', + ], + 'actions' => [ + 'copy_id' => 'Copiar ID de clave API', + 'copy_id_success' => 'ID de clave API copiado', + 'delete' => 'Eliminar', + ], + 'empty' => [ + 'title' => 'No hay claves API', + 'description' => 'Crea una clave API para acceder a tu workspace programáticamente.', + ], + 'delete_modal' => [ + 'title' => 'Eliminar clave API', + 'description' => '¿Estás seguro de que deseas eliminar esta clave API? Las aplicaciones que la usen perderán acceso inmediatamente.', + 'action' => 'Eliminar clave API', + ], + 'create_dialog' => [ + 'title' => 'Crear clave API', + 'description' => 'Crea una nueva clave API para acceso programático a tu workspace.', + 'name' => 'Nombre', + 'name_placeholder' => 'ej. Clave API de Producción', + 'expires' => 'Fecha de expiración (opcional)', + 'expires_placeholder' => 'Sin expiración', + 'submit' => 'Crear', + 'cancel' => 'Cancelar', + ], + 'flash' => [ + 'created' => '¡Clave API creada correctamente!', + 'deleted' => '¡Clave API eliminada correctamente!', + ], + ], +]; diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php new file mode 100644 index 00000000..0eb9016f --- /dev/null +++ b/lang/es/sidebar.php @@ -0,0 +1,56 @@ + 'Workspaces', + 'select_workspace' => 'Seleccionar workspace', + 'create_workspace' => 'Crear workspace', + 'create_post' => 'Crear post', + 'profile' => 'Perfil', + 'account' => 'Cuenta', + 'log_out' => 'Cerrar sesión', + + 'workspace' => 'Workspace: :name', + 'workspace_select' => 'Workspace: Seleccionar', + + 'theme' => 'Tema: :name', + 'theme_light' => 'Claro', + 'theme_dark' => 'Oscuro', + 'theme_system' => 'Sistema', + + 'language' => 'Idioma: :name', + 'language_select' => 'Idioma: Seleccionar', + + 'groups' => [ + 'posts' => 'Posts', + 'configuration' => 'Configuración', + 'support' => 'Soporte', + ], + + 'posts' => [ + 'calendar' => 'Calendario', + 'all' => 'Todos', + 'scheduled' => 'Programados', + 'posted' => 'Publicados', + 'drafts' => 'Borradores', + ], + + 'config' => [ + 'connections' => 'Conexiones', + 'hashtags' => 'Hashtags', + 'labels' => 'Etiquetas', + 'settings' => 'Configuración', + ], + + 'notifications' => 'Notificaciones', + 'mark_all_read' => 'Marcar todo como leído', + 'mark_as_read' => 'Marcar como leído', + 'archive_all' => 'Archivar todo', + 'no_notifications' => 'Sin notificaciones', + + 'support' => [ + 'discord' => 'Discord', + 'share_feedback' => 'Dar feedback', + 'last_updates' => 'Últimas actualizaciones', + 'docs' => 'Documentación', + ], +]; diff --git a/lang/es/validation.php b/lang/es/validation.php new file mode 100644 index 00000000..bb7a9e00 --- /dev/null +++ b/lang/es/validation.php @@ -0,0 +1,165 @@ + 'El campo :attribute debe ser aceptado.', + 'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other es :value.', + 'active_url' => 'El campo :attribute debe ser una URL válida.', + 'after' => 'El campo :attribute debe ser una fecha posterior a :date.', + 'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.', + 'alpha' => 'El campo :attribute solo puede contener letras.', + 'alpha_dash' => 'El campo :attribute solo puede contener letras, números, guiones y guiones bajos.', + 'alpha_num' => 'El campo :attribute solo puede contener letras y números.', + 'any_of' => 'El campo :attribute no es válido.', + 'array' => 'El campo :attribute debe ser un arreglo.', + 'ascii' => 'El campo :attribute solo puede contener caracteres alfanuméricos de un byte y símbolos.', + 'before' => 'El campo :attribute debe ser una fecha anterior a :date.', + 'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.', + 'between' => [ + 'array' => 'El campo :attribute debe tener entre :min y :max elementos.', + 'file' => 'El campo :attribute debe pesar entre :min y :max kilobytes.', + 'numeric' => 'El campo :attribute debe estar entre :min y :max.', + 'string' => 'El campo :attribute debe tener entre :min y :max caracteres.', + ], + 'boolean' => 'El campo :attribute debe ser verdadero o falso.', + 'can' => 'El campo :attribute contiene un valor no autorizado.', + 'confirmed' => 'La confirmación del campo :attribute no coincide.', + 'contains' => 'Al campo :attribute le falta un valor requerido.', + 'current_password' => 'La contraseña es incorrecta.', + 'date' => 'El campo :attribute debe ser una fecha válida.', + 'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.', + 'date_format' => 'El campo :attribute debe coincidir con el formato :format.', + 'decimal' => 'El campo :attribute debe tener :decimal decimales.', + 'declined' => 'El campo :attribute debe ser rechazado.', + 'declined_if' => 'El campo :attribute debe ser rechazado cuando :other es :value.', + 'different' => 'El campo :attribute y :other deben ser diferentes.', + 'digits' => 'El campo :attribute debe tener :digits dígitos.', + 'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.', + 'dimensions' => 'El campo :attribute tiene dimensiones de imagen no válidas.', + 'distinct' => 'El campo :attribute tiene un valor duplicado.', + 'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes: :values.', + 'doesnt_end_with' => 'El campo :attribute no debe terminar con uno de los siguientes: :values.', + 'doesnt_start_with' => 'El campo :attribute no debe comenzar con uno de los siguientes: :values.', + 'email' => 'El campo :attribute debe ser un correo electrónico válido.', + 'encoding' => 'El campo :attribute debe estar codificado en :encoding.', + 'ends_with' => 'El campo :attribute debe terminar con uno de los siguientes: :values.', + 'enum' => 'El :attribute seleccionado no es válido.', + 'exists' => 'El :attribute seleccionado no es válido.', + 'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.', + 'file' => 'El campo :attribute debe ser un archivo.', + 'filled' => 'El campo :attribute debe tener un valor.', + 'gt' => [ + 'array' => 'El campo :attribute debe tener más de :value elementos.', + 'file' => 'El campo :attribute debe pesar más de :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser mayor que :value.', + 'string' => 'El campo :attribute debe tener más de :value caracteres.', + ], + 'gte' => [ + 'array' => 'El campo :attribute debe tener :value elementos o más.', + 'file' => 'El campo :attribute debe pesar :value kilobytes o más.', + 'numeric' => 'El campo :attribute debe ser mayor o igual a :value.', + 'string' => 'El campo :attribute debe tener :value caracteres o más.', + ], + 'hex_color' => 'El campo :attribute debe ser un color hexadecimal válido.', + 'image' => 'El campo :attribute debe ser una imagen.', + 'in' => 'El :attribute seleccionado no es válido.', + 'in_array' => 'El campo :attribute debe existir en :other.', + 'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.', + 'integer' => 'El campo :attribute debe ser un número entero.', + 'ip' => 'El campo :attribute debe ser una dirección IP válida.', + 'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.', + 'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.', + 'json' => 'El campo :attribute debe ser una cadena JSON válida.', + 'list' => 'El campo :attribute debe ser una lista.', + 'lowercase' => 'El campo :attribute debe estar en minúsculas.', + 'lt' => [ + 'array' => 'El campo :attribute debe tener menos de :value elementos.', + 'file' => 'El campo :attribute debe pesar menos de :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser menor que :value.', + 'string' => 'El campo :attribute debe tener menos de :value caracteres.', + ], + 'lte' => [ + 'array' => 'El campo :attribute no debe tener más de :value elementos.', + 'file' => 'El campo :attribute debe pesar :value kilobytes o menos.', + 'numeric' => 'El campo :attribute debe ser menor o igual a :value.', + 'string' => 'El campo :attribute debe tener :value caracteres o menos.', + ], + 'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.', + 'max' => [ + 'array' => 'El campo :attribute no debe tener más de :max elementos.', + 'file' => 'El campo :attribute no debe pesar más de :max kilobytes.', + 'numeric' => 'El campo :attribute no debe ser mayor que :max.', + 'string' => 'El campo :attribute no debe tener más de :max caracteres.', + ], + 'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.', + 'mimes' => 'El campo :attribute debe ser un archivo de tipo: :values.', + 'mimetypes' => 'El campo :attribute debe ser un archivo de tipo: :values.', + 'min' => [ + 'array' => 'El campo :attribute debe tener al menos :min elementos.', + 'file' => 'El campo :attribute debe pesar al menos :min kilobytes.', + 'numeric' => 'El campo :attribute debe ser al menos :min.', + 'string' => 'El campo :attribute debe tener al menos :min caracteres.', + ], + 'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.', + 'missing' => 'El campo :attribute debe estar ausente.', + 'missing_if' => 'El campo :attribute debe estar ausente cuando :other es :value.', + 'missing_unless' => 'El campo :attribute debe estar ausente a menos que :other sea :value.', + 'missing_with' => 'El campo :attribute debe estar ausente cuando :values está presente.', + 'missing_with_all' => 'El campo :attribute debe estar ausente cuando :values están presentes.', + 'multiple_of' => 'El campo :attribute debe ser múltiplo de :value.', + 'not_in' => 'El :attribute seleccionado no es válido.', + 'not_regex' => 'El formato del campo :attribute no es válido.', + 'numeric' => 'El campo :attribute debe ser un número.', + 'password' => [ + 'letters' => 'El campo :attribute debe contener al menos una letra.', + 'mixed' => 'El campo :attribute debe contener al menos una letra mayúscula y una minúscula.', + 'numbers' => 'El campo :attribute debe contener al menos un número.', + 'symbols' => 'El campo :attribute debe contener al menos un símbolo.', + 'uncompromised' => 'El :attribute proporcionado ha aparecido en una filtración de datos. Elige un :attribute diferente.', + ], + 'present' => 'El campo :attribute debe estar presente.', + 'present_if' => 'El campo :attribute debe estar presente cuando :other es :value.', + 'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.', + 'present_with' => 'El campo :attribute debe estar presente cuando :values está presente.', + 'present_with_all' => 'El campo :attribute debe estar presente cuando :values están presentes.', + 'prohibited' => 'El campo :attribute está prohibido.', + 'prohibited_if' => 'El campo :attribute está prohibido cuando :other es :value.', + 'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando :other es aceptado.', + 'prohibited_if_declined' => 'El campo :attribute está prohibido cuando :other es rechazado.', + 'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other esté en :values.', + 'prohibits' => 'El campo :attribute prohíbe que :other esté presente.', + 'regex' => 'El formato del campo :attribute no es válido.', + 'required' => 'El campo :attribute es obligatorio.', + 'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.', + 'required_if' => 'El campo :attribute es obligatorio cuando :other es :value.', + 'required_if_accepted' => 'El campo :attribute es obligatorio cuando :other es aceptado.', + 'required_if_declined' => 'El campo :attribute es obligatorio cuando :other es rechazado.', + 'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.', + 'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.', + 'required_with_all' => 'El campo :attribute es obligatorio cuando :values están presentes.', + 'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.', + 'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values está presente.', + 'same' => 'El campo :attribute debe coincidir con :other.', + 'size' => [ + 'array' => 'El campo :attribute debe contener :size elementos.', + 'file' => 'El campo :attribute debe pesar :size kilobytes.', + 'numeric' => 'El campo :attribute debe ser :size.', + 'string' => 'El campo :attribute debe tener :size caracteres.', + ], + 'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes: :values.', + 'string' => 'El campo :attribute debe ser una cadena de texto.', + 'timezone' => 'El campo :attribute debe ser una zona horaria válida.', + 'unique' => 'El :attribute ya ha sido registrado.', + 'uploaded' => 'El :attribute no se pudo subir.', + 'uppercase' => 'El campo :attribute debe estar en mayúsculas.', + 'url' => 'El campo :attribute debe ser una URL válida.', + 'ulid' => 'El campo :attribute debe ser un ULID válido.', + 'uuid' => 'El campo :attribute debe ser un UUID válido.', + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + 'attributes' => [], +]; diff --git a/lang/es/workspaces.php b/lang/es/workspaces.php new file mode 100644 index 00000000..f1d7ac75 --- /dev/null +++ b/lang/es/workspaces.php @@ -0,0 +1,20 @@ + 'Workspaces', + 'select_title' => 'Tus workspaces', + 'select_description' => 'Selecciona un workspace para continuar', + 'current' => 'Actual', + 'connections' => ':count conexiones', + 'posts' => ':count posts', + + 'create' => [ + 'title' => 'Crear un workspace', + 'description' => 'Dale un nombre a tu workspace para comenzar', + 'name' => 'Nombre del workspace', + 'name_placeholder' => 'Mi Workspace', + 'submit' => 'Crear workspace', + ], +]; diff --git a/lang/php_en.json b/lang/php_en.json deleted file mode 100644 index 156232fe..00000000 --- a/lang/php_en.json +++ /dev/null @@ -1 +0,0 @@ -{"auth.failed":"These credentials do not match our records.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in :seconds seconds.","pagination.previous":"« Previous","pagination.next":"Next »","passwords.reset":"Your password has been reset.","passwords.sent":"We have emailed your password reset link.","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","validation.accepted":"The :attribute field must be accepted.","validation.accepted_if":"The :attribute field must be accepted when :other is :value.","validation.active_url":"The :attribute field must be a valid URL.","validation.after":"The :attribute field must be a date after :date.","validation.after_or_equal":"The :attribute field must be a date after or equal to :date.","validation.alpha":"The :attribute field must only contain letters.","validation.alpha_dash":"The :attribute field must only contain letters, numbers, dashes, and underscores.","validation.alpha_num":"The :attribute field must only contain letters and numbers.","validation.any_of":"The :attribute field is invalid.","validation.array":"The :attribute field must be an array.","validation.ascii":"The :attribute field must only contain single-byte alphanumeric characters and symbols.","validation.before":"The :attribute field must be a date before :date.","validation.before_or_equal":"The :attribute field must be a date before or equal to :date.","validation.between.array":"The :attribute field must have between :min and :max items.","validation.between.file":"The :attribute field must be between :min and :max kilobytes.","validation.between.numeric":"The :attribute field must be between :min and :max.","validation.between.string":"The :attribute field must be between :min and :max characters.","validation.boolean":"The :attribute field must be true or false.","validation.can":"The :attribute field contains an unauthorized value.","validation.confirmed":"The :attribute field confirmation does not match.","validation.contains":"The :attribute field is missing a required value.","validation.current_password":"The password is incorrect.","validation.date":"The :attribute field must be a valid date.","validation.date_equals":"The :attribute field must be a date equal to :date.","validation.date_format":"The :attribute field must match the format :format.","validation.decimal":"The :attribute field must have :decimal decimal places.","validation.declined":"The :attribute field must be declined.","validation.declined_if":"The :attribute field must be declined when :other is :value.","validation.different":"The :attribute field and :other must be different.","validation.digits":"The :attribute field must be :digits digits.","validation.digits_between":"The :attribute field must be between :min and :max digits.","validation.dimensions":"The :attribute field has invalid image dimensions.","validation.distinct":"The :attribute field has a duplicate value.","validation.doesnt_contain":"The :attribute field must not contain any of the following: :values.","validation.doesnt_end_with":"The :attribute field must not end with one of the following: :values.","validation.doesnt_start_with":"The :attribute field must not start with one of the following: :values.","validation.email":"The :attribute field must be a valid email address.","validation.encoding":"The :attribute field must be encoded in :encoding.","validation.ends_with":"The :attribute field must end with one of the following: :values.","validation.enum":"The selected :attribute is invalid.","validation.exists":"The selected :attribute is invalid.","validation.extensions":"The :attribute field must have one of the following extensions: :values.","validation.file":"The :attribute field must be a file.","validation.filled":"The :attribute field must have a value.","validation.gt.array":"The :attribute field must have more than :value items.","validation.gt.file":"The :attribute field must be greater than :value kilobytes.","validation.gt.numeric":"The :attribute field must be greater than :value.","validation.gt.string":"The :attribute field must be greater than :value characters.","validation.gte.array":"The :attribute field must have :value items or more.","validation.gte.file":"The :attribute field must be greater than or equal to :value kilobytes.","validation.gte.numeric":"The :attribute field must be greater than or equal to :value.","validation.gte.string":"The :attribute field must be greater than or equal to :value characters.","validation.hex_color":"The :attribute field must be a valid hexadecimal color.","validation.image":"The :attribute field must be an image.","validation.in":"The selected :attribute is invalid.","validation.in_array":"The :attribute field must exist in :other.","validation.in_array_keys":"The :attribute field must contain at least one of the following keys: :values.","validation.integer":"The :attribute field must be an integer.","validation.ip":"The :attribute field must be a valid IP address.","validation.ipv4":"The :attribute field must be a valid IPv4 address.","validation.ipv6":"The :attribute field must be a valid IPv6 address.","validation.json":"The :attribute field must be a valid JSON string.","validation.list":"The :attribute field must be a list.","validation.lowercase":"The :attribute field must be lowercase.","validation.lt.array":"The :attribute field must have less than :value items.","validation.lt.file":"The :attribute field must be less than :value kilobytes.","validation.lt.numeric":"The :attribute field must be less than :value.","validation.lt.string":"The :attribute field must be less than :value characters.","validation.lte.array":"The :attribute field must not have more than :value items.","validation.lte.file":"The :attribute field must be less than or equal to :value kilobytes.","validation.lte.numeric":"The :attribute field must be less than or equal to :value.","validation.lte.string":"The :attribute field must be less than or equal to :value characters.","validation.mac_address":"The :attribute field must be a valid MAC address.","validation.max.array":"The :attribute field must not have more than :max items.","validation.max.file":"The :attribute field must not be greater than :max kilobytes.","validation.max.numeric":"The :attribute field must not be greater than :max.","validation.max.string":"The :attribute field must not be greater than :max characters.","validation.max_digits":"The :attribute field must not have more than :max digits.","validation.mimes":"The :attribute field must be a file of type: :values.","validation.mimetypes":"The :attribute field must be a file of type: :values.","validation.min.array":"The :attribute field must have at least :min items.","validation.min.file":"The :attribute field must be at least :min kilobytes.","validation.min.numeric":"The :attribute field must be at least :min.","validation.min.string":"The :attribute field must be at least :min characters.","validation.min_digits":"The :attribute field must have at least :min digits.","validation.missing":"The :attribute field must be missing.","validation.missing_if":"The :attribute field must be missing when :other is :value.","validation.missing_unless":"The :attribute field must be missing unless :other is :value.","validation.missing_with":"The :attribute field must be missing when :values is present.","validation.missing_with_all":"The :attribute field must be missing when :values are present.","validation.multiple_of":"The :attribute field must be a multiple of :value.","validation.not_in":"The selected :attribute is invalid.","validation.not_regex":"The :attribute field format is invalid.","validation.numeric":"The :attribute field must be a number.","validation.password.letters":"The :attribute field must contain at least one letter.","validation.password.mixed":"The :attribute field must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The :attribute field must contain at least one number.","validation.password.symbols":"The :attribute field must contain at least one symbol.","validation.password.uncompromised":"The given :attribute has appeared in a data leak. Please choose a different :attribute.","validation.present":"The :attribute field must be present.","validation.present_if":"The :attribute field must be present when :other is :value.","validation.present_unless":"The :attribute field must be present unless :other is :value.","validation.present_with":"The :attribute field must be present when :values is present.","validation.present_with_all":"The :attribute field must be present when :values are present.","validation.prohibited":"The :attribute field is prohibited.","validation.prohibited_if":"The :attribute field is prohibited when :other is :value.","validation.prohibited_if_accepted":"The :attribute field is prohibited when :other is accepted.","validation.prohibited_if_declined":"The :attribute field is prohibited when :other is declined.","validation.prohibited_unless":"The :attribute field is prohibited unless :other is in :values.","validation.prohibits":"The :attribute field prohibits :other from being present.","validation.regex":"The :attribute field format is invalid.","validation.required":"The :attribute field is required.","validation.required_array_keys":"The :attribute field must contain entries for: :values.","validation.required_if":"The :attribute field is required when :other is :value.","validation.required_if_accepted":"The :attribute field is required when :other is accepted.","validation.required_if_declined":"The :attribute field is required when :other is declined.","validation.required_unless":"The :attribute field is required unless :other is in :values.","validation.required_with":"The :attribute field is required when :values is present.","validation.required_with_all":"The :attribute field is required when :values are present.","validation.required_without":"The :attribute field is required when :values is not present.","validation.required_without_all":"The :attribute field is required when none of :values are present.","validation.same":"The :attribute field must match :other.","validation.size.array":"The :attribute field must contain :size items.","validation.size.file":"The :attribute field must be :size kilobytes.","validation.size.numeric":"The :attribute field must be :size.","validation.size.string":"The :attribute field must be :size characters.","validation.starts_with":"The :attribute field must start with one of the following: :values.","validation.string":"The :attribute field must be a string.","validation.timezone":"The :attribute field must be a valid timezone.","validation.unique":"The :attribute has already been taken.","validation.uploaded":"The :attribute failed to upload.","validation.uppercase":"The :attribute field must be uppercase.","validation.url":"The :attribute field must be a valid URL.","validation.ulid":"The :attribute field must be a valid ULID.","validation.uuid":"The :attribute field must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message","accounts.title":"Connections","accounts.page_title":"Connected Accounts","accounts.description":"Connect your social networks to schedule and publish posts","accounts.not_connected":"Not connected","accounts.connect":"Connect","accounts.connection_lost":"Connection lost","accounts.reconnect_account":"Reconnect account","accounts.view_profile":"View profile","accounts.disconnect":"Disconnect","accounts.disconnect_modal.title":"Disconnect Account","accounts.disconnect_modal.description":"Are you sure you want to disconnect this account? You can reconnect it at any time.","accounts.disconnect_modal.confirm":"Disconnect","accounts.disconnect_modal.cancel":"Cancel","accounts.bluesky.title":"Connect Bluesky","accounts.bluesky.description":"Enter your credentials to connect","accounts.bluesky.email":"Email","accounts.bluesky.email_placeholder":"yourhandle.bsky.social","accounts.bluesky.app_password":"App Password","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use an App Password for security. Create one at bsky.app/settings.","accounts.bluesky.submit":"Connect Bluesky","accounts.bluesky.submitting":"Connecting...","accounts.mastodon.title":"Connect Mastodon","accounts.mastodon.description":"Enter your Mastodon instance","accounts.mastodon.instance_url":"Instance URL","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social)","accounts.mastodon.submit":"Continue with Mastodon","accounts.mastodon.submitting":"Connecting...","accounts.facebook.title":"Select Facebook Page","accounts.facebook.description":"Choose which page you want to connect","accounts.facebook.no_pages":"No pages found","accounts.facebook.no_pages_description":"You are not an admin of any Facebook page.","accounts.facebook.page_label":"Facebook Page","accounts.linkedin.title":"Select LinkedIn Page","accounts.linkedin.description":"Choose which page you want to connect","accounts.linkedin.no_pages":"No pages found","accounts.linkedin.no_pages_description":"You are not an administrator of any LinkedIn page.","accounts.linkedin.page_label":"LinkedIn Page","accounts.flash.disconnected":"Account disconnected successfully!","accounts.flash.connected":"Account connected successfully!","accounts.flash.session_expired":"Session expired. Please try again.","accounts.flash.workspace_not_found":"Workspace not found.","accounts.flash.already_connected":"This platform is already connected.","accounts.flash.no_youtube_channels":"No YouTube channels found. Please create a channel first.","auth.flash.welcome":"Welcome to TryPost!","auth.flash.welcome_trial":"Welcome to TryPost! Your trial has started.","auth.login.title":"Log in to your account","auth.login.description":"Enter your email and password below to log in","auth.login.page_title":"Log in","auth.login.email":"Email address","auth.login.password":"Password","auth.login.forgot_password":"Forgot password?","auth.login.remember_me":"Remember me","auth.login.submit":"Log in","auth.login.no_account":"Don't have an account?","auth.login.sign_up":"Sign up","auth.register.title":"Create an account","auth.register.description":"Enter your details below to create your account","auth.register.page_title":"Register","auth.register.name":"Name","auth.register.name_placeholder":"Full name","auth.register.email":"Email address","auth.register.password":"Password","auth.register.submit":"Create account","auth.register.has_account":"Already have an account?","auth.register.log_in":"Log in","auth.forgot_password.title":"Forgot password","auth.forgot_password.description":"Enter your email to receive a password reset link","auth.forgot_password.page_title":"Forgot password","auth.forgot_password.email":"Email address","auth.forgot_password.submit":"Email password reset link","auth.forgot_password.return_to":"Or, return to","auth.forgot_password.log_in":"log in","auth.reset_password.title":"Reset password","auth.reset_password.description":"Please enter your new password below","auth.reset_password.page_title":"Reset password","auth.reset_password.email":"Email","auth.reset_password.password":"Password","auth.reset_password.confirm_password":"Confirm Password","auth.reset_password.confirm_placeholder":"Confirm password","auth.reset_password.submit":"Reset password","auth.verify_email.title":"Verify email","auth.verify_email.description":"Please verify your email address by clicking on the link we just emailed to you.","auth.verify_email.page_title":"Email verification","auth.verify_email.link_sent":"A new verification link has been sent to the email address you provided during registration.","auth.verify_email.resend":"Resend verification email","auth.verify_email.log_out":"Log out","auth.accept_invite.page_title":"Accept Invite","auth.accept_invite.title":"You've been invited!","auth.accept_invite.description":"You've been invited to join the :workspace workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Your role","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Accept Invite","auth.accept_invite.decline":"Decline Invite","auth.accept_invite.login_prompt":"Log in or create an account to accept this invite.","auth.accept_invite.log_in":"Log in","auth.accept_invite.create_account":"Create Account","billing.title":"Subscription","billing.description":"Manage your subscription and payment method","billing.trial.title":"Trial period active","billing.trial.description":"Your trial ends on :date. After that, your subscription will be charged automatically.","billing.subscription.title":"Your Subscription","billing.subscription.workspaces":"Workspaces","billing.subscription.quantity":"Subscription quantity","billing.subscription.expires":"Expires :date","billing.subscription.canceled_on":"Your subscription will be canceled on :date","billing.subscription.manage":"Manage on Stripe","billing.invoices.title":"Invoices","billing.invoices.description":"Payment history","billing.invoices.empty":"No invoices found","billing.invoices.paid":"Paid","billing.status.active":"Active","billing.status.canceled":"Canceled","billing.status.incomplete":"Incomplete","billing.status.incomplete_expired":"Expired","billing.status.past_due":"Past due","billing.status.trialing":"Trial","billing.status.unpaid":"Unpaid","calendar.title":"Calendar","calendar.today":"Today","calendar.day":"Day","calendar.week":"Week","calendar.month":"Month","calendar.new_post":"New Post","calendar.no_content":"No content","calendar.more":"+:count more","hashtags.title":"Hashtags","hashtags.description":"Create hashtag groups to quickly add to your posts","hashtags.new_group":"New Group","hashtags.no_groups_yet":"No hashtag groups yet","hashtags.no_groups_description":"Create hashtag groups to quickly add popular hashtags to your posts","hashtags.create_first_group":"Create your first group","hashtags.hashtags_count":":count hashtags","hashtags.create.title":"Create Hashtag Group","hashtags.create.description":"Give your group a name and add hashtags separated by spaces or commas","hashtags.create.name":"Group Name","hashtags.create.name_placeholder":"e.g. Marketing, Travel, Food","hashtags.create.hashtags":"Hashtags","hashtags.create.hashtags_placeholder":"#marketing #socialmedia #business #growth","hashtags.create.hashtags_hint":"Enter hashtags separated by spaces or commas. Include the # symbol.","hashtags.create.submit":"Create Group","hashtags.create.submitting":"Creating...","hashtags.edit.title":"Edit Hashtag Group","hashtags.edit.description":"Update the name and hashtags for this group","hashtags.edit.name":"Group Name","hashtags.edit.name_placeholder":"e.g. Marketing, Travel, Food","hashtags.edit.hashtags":"Hashtags","hashtags.edit.hashtags_placeholder":"#marketing #socialmedia #business #growth","hashtags.edit.hashtags_hint":"Enter hashtags separated by spaces or commas. Include the # symbol.","hashtags.edit.submit":"Save Changes","hashtags.edit.submitting":"Saving...","hashtags.delete.title":"Delete Hashtag Group","hashtags.delete.description":"Are you sure you want to delete this hashtag group? This action cannot be undone.","hashtags.delete.confirm":"Delete","hashtags.delete.cancel":"Cancel","hashtags.flash.created":"Hashtag group created successfully!","hashtags.flash.updated":"Hashtag group updated successfully!","hashtags.flash.deleted":"Hashtag group deleted successfully!","labels.title":"Labels","labels.description":"Create labels to organize and categorize your posts","labels.new_label":"New Label","labels.no_labels_yet":"No labels yet","labels.create_first_label":"Create your first label","labels.create.title":"Create Label","labels.create.description":"Give your label a name and pick a color","labels.create.name":"Name","labels.create.name_placeholder":"Enter label name...","labels.create.color":"Color","labels.create.submit":"Create Label","labels.create.submitting":"Creating...","labels.edit.title":"Edit Label","labels.edit.description":"Update the name and color for this label","labels.edit.name":"Name","labels.edit.name_placeholder":"Enter label name...","labels.edit.color":"Color","labels.edit.submit":"Save Changes","labels.edit.submitting":"Saving...","labels.delete.title":"Delete Label","labels.delete.description":"Are you sure you want to delete this label? This action cannot be undone.","labels.delete.confirm":"Delete","labels.delete.cancel":"Cancel","labels.flash.created":"Label created successfully!","labels.flash.updated":"Label updated successfully!","labels.flash.deleted":"Label deleted successfully!","mail.workspace_connections_disconnected.subject":"{1} :count account needs to be reconnected in :workspace|[2,*] :count accounts need to be reconnected in :workspace","mail.workspace_connections_disconnected.title":"Accounts Need Reconnection","mail.workspace_connections_disconnected.intro":"The following social accounts in your :workspace workspace have been disconnected and need to be reconnected:","mail.workspace_connections_disconnected.reasons_title":"This may have happened because:","mail.workspace_connections_disconnected.reason_expired":"Access tokens expired","mail.workspace_connections_disconnected.reason_revoked":"You revoked access to TryPost on the platform","mail.workspace_connections_disconnected.reason_changed":"The platform changed their authentication requirements","mail.workspace_connections_disconnected.reconnect_cta":"Please reconnect these accounts to continue scheduling and publishing posts.","mail.workspace_connections_disconnected.button":"Reconnect Accounts","posts.title":"Posts","posts.all_posts":"All Posts","posts.new_post":"New Post","posts.no_posts":"No posts found","posts.start_creating":"Start by creating your first post.","posts.manage_posts":"Manage all your posts","posts.delete_confirm":"Are you sure you want to delete this post?","posts.by":"by","posts.actions.view":"View post","posts.actions.delete":"Delete post","posts.form.post_type":"Post Type","posts.form.board":"Board","posts.form.select_board":"Select a board","posts.form.search_board":"Search board...","posts.form.no_board_found":"No board found","posts.form.media":"Media","posts.form.min":"Min","posts.form.uploading":"Uploading...","posts.form.drop_to_upload":"Drop to upload","posts.form.drag_and_drop":"Drag & drop or click to upload","posts.form.photos_and_videos":"Photos and videos","posts.form.photos_only":"Photos only","posts.form.videos_only":"Videos only","posts.form.drag_to_reorder":"Drag to reorder","posts.form.caption":"Caption","posts.form.write_caption":"Write your caption...","posts.status.draft":"Draft","posts.status.scheduled":"Scheduled","posts.status.publishing":"Publishing","posts.status.published":"Published","posts.status.partially_published":"Partially Published","posts.status.failed":"Failed","posts.descriptions.draft":"Posts waiting to be scheduled","posts.descriptions.scheduled":"Posts scheduled for publishing","posts.descriptions.published":"Posts already published","posts.edit.title":"Edit Post","posts.edit.view_title":"View Post","posts.edit.manage_platforms":"Manage platforms","posts.edit.sync":"Sync","posts.edit.labels":"Labels","posts.edit.hashtags":"Hashtags","posts.edit.schedule":"Schedule","posts.edit.publish":"Publish","posts.edit.delete":"Delete","posts.edit.settings":"Settings","posts.edit.schedule_for":"Schedule for","posts.edit.saving":"Saving...","posts.edit.saved":"Saved","posts.edit.scheduled_at":"Scheduled:","posts.edit.published_at":"Published:","posts.edit.media":"Media","posts.edit.caption":"Caption","posts.edit.no_caption":"No caption","posts.edit.no_content":"No content","posts.edit.empty_state.title":"No platforms selected","posts.edit.empty_state.description":"Select at least one platform to create your post","posts.edit.delete_modal.title":"Delete Post","posts.edit.delete_modal.description":"Are you sure you want to delete this post? This action cannot be undone.","posts.edit.delete_modal.action":"Delete","posts.edit.delete_modal.cancel":"Cancel","posts.edit.sync_enable.title":"Enable sync?","posts.edit.sync_enable.description":"All platforms will share the same content. Any custom edits made to individual platforms will be replaced with the current content.","posts.edit.sync_enable.cancel":"Cancel","posts.edit.sync_enable.action":"Enable sync","posts.edit.sync_disable.title":"Disable sync?","posts.edit.sync_disable.description":"Each platform will keep its current content, but future edits will only apply to the platform you're editing.","posts.edit.sync_disable.customize_note":"You'll be able to customize the content for each platform individually.","posts.edit.sync_disable.cancel":"Cancel","posts.edit.sync_disable.action":"Disable sync","posts.edit.platforms_dialog.title":"Select Platforms","posts.edit.platforms_dialog.description":"Choose which platforms to publish this post to.","posts.edit.hashtags_modal.search":"Search hashtags...","posts.edit.hashtags_modal.no_results":"No hashtags found.","posts.edit.validation.select_board":"Select a board","posts.edit.validation.images_not_supported":"Images not supported","posts.edit.validation.videos_not_supported":"Videos not supported","posts.edit.validation.max_images":"Max :count images","posts.edit.validation.requires_media":"Requires media","posts.edit.validation.exceeded":":count exceeded","posts.edit.validation.does_not_support_images":":platform does not support images","posts.edit.validation.supports_up_to_images":":platform supports up to :count images","posts.edit.validation.does_not_support_videos":":platform does not support videos","posts.content_types.instagram_feed.label":"Feed Post","posts.content_types.instagram_feed.description":"Appears in your feed and profile","posts.content_types.instagram_reel.label":"Reel","posts.content_types.instagram_reel.description":"Short video up to 90 seconds","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Disappears after 24 hours","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Standard post with text and media","posts.content_types.linkedin_carousel.label":"Carousel","posts.content_types.linkedin_carousel.description":"Swipeable images","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Standard post with text and media","posts.content_types.linkedin_page_carousel.label":"Carousel","posts.content_types.linkedin_page_carousel.description":"Swipeable images","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Standard post on your page","posts.content_types.facebook_reel.label":"Reel","posts.content_types.facebook_reel.description":"Short video up to 90 seconds","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Disappears after 24 hours","posts.content_types.tiktok_video.label":"Video","posts.content_types.tiktok_video.description":"Short-form video content","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vertical video up to 60 seconds","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet with text and media","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Text post with optional media","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Image pin with link","posts.content_types.pinterest_video_pin.label":"Video Pin","posts.content_types.pinterest_video_pin.description":"Video content","posts.content_types.pinterest_carousel.label":"Carousel","posts.content_types.pinterest_carousel.description":"2-5 images","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Text post with optional images","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Text post with optional media","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"LinkedIn Page","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Facebook Page","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post scheduled successfully!","posts.flash.publishing":"Post is being published!","posts.flash.deleted":"Post deleted successfully!","posts.flash.cannot_edit_published":"Published posts cannot be edited.","posts.flash.connect_first":"Connect at least one social network before creating a post.","posts.errors.account_disconnected":"Social account is disconnected","settings.title":"Settings","settings.description":"Manage your profile and account settings","settings.nav.profile":"Profile","settings.nav.password":"Password","settings.nav.workspace":"Workspace","settings.nav.members":"Members","settings.nav.billing":"Billing","settings.profile.title":"Profile settings","settings.profile.heading":"Profile information","settings.profile.description":"Update your name and email address","settings.profile.avatar":"Avatar","settings.profile.name":"Name","settings.profile.name_placeholder":"Full name","settings.profile.email":"Email address","settings.profile.email_placeholder":"Email address","settings.profile.email_unverified":"Your email address is unverified.","settings.profile.resend_verification":"Click here to resend the verification email.","settings.profile.verification_sent":"A new verification link has been sent to your email address.","settings.profile.save":"Save","settings.password.title":"Password settings","settings.password.heading":"Update password","settings.password.description":"Ensure your account is using a long, random password to stay secure","settings.password.current_password":"Current password","settings.password.current_password_placeholder":"Current password","settings.password.new_password":"New password","settings.password.new_password_placeholder":"New password","settings.password.confirm_password":"Confirm password","settings.password.confirm_password_placeholder":"Confirm password","settings.password.save":"Save password","settings.delete_account.heading":"Delete account","settings.delete_account.description":"Delete your account and all of its resources","settings.delete_account.warning":"Warning","settings.delete_account.warning_message":"Please proceed with caution, this cannot be undone.","settings.delete_account.button":"Delete account","settings.delete_account.modal_title":"Are you sure you want to delete your account?","settings.delete_account.modal_description":"Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.","settings.delete_account.password":"Password","settings.delete_account.password_placeholder":"Password","settings.delete_account.cancel":"Cancel","settings.delete_account.confirm":"Delete account","settings.workspace.title":"Workspace settings","settings.workspace.heading":"Workspace settings","settings.workspace.description":"Update your workspace name, logo, and timezone","settings.workspace.logo":"Logo","settings.workspace.name":"Name","settings.workspace.name_placeholder":"My Workspace","settings.workspace.timezone":"Timezone","settings.workspace.save":"Save","settings.workspace.saved":"Saved.","settings.members.title":"Members","settings.members.heading":"Team members","settings.members.description":"Manage members and invites for this workspace","settings.members.invite.title":"Invite Member","settings.members.invite.description":"Send an email invite to add collaborators","settings.members.invite.email":"Email","settings.members.invite.email_placeholder":"collaborator@email.com","settings.members.invite.role":"Role","settings.members.invite.role_placeholder":"Select a role","settings.members.invite.submit":"Send Invite","settings.members.invite.cancel_confirm":"Are you sure you want to cancel this invite?","settings.members.pending.title":"Pending Invites","settings.members.pending.description":"Invites awaiting acceptance","settings.members.pending.empty":"No pending invites","settings.members.list.title":"Members","settings.members.list.description":"People with access to this workspace","settings.members.list.empty":"No members besides the owner","settings.members.list.remove_confirm":"Are you sure you want to remove this member?","settings.members.roles.owner":"Owner","settings.members.roles.admin":"Admin","settings.members.roles.member":"Member","settings.members.flash.invite_sent":"Invite sent successfully!","settings.members.flash.invite_deleted":"Invite deleted.","settings.members.flash.member_removed":"Member removed successfully.","settings.members.flash.wrong_email":"This invite is for a different email address.","settings.members.flash.already_member":"You are already a member of this workspace.","settings.members.flash.invite_accepted":"Welcome! You are now a member of the workspace.","settings.members.flash.invite_declined":"Invite declined.","settings.flash.profile_updated":"Profile updated successfully!","settings.flash.language_updated":"Language updated successfully!","settings.flash.password_updated":"Password updated successfully!","settings.flash.workspace_updated":"Settings updated successfully!","sidebar.select_workspace":"Select workspace","sidebar.create_workspace":"Create workspace","sidebar.create_post":"Create post","sidebar.profile":"Profile","sidebar.log_out":"Log out","sidebar.workspace":"Workspace: :name","sidebar.workspace_select":"Workspace: Select","sidebar.theme":"Theme: :name","sidebar.theme_light":"Light","sidebar.theme_dark":"Dark","sidebar.theme_system":"System","sidebar.language":"Language: :name","sidebar.language_select":"Language: Select","sidebar.groups.posts":"Posts","sidebar.groups.configuration":"Configurations","sidebar.groups.support":"Support","sidebar.posts.calendar":"Calendar","sidebar.posts.all":"All","sidebar.posts.scheduled":"Scheduled","sidebar.posts.posted":"Posted","sidebar.posts.drafts":"Drafts","sidebar.config.connections":"Connections","sidebar.config.hashtags":"Hashtags","sidebar.config.labels":"Labels","sidebar.config.settings":"Settings","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Share feedback","sidebar.support.last_updates":"Last Updates","sidebar.support.docs":"Docs"} \ No newline at end of file diff --git a/lang/php_pt-br.json b/lang/php_pt-br.json deleted file mode 100644 index b71de77d..00000000 --- a/lang/php_pt-br.json +++ /dev/null @@ -1 +0,0 @@ -{"accounts.title":"Conexões","accounts.page_title":"Contas Conectadas","accounts.description":"Conecte suas redes sociais para agendar e publicar posts","accounts.not_connected":"Não conectado","accounts.connect":"Conectar","accounts.connection_lost":"Conexão perdida","accounts.reconnect_account":"Reconectar conta","accounts.view_profile":"Ver perfil","accounts.disconnect":"Desconectar","accounts.disconnect_modal.title":"Desconectar Conta","accounts.disconnect_modal.description":"Tem certeza que deseja desconectar esta conta? Você pode reconectá-la a qualquer momento.","accounts.disconnect_modal.confirm":"Desconectar","accounts.disconnect_modal.cancel":"Cancelar","accounts.bluesky.title":"Conectar Bluesky","accounts.bluesky.description":"Digite suas credenciais para conectar","accounts.bluesky.email":"E-mail","accounts.bluesky.email_placeholder":"seuhandle.bsky.social","accounts.bluesky.app_password":"Senha do App","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use uma Senha do App por segurança. Crie uma em bsky.app/settings.","accounts.bluesky.submit":"Conectar Bluesky","accounts.bluesky.submitting":"Conectando...","accounts.mastodon.title":"Conectar Mastodon","accounts.mastodon.description":"Digite a instância do seu Mastodon","accounts.mastodon.instance_url":"URL da Instância","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Digite a URL da sua instância Mastodon (ex: mastodon.social, techhub.social)","accounts.mastodon.submit":"Continuar com Mastodon","accounts.mastodon.submitting":"Conectando...","accounts.facebook.title":"Selecionar Página do Facebook","accounts.facebook.description":"Escolha qual página você deseja conectar","accounts.facebook.no_pages":"Nenhuma página encontrada","accounts.facebook.no_pages_description":"Você não é administrador de nenhuma página do Facebook.","accounts.facebook.page_label":"Página do Facebook","accounts.linkedin.title":"Selecionar Página do LinkedIn","accounts.linkedin.description":"Escolha qual página você deseja conectar","accounts.linkedin.no_pages":"Nenhuma página encontrada","accounts.linkedin.no_pages_description":"Você não é administrador de nenhuma página do LinkedIn.","accounts.linkedin.page_label":"Página do LinkedIn","accounts.flash.disconnected":"Conta desconectada com sucesso!","accounts.flash.connected":"Conta conectada com sucesso!","accounts.flash.session_expired":"Sessão expirada. Por favor, tente novamente.","accounts.flash.workspace_not_found":"Workspace não encontrado.","accounts.flash.already_connected":"Esta plataforma já está conectada.","accounts.flash.no_youtube_channels":"Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.","auth.failed":"Essas credenciais não correspondem aos nossos registros.","auth.password":"A senha fornecida está incorreta.","auth.throttle":"Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.","auth.flash.welcome":"Bem-vindo ao TryPost!","auth.flash.welcome_trial":"Bem-vindo ao TryPost! Seu período de teste começou.","auth.login.title":"Entrar na sua conta","auth.login.description":"Digite seu email e senha abaixo para entrar","auth.login.page_title":"Entrar","auth.login.email":"Endereço de email","auth.login.password":"Senha","auth.login.forgot_password":"Esqueceu a senha?","auth.login.remember_me":"Lembrar de mim","auth.login.submit":"Entrar","auth.login.no_account":"Não tem uma conta?","auth.login.sign_up":"Cadastre-se","auth.register.title":"Criar uma conta","auth.register.description":"Digite seus dados abaixo para criar sua conta","auth.register.page_title":"Cadastro","auth.register.name":"Nome","auth.register.name_placeholder":"Nome completo","auth.register.email":"Endereço de email","auth.register.password":"Senha","auth.register.submit":"Criar conta","auth.register.has_account":"Já tem uma conta?","auth.register.log_in":"Entrar","auth.forgot_password.title":"Esqueceu a senha","auth.forgot_password.description":"Digite seu email para receber um link de redefinição de senha","auth.forgot_password.page_title":"Esqueceu a senha","auth.forgot_password.email":"Endereço de email","auth.forgot_password.submit":"Enviar link de redefinição","auth.forgot_password.return_to":"Ou, volte para","auth.forgot_password.log_in":"entrar","auth.reset_password.title":"Redefinir senha","auth.reset_password.description":"Por favor, digite sua nova senha abaixo","auth.reset_password.page_title":"Redefinir senha","auth.reset_password.email":"Email","auth.reset_password.password":"Senha","auth.reset_password.confirm_password":"Confirmar Senha","auth.reset_password.confirm_placeholder":"Confirmar senha","auth.reset_password.submit":"Redefinir senha","auth.verify_email.title":"Verificar email","auth.verify_email.description":"Por favor, verifique seu endereço de email clicando no link que acabamos de enviar.","auth.verify_email.page_title":"Verificação de email","auth.verify_email.link_sent":"Um novo link de verificação foi enviado para o endereço de email que você forneceu durante o cadastro.","auth.verify_email.resend":"Reenviar email de verificação","auth.verify_email.log_out":"Sair","auth.accept_invite.page_title":"Aceitar Convite","auth.accept_invite.title":"Você foi convidado!","auth.accept_invite.description":"Você foi convidado para participar do workspace :workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Seu cargo","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Aceitar Convite","auth.accept_invite.decline":"Recusar Convite","auth.accept_invite.login_prompt":"Entre ou crie uma conta para aceitar este convite.","auth.accept_invite.log_in":"Entrar","auth.accept_invite.create_account":"Criar Conta","billing.title":"Assinatura","billing.description":"Gerencie sua assinatura e método de pagamento","billing.trial.title":"Período de teste ativo","billing.trial.description":"Seu período de teste termina em :date. Após isso, sua assinatura será cobrada automaticamente.","billing.subscription.title":"Sua Assinatura","billing.subscription.workspaces":"Workspaces","billing.subscription.quantity":"Quantidade da assinatura","billing.subscription.expires":"Expira em :date","billing.subscription.canceled_on":"Sua assinatura será cancelada em :date","billing.subscription.manage":"Gerenciar no Stripe","billing.invoices.title":"Faturas","billing.invoices.description":"Histórico de pagamentos","billing.invoices.empty":"Nenhuma fatura encontrada","billing.invoices.paid":"Pago","billing.status.active":"Ativo","billing.status.canceled":"Cancelado","billing.status.incomplete":"Incompleto","billing.status.incomplete_expired":"Expirado","billing.status.past_due":"Vencido","billing.status.trialing":"Teste","billing.status.unpaid":"Não pago","calendar.title":"Calendário","calendar.today":"Hoje","calendar.day":"Dia","calendar.week":"Semana","calendar.month":"Mês","calendar.new_post":"Novo Post","calendar.no_content":"Sem conteúdo","calendar.more":"+:count mais","hashtags.title":"Hashtags","hashtags.description":"Crie grupos de hashtags para adicionar rapidamente aos seus posts","hashtags.new_group":"Novo Grupo","hashtags.no_groups_yet":"Nenhum grupo de hashtags ainda","hashtags.no_groups_description":"Crie grupos de hashtags para adicionar rapidamente hashtags populares aos seus posts","hashtags.create_first_group":"Crie seu primeiro grupo","hashtags.hashtags_count":":count hashtags","hashtags.create.title":"Criar Grupo de Hashtags","hashtags.create.description":"Dê um nome ao grupo e adicione hashtags separadas por espaços ou vírgulas","hashtags.create.name":"Nome do Grupo","hashtags.create.name_placeholder":"ex: Marketing, Viagem, Comida","hashtags.create.hashtags":"Hashtags","hashtags.create.hashtags_placeholder":"#marketing #redessociais #negocios #crescimento","hashtags.create.hashtags_hint":"Digite as hashtags separadas por espaços ou vírgulas. Inclua o símbolo #.","hashtags.create.submit":"Criar Grupo","hashtags.create.submitting":"Criando...","hashtags.edit.title":"Editar Grupo de Hashtags","hashtags.edit.description":"Atualize o nome e as hashtags deste grupo","hashtags.edit.name":"Nome do Grupo","hashtags.edit.name_placeholder":"ex: Marketing, Viagem, Comida","hashtags.edit.hashtags":"Hashtags","hashtags.edit.hashtags_placeholder":"#marketing #redessociais #negocios #crescimento","hashtags.edit.hashtags_hint":"Digite as hashtags separadas por espaços ou vírgulas. Inclua o símbolo #.","hashtags.edit.submit":"Salvar Alterações","hashtags.edit.submitting":"Salvando...","hashtags.delete.title":"Excluir Grupo de Hashtags","hashtags.delete.description":"Tem certeza que deseja excluir este grupo de hashtags? Esta ação não pode ser desfeita.","hashtags.delete.confirm":"Excluir","hashtags.delete.cancel":"Cancelar","hashtags.flash.created":"Grupo de hashtags criado com sucesso!","hashtags.flash.updated":"Grupo de hashtags atualizado com sucesso!","hashtags.flash.deleted":"Grupo de hashtags excluído com sucesso!","labels.title":"Etiquetas","labels.description":"Crie etiquetas para organizar e categorizar seus posts","labels.new_label":"Nova Etiqueta","labels.no_labels_yet":"Nenhuma etiqueta ainda","labels.create_first_label":"Crie sua primeira etiqueta","labels.create.title":"Criar Etiqueta","labels.create.description":"Dê um nome e escolha uma cor para sua etiqueta","labels.create.name":"Nome","labels.create.name_placeholder":"Digite o nome da etiqueta...","labels.create.color":"Cor","labels.create.submit":"Criar Etiqueta","labels.create.submitting":"Criando...","labels.edit.title":"Editar Etiqueta","labels.edit.description":"Atualize o nome e a cor desta etiqueta","labels.edit.name":"Nome","labels.edit.name_placeholder":"Digite o nome da etiqueta...","labels.edit.color":"Cor","labels.edit.submit":"Salvar Alterações","labels.edit.submitting":"Salvando...","labels.delete.title":"Excluir Etiqueta","labels.delete.description":"Tem certeza que deseja excluir esta etiqueta? Esta ação não pode ser desfeita.","labels.delete.confirm":"Excluir","labels.delete.cancel":"Cancelar","labels.flash.created":"Etiqueta criada com sucesso!","labels.flash.updated":"Etiqueta atualizada com sucesso!","labels.flash.deleted":"Etiqueta excluída com sucesso!","mail.workspace_connections_disconnected.subject":"{1} :count conta precisa ser reconectada em :workspace|[2,*] :count contas precisam ser reconectadas em :workspace","mail.workspace_connections_disconnected.title":"Contas Precisam ser Reconectadas","mail.workspace_connections_disconnected.intro":"As seguintes contas de redes sociais no seu workspace :workspace foram desconectadas e precisam ser reconectadas:","mail.workspace_connections_disconnected.reasons_title":"Isso pode ter acontecido porque:","mail.workspace_connections_disconnected.reason_expired":"Os tokens de acesso expiraram","mail.workspace_connections_disconnected.reason_revoked":"Você revogou o acesso ao TryPost na plataforma","mail.workspace_connections_disconnected.reason_changed":"A plataforma mudou os requisitos de autenticação","mail.workspace_connections_disconnected.reconnect_cta":"Por favor, reconecte essas contas para continuar agendando e publicando posts.","mail.workspace_connections_disconnected.button":"Reconectar Contas","pagination.previous":"« Anterior","pagination.next":"Próximo »","passwords.reset":"Sua senha foi redefinida.","passwords.sent":"Enviamos o link de redefinição de senha por e-mail.","passwords.throttled":"Por favor, aguarde antes de tentar novamente.","passwords.token":"Este token de redefinição de senha é inválido.","passwords.user":"Não conseguimos encontrar um usuário com esse endereço de e-mail.","posts.title":"Posts","posts.all_posts":"Todos os Posts","posts.new_post":"Novo Post","posts.no_posts":"Nenhum post encontrado","posts.start_creating":"Comece criando seu primeiro post.","posts.manage_posts":"Gerencie todos os seus posts","posts.delete_confirm":"Tem certeza que deseja excluir este post?","posts.by":"por","posts.actions.view":"Ver post","posts.actions.delete":"Excluir post","posts.form.post_type":"Tipo de Post","posts.form.board":"Pasta","posts.form.select_board":"Selecione uma pasta","posts.form.search_board":"Buscar pasta...","posts.form.no_board_found":"Nenhuma pasta encontrada","posts.form.media":"Mídia","posts.form.min":"Mín","posts.form.uploading":"Enviando...","posts.form.drop_to_upload":"Solte para enviar","posts.form.drag_and_drop":"Arraste e solte ou clique para enviar","posts.form.photos_and_videos":"Fotos e vídeos","posts.form.photos_only":"Apenas fotos","posts.form.videos_only":"Apenas vídeos","posts.form.drag_to_reorder":"Arraste para reordenar","posts.form.caption":"Legenda","posts.form.write_caption":"Escreva sua legenda...","posts.status.draft":"Rascunho","posts.status.scheduled":"Agendado","posts.status.publishing":"Publicando","posts.status.published":"Publicado","posts.status.partially_published":"Parcialmente Publicado","posts.status.failed":"Falhou","posts.descriptions.draft":"Posts aguardando agendamento","posts.descriptions.scheduled":"Posts agendados para publicação","posts.descriptions.published":"Posts já publicados","posts.edit.title":"Editar Post","posts.edit.view_title":"Visualizar Post","posts.edit.manage_platforms":"Gerenciar plataformas","posts.edit.sync":"Sincronizar","posts.edit.labels":"Etiquetas","posts.edit.hashtags":"Hashtags","posts.edit.schedule":"Agendar","posts.edit.publish":"Publicar","posts.edit.delete":"Excluir","posts.edit.settings":"Configurações","posts.edit.schedule_for":"Agendar para","posts.edit.saving":"Salvando...","posts.edit.saved":"Salvo","posts.edit.scheduled_at":"Agendado:","posts.edit.published_at":"Publicado:","posts.edit.media":"Mídia","posts.edit.caption":"Legenda","posts.edit.no_caption":"Sem legenda","posts.edit.no_content":"Sem conteúdo","posts.edit.empty_state.title":"Nenhuma plataforma selecionada","posts.edit.empty_state.description":"Selecione pelo menos uma plataforma para criar seu post","posts.edit.delete_modal.title":"Excluir Post","posts.edit.delete_modal.description":"Tem certeza que deseja excluir este post? Esta ação não pode ser desfeita.","posts.edit.delete_modal.action":"Excluir","posts.edit.delete_modal.cancel":"Cancelar","posts.edit.sync_enable.title":"Ativar sincronização?","posts.edit.sync_enable.description":"Todas as plataformas compartilharão o mesmo conteúdo. Qualquer edição personalizada feita em plataformas individuais será substituída pelo conteúdo atual.","posts.edit.sync_enable.cancel":"Cancelar","posts.edit.sync_enable.action":"Ativar sincronização","posts.edit.sync_disable.title":"Desativar sincronização?","posts.edit.sync_disable.description":"Cada plataforma manterá seu conteúdo atual, mas edições futuras serão aplicadas apenas à plataforma que você estiver editando.","posts.edit.sync_disable.customize_note":"Você poderá personalizar o conteúdo para cada plataforma individualmente.","posts.edit.sync_disable.cancel":"Cancelar","posts.edit.sync_disable.action":"Desativar sincronização","posts.edit.platforms_dialog.title":"Selecionar Plataformas","posts.edit.platforms_dialog.description":"Escolha em quais plataformas publicar este post.","posts.edit.hashtags_modal.search":"Buscar hashtags...","posts.edit.hashtags_modal.no_results":"Nenhuma hashtag encontrada.","posts.edit.validation.select_board":"Selecione uma pasta","posts.edit.validation.images_not_supported":"Imagens não suportadas","posts.edit.validation.videos_not_supported":"Vídeos não suportados","posts.edit.validation.max_images":"Máx :count imagens","posts.edit.validation.requires_media":"Requer mídia","posts.edit.validation.exceeded":":count excedido","posts.edit.validation.does_not_support_images":":platform não suporta imagens","posts.edit.validation.supports_up_to_images":":platform suporta até :count imagens","posts.edit.validation.does_not_support_videos":":platform não suporta vídeos","posts.content_types.instagram_feed.label":"Post do Feed","posts.content_types.instagram_feed.description":"Aparece no seu feed e perfil","posts.content_types.instagram_reel.label":"Reels","posts.content_types.instagram_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Desaparece após 24 horas","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_carousel.label":"Carrossel","posts.content_types.linkedin_carousel.description":"Imagens deslizáveis","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_page_carousel.label":"Carrossel","posts.content_types.linkedin_page_carousel.description":"Imagens deslizáveis","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Post padrão na sua página","posts.content_types.facebook_reel.label":"Reels","posts.content_types.facebook_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Desaparece após 24 horas","posts.content_types.tiktok_video.label":"Vídeo","posts.content_types.tiktok_video.description":"Conteúdo de vídeo curto","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vídeo vertical de até 60 segundos","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet com texto e mídia","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Post de texto com mídia opcional","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Pin de imagem com link","posts.content_types.pinterest_video_pin.label":"Pin de Vídeo","posts.content_types.pinterest_video_pin.description":"Conteúdo em vídeo","posts.content_types.pinterest_carousel.label":"Carrossel","posts.content_types.pinterest_carousel.description":"2-5 imagens","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Post de texto com imagens opcionais","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Post de texto com mídia opcional","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"Página do LinkedIn","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Página do Facebook","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post agendado com sucesso!","posts.flash.publishing":"Post está sendo publicado!","posts.flash.deleted":"Post excluído com sucesso!","posts.flash.cannot_edit_published":"Posts publicados não podem ser editados.","posts.flash.connect_first":"Conecte pelo menos uma rede social antes de criar um post.","posts.errors.account_disconnected":"Conta social está desconectada","settings.title":"Configurações","settings.description":"Gerencie seu perfil e configurações da conta","settings.nav.profile":"Perfil","settings.nav.password":"Senha","settings.nav.workspace":"Workspace","settings.nav.members":"Membros","settings.nav.billing":"Faturamento","settings.profile.title":"Configurações do perfil","settings.profile.heading":"Informações do perfil","settings.profile.description":"Atualize seu nome e endereço de e-mail","settings.profile.avatar":"Avatar","settings.profile.name":"Nome","settings.profile.name_placeholder":"Nome completo","settings.profile.email":"Endereço de e-mail","settings.profile.email_placeholder":"Endereço de e-mail","settings.profile.email_unverified":"Seu endereço de e-mail não foi verificado.","settings.profile.resend_verification":"Clique aqui para reenviar o e-mail de verificação.","settings.profile.verification_sent":"Um novo link de verificação foi enviado para seu endereço de e-mail.","settings.profile.save":"Salvar","settings.password.title":"Configurações de senha","settings.password.heading":"Atualizar senha","settings.password.description":"Certifique-se de que sua conta esteja usando uma senha longa e aleatória para se manter seguro","settings.password.current_password":"Senha atual","settings.password.current_password_placeholder":"Senha atual","settings.password.new_password":"Nova senha","settings.password.new_password_placeholder":"Nova senha","settings.password.confirm_password":"Confirmar senha","settings.password.confirm_password_placeholder":"Confirmar senha","settings.password.save":"Salvar senha","settings.delete_account.heading":"Excluir conta","settings.delete_account.description":"Exclua sua conta e todos os seus recursos","settings.delete_account.warning":"Atenção","settings.delete_account.warning_message":"Por favor, prossiga com cuidado, isso não pode ser desfeito.","settings.delete_account.button":"Excluir conta","settings.delete_account.modal_title":"Tem certeza que deseja excluir sua conta?","settings.delete_account.modal_description":"Uma vez que sua conta for excluída, todos os seus recursos e dados também serão permanentemente excluídos. Por favor, digite sua senha para confirmar que deseja excluir permanentemente sua conta.","settings.delete_account.password":"Senha","settings.delete_account.password_placeholder":"Senha","settings.delete_account.cancel":"Cancelar","settings.delete_account.confirm":"Excluir conta","settings.workspace.title":"Configurações do workspace","settings.workspace.heading":"Configurações do workspace","settings.workspace.description":"Atualize o nome, logo e fuso horário do workspace","settings.workspace.logo":"Logo","settings.workspace.name":"Nome","settings.workspace.name_placeholder":"Meu Workspace","settings.workspace.timezone":"Fuso horário","settings.workspace.save":"Salvar","settings.workspace.saved":"Salvo.","settings.members.title":"Membros","settings.members.heading":"Membros da equipe","settings.members.description":"Gerencie membros e convites deste workspace","settings.members.invite.title":"Convidar Membro","settings.members.invite.description":"Envie um convite por e-mail para adicionar colaboradores","settings.members.invite.email":"E-mail","settings.members.invite.email_placeholder":"colaborador@email.com","settings.members.invite.role":"Função","settings.members.invite.role_placeholder":"Selecione uma função","settings.members.invite.submit":"Enviar Convite","settings.members.invite.cancel_confirm":"Tem certeza que deseja cancelar este convite?","settings.members.pending.title":"Convites Pendentes","settings.members.pending.description":"Convites aguardando aceitação","settings.members.pending.empty":"Nenhum convite pendente","settings.members.list.title":"Membros","settings.members.list.description":"Pessoas com acesso a este workspace","settings.members.list.empty":"Nenhum membro além do proprietário","settings.members.list.remove_confirm":"Tem certeza que deseja remover este membro?","settings.members.roles.owner":"Proprietário","settings.members.roles.admin":"Administrador","settings.members.roles.member":"Membro","settings.members.flash.invite_sent":"Convite enviado com sucesso!","settings.members.flash.invite_deleted":"Convite excluído.","settings.members.flash.member_removed":"Membro removido com sucesso.","settings.members.flash.wrong_email":"Este convite é para um endereço de e-mail diferente.","settings.members.flash.already_member":"Você já é membro deste workspace.","settings.members.flash.invite_accepted":"Bem-vindo! Você agora é membro do workspace.","settings.members.flash.invite_declined":"Convite recusado.","settings.flash.profile_updated":"Perfil atualizado com sucesso!","settings.flash.language_updated":"Idioma atualizado com sucesso!","settings.flash.password_updated":"Senha atualizada com sucesso!","settings.flash.workspace_updated":"Configurações atualizadas com sucesso!","sidebar.select_workspace":"Selecionar workspace","sidebar.create_workspace":"Criar workspace","sidebar.create_post":"Novo post","sidebar.profile":"Perfil","sidebar.log_out":"Sair","sidebar.workspace":"Workspace: :name","sidebar.workspace_select":"Workspace: Selecionar","sidebar.theme":"Tema: :name","sidebar.theme_light":"Claro","sidebar.theme_dark":"Escuro","sidebar.theme_system":"Sistema","sidebar.language":"Idioma: :name","sidebar.language_select":"Idioma: Selecionar","sidebar.groups.posts":"Posts","sidebar.groups.configuration":"Configurações","sidebar.groups.support":"Suporte","sidebar.posts.calendar":"Calendário","sidebar.posts.all":"Todos","sidebar.posts.scheduled":"Agendados","sidebar.posts.posted":"Publicados","sidebar.posts.drafts":"Rascunhos","sidebar.config.connections":"Conexões","sidebar.config.hashtags":"Hashtags","sidebar.config.labels":"Etiquetas","sidebar.config.settings":"Configurações","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Enviar feedback","sidebar.support.last_updates":"Últimas Atualizações","sidebar.support.docs":"Documentação","validation.accepted":"O campo :attribute deve ser aceito.","validation.accepted_if":"O campo :attribute deve ser aceito quando :other for :value.","validation.active_url":"O campo :attribute deve ser uma URL válida.","validation.after":"O campo :attribute deve ser uma data posterior a :date.","validation.after_or_equal":"O campo :attribute deve ser uma data posterior ou igual a :date.","validation.alpha":"O campo :attribute deve conter apenas letras.","validation.alpha_dash":"O campo :attribute deve conter apenas letras, números, hifens e underscores.","validation.alpha_num":"O campo :attribute deve conter apenas letras e números.","validation.any_of":"O campo :attribute é inválido.","validation.array":"O campo :attribute deve ser um array.","validation.ascii":"O campo :attribute deve conter apenas caracteres alfanuméricos e símbolos de um byte.","validation.before":"O campo :attribute deve ser uma data anterior a :date.","validation.before_or_equal":"O campo :attribute deve ser uma data anterior ou igual a :date.","validation.between.array":"O campo :attribute deve ter entre :min e :max itens.","validation.between.file":"O campo :attribute deve estar entre :min e :max kilobytes.","validation.between.numeric":"O campo :attribute deve estar entre :min e :max.","validation.between.string":"O campo :attribute deve estar entre :min e :max caracteres.","validation.boolean":"O campo :attribute deve ser verdadeiro ou falso.","validation.can":"O campo :attribute contém um valor não autorizado.","validation.confirmed":"A confirmação do campo :attribute não corresponde.","validation.contains":"O campo :attribute está faltando um valor obrigatório.","validation.current_password":"A senha está incorreta.","validation.date":"O campo :attribute deve ser uma data válida.","validation.date_equals":"O campo :attribute deve ser uma data igual a :date.","validation.date_format":"O campo :attribute deve corresponder ao formato :format.","validation.decimal":"O campo :attribute deve ter :decimal casas decimais.","validation.declined":"O campo :attribute deve ser recusado.","validation.declined_if":"O campo :attribute deve ser recusado quando :other for :value.","validation.different":"O campo :attribute e :other devem ser diferentes.","validation.digits":"O campo :attribute deve ter :digits dígitos.","validation.digits_between":"O campo :attribute deve ter entre :min e :max dígitos.","validation.dimensions":"O campo :attribute deve ter dimensões de imagem válidas.","validation.distinct":"O campo :attribute tem um valor duplicado.","validation.doesnt_contain":"O campo :attribute não deve conter nenhum dos seguintes: :values.","validation.doesnt_end_with":"O campo :attribute não deve terminar com nenhum dos seguintes: :values.","validation.doesnt_start_with":"O campo :attribute não deve começar com nenhum dos seguintes: :values.","validation.email":"O campo :attribute deve ser um endereço de e-mail válido.","validation.encoding":"O campo :attribute deve ser codificado em :encoding.","validation.ends_with":"O campo :attribute deve terminar com um dos seguintes: :values.","validation.enum":"O :attribute selecionado é inválido.","validation.exists":"O :attribute selecionado é inválido.","validation.extensions":"O campo :attribute deve ter uma das seguintes extensões: :values.","validation.file":"O campo :attribute deve ser um arquivo.","validation.filled":"O campo :attribute deve ter um valor.","validation.gt.array":"O campo :attribute deve ter mais de :value itens.","validation.gt.file":"O campo :attribute deve ser maior que :value kilobytes.","validation.gt.numeric":"O campo :attribute deve ser maior que :value.","validation.gt.string":"O campo :attribute deve ser maior que :value caracteres.","validation.gte.array":"O campo :attribute deve ter :value itens ou mais.","validation.gte.file":"O campo :attribute deve ser maior ou igual a :value kilobytes.","validation.gte.numeric":"O campo :attribute deve ser maior ou igual a :value.","validation.gte.string":"O campo :attribute deve ser maior ou igual a :value caracteres.","validation.hex_color":"O campo :attribute deve ser uma cor hexadecimal válida.","validation.image":"O campo :attribute deve ser uma imagem.","validation.in":"O :attribute selecionado é inválido.","validation.in_array":"O campo :attribute deve existir em :other.","validation.in_array_keys":"O campo :attribute deve conter pelo menos uma das seguintes chaves: :values.","validation.integer":"O campo :attribute deve ser um inteiro.","validation.ip":"O campo :attribute deve ser um endereço IP válido.","validation.ipv4":"O campo :attribute deve ser um endereço IPv4 válido.","validation.ipv6":"O campo :attribute deve ser um endereço IPv6 válido.","validation.json":"O campo :attribute deve ser uma string JSON válida.","validation.list":"O campo :attribute deve ser uma lista.","validation.lowercase":"O campo :attribute deve estar em minúsculas.","validation.lt.array":"O campo :attribute deve ter menos de :value itens.","validation.lt.file":"O campo :attribute deve ser menor que :value kilobytes.","validation.lt.numeric":"O campo :attribute deve ser menor que :value.","validation.lt.string":"O campo :attribute deve ser menor que :value caracteres.","validation.lte.array":"O campo :attribute deve ter :value itens ou menos.","validation.lte.file":"O campo :attribute deve ser menor ou igual a :value kilobytes.","validation.lte.numeric":"O campo :attribute deve ser menor ou igual a :value.","validation.lte.string":"O campo :attribute deve ser menor ou igual a :value caracteres.","validation.mac_address":"O campo :attribute deve ser um endereço MAC válido.","validation.max.array":"O campo :attribute deve ter no máximo :max itens.","validation.max.file":"O campo :attribute deve ter no máximo :max kilobytes.","validation.max.numeric":"O campo :attribute deve ter no máximo :max.","validation.max.string":"O campo :attribute deve ter no máximo :max caracteres.","validation.max_digits":"O campo :attribute não deve ter mais que :max dígitos.","validation.mimes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.mimetypes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.min.array":"O campo :attribute deve ter pelo menos :min itens.","validation.min.file":"O campo :attribute deve ter pelo menos :min kilobytes.","validation.min.numeric":"O campo :attribute deve ter pelo menos :min.","validation.min.string":"O campo :attribute deve ter pelo menos :min caracteres.","validation.min_digits":"O campo :attribute deve ter pelo menos :min dígitos.","validation.missing":"O campo :attribute deve estar ausente.","validation.missing_if":"O campo :attribute deve estar ausente quando :other for :value.","validation.missing_unless":"O campo :attribute deve estar ausente a menos que :other seja :value.","validation.missing_with":"O campo :attribute deve estar ausente quando :values estiver presente.","validation.missing_with_all":"O campo :attribute deve estar ausente quando :values estiverem presentes.","validation.multiple_of":"O campo :attribute deve ser um múltiplo de :value.","validation.not_in":"O :attribute selecionado é inválido.","validation.not_regex":"O formato do campo :attribute é inválido.","validation.numeric":"O campo :attribute deve ser um número.","validation.password.letters":"O campo :attribute deve conter pelo menos uma letra.","validation.password.mixed":"O campo :attribute deve conter pelo menos uma letra maiúscula e uma minúscula.","validation.password.numbers":"O campo :attribute deve conter pelo menos um número.","validation.password.symbols":"O campo :attribute deve conter pelo menos um símbolo.","validation.password.uncompromised":"O :attribute fornecido apareceu em um vazamento de dados. Por favor, escolha um :attribute diferente.","validation.present":"O campo :attribute deve estar presente.","validation.present_if":"O campo :attribute deve estar presente quando :other for :value.","validation.present_unless":"O campo :attribute deve estar presente a menos que :other seja :value.","validation.present_with":"O campo :attribute deve estar presente quando :values estiver presente.","validation.present_with_all":"O campo :attribute deve estar presente quando :values estiverem presentes.","validation.prohibited":"O campo :attribute é proibido.","validation.prohibited_if":"O campo :attribute é proibido quando :other for :value.","validation.prohibited_if_accepted":"O campo :attribute é proibido quando :other for aceito.","validation.prohibited_if_declined":"O campo :attribute é proibido quando :other for recusado.","validation.prohibited_unless":"O campo :attribute é proibido a menos que :other esteja em :values.","validation.prohibits":"O campo :attribute proíbe :other de estar presente.","validation.regex":"O formato do campo :attribute é inválido.","validation.required":"O campo :attribute é obrigatório.","validation.required_array_keys":"O campo :attribute deve conter entradas para: :values.","validation.required_if":"O campo :attribute é obrigatório quando :other for :value.","validation.required_if_accepted":"O campo :attribute é obrigatório quando :other for aceito.","validation.required_if_declined":"O campo :attribute é obrigatório quando :other for recusado.","validation.required_unless":"O campo :attribute é obrigatório a menos que :other esteja em :values.","validation.required_with":"O campo :attribute é obrigatório quando :values estiver presente.","validation.required_with_all":"O campo :attribute é obrigatório quando :values estiverem presentes.","validation.required_without":"O campo :attribute é obrigatório quando :values não estiver presente.","validation.required_without_all":"O campo :attribute é obrigatório quando nenhum dos :values estiver presente.","validation.same":"O campo :attribute deve ser igual a :other.","validation.size.array":"O campo :attribute deve conter :size itens.","validation.size.file":"O campo :attribute deve ter :size kilobytes.","validation.size.numeric":"O campo :attribute deve ser :size.","validation.size.string":"O campo :attribute deve ter :size caracteres.","validation.starts_with":"O campo :attribute deve começar com um dos seguintes: :values.","validation.string":"O campo :attribute deve ser uma string.","validation.timezone":"O campo :attribute deve ser um fuso horário válido.","validation.unique":"O :attribute já foi utilizado.","validation.uploaded":"O :attribute falhou ao ser enviado.","validation.uppercase":"O campo :attribute deve estar em maiúsculo.","validation.url":"O campo :attribute deve ser uma URL válida.","validation.ulid":"O campo :attribute deve ser um ULID válido.","validation.uuid":"O campo :attribute deve ser um UUID válido.","validation.custom.attribute-name.rule-name":"custom-message"} \ No newline at end of file diff --git a/lang/pt-br/accounts.php b/lang/pt-br/accounts.php index 65667854..f3729167 100644 --- a/lang/pt-br/accounts.php +++ b/lang/pt-br/accounts.php @@ -62,6 +62,8 @@ 'connected' => 'Conta conectada com sucesso!', 'session_expired' => 'Sessão expirada. Por favor, tente novamente.', 'workspace_not_found' => 'Workspace não encontrado.', + 'activated' => 'Conta ativada!', + 'deactivated' => 'Conta desativada!', 'already_connected' => 'Esta plataforma já está conectada.', 'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.', ], diff --git a/lang/pt-br/auth.php b/lang/pt-br/auth.php index 1333a3a5..4afceb5f 100644 --- a/lang/pt-br/auth.php +++ b/lang/pt-br/auth.php @@ -22,6 +22,45 @@ 'welcome_trial' => 'Bem-vindo ao TryPost! Seu período de teste começou.', ], + 'legal' => 'Ao continuar, você concorda com nossos Termos de Serviço e Política de Privacidade.', + + 'slides' => [ + 'calendar' => [ + 'title' => 'Calendário Visual', + 'description' => 'Planeje e agende seu conteúdo com um calendário intuitivo de arrastar e soltar em todas as suas contas sociais.', + ], + 'scheduling' => [ + 'title' => 'Agendamento Inteligente', + 'description' => 'Agende posts no LinkedIn, X, Instagram, TikTok, YouTube e mais — tudo em um só lugar.', + ], + 'media' => [ + 'title' => 'Mídia Rica', + 'description' => 'Publique imagens, carrosséis, stories e reels. Cada plataforma recebe o formato correto automaticamente.', + ], + 'video' => [ + 'title' => 'Publicação de Vídeo', + 'description' => 'Envie vídeos uma vez e publique no TikTok, YouTube Shorts, Instagram Reels e Facebook Reels.', + ], + 'team' => [ + 'title' => 'Workspaces em Equipe', + 'description' => 'Convide sua equipe, atribua funções e gerencie múltiplas marcas em workspaces separados.', + ], + 'hashtags' => [ + 'title' => 'Grupos de Hashtags', + 'description' => 'Salve coleções de hashtags e adicione aos posts com um clique. Organize com etiquetas e filtros.', + ], + ], + + 'or_continue_with' => 'Ou continue com', + 'google_login' => 'Entrar com Google', + 'google_signup' => 'Cadastrar com Google', + + 'signup_success' => [ + 'page_title' => 'Bem-vindo', + 'title' => 'Configurando sua conta', + 'description' => 'Isso geralmente leva apenas alguns segundos...', + ], + 'login' => [ 'title' => 'Entrar na sua conta', 'description' => 'Digite seu email e senha abaixo para entrar', @@ -43,6 +82,8 @@ 'name_placeholder' => 'Nome completo', 'email' => 'Endereço de email', 'password' => 'Senha', + 'show_password' => 'Mostrar senha', + 'hide_password' => 'Esconder senha', 'submit' => 'Criar conta', 'has_account' => 'Já tem uma conta?', 'log_in' => 'Entrar', diff --git a/lang/pt-br/billing.php b/lang/pt-br/billing.php index 943b13f9..b4427490 100644 --- a/lang/pt-br/billing.php +++ b/lang/pt-br/billing.php @@ -4,6 +4,23 @@ 'title' => 'Assinatura', 'description' => 'Gerencie sua assinatura e método de pagamento', + 'subscribe' => [ + 'page_title' => 'Comece seu teste gratuito', + 'title' => 'Comece seu teste gratuito', + 'description' => ':days dias grátis para explorar todos os recursos.', + 'start_trial' => 'Começar teste de :days dias', + 'cancel_anytime' => 'Cancele a qualquer momento. Sem perguntas.', + 'switch_workspace' => 'Trocar workspace', + 'features' => [ + 'calendar' => 'Calendário visual com arrastar e soltar', + 'scheduling' => 'Agendamento ilimitado de posts', + 'media' => 'Imagens, carrosséis e stories', + 'video' => 'Publicação de vídeos em todas as plataformas', + 'team' => 'Colaboração em equipe e workspaces', + 'hashtags' => 'Grupos de hashtags e etiquetas', + ], + ], + 'trial' => [ 'title' => 'Período de teste ativo', 'description' => 'Seu período de teste termina em :date. Após isso, sua assinatura será cobrada automaticamente.', @@ -11,6 +28,7 @@ 'subscription' => [ 'title' => 'Sua Assinatura', + 'status' => 'Status', 'workspaces' => 'Workspaces', 'quantity' => 'Quantidade da assinatura', 'expires' => 'Expira em :date', @@ -25,6 +43,17 @@ 'paid' => 'Pago', ], + 'processing' => [ + 'page_title' => 'Processando...', + 'title' => 'Processando sua assinatura', + 'description' => 'Aguarde enquanto configuramos sua conta. Isso levará apenas um momento.', + 'success_title' => 'Tudo pronto!', + 'success_description' => 'Sua assinatura está ativa. Redirecionando para seus workspaces...', + 'cancelled_title' => 'Pagamento cancelado', + 'cancelled_description' => 'Seu pagamento foi cancelado. Nenhuma cobrança foi realizada.', + 'retry' => 'Tentar novamente', + ], + 'status' => [ 'active' => 'Ativo', 'canceled' => 'Cancelado', diff --git a/lang/pt-br/common.php b/lang/pt-br/common.php new file mode 100644 index 00000000..ba87f14f --- /dev/null +++ b/lang/pt-br/common.php @@ -0,0 +1,31 @@ + [ + 'cannot_be_undone' => 'Esta ação não pode ser desfeita.', + 'type' => 'Digite', + 'to_confirm' => 'para confirmar.', + 'copy_to_clipboard' => 'Copiar para a área de transferência', + ], + + 'photo_upload' => [ + 'upload' => 'Enviar', + 'uploading' => 'Enviando...', + 'remove' => 'Remover foto', + 'hint' => 'Recomendado: imagem quadrada, máximo 2 MB.', + ], + + 'timezone' => [ + 'select' => 'Selecionar fuso horário', + 'search' => 'Buscar fuso horário...', + 'empty' => 'Fuso horário não encontrado', + ], + + 'date_picker' => [ + 'select' => 'Selecionar data', + ], + + 'cancel' => 'Cancelar', +]; diff --git a/lang/pt-br/hashtags.php b/lang/pt-br/hashtags.php index b3c18956..a035354b 100644 --- a/lang/pt-br/hashtags.php +++ b/lang/pt-br/hashtags.php @@ -3,6 +3,7 @@ return [ 'title' => 'Hashtags', 'description' => 'Crie grupos de hashtags para adicionar rapidamente aos seus posts', + 'search' => 'Buscar hashtags...', 'new_group' => 'Novo Grupo', 'no_groups_yet' => 'Nenhum grupo de hashtags ainda', 'no_groups_description' => 'Crie grupos de hashtags para adicionar rapidamente hashtags populares aos seus posts', diff --git a/lang/pt-br/labels.php b/lang/pt-br/labels.php index 78f646a4..ed288b95 100644 --- a/lang/pt-br/labels.php +++ b/lang/pt-br/labels.php @@ -3,6 +3,7 @@ return [ 'title' => 'Etiquetas', 'description' => 'Crie etiquetas para organizar e categorizar seus posts', + 'search' => 'Buscar etiquetas...', 'new_label' => 'Nova Etiqueta', 'no_labels_yet' => 'Nenhuma etiqueta ainda', 'create_first_label' => 'Crie sua primeira etiqueta', diff --git a/lang/pt-br/onboarding.php b/lang/pt-br/onboarding.php new file mode 100644 index 00000000..388bbd64 --- /dev/null +++ b/lang/pt-br/onboarding.php @@ -0,0 +1,48 @@ + [ + 'page_title' => 'Bem-vindo - Conte-nos sobre você', + 'title' => 'Conte-nos sobre você', + 'description' => 'Nos ajude a personalizar sua experiência', + 'submit' => 'Continuar', + ], + + 'personas' => [ + 'founder' => [ + 'label' => 'Fundador', + 'description' => 'Construindo uma startup ou novo negócio', + ], + 'creator' => [ + 'label' => 'Criador de Conteúdo', + 'description' => 'Criador de conteúdo ou influenciador', + ], + 'agency' => [ + 'label' => 'Agência', + 'description' => 'Agência de marketing ou mídias sociais', + ], + 'enterprise' => [ + 'label' => 'Empresa', + 'description' => 'Grande empresa ou corporação', + ], + 'small_business' => [ + 'label' => 'Pequeno Negócio', + 'description' => 'Pequena ou média empresa', + ], + 'personal' => [ + 'label' => 'Pessoal', + 'description' => 'Marca pessoal ou hobby', + ], + ], + + 'connect' => [ + 'page_title' => 'Conecte suas contas', + 'title' => 'Conecte suas contas', + 'description' => 'Conecte pelo menos uma rede social para começar', + 'submit' => 'Continuar', + 'error' => 'Algo deu errado. Tente novamente.', + 'go_back' => 'Voltar', + ], +]; diff --git a/lang/pt-br/passwords.php b/lang/pt-br/passwords.php index 49fa8f07..600b63b8 100644 --- a/lang/pt-br/passwords.php +++ b/lang/pt-br/passwords.php @@ -17,6 +17,6 @@ 'sent' => 'Enviamos o link de redefinição de senha por e-mail.', 'throttled' => 'Por favor, aguarde antes de tentar novamente.', 'token' => 'Este token de redefinição de senha é inválido.', - 'user' => "Não conseguimos encontrar um usuário com esse endereço de e-mail.", + 'user' => 'Não conseguimos encontrar um usuário com esse endereço de e-mail.', ]; diff --git a/lang/pt-br/posts.php b/lang/pt-br/posts.php index a7a052a8..4d940bd2 100644 --- a/lang/pt-br/posts.php +++ b/lang/pt-br/posts.php @@ -2,6 +2,7 @@ return [ 'title' => 'Posts', + 'search' => 'Buscar posts...', 'all_posts' => 'Todos os Posts', 'new_post' => 'Novo Post', 'no_posts' => 'Nenhum post encontrado', @@ -35,6 +36,7 @@ ], 'status' => [ + 'pending' => 'Pendente', 'draft' => 'Rascunho', 'scheduled' => 'Agendado', 'publishing' => 'Publicando', @@ -54,7 +56,7 @@ 'view_title' => 'Visualizar Post', 'manage_platforms' => 'Gerenciar plataformas', 'sync' => 'Sincronizar', - 'labels' => 'Etiquetas', + 'labels' => 'Etiqueta', 'hashtags' => 'Hashtags', 'schedule' => 'Agendar', 'publish' => 'Publicar', @@ -113,6 +115,7 @@ 'videos_not_supported' => 'Vídeos não suportados', 'max_images' => 'Máx :count imagens', 'requires_media' => 'Requer mídia', + 'requires_content' => 'Texto é obrigatório', 'exceeded' => ':count excedido', 'does_not_support_images' => ':platform não suporta imagens', 'supports_up_to_images' => ':platform suporta até :count imagens', @@ -223,5 +226,6 @@ 'errors' => [ 'account_disconnected' => 'Conta social está desconectada', + 'account_inactive' => 'Conta social está desativada', ], ]; diff --git a/lang/pt-br/settings.php b/lang/pt-br/settings.php index b448c655..12658242 100644 --- a/lang/pt-br/settings.php +++ b/lang/pt-br/settings.php @@ -9,11 +9,27 @@ 'password' => 'Senha', 'workspace' => 'Workspace', 'members' => 'Membros', + 'notifications' => 'Notificações', 'billing' => 'Faturamento', ], + 'notifications' => [ + 'title' => 'Preferências de notificações', + 'heading' => 'Notificações por e-mail', + 'description' => 'Escolha quais notificações por e-mail deseja receber', + 'post_published' => 'Post publicado', + 'post_published_description' => 'Receber um e-mail quando seu post for publicado com sucesso', + 'post_failed' => 'Post falhou', + 'post_failed_description' => 'Receber um e-mail quando seu post falhar ao publicar', + 'account_disconnected' => 'Conta desconectada', + 'account_disconnected_description' => 'Receber um e-mail quando uma conta social for desconectada', + 'save' => 'Salvar preferências', + ], + 'profile' => [ 'title' => 'Configurações do perfil', + 'photo_heading' => 'Foto do perfil', + 'photo_description' => 'Envie uma foto de perfil', 'heading' => 'Informações do perfil', 'description' => 'Atualize seu nome e endereço de e-mail', 'avatar' => 'Avatar', @@ -56,14 +72,16 @@ 'workspace' => [ 'title' => 'Configurações do workspace', - 'heading' => 'Configurações do workspace', - 'description' => 'Atualize o nome, logo e fuso horário do workspace', - 'logo' => 'Logo', + 'logo_heading' => 'Logo do workspace', + 'logo_description' => 'Envie um logo para o workspace', + 'heading' => 'Nome do workspace', + 'description' => 'Atualize o nome e fuso horário do workspace', + 'members_heading' => 'Membros', + 'members_description' => 'Gerencie membros e convites do workspace', 'name' => 'Nome', 'name_placeholder' => 'Meu Workspace', 'timezone' => 'Fuso horário', 'save' => 'Salvar', - 'saved' => 'Salvo.', ], 'members' => [ @@ -71,6 +89,11 @@ 'heading' => 'Membros da equipe', 'description' => 'Gerencie membros e convites deste workspace', + 'cancel' => 'Cancelar', + 'remove' => 'Remover', + 'make_admin' => 'Tornar administrador', + 'make_member' => 'Tornar membro', + 'invite' => [ 'title' => 'Convidar Membro', 'description' => 'Envie um convite por e-mail para adicionar colaboradores', @@ -79,7 +102,6 @@ 'role' => 'Função', 'role_placeholder' => 'Selecione uma função', 'submit' => 'Enviar Convite', - 'cancel_confirm' => 'Tem certeza que deseja cancelar este convite?', ], 'pending' => [ @@ -92,7 +114,18 @@ 'title' => 'Membros', 'description' => 'Pessoas com acesso a este workspace', 'empty' => 'Nenhum membro além do proprietário', - 'remove_confirm' => 'Tem certeza que deseja remover este membro?', + ], + + 'remove_modal' => [ + 'title' => 'Remover membro', + 'description' => 'Tem certeza que deseja remover este membro do workspace? Ele perderá acesso a todos os recursos do workspace.', + 'action' => 'Remover membro', + ], + + 'cancel_invite_modal' => [ + 'title' => 'Cancelar convite', + 'description' => 'Tem certeza que deseja cancelar este convite?', + 'action' => 'Cancelar convite', ], 'roles' => [ @@ -105,6 +138,7 @@ 'invite_sent' => 'Convite enviado com sucesso!', 'invite_deleted' => 'Convite excluído.', 'member_removed' => 'Membro removido com sucesso.', + 'role_updated' => 'Função do membro atualizada.', 'wrong_email' => 'Este convite é para um endereço de e-mail diferente.', 'already_member' => 'Você já é membro deste workspace.', 'invite_accepted' => 'Bem-vindo! Você agora é membro do workspace.', @@ -117,5 +151,56 @@ 'language_updated' => 'Idioma atualizado com sucesso!', 'password_updated' => 'Senha atualizada com sucesso!', 'workspace_updated' => 'Configurações atualizadas com sucesso!', + 'photo_updated' => 'Foto atualizada com sucesso!', + 'photo_deleted' => 'Foto removida com sucesso!', + 'logo_updated' => 'Logo enviado com sucesso!', + 'logo_deleted' => 'Logo removido com sucesso!', + 'notifications_updated' => 'Preferências de notificações atualizadas!', + ], + + 'api_keys' => [ + 'title' => 'Chaves API', + 'page_title' => 'Chaves API', + 'heading' => 'Chaves API', + 'description' => 'Gerencie chaves API para acesso programático ao seu workspace.', + 'create' => 'Criar chave API', + 'copy' => 'Copiar', + 'new_token_message' => 'Sua nova chave API foi criada. Copie agora — você não poderá vê-la novamente.', + 'table' => [ + 'name' => 'Nome', + 'key' => 'Chave', + 'status' => 'Status', + 'expires' => 'Expira', + 'last_used' => 'Último uso', + 'never' => 'Nunca', + ], + 'actions' => [ + 'copy_id' => 'Copiar ID da chave API', + 'copy_id_success' => 'ID da chave API copiado', + 'delete' => 'Excluir', + ], + 'empty' => [ + 'title' => 'Nenhuma chave API', + 'description' => 'Crie uma chave API para acessar seu workspace programaticamente.', + ], + 'delete_modal' => [ + 'title' => 'Excluir chave API', + 'description' => 'Tem certeza que deseja excluir esta chave API? Aplicações que a usam perderão acesso imediatamente.', + 'action' => 'Excluir chave API', + ], + 'create_dialog' => [ + 'title' => 'Criar chave API', + 'description' => 'Crie uma nova chave API para acesso programático ao seu workspace.', + 'name' => 'Nome', + 'name_placeholder' => 'ex. Chave API de Produção', + 'expires' => 'Data de expiração (opcional)', + 'expires_placeholder' => 'Sem expiração', + 'submit' => 'Criar', + 'cancel' => 'Cancelar', + ], + 'flash' => [ + 'created' => 'Chave de API criada com sucesso!', + 'deleted' => 'Chave de API excluída com sucesso!', + ], ], ]; diff --git a/lang/pt-br/sidebar.php b/lang/pt-br/sidebar.php index 75be9359..bea4b352 100644 --- a/lang/pt-br/sidebar.php +++ b/lang/pt-br/sidebar.php @@ -1,10 +1,12 @@ 'Espaços de trabalho', 'select_workspace' => 'Selecionar workspace', 'create_workspace' => 'Criar workspace', 'create_post' => 'Novo post', 'profile' => 'Perfil', + 'account' => 'Conta', 'log_out' => 'Sair', 'workspace' => 'Workspace: :name', @@ -39,6 +41,12 @@ 'settings' => 'Configurações', ], + 'notifications' => 'Notificações', + 'mark_all_read' => 'Marcar tudo como lido', + 'mark_as_read' => 'Marcar como lido', + 'archive_all' => 'Arquivar tudo', + 'no_notifications' => 'Sem notificações', + 'support' => [ 'discord' => 'Discord', 'share_feedback' => 'Enviar feedback', diff --git a/lang/pt-br/workspaces.php b/lang/pt-br/workspaces.php new file mode 100644 index 00000000..5f13371d --- /dev/null +++ b/lang/pt-br/workspaces.php @@ -0,0 +1,20 @@ + 'Workspaces', + 'select_title' => 'Seus workspaces', + 'select_description' => 'Selecione um workspace para continuar', + 'current' => 'Atual', + 'connections' => ':count conexões', + 'posts' => ':count posts', + + 'create' => [ + 'title' => 'Criar um workspace', + 'description' => 'Dê um nome ao seu workspace para começar', + 'name' => 'Nome do workspace', + 'name_placeholder' => 'Meu Workspace', + 'submit' => 'Criar workspace', + ], +]; diff --git a/maizzle/templates/post-publish-failed.html b/maizzle/templates/post-publish-failed.html new file mode 100644 index 00000000..206dc8b8 --- /dev/null +++ b/maizzle/templates/post-publish-failed.html @@ -0,0 +1,50 @@ + +

+ + + + +
+ + + + + + +
+

+ @{{ $title }} +

+ +

+ @{{ $body }} +

+ +
+

+ Failed platforms: +

+
    + @foreach($failedPlatforms as $platform) +
  • + @{{ $platform['name'] }} + @if($platform['error']) + — @{{ $platform['error'] }} + @endif +
  • + @endforeach +
+
+ + + +
+ + View Post → + +
+
+ +
+
+ diff --git a/maizzle/templates/post-published.html b/maizzle/templates/post-published.html new file mode 100644 index 00000000..ad79ddad --- /dev/null +++ b/maizzle/templates/post-published.html @@ -0,0 +1,50 @@ + +
+ + + + +
+ + + + + + +
+

+ @{{ $title }} +

+ +

+ @{{ $body }} +

+ +
+

+ Published on: +

+
    + @foreach($publishedPlatforms as $platform) +
  • + @{{ $platform['name'] }} + @if($platform['url']) + — View post + @endif +
  • + @endforeach +
+
+ + + +
+ + View Post → + +
+
+ +
+
+
diff --git a/package-lock.json b/package-lock.json index cdc64ad6..6a391c67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "laravel-vite-plugin": "^2.0.0", "laravel-vue-i18n": "^2.8.0", "maska": "^3.2.0", + "posthog-js": "^1.364.2", "reka-ui": "^2.7.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", @@ -32,6 +33,8 @@ "@laravel/vite-plugin-wayfinder": "^0.1.3", "@tailwindcss/vite": "^4.1.11", "@types/node": "^22.13.5", + "@unovis/ts": "^1.6.4", + "@unovis/vue": "^1.6.4", "@vitejs/plugin-vue": "^6.0.0", "@vue/eslint-config-typescript": "^14.3.0", "chokidar": "^5.0.0", @@ -60,6 +63,62 @@ "lightningcss-win32-x64-msvc": "^1.29.1" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -79,12 +138,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -93,10 +152,54 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/types": { + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -140,6 +243,110 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -947,6 +1154,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@laravel/echo-vue": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@laravel/echo-vue/-/echo-vue-2.3.0.tgz", @@ -969,6 +1183,77 @@ "dev": true, "license": "MIT" }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "dev": true, + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz", + "integrity": "sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1020,6 +1305,331 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@posthog/core": { + "version": "1.24.4", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.24.4.tgz", + "integrity": "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, + "node_modules/@posthog/types": { + "version": "1.364.2", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.364.2.tgz", + "integrity": "sha512-SMTdaYanvRmatgheXtu2XkewhuhdXe8C3JCi7m/Hd2n+sa2DaJphcwg3nAkPtfV69JHMxJLe/gyOt7yFtbQSjQ==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1734,12 +2344,344 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-collection": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@types/d3-collection/-/d3-collection-1.0.13.tgz", + "integrity": "sha512-v0Rgw3IZebRyamcwVmtTDCZ8OmQcj4siaYjNc7wGMZT7PmdSHawGsCOQMxyLvZ7lWjfohYLK0oXtilMOMgfY8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1754,16 +2696,146 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.6.tgz", + "integrity": "sha512-Emkz3V08QnlelSbpT46OEAx+TBZYTOX2r1yM7W+hWg5+djHtQ1GbEXBDRLaqQDOYcDI51Ss0ayoqoKD4CtLUDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, "node_modules/@types/node": { "version": "22.19.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/supercluster": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-5.0.3.tgz", + "integrity": "sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/three": { + "version": "0.135.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.135.0.tgz", + "integrity": "sha512-l7WLhIHjhHMtlpyTSltPPAKLpiMwgMD1hXHj59AVUpYRoZP7Fd9NNOSRSvZBCPLpTHPYojgQvSJCoza9zoL7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/topojson": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@types/topojson/-/topojson-3.2.6.tgz", + "integrity": "sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-client": "*", + "@types/topojson-server": "*", + "@types/topojson-simplify": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-server": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/topojson-server/-/topojson-server-3.0.4.tgz", + "integrity": "sha512-5+ieK8ePfP+K2VH6Vgs1VCt+fO1U8XZHj0UsF+NktaF0DavAo1q3IvCBXgokk/xmtvoPltSUs6vxuR/zMdOE1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-simplify": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/topojson-simplify/-/topojson-simplify-3.0.3.tgz", + "integrity": "sha512-sBO5UZ0O2dB0bNwo0vut2yLHhj3neUGi9uL7/ROdm8Gs6dtt4jcB9OGDKr+M2isZwQM2RuzVmifnMZpxj4IGNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -2013,6 +3085,129 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unovis/dagre-layout": { + "version": "0.8.8-2", + "resolved": "https://registry.npmjs.org/@unovis/dagre-layout/-/dagre-layout-0.8.8-2.tgz", + "integrity": "sha512-ZfDvfcYtzzhZhgKZty8XDi+zQIotfRqfNVF5M3dFQ9d9C5MTaRdbeBnPUkNrmlLJGgQ42HMOE2ajZLfm2VlRhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unovis/graphlibrary": "^2.2.0-2", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@unovis/graphlibrary": { + "version": "2.2.0-2", + "resolved": "https://registry.npmjs.org/@unovis/graphlibrary/-/graphlibrary-2.2.0-2.tgz", + "integrity": "sha512-HeEzpd/vDyWiIJt0rnh+2ICXUIuF2N0+Z9OJJiKg0DB+eFUcD+bk+9QPhYHwkFwfxdjDA9fHi1DZ/O/bbV58Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + } + }, + "node_modules/@unovis/ts": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@unovis/ts/-/ts-1.6.4.tgz", + "integrity": "sha512-LH8AqYuiVxMcm/SP/VsBKfBa6tu37CJapcn8qeRATZvtYuh8RBDnXr3ejwJyEUvIYJzbPuHOEQo9WIDre9CK1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@emotion/css": "^11.7.1", + "@juggle/resize-observer": "^3.3.1", + "@types/d3": "^7.4.0", + "@types/d3-array": "~3.2.2", + "@types/d3-axis": "~3.0.6", + "@types/d3-brush": "~3.0.6", + "@types/d3-chord": "~3.0.6", + "@types/d3-collection": "^1.0.10", + "@types/d3-color": "~3.1.3", + "@types/d3-drag": "~3.0.7", + "@types/d3-ease": "~3.0.2", + "@types/d3-force": "~3.0.10", + "@types/d3-geo": "~3.1.0", + "@types/d3-hierarchy": "~3.1.7", + "@types/d3-interpolate": "~3.0.4", + "@types/d3-path": "~3.1.1", + "@types/d3-sankey": "^0.12.4", + "@types/d3-scale": "~4.0.9", + "@types/d3-selection": "~3.0.0", + "@types/d3-shape": "~3.1.7", + "@types/d3-timer": "~3.0.2", + "@types/d3-transition": "~3.0.9", + "@types/d3-zoom": "~3.0.8", + "@types/dagre": "^0.7.50", + "@types/geojson": "^7946.0.8", + "@types/leaflet": "1.7.6", + "@types/supercluster": "^5.0.2", + "@types/three": "^0.135.0", + "@types/throttle-debounce": "^5.0.0", + "@types/topojson": "^3.2.3", + "@types/topojson-client": "^3.0.0", + "@types/topojson-specification": "^1.0.2", + "@unovis/dagre-layout": "0.8.8-2", + "@unovis/graphlibrary": "2.2.0-2", + "d3": "^7.2.1", + "d3-array": "~3", + "d3-axis": "~3", + "d3-brush": "~3", + "d3-chord": "~3", + "d3-collection": "^1.0.7", + "d3-color": "~3", + "d3-drag": "~3", + "d3-ease": "~3", + "d3-force": "~3", + "d3-geo": "~3", + "d3-geo-projection": "^4.0.0", + "d3-hierarchy": "~3", + "d3-interpolate": "~3", + "d3-interpolate-path": "^2.2.3", + "d3-path": "~3", + "d3-sankey": "^0.12.3", + "d3-scale": "~4", + "d3-selection": "~3", + "d3-shape": "~3", + "d3-timer": "~3", + "d3-transition": "~3", + "d3-zoom": "~3", + "elkjs": "^0.10.0", + "geojson": "^0.5.0", + "leaflet": "1.7.1", + "maplibre-gl": "^2.1.9", + "striptags": "^3.2.0", + "supercluster": "^7.1.5", + "three": "^0.135.0", + "throttle-debounce": "^5.0.0", + "topojson-client": "^3.1.0", + "tslib": "^2.3.1", + "typescript": "~4.2.4" + } + }, + "node_modules/@unovis/ts/node_modules/typescript": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", + "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/@unovis/vue": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@unovis/vue/-/vue-1.6.4.tgz", + "integrity": "sha512-Gt5LwmwiMoB0/f1eJL29sfKD9jzlqgTHxc+lW4rMFqIG/PpHLpWL2jTJYVVFfWFd1MjTJWXYiDA06hCEjFcrAg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@unovis/ts": "1.6.4", + "vue": "^3" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -2783,6 +3978,22 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2993,6 +4204,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3025,11 +4246,55 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3040,6 +4305,13 @@ "node": ">= 8" } }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3058,6 +4330,519 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", + "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "d3-array": "1 - 3", + "d3-geo": "1.12.0 - 3" + }, + "bin": { + "geo2svg": "bin/geo2svg.js", + "geograticule": "bin/geograticule.js", + "geoproject": "bin/geoproject.js", + "geoquantize": "bin/geoquantize.js", + "geostitch": "bin/geostitch.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.3.0.tgz", + "integrity": "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "dev": true, + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3192,6 +4977,16 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3224,6 +5019,15 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3238,6 +5042,20 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.2.tgz", + "integrity": "sha512-Yx3ORtbAFrXelYkAy2g0eYyVY8QG0XEmGdQXmy0eithKKjbWRfl3Xe884lfkszfBF6UKyIy4LwfcZ3AZc8oxFw==", + "dev": true, + "license": "EPL-2.0" + }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", @@ -3325,6 +5143,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -4118,6 +5946,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4144,6 +5978,13 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true, + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4298,6 +6139,23 @@ "node": ">= 0.4" } }, + "node_modules/geojson": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz", + "integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "dev": true, + "license": "ISC" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4345,6 +6203,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -4376,6 +6247,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "dev": true, + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4389,6 +6267,34 @@ "node": ">=10.13.0" } }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4539,6 +6445,40 @@ "he": "bin/he" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4576,6 +6516,13 @@ "node": ">=0.8.19" } }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4591,6 +6538,16 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4609,6 +6566,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -5014,7 +6978,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -5027,6 +6990,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -5040,6 +7010,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5047,6 +7030,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5074,6 +7064,13 @@ "json5": "lib/cli.js" } }, + "node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "dev": true, + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5084,6 +7081,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/laravel-echo": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.0.tgz", @@ -5144,6 +7151,13 @@ "vue": "^3.5.13" } }, + "node_modules/leaflet": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.7.1.tgz", + "integrity": "sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5408,6 +7422,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5431,6 +7452,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5438,6 +7466,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5447,6 +7481,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-2.4.0.tgz", + "integrity": "sha512-csNFylzntPmHWidczfgCZpvbTSmhaWvLRj9e1ezUDBEPizGgshgm3ea1T5TCNEEBq0roauu7BPuRZjA3wO4KqA==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^2.0.1", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.5", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@types/geojson": "^7946.0.10", + "@types/mapbox__point-geometry": "^0.1.2", + "@types/mapbox__vector-tile": "^1.3.0", + "@types/pbf": "^3.0.2", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.4", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.4.3", + "global-prefix": "^3.0.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.2", + "quickselect": "^2.0.0", + "supercluster": "^7.1.5", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.3" + } + }, "node_modules/maska": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz", @@ -5547,6 +7615,13 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5785,6 +7860,25 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -5806,7 +7900,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5819,6 +7912,30 @@ "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/php-parser": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.2.2.tgz", @@ -5895,6 +8012,44 @@ "node": ">=4" } }, + "node_modules/posthog-js": { + "version": "1.364.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.364.2.tgz", + "integrity": "sha512-ryeCFcaORLouVI5wsKxnraIDvKFM6RAxbbKlKuqo+A8VFZ9JvvRpwzfiMQR2trGsJUYcn6B3R4Rn0Xht9NrhAQ==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@posthog/core": "1.24.4", + "@posthog/types": "1.364.2", + "core-js": "^3.38.1", + "dompurify": "^3.3.2", + "fflate": "^0.4.8", + "preact": "^10.28.2", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.1.0" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/preact": { + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", + "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6025,6 +8180,37 @@ } } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -6051,6 +8237,12 @@ "tweetnacl": "^1.0.3" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6072,6 +8264,13 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "dev": true, + "license": "ISC" + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -6202,6 +8401,16 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6213,6 +8422,13 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", @@ -6307,6 +8523,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -6372,6 +8595,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -6438,7 +8668,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6451,7 +8680,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6578,6 +8806,16 @@ "node": ">=10.0.0" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6721,6 +8959,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/striptags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz", + "integrity": "sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "dev": true, + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -6780,6 +9042,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.135.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.135.0.tgz", + "integrity": "sha512-kuEpuuxRzLv0MDsXai9huCxOSQPZ4vje6y0gn80SRmQvgz6/+rI0NAvCRAw56zYaWKMGMfqKWsxF9Qa2Z9xymQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6825,6 +9104,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "dev": true, + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6838,6 +9124,28 @@ "node": ">=8.0" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7061,7 +9369,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -7343,6 +9650,18 @@ "dev": true, "license": "MIT" }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, "node_modules/vue": { "version": "3.5.26", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", @@ -7453,11 +9772,16 @@ "typescript": ">=5.0.0" } }, + "node_modules/web-vitals": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", + "integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==", + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7639,6 +9963,23 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index c23cd5fc..e9d1147b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@laravel/vite-plugin-wayfinder": "^0.1.3", "@tailwindcss/vite": "^4.1.11", "@types/node": "^22.13.5", + "@unovis/ts": "^1.6.4", + "@unovis/vue": "^1.6.4", "@vitejs/plugin-vue": "^6.0.0", "@vue/eslint-config-typescript": "^14.3.0", "chokidar": "^5.0.0", @@ -48,6 +50,7 @@ "laravel-vite-plugin": "^2.0.0", "laravel-vue-i18n": "^2.8.0", "maska": "^3.2.0", + "posthog-js": "^1.364.2", "reka-ui": "^2.7.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", diff --git a/public/images/social/google.svg b/public/images/social/google.svg new file mode 100644 index 00000000..710f7ae4 --- /dev/null +++ b/public/images/social/google.svg @@ -0,0 +1,22 @@ + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index 86d315e7..bb5c8233 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,24 +1,213 @@ @import 'tailwindcss'; - @import 'tw-animate-css'; - +@import 'vue-sonner/style.css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; - @custom-variant dark (&:is(.dark *)); -@layer utilities { - body, - html { - --font-sans: - 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; +:root { + --background: #fafafa; + --foreground: #0a0a0a; + --card: #ffffff; + --card-foreground: #0a0a0a; + --popover: #ffffff; + --popover-foreground: #0a0a0a; + --primary: #171717; + --primary-foreground: #fafafa; + --secondary: #e5e5e5; + --secondary-foreground: #171717; + --muted: #f5f5f5; + --muted-foreground: #737373; + --accent: #e5e5e5; + --accent-foreground: #171717; + --destructive: #dc2626; + --destructive-foreground: #fafafa; + --success: #16a34a; + --error: #dc2626; + --warning: #d97706; + --info: #2563eb; + --border: #e5e5e5; + --input: #d4d4d4; + --ring: #525252; + --chart-1: #1f3fad; + --chart-2: #1a4eda; + --chart-3: #2563ef; + --chart-4: #3a81f6; + --chart-5: #91c5ff; + --sidebar: #f5f5f5; + --sidebar-foreground: #0a0a0a; + --sidebar-primary: #1447e6; + --sidebar-primary-foreground: #fafafa; + --sidebar-accent: #e5e5e5; + --sidebar-accent-foreground: #171717; + --sidebar-border: #e5e5e5; + --sidebar-ring: #a3a3a3; + --font-sans: + 'Inter', ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + --radius: 0.725rem; + --shadow-x: 0; + --shadow-y: 1px; + --shadow-blur: 2.5px; + --shadow-spread: 0px; + --shadow-opacity: 0.04; + --shadow-color: oklch(0.3065 0.0243 243.82); + --shadow-2xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02); + --shadow-xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02); + --shadow-sm: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04); + --shadow: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04); + --shadow-md: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04); + --shadow-lg: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04); + --shadow-xl: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04); + --shadow-2xl: 0 1px 2.5px 0px hsl(0 0% 0% / 0.1); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +.dark { + --background: #0a0a0a; + --foreground: #fafafa; + --card: #171717; + --card-foreground: #fafafa; + --popover: #262626; + --popover-foreground: #fafafa; + --primary: #e5e5e5; + --primary-foreground: #171717; + --secondary: #262626; + --secondary-foreground: #fafafa; + --muted: #262626; + --muted-foreground: #a1a1a1; + --accent: #404040; + --accent-foreground: #fafafa; + --destructive: #ff6467; + --destructive-foreground: #fafafa; + --success: #16a34a; + --error: #dc2626; + --warning: #d97706; + --info: #2563eb; + --border: #333333; + --input: #343434; + --ring: #737373; + --chart-1: #91c5ff; + --chart-2: #3a81f6; + --chart-3: #2563ef; + --chart-4: #1a4eda; + --chart-5: #1f3fad; + --sidebar: #171717; + --sidebar-foreground: #fafafa; + --sidebar-primary: #1447e6; + --sidebar-primary-foreground: #fafafa; + --sidebar-accent: #262626; + --sidebar-accent-foreground: #fafafa; + --sidebar-border: #282828; + --sidebar-ring: #525252; + --font-sans: + 'Inter', ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + --radius: 0.725rem; + --shadow-x: 0; + --shadow-y: 1px; + --shadow-blur: 2.5px; + --shadow-spread: 0px; + --shadow-opacity: 0.04; + --shadow-color: oklch(0 0 0); + --shadow-2xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02); + --shadow-xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02); + --shadow-sm: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04); + --shadow: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04); + --shadow-md: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04); + --shadow-lg: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04); + --shadow-xl: + 0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04); + --shadow-2xl: 0 1px 2.5px 0px hsl(0 0% 0% / 0.1); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; } } -:root { +/* :root { --background: #f7f9f3; --foreground: #000000; --card: #ffffff; @@ -186,10 +375,6 @@ @theme inline { --tracking-widest: calc(var(--tracking-normal) + 0.1em); } -body { - letter-spacing: var(--tracking-normal); -} - @layer base { * { @apply border-border outline-ring/50; @@ -197,5 +382,6 @@ @layer base { body { @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); } -} \ No newline at end of file +} */ \ No newline at end of file diff --git a/resources/js/app.ts b/resources/js/app.ts index bb1d065a..f2e142a8 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,6 +1,6 @@ import '../css/app.css'; -import { createInertiaApp } from '@inertiajs/vue3'; +import { createInertiaApp, router } from '@inertiajs/vue3'; import { configureEcho } from '@laravel/echo-vue'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { i18nVue } from 'laravel-vue-i18n'; @@ -9,6 +9,8 @@ import { createApp, h } from 'vue'; import { initializeTheme } from './composables/useAppearance'; import dayjs from './dayjs'; +import posthog from './posthog'; +import type { Auth } from './types'; configureEcho({ broadcaster: 'reverb', @@ -30,6 +32,27 @@ createInertiaApp({ // Set dayjs locale based on user's language dayjs.locale(locale.toLowerCase()); + const auth = props.initialPage.props.auth as Auth | undefined; + + if (auth?.user) { + posthog.identify(auth.user.id, { + $email: auth.user.email, + $name: auth.user.name, + }); + + if (auth.currentWorkspace) { + posthog.group('workspace', auth.currentWorkspace.id, { + name: auth.currentWorkspace.name, + }); + } + } + + router.on('navigate', () => { + posthog.capture('$pageview', { + $current_url: window.location.href, + }); + }); + createApp({ render: () => h(App, props) }) .use(i18nVue, { lang: locale, diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 77604597..23f20d3f 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -1,176 +1,36 @@ \ No newline at end of file +
+ +
+ + diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 1ca17e10..6c84df71 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,85 +1,67 @@ \ No newline at end of file diff --git a/resources/js/components/AppSidebarHeader.vue b/resources/js/components/AppSidebarHeader.vue deleted file mode 100644 index 29ae7e9b..00000000 --- a/resources/js/components/AppSidebarHeader.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/js/components/ConfirmDeleteModal.vue b/resources/js/components/ConfirmDeleteModal.vue index 3913cf15..85434bfa 100644 --- a/resources/js/components/ConfirmDeleteModal.vue +++ b/resources/js/components/ConfirmDeleteModal.vue @@ -1,135 +1,197 @@ \ No newline at end of file + + + + + {{ title }} + + {{ description }} + + {{ trans('common.confirm_modal.cannot_be_undone') }} + + + +
+

+ {{ trans('common.confirm_modal.type') }} + + {{ confirmText }} + + + + + + +

{{ trans('common.confirm_modal.copy_to_clipboard') }}

+ + + + + {{ trans('common.confirm_modal.to_confirm') }} +

+ +
+ + + + +
+
+ diff --git a/resources/js/components/DatePicker.vue b/resources/js/components/DatePicker.vue index 969a1b8d..40e12dfe 100644 --- a/resources/js/components/DatePicker.vue +++ b/resources/js/components/DatePicker.vue @@ -22,7 +22,7 @@ import dayjs from '@/dayjs'; const props = defineProps({ name: { type: String, - required: true, + default: '', }, modelValue: { type: String, @@ -43,14 +43,16 @@ const props = defineProps({ }, placeholder: { type: String, - default: 'Select date', + default: '', }, }); -const emit = defineEmits(['update:modelValue']); +const emit = defineEmits<{ + 'update:modelValue': [value: string | null]; +}>(); // Parse input value into date -function parseInput(value: string) { +const parseInput = (value: string) => { if (!value) return undefined; try { @@ -62,7 +64,7 @@ function parseInput(value: string) { return undefined; } return undefined; -} +}; const internalDate = ref(parseInput(props.modelValue)); const popoverOpen = ref(false); @@ -94,7 +96,7 @@ const minutes = computed(() => { }); // Build full datetime string -function buildDateTime(dateStr: string | null): string | null { +const buildDateTime = (dateStr: string | null): string | null => { if (!dateStr) return null; if (!props.showTime) { @@ -103,15 +105,14 @@ function buildDateTime(dateStr: string | null): string | null { const timeStr = `${selectedHour.value}:${selectedMinute.value}:00`; return `${dateStr}T${timeStr}`; -} +}; -// Handle time change -function onTimeChange() { +const onTimeChange = () => { if (internalDate.value) { const dateStr = internalDate.value.toString(); emit('update:modelValue', buildDateTime(dateStr)); } -} +}; // Parse input value into date component const isInternalUpdate = ref(false); @@ -174,14 +175,14 @@ const displayText = computed(() => {