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-05-04 01:37:51 +00:00
|
|
|
use App\Actions\Post\DuplicatePost;
|
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-06-17 20:42:23 +00:00
|
|
|
use App\Ai\Templates\AiTemplateRegistry;
|
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;
|
2026-05-06 20:21:30 +00:00
|
|
|
use App\Http\Requests\App\Post\StorePostRequest;
|
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-05-04 01:11:58 +00:00
|
|
|
use App\Http\Resources\Api\PostResource;
|
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;
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
use App\Services\Post\PostMetricsFetcher;
|
2026-03-29 22:24:28 +00:00
|
|
|
use App\Services\Social\PinterestPublisher;
|
2026-04-23 16:23:24 +00:00
|
|
|
use App\Services\Social\TikTokCreatorInfo;
|
2026-05-21 22:32:42 +00:00
|
|
|
use App\Support\PostStatusRules;
|
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;
|
|
|
|
|
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-05-14 13:02:38 +00:00
|
|
|
$labelIds = $request->collect('labels')
|
|
|
|
|
->filter(fn ($id) => is_string($id) && $id !== '')
|
|
|
|
|
->values()
|
|
|
|
|
->all();
|
|
|
|
|
|
|
|
|
|
$query->when($labelIds, fn ($q) => $q->whereHas(
|
|
|
|
|
'labels',
|
|
|
|
|
fn ($q) => $q->whereIn('workspace_labels.id', $labelIds),
|
feat(posts): multi-select label filter on the posts list
Adds a combobox-style filter to the posts index toolbar so users can
narrow All / Scheduled / Posted / Drafts views by one or more labels.
- `PostController::index` accepts `?labels[]=<id>` and applies
`whereHas('labels', whereIn(...))` (OR semantics across selected labels).
Workspace labels are exposed to the page (sorted by name) and the
selected set comes back under `filters.labels`.
- New `LabelFilter.vue` component reuses the existing Popover + Command
pattern (matching `FontPicker` in the Brand settings page). Trigger
renders the selected `LabelBadge`s inline (mirroring how each post row
already displays its labels): 1-3 shown directly, 4+ shown as the
first three plus a "+N" overflow indicator. Clear button has a
tooltip and `cursor-pointer`, and stops `click`/`pointerdown`/
`mousedown` so it doesn't reopen the Popover.
- Existing search debounce is shared with the new label watcher via a
single `buildFilterUrl` helper. URL is updated with `preserveState +
replace` so the back stack stays clean.
- i18n in en / pt-BR / es: `filter_by_label`, `label_search_placeholder`,
`no_labels`, `clear_label_filter`.
Tests: 4 new index tests covering the labels prop exposure, single-label
filter, multi-label OR filter, and blank-id sanitization. Full suite:
1509 passed, 2 skipped, 0 failed.
2026-05-14 12:57:55 +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,
|
feat(posts): multi-select label filter on the posts list
Adds a combobox-style filter to the posts index toolbar so users can
narrow All / Scheduled / Posted / Drafts views by one or more labels.
- `PostController::index` accepts `?labels[]=<id>` and applies
`whereHas('labels', whereIn(...))` (OR semantics across selected labels).
Workspace labels are exposed to the page (sorted by name) and the
selected set comes back under `filters.labels`.
- New `LabelFilter.vue` component reuses the existing Popover + Command
pattern (matching `FontPicker` in the Brand settings page). Trigger
renders the selected `LabelBadge`s inline (mirroring how each post row
already displays its labels): 1-3 shown directly, 4+ shown as the
first three plus a "+N" overflow indicator. Clear button has a
tooltip and `cursor-pointer`, and stops `click`/`pointerdown`/
`mousedown` so it doesn't reopen the Popover.
- Existing search debounce is shared with the new label watcher via a
single `buildFilterUrl` helper. URL is updated with `preserveState +
replace` so the back stack stays clean.
- i18n in en / pt-BR / es: `filter_by_label`, `label_search_placeholder`,
`no_labels`, `clear_label_filter`.
Tests: 4 new index tests covering the labels prop exposure, single-label
filter, multi-label OR filter, and blank-id sanitization. Full suite:
1509 passed, 2 skipped, 0 failed.
2026-05-14 12:57:55 +00:00
|
|
|
'labels' => $workspace->labels()->orderBy('name')->get(['id', 'name', 'color']),
|
2026-03-31 00:18:07 +00:00
|
|
|
'filters' => [
|
|
|
|
|
'search' => $request->input('search', ''),
|
feat(posts): multi-select label filter on the posts list
Adds a combobox-style filter to the posts index toolbar so users can
narrow All / Scheduled / Posted / Drafts views by one or more labels.
- `PostController::index` accepts `?labels[]=<id>` and applies
`whereHas('labels', whereIn(...))` (OR semantics across selected labels).
Workspace labels are exposed to the page (sorted by name) and the
selected set comes back under `filters.labels`.
- New `LabelFilter.vue` component reuses the existing Popover + Command
pattern (matching `FontPicker` in the Brand settings page). Trigger
renders the selected `LabelBadge`s inline (mirroring how each post row
already displays its labels): 1-3 shown directly, 4+ shown as the
first three plus a "+N" overflow indicator. Clear button has a
tooltip and `cursor-pointer`, and stops `click`/`pointerdown`/
`mousedown` so it doesn't reopen the Popover.
- Existing search debounce is shared with the new label watcher via a
single `buildFilterUrl` helper. URL is updated with `preserveState +
replace` so the back stack stays clean.
- i18n in en / pt-BR / es: `filter_by_label`, `label_search_placeholder`,
`no_labels`, `clear_label_filter`.
Tests: 4 new index tests covering the labels prop exposure, single-label
filter, multi-label OR filter, and blank-id sanitization. Full suite:
1509 passed, 2 skipped, 0 failed.
2026-05-14 12:57:55 +00:00
|
|
|
'labels' => $labelIds,
|
2026-03-31 00:18:07 +00:00
|
|
|
],
|
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);
|
|
|
|
|
|
2026-06-17 20:42:23 +00:00
|
|
|
$registry = app(AiTemplateRegistry::class);
|
|
|
|
|
|
|
|
|
|
$templates = array_map(fn ($t) => [
|
|
|
|
|
'key' => $t->key(),
|
|
|
|
|
'name' => trans($t->name()),
|
|
|
|
|
'description' => trans($t->description()),
|
|
|
|
|
'preview' => $t->previewAsset(),
|
|
|
|
|
'needs_account' => $t->needsAccount(),
|
|
|
|
|
'supported_formats' => $t->supportedFormats(),
|
|
|
|
|
], $registry->all());
|
|
|
|
|
|
2026-05-03 12:36:50 +00:00
|
|
|
return Inertia::render('posts/Create', [
|
|
|
|
|
'date' => $request->query('date'),
|
|
|
|
|
'socialAccounts' => SocialAccountResource::collection(
|
|
|
|
|
$workspace->socialAccounts()->active()->get()
|
|
|
|
|
),
|
2026-06-17 20:42:23 +00:00
|
|
|
'templates' => $templates,
|
2026-05-03 12:36:50 +00:00
|
|
|
]);
|
|
|
|
|
}
|
2026-03-29 22:24:28 +00:00
|
|
|
|
2026-05-06 20:21:30 +00:00
|
|
|
public function store(StorePostRequest $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response
|
2026-03-29 22:24:28 +00:00
|
|
|
{
|
|
|
|
|
$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
|
|
|
|
|
{
|
2026-05-04 16:20:52 +00:00
|
|
|
$this->authorize('view', $post);
|
2026-05-02 15:22:42 +00:00
|
|
|
|
|
|
|
|
if ($postPlatform->post_id !== $post->id) {
|
|
|
|
|
abort(404);
|
|
|
|
|
}
|
|
|
|
|
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
return response()->json(app(PostMetricsFetcher::class)->forPlatform($postPlatform));
|
2026-05-02 15:22:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 16:20:52 +00:00
|
|
|
$this->authorize('view', $post);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
2026-05-19 17:18:13 +00:00
|
|
|
if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled], 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,
|
2026-05-04 01:11:58 +00:00
|
|
|
'post' => (new PostResource($post))->resolve(),
|
2026-05-02 15:22:42 +00:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function edit(Request $request, Post $post): Response|RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
|
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return redirect()->route('app.workspaces.create');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 16:20:52 +00:00
|
|
|
$this->authorize('update', $post);
|
2026-05-02 15:22:42 +00:00
|
|
|
|
2026-05-21 22:32:42 +00:00
|
|
|
if (PostStatusRules::blocksEditing($post)) {
|
2026-05-02 15:22:42 +00:00
|
|
|
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
|
|
|
]);
|
|
|
|
|
|
2026-05-15 14:51:20 +00:00
|
|
|
$pinterestBoards = $socialAccounts
|
|
|
|
|
->where('platform', Platform::Pinterest)
|
|
|
|
|
->mapWithKeys(fn ($account) => [
|
|
|
|
|
$account->id => rescue(
|
|
|
|
|
fn () => app(PinterestPublisher::class)->getBoards($account),
|
|
|
|
|
[],
|
|
|
|
|
report: false,
|
|
|
|
|
),
|
|
|
|
|
]);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
2026-05-09 18:01:22 +00:00
|
|
|
$tiktokCreatorInfos = $socialAccounts
|
|
|
|
|
->where('platform', Platform::TikTok)
|
|
|
|
|
->mapWithKeys(fn ($account) => [
|
|
|
|
|
$account->id => rescue(
|
|
|
|
|
fn () => app(TikTokCreatorInfo::class)->fetch($account),
|
|
|
|
|
null,
|
|
|
|
|
report: false,
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
->filter();
|
2026-04-23 16:23:24 +00:00
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
return Inertia::render('posts/Edit', [
|
|
|
|
|
'workspace' => $workspace,
|
|
|
|
|
'post' => $post,
|
|
|
|
|
'socialAccounts' => $socialAccounts,
|
|
|
|
|
'platformConfigs' => $platformConfigs,
|
|
|
|
|
'pinterestBoards' => $pinterestBoards,
|
2026-05-09 18:01:22 +00:00
|
|
|
'tiktokCreatorInfos' => $tiktokCreatorInfos,
|
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-05-04 16:20:52 +00:00
|
|
|
$this->authorize('update', $post);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
|
|
|
|
$result = UpdatePost::execute($workspace, $post, $request->validated());
|
|
|
|
|
|
|
|
|
|
$action = data_get($result, 'action');
|
|
|
|
|
|
fix(facebook): empty-message rejection + state consistency + no re-publish on terminal
Production incident: a customer's Facebook Page post failed with 'The post
is empty. Please enter a message to share.' (error code 197) and ended up
with a contradictory DB state (status=published + error_message=set).
Three independent bugs were uncovered:
A. FacebookPublisher sends 'message'/'description' as null when the user
posts media without text. Graph API requires the key be omitted, not
null. Fixed in publishSingleImagePost, publishMultiImagePost,
publishVideoPost, publishReel.
B. markAsPublished/markAsFailed leak stale fields across transitions
(a published row could retain error_message from a prior failure,
vice-versa). Both transitions now explicitly clear the opposite
side's fields.
C. status='failed' was editable in the UI and the backend, so users
were re-clicking Publish, generating duplicate failure emails and
the contradictory state from bug B. The frontend isReadOnly check
and the UpdatePost backend guard now treat Published/PartiallyPublished/
Failed/Publishing as terminal. To retry, the user duplicates the post.
11 new tests guarantee these can't regress silently: FB payload shape
per content type, PostPlatform field-clearing on transitions, and the
terminal-status block at the controller level.
2026-05-15 16:01:49 +00:00
|
|
|
if ($action === PostAction::Finalized) {
|
|
|
|
|
session()->flash('flash.banner', __('posts.flash.cannot_edit_finalized'));
|
2026-03-29 22:24:28 +00:00
|
|
|
session()->flash('flash.bannerStyle', 'danger');
|
|
|
|
|
|
|
|
|
|
return back();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:18 +00:00
|
|
|
if ($action === PostAction::Publishing) {
|
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-05-04 16:20:52 +00:00
|
|
|
$this->authorize('delete', $post);
|
2026-03-29 22:24:28 +00:00
|
|
|
|
2026-05-21 22:32:42 +00:00
|
|
|
if (PostStatusRules::blocksDeletion($post)) {
|
2026-05-02 15:22:42 +00:00
|
|
|
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
|
|
|
}
|
2026-05-04 01:37:51 +00:00
|
|
|
|
|
|
|
|
public function duplicate(Request $request, Post $post): RedirectResponse
|
|
|
|
|
{
|
|
|
|
|
$this->authorize('duplicate', $post);
|
|
|
|
|
|
|
|
|
|
$post->load(['postPlatforms', 'labels']);
|
|
|
|
|
|
|
|
|
|
$copy = DuplicatePost::execute($post, $request->user());
|
|
|
|
|
|
|
|
|
|
session()->flash('flash.banner', __('posts.flash.duplicated'));
|
|
|
|
|
session()->flash('flash.bannerStyle', 'success');
|
|
|
|
|
|
|
|
|
|
return redirect()->route('app.posts.edit', $copy);
|
|
|
|
|
}
|
2026-03-29 22:24:28 +00:00
|
|
|
}
|