Merge pull request #10 from trypost-it/refactor/actions-subdomain-routes

refactor: single-domain routing, actions, policies, Google login, PostHog, full test coverage
This commit is contained in:
Paulo Castellano 2026-04-01 15:20:51 -03:00 committed by GitHub
commit 6e86b83433
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
568 changed files with 27647 additions and 5842 deletions

View file

@ -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 | - | - |
| 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;
// ...
```

View file

@ -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 <Link>, <Form>, 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 <Link>, <Form>, 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 `<Form>`, `useForm`, or `useHttp`)
- Implementing client-side navigation with `<Link>` 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.
<!-- Basic Vue Page Component -->
```vue
<script setup>
@ -271,7 +279,137 @@ ### `useForm` Composable
</template>
```
## Inertia v2 Features
## Inertia v3 Features
### HTTP Requests
Use the `useHttp` hook for standalone HTTP requests that do not trigger Inertia page visits. It provides the same developer experience as `useForm`, but for plain JSON endpoints.
<!-- useHttp Example -->
```vue
<script setup>
import { useHttp } from '@inertiajs/vue3'
const http = useHttp({
query: '',
})
function search() {
http.get('/api/search', {
onSuccess: (response) => {
console.log(response)
},
})
}
</script>
<template>
<input v-model="http.query" @input="search" />
<div v-if="http.processing">Searching...</div>
</template>
```
### Optimistic Updates
Apply data changes instantly before the server responds, with automatic rollback on failure:
<!-- Optimistic Update with Router -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
function like(post) {
router.optimistic((props) => ({
post: {
...props.post,
likes: props.post.likes + 1,
},
})).post(`/posts/${post.id}/like`)
}
</script>
```
Optimistic updates also work with `useForm` and the `<Form>` component:
<!-- Optimistic Update with Form Component -->
```vue
<template>
<Form
action="/todos"
method="post"
:optimistic="(props, data) => ({
todos: [...props.todos, { id: Date.now(), name: data.name, done: false }],
})"
>
<input type="text" name="name" />
<button type="submit">Add Todo</button>
</Form>
</template>
```
### 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.
<!-- Instant Visit with Link -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/dashboard" component="Dashboard">Dashboard</Link>
<Link
href="/posts/1"
component="Posts/Show"
:page-props="{ post: { id: 1, title: 'My Post' } }"
>
View Post
</Link>
</template>
```
### Layout Props
Share dynamic data between pages and persistent layouts:
<!-- Layout Props in Layout -->
```vue
<script setup>
withDefaults(defineProps({
title: String,
showSidebar: Boolean,
}), {
title: 'My App',
showSidebar: true,
})
</script>
<template>
<header>{{ title }}</header>
<aside v-if="showSidebar">Sidebar</aside>
<main>
<slot />
</main>
</template>
```
<!-- Setting Layout Props from Page -->
```vue
<script setup>
import { setLayoutProps } from '@inertiajs/vue3'
setLayoutProps({
title: 'Dashboard',
showSidebar: false,
})
</script>
<template>
<h1>Dashboard</h1>
</template>
```
### Deferred Props
@ -358,42 +496,69 @@ ### Polling
</template>
```
- `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:
<!-- Infinite Scroll with WhenVisible -->
<!-- WhenVisible Example -->
```vue
<script setup>
import { WhenVisible } from '@inertiajs/vue3'
defineProps({
stats: Object
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<WhenVisible data="stats" :buffer="200">
<template #fallback>
<div class="animate-pulse">Loading stats...</div>
</template>
<template #default="{ fetching }">
<div>
<p>Total Users: {{ stats.total_users }}</p>
<p>Revenue: {{ stats.revenue }}</p>
<span v-if="fetching">Refreshing...</span>
</div>
</template>
</WhenVisible>
</div>
</template>
```
### InfiniteScroll
Automatically load additional pages of paginated data as users scroll:
<!-- InfiniteScroll Example -->
```vue
<script setup>
import { InfiniteScroll } from '@inertiajs/vue3'
defineProps({
users: Object
})
</script>
<template>
<div>
<InfiniteScroll data="users">
<div v-for="user in users.data" :key="user.id">
{{ user.name }}
</div>
<WhenVisible
v-if="users.next_page_url"
data="users"
:params="{ page: users.current_page + 1 }"
>
<template #fallback>
<div>Loading more...</div>
</template>
</WhenVisible>
</div>
</InfiniteScroll>
</template>
```
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 `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
- Forgetting to check if `<Form>` component is available in your Inertia version
- Forgetting to check if `<Form>` 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

View file

@ -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.
<!-- Search MCP 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
<!-- Tool Example -->
<!-- Register MCP Server -->
```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
<!-- MCP Tool Example -->
```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
<!-- Resource Example -->
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
<!-- Register Primitives in MCP Server -->
```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
<!-- Available Responses -->
```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:
<!-- Test MCP Primitives -->
```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:
<!--Launch MCP 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
<!-- Correct 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)
- 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.

View file

@ -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}"

View file

@ -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:

View file

@ -1,7 +1,7 @@
{
"mcpServers": {
"laravel-boost": {
"command": "vendor/bin/sail",
"command": "php",
"args": [
"artisan",
"boost:mcp"

109
CLAUDE.md
View file

@ -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 <Link>, <Form>, 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 <Link>, <Form>, 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.
</laravel-boost-guidelines>
# 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.

113
README.md
View file

@ -19,36 +19,40 @@
</p>
<p align="center">
<a href="https://docs.trypost.it">📚 Documentation</a> •
<a href="https://github.com/orgs/trypost-it/projects/1">🗺️ Roadmap</a> •
<a href="https://github.com/trypost-it/trypost/discussions">💬 Community</a>
<a href="https://trypost.it/docs">Documentation</a> &bull;
<a href="https://github.com/orgs/trypost-it/projects/1">Roadmap</a> &bull;
<a href="https://github.com/trypost-it/trypost/discussions">Community</a>
</p>
---
## 🤔 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
<table>
<tr>
@ -67,34 +71,75 @@ ## 🌐 Supported Platforms
</tr>
</table>
## 🚀 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.
---
<p align="center">
<strong>If TryPost helps you, please give us a ⭐</strong>
<strong>If TryPost helps you, please give us a star</strong>
</p>

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Actions\ApiKey;
use App\Models\ApiToken;
use App\Models\Workspace;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class CreateApiKey
{
/**
* @param array{name: string, expires_at?: string|null} $data
* @return array{token: ApiToken, plain_token: string}
*/
public static function execute(Workspace $workspace, array $data): array
{
$plainToken = 'tp_'.Str::random(48);
$apiToken = ApiToken::create([
'workspace_id' => $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];
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\ApiKey;
use App\Models\ApiToken;
class DeleteApiKey
{
public static function execute(ApiToken $apiToken): void
{
$apiToken->delete();
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\Hashtag;
use App\Models\Workspace;
use App\Models\WorkspaceHashtag;
class CreateHashtag
{
public static function execute(Workspace $workspace, array $data): WorkspaceHashtag
{
return $workspace->hashtags()->create([
'name' => data_get($data, 'name'),
'hashtags' => data_get($data, 'hashtags'),
]);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Hashtag;
use App\Models\WorkspaceHashtag;
class DeleteHashtag
{
public static function execute(WorkspaceHashtag $hashtag): void
{
$hashtag->delete();
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Actions\Hashtag;
use App\Models\WorkspaceHashtag;
class UpdateHashtag
{
public static function execute(WorkspaceHashtag $hashtag, array $data): WorkspaceHashtag
{
$hashtag->update([
'name' => data_get($data, 'name'),
'hashtags' => data_get($data, 'hashtags'),
]);
return $hashtag;
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Invite;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
use App\Models\Workspace;
use App\Models\WorkspaceInvite;
use Illuminate\Support\Facades\Mail;
class CreateInvite
{
public static function execute(Workspace $workspace, array $data): WorkspaceInvite
{
$invite = $workspace->invites()->create([
'email' => data_get($data, 'email'),
'role' => data_get($data, 'role', WorkspaceRole::Member),
]);
Mail::to($invite->email)->send(new WorkspaceInviteMail($invite));
return $invite;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Invite;
use App\Models\WorkspaceInvite;
class DeleteInvite
{
public static function execute(WorkspaceInvite $invite): void
{
$invite->delete();
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Invite;
use App\Models\Workspace;
class RemoveMember
{
public static function execute(Workspace $workspace, string $userId): void
{
$workspace->members()->detach($userId);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\Label;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
class CreateLabel
{
public static function execute(Workspace $workspace, array $data): WorkspaceLabel
{
return $workspace->labels()->create([
'name' => data_get($data, 'name'),
'color' => data_get($data, 'color'),
]);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Label;
use App\Models\WorkspaceLabel;
class DeleteLabel
{
public static function execute(WorkspaceLabel $label): void
{
$label->delete();
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Actions\Label;
use App\Models\WorkspaceLabel;
class UpdateLabel
{
public static function execute(WorkspaceLabel $label, array $data): WorkspaceLabel
{
$label->update([
'name' => data_get($data, 'name'),
'color' => data_get($data, 'color'),
]);
return $label;
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Actions\Post;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Carbon\Carbon;
class CreatePost
{
public static function execute(Workspace $workspace, User $user, array $data): Post
{
$date = data_get($data, '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' => $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;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Post;
use App\Models\Post;
class DeletePost
{
public static function execute(Post $post): void
{
$post->delete();
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Actions\Post;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status as PostStatus;
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdatePost
{
/**
* @return array{post: Post, action: PostAction|null}
*/
public static function execute(Workspace $workspace, Post $post, array $data): array
{
if ($post->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];
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Models\SocialAccount;
class ToggleSocialAccount
{
public static function execute(SocialAccount $account): SocialAccount
{
$account->update(['is_active' => ! $account->is_active]);
return $account;
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Actions\User;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
class CreateUser
{
/**
* @param array{name: string, email: string, password?: string, timezone?: string, setup?: Setup, email_verified_at?: \DateTimeInterface|null} $data
*/
public static function execute(array $data): User
{
return DB::transaction(function () use ($data): User {
$isInviteRegistration = data_get($data, 'is_invite', false);
$user = User::create([
'name' => 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;
});
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Actions\Workspace;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
class CreateWorkspace
{
public static function execute(User $user, array $data): Workspace
{
$workspace = Workspace::create([
'user_id' => $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;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Workspace;
use App\Models\User;
use App\Models\Workspace;
class DeleteWorkspace
{
public static function execute(User $user, Workspace $workspace): void
{
User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]);
$workspace->delete();
if ($user->hasActiveSubscription()) {
$user->decrementWorkspaceQuantity();
}
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Broadcasting;
use App\Models\Post;

View file

@ -1,7 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Concerns;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
@ -9,7 +12,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
* @return array<int, Rule|array<mixed>|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<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
* @return array<int, Rule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Concerns;
use App\Models\User;

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\SocialAccount\Status;
@ -17,7 +19,7 @@ public function handle(): void
{
Workspace::query()
->whereHas('socialAccounts', function ($query) {
$query->where('status', Status::Connected);
$query->whereIn('status', [Status::Connected, Status::TokenExpired]);
})
->with('owner')
->chunk(100, function ($workspaces) {

View file

@ -1,7 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\Post\Status as PostStatus;
use App\Jobs\PublishPost;
use App\Models\Post;
use Illuminate\Console\Command;
@ -16,9 +19,13 @@ public function handle(): void
{
Post::query()
->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);
}
});

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Models\Post;
use Illuminate\Console\Command;
class RecoverStuckPosts extends Command
{
protected $signature = 'social:recover-stuck-posts';
protected $description = 'Recover posts stuck in publishing status for more than 1 hour';
public function handle(): void
{
$count = 0;
Post::query()
->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.");
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';
protected $description = 'Proactively refresh tokens expiring in the next 2 hours';
public function handle(): void
{
$count = 0;
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) use (&$count) {
foreach ($accounts as $account) {
RefreshSocialToken::dispatch($account);
$count++;
}
});
$this->info("Dispatched {$count} token refresh jobs.");
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums\ApiToken;
enum Status: string
{
case Active = 'active';
case Expired = 'expired';
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\Media;
enum Type: string

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\Notification;
enum Channel: string
{
case Email = 'email';
case InApp = 'in_app';
case Both = 'both';
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums\Notification;
enum Type: string
{
case PostPublished = 'post_published';
case PostFailed = 'post_failed';
case PostPartiallyPublished = 'post_partially_published';
case AccountDisconnected = 'account_disconnected';
case InviteReceived = 'invite_received';
case MemberJoined = 'member_joined';
case MemberRemoved = 'member_removed';
}

12
app/Enums/Post/Action.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\Post;
enum Action: string
{
case AlreadyPublished = 'already_published';
case Publishing = 'publishing';
case Scheduled = 'scheduled';
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\Post;
enum Status: string
@ -14,12 +16,12 @@ enum Status: string
public function label(): string
{
return match ($this) {
self::Draft => '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'),
};
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostPlatform;
use App\Enums\SocialAccount\Platform as SocialPlatform;

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostPlatform;
enum Status: string
{
case Pending = 'pending';
case Publishing = 'publishing';
case Published = 'published';
case Failed = 'failed';
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\SocialAccount;
use App\Enums\Media\Type as MediaType;
@ -99,6 +101,26 @@ public function maxContentLength(): int
};
}
/**
* @return array<string>
*/
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<string>
*/
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);

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\SocialAccount;
enum Status: string

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\User;
enum Persona: string
@ -13,26 +15,12 @@ enum Persona: string
public function label(): string
{
return match ($this) {
self::Founder => '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

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\User;
enum Setup: string

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Enums\UserWorkspace;
enum Role: string

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\PostPlatform;
@ -29,7 +31,7 @@ public function broadcastWith(): array
return [
'post_platform' => [
'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(),

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class BlueskyPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->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';
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
enum ErrorCategory: string
{
case MediaFormat = 'media_format';
case RateLimit = 'rate_limit';
case Permission = 'permission';
case ContentPolicy = 'content_policy';
case ServerError = 'server_error';
case Unknown = 'unknown';
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class FacebookPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->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';
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class InstagramPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->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';
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class LinkedInPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->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';
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class MastodonPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->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';
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class PinterestPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->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';
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use RuntimeException;
abstract class SocialPublishException extends RuntimeException
{
public function __construct(
public readonly string $userMessage,
public readonly ErrorCategory $category,
public readonly ?string $platformErrorCode = null,
public readonly ?string $rawResponse = null,
) {
parent::__construct($userMessage);
}
/**
* @return array{platform: string, category: string, platform_error_code: ?string, user_message: string, raw_response: ?string}
*/
public function context(): array
{
return [
'platform' => 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;
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class ThreadsPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->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';
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class TikTokPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->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';
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class XPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->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';
}
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Google\Service\Exception as GoogleServiceException;
use Illuminate\Http\Client\Response;
class YouTubePublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$body = $response->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],
};
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@ -49,7 +51,7 @@ function uploadFromUrl(?string $url, string $directory = 'social-accounts'): ?st
Storage::put($filename, $response->body(), 'public');
return $filename;
} catch (\Exception $e) {
} catch (Exception $e) {
Log::warning('uploadFromUrl: Exception', [
'url' => $url,
'error' => $e->getMessage(),

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\ApiKey\CreateApiKey;
use App\Actions\ApiKey\DeleteApiKey;
use App\Http\Requests\Api\ApiKey\StoreApiKeyRequest;
use App\Http\Resources\Api\ApiKeyResource;
use App\Models\ApiToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class ApiKeyController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$apiKeys = $request->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);
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller as BaseController;
abstract class Controller extends BaseController
{
//
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\Hashtag\CreateHashtag;
use App\Actions\Hashtag\DeleteHashtag;
use App\Actions\Hashtag\UpdateHashtag;
use App\Http\Requests\Api\Hashtag\StoreHashtagRequest;
use App\Http\Requests\Api\Hashtag\UpdateHashtagRequest;
use App\Http\Resources\Api\HashtagResource;
use App\Models\WorkspaceHashtag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class HashtagController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$hashtags = $request->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);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\Label\CreateLabel;
use App\Actions\Label\DeleteLabel;
use App\Actions\Label\UpdateLabel;
use App\Http\Requests\Api\Label\StoreLabelRequest;
use App\Http\Requests\Api\Label\UpdateLabelRequest;
use App\Http\Resources\Api\LabelResource;
use App\Models\WorkspaceLabel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class LabelController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$labels = $request->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);
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\UpdatePost;
use App\Enums\Post\Action as PostAction;
use App\Http\Requests\Api\Post\StorePostRequest;
use App\Http\Requests\Api\Post\UpdatePostRequest;
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class PostController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$posts = $request->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);
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\SocialAccount\ToggleSocialAccount;
use App\Http\Resources\Api\SocialAccountResource;
use App\Models\SocialAccount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class SocialAccountController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$accounts = $request->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);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Resources\Api\WorkspaceResource;
use Illuminate\Http\Request;
class WorkspaceController extends Controller
{
public function show(Request $request): WorkspaceResource
{
return new WorkspaceResource($request->workspace);
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\ApiKey\CreateApiKey;
use App\Actions\ApiKey\DeleteApiKey;
use App\Models\ApiToken;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ApiKeyController extends Controller
{
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->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();
}
}

View file

@ -1,7 +1,10 @@
<?php
namespace App\Http\Controllers;
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -10,16 +13,14 @@
class BillingController extends Controller
{
/**
* Show the subscription selection page for new users.
*/
public function subscribe(Request $request): Response|RedirectResponse
{
$user = $request->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.');
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller as BaseController;
abstract class Controller extends BaseController
{
//
}

View file

@ -1,10 +1,17 @@
<?php
namespace App\Http\Controllers;
declare(strict_types=1);
use App\Http\Requests\StoreChunkedMediaRequest;
use App\Http\Requests\StoreMediaRequest;
namespace App\Http\Controllers\App;
use App\Http\Requests\App\Media\DuplicateMediaRequest;
use App\Http\Requests\App\Media\StoreChunkedMediaRequest;
use App\Http\Requests\App\Media\StoreMediaRequest;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\JsonResponse;
@ -15,6 +22,7 @@ class MediaController extends Controller
public function store(StoreMediaRequest $request): JsonResponse
{
$model = $this->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);
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Models\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$workspace = $request->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]);
}
}

View file

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class OnboardingController extends Controller
{
public function role(Request $request): Response|RedirectResponse
{
$redirect = $this->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'),
};
}
}

View file

@ -1,12 +1,16 @@
<?php
namespace App\Http\Controllers;
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\UpdatePost;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Http\Requests\UpdatePostRequest;
use App\Jobs\PublishPost;
use App\Http\Requests\App\Post\UpdatePostRequest;
use App\Models\Post;
use App\Services\Social\PinterestPublisher;
use Carbon\Carbon;
@ -22,30 +26,34 @@ public function index(Request $request, ?string $status = null): Response|Redire
$workspace = $request->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');
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Http\Controllers\App\Controller;
use App\Models\NotificationPreference;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class NotificationPreferenceController extends Controller
{
public function edit(Request $request): Response
{
$preferences = NotificationPreference::firstOrCreate(
['user_id' => $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();
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
public function edit(): Response
{
return Inertia::render('settings/Password');
}
public function update(Request $request): RedirectResponse
{
$validated = $request->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();
}
}

View file

@ -1,11 +1,14 @@
<?php
namespace App\Http\Controllers\Settings;
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Enums\UserWorkspace\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use App\Http\Requests\App\Settings\ProfileDeleteRequest;
use App\Http\Requests\App\Settings\ProfileUpdateRequest;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -16,9 +19,6 @@
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/Profile', [
@ -27,9 +27,6 @@ public function edit(Request $request): Response
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->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();

View file

@ -1,9 +1,14 @@
<?php
namespace App\Http\Controllers;
declare(strict_types=1);
use App\Http\Requests\StoreWorkspaceRequest;
use App\Http\Requests\UpdateWorkspaceRequest;
namespace App\Http\Controllers\App;
use App\Actions\Workspace\CreateWorkspace;
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\UserWorkspace\Role;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -12,14 +17,12 @@
class WorkspaceController extends Controller
{
/**
* List all workspaces.
*/
public function index(Request $request): Response
{
$user = $request->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!');
}
}

View file

@ -1,7 +1,12 @@
<?php
namespace App\Http\Controllers;
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Hashtag\CreateHashtag;
use App\Actions\Hashtag\DeleteHashtag;
use App\Actions\Hashtag\UpdateHashtag;
use App\Models\WorkspaceHashtag;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -15,14 +20,22 @@ 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('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');
}
}

View file

@ -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();
}
}

View file

@ -1,7 +1,12 @@
<?php
namespace App\Http\Controllers;
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Label\CreateLabel;
use App\Actions\Label\DeleteLabel;
use App\Actions\Label\UpdateLabel;
use App\Models\WorkspaceLabel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -15,14 +20,22 @@ 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('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');
}
}

View file

@ -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');
}
}

View file

@ -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'));
}
/**

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -23,7 +25,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->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,

View file

@ -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();

View file

@ -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'),
]);

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -7,6 +9,7 @@
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
@ -34,7 +37,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->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', [

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -31,7 +33,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -34,7 +36,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -39,7 +41,7 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse
$workspace = $request->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,
];
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -30,7 +32,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->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

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -33,7 +35,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);

View file

@ -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');
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class SignupSuccessController extends Controller
{
public function __invoke(Request $request): Response
{
return Inertia::render('auth/SignupSuccess', [
'authProvider' => $request->session()->get('auth_provider', 'email'),
]);
}
}

View file

@ -1,7 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Actions\SocialAccount\ToggleSocialAccount;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Http\Controllers\Controller;
@ -32,7 +35,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('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';
}
/**

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Actions\User\CreateUser;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
class SocialLoginController extends Controller
{
public function redirect(): RedirectResponse
{
return Socialite::driver('google-auth')->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');
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -29,7 +31,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->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,

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
@ -31,7 +33,7 @@ public function connect(Request $request): Response|RedirectResponse
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);

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