2026-03-29 22:24:28 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\App;
|
|
|
|
|
|
|
|
|
|
use App\Actions\Post\CreatePost;
|
|
|
|
|
use App\Actions\Post\DeletePost;
|
2026-04-17 02:05:51 +00:00
|
|
|
use App\Actions\Post\SyncPostPlatforms;
|
2026-03-29 22:24:28 +00:00
|
|
|
use App\Actions\Post\UpdatePost;
|
2026-03-31 03:40:18 +00:00
|
|
|
use App\Enums\Post\Action as PostAction;
|
|
|
|
|
use App\Enums\Post\Status as PostStatus;
|
2026-03-29 22:24:28 +00:00
|
|
|
use App\Enums\SocialAccount\Platform;
|
refactor: organize middleware/requests into App/ subdirs, add Resources, fix auth routes
- Move middleware to App/ subdir (HandleInertiaRequests, HandleAppearance,
EnsureSubscribed, EnsureUserSetupIsComplete) matching Sendkit pattern
- Move all Form Requests into organized subdirs (App/Post, App/Workspace,
App/Media, App/Invite, App/Settings, App/Auth)
- Create AuthUserResource and AuthWorkspaceResource for HandleInertiaRequests
shared data (role inside currentWorkspace, matching Sendkit pattern)
- Split auth.php into 3 route groups (no middleware, guest, auth) matching
Sendkit pattern exactly
- Fix UserFactory to include all nullable attributes (current_workspace_id,
stripe_id, pm_type, pm_last_four, trial_ends_at)
- Fix SocialAccountResource (display_name not name)
- Update frontend for new auth prop structure
- 702 tests passing (2 pre-existing Mastodon failures)
2026-03-30 00:13:30 +00:00
|
|
|
use App\Http\Requests\App\Post\UpdatePostRequest;
|
2026-04-23 16:23:24 +00:00
|
|
|
use App\Http\Resources\App\PlatformConfigResource;
|
2026-05-03 12:36:50 +00:00
|
|
|
use App\Http\Resources\App\SocialAccountResource;
|
2026-03-29 22:24:28 +00:00
|
|
|
use App\Models\Post;
|
2026-05-02 15:22:42 +00:00
|
|
|
use App\Models\PostPlatform;
|
|
|
|
|
use App\Services\Social\BlueskyAnalytics;
|
|
|
|
|
use App\Services\Social\FacebookAnalytics;
|
|
|
|
|
use App\Services\Social\InstagramAnalytics;
|
|
|
|
|
use App\Services\Social\LinkedInPageAnalytics;
|
|
|
|
|
use App\Services\Social\MastodonAnalytics;
|
|
|
|
|
use App\Services\Social\PinterestAnalytics;
|
2026-03-29 22:24:28 +00:00
|
|
|
use App\Services\Social\PinterestPublisher;
|
2026-05-02 15:22:42 +00:00
|
|
|
use App\Services\Social\ThreadsAnalytics;
|
2026-04-23 16:23:24 +00:00
|
|
|
use App\Services\Social\TikTokCreatorInfo;
|
2026-05-02 15:22:42 +00:00
|
|
|
use App\Services\Social\XAnalytics;
|
|
|
|
|
use App\Services\Social\YouTubeAnalytics;
|
2026-03-29 22:24:28 +00:00
|
|
|
use Carbon\Carbon;
|
2026-05-02 15:22:42 +00:00
|
|
|
use Illuminate\Http\JsonResponse;
|
2026-03-29 22:24:28 +00:00
|
|
|
use Illuminate\Http\RedirectResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
2026-05-02 15:22:42 +00:00
|
|
|
use Illuminate\Support\Facades\Cache;
|
2026-03-29 22:24:28 +00:00
|
|
|
use Inertia\Inertia;
|
|
|
|
|
use Inertia\Response;
|
|
|
|
|
|
|
|
|
|
class PostController extends Controller
|
|
|
|
|
{
|
|
|
|
|
public function index(Request $request, ?string $status = null): Response|RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->authorize('view', $workspace);
|
|
|
|
|
|
|
|
|
|
$query = $workspace->posts()
|
|
|
|
|
->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount'), 'user', 'labels']);
|
|
|
|
|
|
|
|
|
|
if ($status) {
|
|
|
|
|
$query = match ($status) {
|
2026-03-31 03:40:18 +00:00
|
|
|
PostStatus::Draft->value => $query->draft(),
|
|
|
|
|
PostStatus::Scheduled->value => $query->scheduled(),
|
|
|
|
|
PostStatus::Published->value => $query->published(),
|
2026-03-29 22:24:28 +00:00
|
|
|
default => $query,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 00:18:07 +00:00
|
|
|
if ($search = $request->input('search')) {
|
2026-04-15 23:11:36 +00:00
|
|
|
$query->where('content', 'ilike', "%{$search}%");
|
2026-03-31 00:18:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
return Inertia::render('posts/Index', [
|
|
|
|
|
'workspace' => $workspace,
|
2026-03-31 00:18:07 +00:00
|
|
|
'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(config('app.pagination.default'))),
|
2026-03-29 22:24:28 +00:00
|
|
|
'currentStatus' => $status,
|
2026-03-31 00:18:07 +00:00
|
|
|
'filters' => [
|
|
|
|
|
'search' => $request->input('search', ''),
|
|
|
|
|
],
|
2026-03-29 22:24:28 +00:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function calendar(Request $request): Response|RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->authorize('view', $workspace);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$tz = 'UTC';
|
2026-03-29 22:24:28 +00:00
|
|
|
$view = $request->input('view', 'week');
|
|
|
|
|
|
|
|
|
|
$currentDay = $request->input('day')
|
|
|
|
|
? Carbon::parse($request->input('day'), $tz)->startOfDay()
|
|
|
|
|
: Carbon::now($tz)->startOfDay();
|
|
|
|
|
|
|
|
|
|
$weekStart = $request->input('week')
|
|
|
|
|
? Carbon::parse($request->input('week'), $tz)->startOfWeek()
|
|
|
|
|
: Carbon::now($tz)->startOfWeek();
|
|
|
|
|
$weekEnd = $weekStart->copy()->endOfWeek();
|
|
|
|
|
|
|
|
|
|
$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();
|
|
|
|
|
|
|
|
|
|
$rangeStart = match ($view) {
|
|
|
|
|
'day' => $currentDay,
|
|
|
|
|
'month' => $monthStart,
|
|
|
|
|
default => $weekStart,
|
|
|
|
|
};
|
|
|
|
|
$rangeEnd = match ($view) {
|
|
|
|
|
'day' => $currentDay->copy()->endOfDay(),
|
|
|
|
|
'month' => $monthEnd,
|
|
|
|
|
default => $weekEnd,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$posts = $workspace->posts()
|
|
|
|
|
->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount')])
|
|
|
|
|
->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()])
|
|
|
|
|
->orderBy('scheduled_at')
|
|
|
|
|
->get()
|
|
|
|
|
->groupBy(fn ($post) => $post->scheduled_at?->setTimezone($tz)->format('Y-m-d'));
|
|
|
|
|
|
|
|
|
|
return Inertia::render('posts/Calendar', [
|
|
|
|
|
'workspace' => $workspace,
|
|
|
|
|
'posts' => $posts,
|
|
|
|
|
'currentDay' => $currentDay->format('Y-m-d'),
|
|
|
|
|
'currentWeekStart' => $weekStart->format('Y-m-d'),
|
|
|
|
|
'currentMonth' => $monthDate->format('Y-m-d'),
|
|
|
|
|
'view' => $view,
|
|
|
|
|
]);
|
|
|
|
|
}
|
2026-05-03 12:36:50 +00:00
|
|
|
|
|
|
|
|
public function create(Request $request): Response
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
$this->authorize('createPost', $workspace);
|
|
|
|
|
|
|
|
|
|
return Inertia::render('posts/Create', [
|
|
|
|
|
'date' => $request->query('date'),
|
|
|
|
|
'socialAccounts' => SocialAccountResource::collection(
|
|
|
|
|
$workspace->socialAccounts()->active()->get()
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
}
|
2026-03-29 22:24:28 +00:00
|
|
|
|
|
|
|
|
public function store(Request $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 17:58:25 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
2026-03-31 00:18:07 +00:00
|
|
|
$socialAccounts = $workspace->socialAccounts()->active()->get();
|
2026-03-29 22:24:28 +00:00
|
|
|
|
|
|
|
|
if ($socialAccounts->isEmpty()) {
|
|
|
|
|
session()->flash('flash.banner', __('posts.flash.connect_first'));
|
|
|
|
|
session()->flash('flash.bannerStyle', 'danger');
|
|
|
|
|
|
|
|
|
|
return redirect()->route('app.accounts');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$post = CreatePost::execute($workspace, $request->user(), [
|
|
|
|
|
'date' => $request->input('date'),
|
2026-04-15 23:11:36 +00:00
|
|
|
'media' => $request->input('media', []),
|
2026-03-29 22:24:28 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return Inertia::location(route('app.posts.edit', $post));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
public function platformMetrics(Request $request, Post $post, PostPlatform $postPlatform): JsonResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace || $post->workspace_id !== $workspace->id) {
|
|
|
|
|
abort(404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->authorize('view', $workspace);
|
|
|
|
|
|
|
|
|
|
if ($postPlatform->post_id !== $post->id) {
|
|
|
|
|
abort(404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($postPlatform->status->value !== 'published' || ! $postPlatform->platform_post_id) {
|
|
|
|
|
return response()->json(['unsupported' => true, 'reason' => 'not_published']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$cacheKey = "post_metrics:{$postPlatform->id}";
|
|
|
|
|
|
|
|
|
|
$metrics = Cache::remember($cacheKey, 300, fn () => match ($postPlatform->platform) {
|
|
|
|
|
Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform),
|
|
|
|
|
default => ['unsupported' => true, 'reason' => 'platform_not_supported'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return response()->json($metrics);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function show(Request $request, Post $post): Response|RedirectResponse
|
2026-03-29 22:24:28 +00:00
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->authorize('view', $workspace);
|
|
|
|
|
|
|
|
|
|
if ($post->workspace_id !== $workspace->id) {
|
|
|
|
|
abort(404);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed], true)) {
|
2026-05-02 15:22:42 +00:00
|
|
|
return redirect()->route('app.posts.edit', $post);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$post->load(['postPlatforms.socialAccount', 'labels']);
|
|
|
|
|
|
|
|
|
|
return Inertia::render('posts/Show', [
|
|
|
|
|
'workspace' => $workspace,
|
|
|
|
|
'post' => $post,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function edit(Request $request, Post $post): Response|RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->authorize('view', $workspace);
|
|
|
|
|
|
|
|
|
|
if ($post->workspace_id !== $workspace->id) {
|
|
|
|
|
abort(404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
|
|
|
|
|
return redirect()->route('app.posts.show', $post);
|
2026-04-17 02:05:51 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
SyncPostPlatforms::execute($post);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$post->load(['postPlatforms.socialAccount', 'labels']);
|
2026-03-31 00:18:07 +00:00
|
|
|
$socialAccounts = $workspace->socialAccounts()->active()->get();
|
2026-03-29 22:24:28 +00:00
|
|
|
$labels = $workspace->labels;
|
2026-05-03 18:23:30 +00:00
|
|
|
$signatures = $workspace->signatures;
|
2026-03-29 22:24:28 +00:00
|
|
|
|
|
|
|
|
$platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [
|
2026-04-23 16:23:24 +00:00
|
|
|
$account->id => new PlatformConfigResource($account),
|
2026-03-29 22:24:28 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$pinterestBoards = [];
|
|
|
|
|
$pinterestAccount = $socialAccounts->firstWhere('platform', Platform::Pinterest);
|
|
|
|
|
if ($pinterestAccount) {
|
|
|
|
|
try {
|
|
|
|
|
$pinterestBoards = app(PinterestPublisher::class)->getBoards($pinterestAccount);
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
// Silently fail - boards will be empty
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 16:23:24 +00:00
|
|
|
$tiktokAccounts = $socialAccounts->where('platform', Platform::TikTok);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
return Inertia::render('posts/Edit', [
|
|
|
|
|
'workspace' => $workspace,
|
|
|
|
|
'post' => $post,
|
|
|
|
|
'socialAccounts' => $socialAccounts,
|
|
|
|
|
'platformConfigs' => $platformConfigs,
|
|
|
|
|
'pinterestBoards' => $pinterestBoards,
|
2026-04-23 16:23:24 +00:00
|
|
|
'tiktokCreatorInfos' => Inertia::defer(fn () => $tiktokAccounts->mapWithKeys(fn ($account) => [
|
|
|
|
|
$account->id => rescue(
|
|
|
|
|
fn () => app(TikTokCreatorInfo::class)->fetch($account),
|
|
|
|
|
null,
|
|
|
|
|
report: false,
|
|
|
|
|
),
|
|
|
|
|
])->filter()),
|
2026-03-29 22:24:28 +00:00
|
|
|
'labels' => $labels,
|
2026-05-03 18:23:30 +00:00
|
|
|
'signatures' => $signatures,
|
2026-04-15 23:15:29 +00:00
|
|
|
'authUserId' => $request->user()->id,
|
2026-03-29 22:24:28 +00:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 17:58:25 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
|
|
|
|
if ($post->workspace_id !== $workspace->id) {
|
|
|
|
|
abort(404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$result = UpdatePost::execute($workspace, $post, $request->validated());
|
|
|
|
|
|
|
|
|
|
$action = data_get($result, 'action');
|
|
|
|
|
|
2026-03-31 03:40:18 +00:00
|
|
|
if ($action === PostAction::AlreadyPublished) {
|
2026-03-29 22:24:28 +00:00
|
|
|
session()->flash('flash.banner', __('posts.flash.cannot_edit_published'));
|
|
|
|
|
session()->flash('flash.bannerStyle', 'danger');
|
|
|
|
|
|
|
|
|
|
return back();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:18 +00:00
|
|
|
if ($action === PostAction::Publishing) {
|
2026-03-29 22:24:28 +00:00
|
|
|
session()->flash('flash.banner', __('posts.flash.publishing'));
|
|
|
|
|
session()->flash('flash.bannerStyle', 'success');
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
return redirect()->route('app.posts.show', $post);
|
2026-03-29 22:24:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:18 +00:00
|
|
|
if ($action === PostAction::Scheduled) {
|
2026-03-29 22:24:28 +00:00
|
|
|
session()->flash('flash.banner', __('posts.flash.scheduled'));
|
|
|
|
|
session()->flash('flash.bannerStyle', 'success');
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
return redirect()->route('app.posts.show', $post);
|
2026-03-29 22:24:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return back();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function destroy(Request $request, Post $post): RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 17:58:25 +00:00
|
|
|
$this->authorize('createPost', $workspace);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
|
|
|
|
if ($post->workspace_id !== $workspace->id) {
|
|
|
|
|
abort(404);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
|
|
|
|
|
session()->flash('flash.banner', __('posts.flash.cannot_delete_published'));
|
|
|
|
|
session()->flash('flash.bannerStyle', 'danger');
|
|
|
|
|
|
|
|
|
|
return back();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
DeletePost::execute($post);
|
|
|
|
|
|
|
|
|
|
session()->flash('flash.banner', __('posts.flash.deleted'));
|
|
|
|
|
session()->flash('flash.bannerStyle', 'success');
|
|
|
|
|
|
2026-04-01 16:21:22 +00:00
|
|
|
$allowedRedirects = ['app.posts.index', 'app.calendar'];
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
if ($redirect = $request->input('redirect')) {
|
2026-04-01 16:21:22 +00:00
|
|
|
if (in_array($redirect, $allowedRedirects)) {
|
|
|
|
|
return redirect()->route($redirect);
|
|
|
|
|
}
|
2026-03-29 22:24:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 18:04:13 +00:00
|
|
|
return redirect()->route('app.posts.index');
|
2026-03-29 22:24:28 +00:00
|
|
|
}
|
|
|
|
|
}
|