From 1d116cfbb208274d9ece61518977f6e8eaa9dd70 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 6 May 2026 17:21:30 -0300 Subject: [PATCH] feat: pass and persist post dates through AI creation wizard and template application flows --- CLAUDE.md | 11 +++ .../App/PostAiCreateController.php | 2 + app/Http/Controllers/App/PostController.php | 3 +- .../App/PostTemplateController.php | 18 ++-- .../App/Ai/StartPostCreationRequest.php | 1 + .../Requests/App/Post/StorePostRequest.php | 26 +++++ .../PostTemplate/ApplyPostTemplateRequest.php | 26 +++++ .../PostTemplate/IndexPostTemplateRequest.php | 27 ++++++ app/Jobs/Ai/StreamPostCreation.php | 3 + .../components/posts/create/AiPostWizard.vue | 10 +- resources/js/pages/posts/Create.vue | 3 +- resources/js/pages/posts/templates/Index.vue | 10 +- tests/Feature/Ai/PostAiCreateTest.php | 94 ++++++++++++++++--- tests/Feature/PostControllerTest.php | 24 +++++ tests/Feature/PostTemplateControllerTest.php | 33 +++++++ 15 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 app/Http/Requests/App/Post/StorePostRequest.php create mode 100644 app/Http/Requests/App/PostTemplate/ApplyPostTemplateRequest.php create mode 100644 app/Http/Requests/App/PostTemplate/IndexPostTemplateRequest.php diff --git a/CLAUDE.md b/CLAUDE.md index 4764be1f..a5a46144 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,6 +259,17 @@ ## Form Validation - NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation. +## Backend Validation + +- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App//`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller. +- Naming: `Request.php` (e.g. `StorePostRequest`, `ApplyPostTemplateRequest`, `IndexPostTemplateRequest`). + +## Pest / Feature Tests + +- ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'`. + - Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')`. + - With params: `route('app.posts.ai.create.finalize', $creationId)`. + ## Dusk (Browser Tests) - In Dusk tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`. diff --git a/app/Http/Controllers/App/PostAiCreateController.php b/app/Http/Controllers/App/PostAiCreateController.php index cac14ec8..ee11799e 100644 --- a/app/Http/Controllers/App/PostAiCreateController.php +++ b/app/Http/Controllers/App/PostAiCreateController.php @@ -54,6 +54,7 @@ public function start(StartPostCreationRequest $request): JsonResponse socialAccountId: $socialAccountId, imageCount: (int) $request->input('image_count', 0), prompt: $request->string('prompt')->toString(), + date: $request->input('date'), ); return response()->json([ @@ -100,6 +101,7 @@ public function finalize(Request $request, string $creationId): JsonResponse $post = CreatePost::execute($workspace, $request->user(), [ 'content' => $caption, 'media' => $media, + 'date' => data_get($state, 'date'), ]); // Sync the post_platform with the wizard choice: set content_type to diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index b2b62409..83a695b4 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -12,6 +12,7 @@ use App\Enums\Post\Action as PostAction; use App\Enums\Post\Status as PostStatus; use App\Enums\SocialAccount\Platform; +use App\Http\Requests\App\Post\StorePostRequest; use App\Http\Requests\App\Post\UpdatePostRequest; use App\Http\Resources\Api\PostResource; use App\Http\Resources\App\PlatformConfigResource; @@ -136,7 +137,7 @@ public function create(Request $request): Response ]); } - public function store(Request $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response + public function store(StorePostRequest $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response { $workspace = $request->user()->currentWorkspace; diff --git a/app/Http/Controllers/App/PostTemplateController.php b/app/Http/Controllers/App/PostTemplateController.php index a6de5061..06424de6 100644 --- a/app/Http/Controllers/App/PostTemplateController.php +++ b/app/Http/Controllers/App/PostTemplateController.php @@ -6,13 +6,14 @@ use App\Actions\Post\CreatePost; use App\Enums\Media\Type as MediaType; +use App\Http\Requests\App\PostTemplate\ApplyPostTemplateRequest; +use App\Http\Requests\App\PostTemplate\IndexPostTemplateRequest; use App\Http\Resources\App\PostTemplateResource; use App\Models\SocialAccount; use App\Models\Workspace; use App\Services\Image\TemplateImageGenerator; use App\Services\PostTemplate\Registry; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Inertia\Response as InertiaResponse; @@ -22,13 +23,8 @@ class PostTemplateController extends Controller { public function __construct(private readonly Registry $registry) {} - public function index(Request $request): InertiaResponse + public function index(IndexPostTemplateRequest $request): InertiaResponse { - $request->validate([ - 'platform' => ['nullable', 'string'], - 'search' => ['nullable', 'string', 'max:120'], - ]); - $paginator = $this->registry->paginate( locale: app()->getLocale(), platform: $request->input('platform'), @@ -45,19 +41,16 @@ public function index(Request $request): InertiaResponse 'search' => $request->input('search', ''), 'platform' => $request->input('platform', ''), ], + 'date' => $request->input('date'), ]); } - public function apply(Request $request, string $slug, TemplateImageGenerator $generator): JsonResponse + public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateImageGenerator $generator): JsonResponse { $workspace = $request->user()->currentWorkspace; $this->authorize('createPost', $workspace); - $request->validate([ - 'social_account_id' => ['nullable', 'uuid'], - ]); - $template = $this->registry->find($slug, app()->getLocale()); $socialAccountId = $request->input('social_account_id'); @@ -98,6 +91,7 @@ public function apply(Request $request, string $slug, TemplateImageGenerator $ge $post = CreatePost::execute($workspace, $request->user(), [ 'content' => $content, 'media' => $media, + 'date' => $request->input('date'), ]); return response()->json([ diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index debf3816..6720b6df 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -30,6 +30,7 @@ public function rules(): array 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], // Stories accept 1 image, no carousel — the wizard handles this client-side too. 'prompt' => ['required', 'string', 'max:2000'], + 'date' => ['nullable', 'date_format:Y-m-d'], ]; } } diff --git a/app/Http/Requests/App/Post/StorePostRequest.php b/app/Http/Requests/App/Post/StorePostRequest.php new file mode 100644 index 00000000..c564ec58 --- /dev/null +++ b/app/Http/Requests/App/Post/StorePostRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'date' => ['nullable', 'date_format:Y-m-d'], + 'media' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/App/PostTemplate/ApplyPostTemplateRequest.php b/app/Http/Requests/App/PostTemplate/ApplyPostTemplateRequest.php new file mode 100644 index 00000000..31542e73 --- /dev/null +++ b/app/Http/Requests/App/PostTemplate/ApplyPostTemplateRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'social_account_id' => ['nullable', 'uuid'], + 'date' => ['nullable', 'date_format:Y-m-d'], + ]; + } +} diff --git a/app/Http/Requests/App/PostTemplate/IndexPostTemplateRequest.php b/app/Http/Requests/App/PostTemplate/IndexPostTemplateRequest.php new file mode 100644 index 00000000..8a24cd39 --- /dev/null +++ b/app/Http/Requests/App/PostTemplate/IndexPostTemplateRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'platform' => ['nullable', 'string'], + 'search' => ['nullable', 'string', 'max:120'], + 'date' => ['nullable', 'date_format:Y-m-d'], + ]; + } +} diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index b717184a..e6c35e37 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -34,6 +34,7 @@ public function __construct( public ?string $socialAccountId, public int $imageCount, public string $prompt, + public ?string $date = null, ) { $this->onQueue('ai'); } @@ -251,6 +252,7 @@ private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAcco 'social_account_id' => $this->socialAccountId, 'content' => $caption, 'slides' => $renderedSlides, + 'date' => $this->date, 'created_at' => now()->toIso8601String(), ], now()->addMinutes(30)); @@ -296,6 +298,7 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun 'image_body' => $imageBody, 'image_keywords' => $keywords, 'image_path' => $imagePath, + 'date' => $this->date, 'created_at' => now()->toIso8601String(), ], now()->addMinutes(30)); diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 41f2bcc1..86dea005 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -28,9 +28,13 @@ interface SocialAccount { interface Props { socialAccounts: SocialAccount[]; + /** ISO date (YYYY-MM-DD) carried over from the calendar's per-day "+" button. */ + date?: string | null; } -const props = defineProps(); +const props = withDefaults(defineProps(), { + date: null, +}); type WizardStep = 'configure' | 'preview'; @@ -67,7 +71,8 @@ const httpStart = useHttp<{ social_account_id: string | null; image_count: number; prompt: string; -}>({ format: null, social_account_id: null, image_count: 0, prompt: '' }); + date: string | null; +}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null }); const httpFinalize = useHttp<{ content: string; image_title: string; image_body: string }>({ content: '', @@ -253,6 +258,7 @@ const startGeneration = async () => { httpStart.social_account_id = selectedAccountId.value; httpStart.image_count = submittedImageCount.value; httpStart.prompt = promptText.value.trim(); + httpStart.date = props.date; try { const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string }; diff --git a/resources/js/pages/posts/Create.vue b/resources/js/pages/posts/Create.vue index ead62712..337898b2 100644 --- a/resources/js/pages/posts/Create.vue +++ b/resources/js/pages/posts/Create.vue @@ -112,7 +112,7 @@ const stepHeader = computed(() => {
@@ -132,6 +132,7 @@ const stepHeader = computed(() => { diff --git a/resources/js/pages/posts/templates/Index.vue b/resources/js/pages/posts/templates/Index.vue index a0bf1a6e..89602a5a 100644 --- a/resources/js/pages/posts/templates/Index.vue +++ b/resources/js/pages/posts/templates/Index.vue @@ -53,9 +53,13 @@ interface Props { search: string; platform: string; }; + /** ISO date carried over from the calendar's per-day "+" button. */ + date?: string | null; } -const props = defineProps(); +const props = withDefaults(defineProps(), { + date: null, +}); const searchQuery = ref(props.filters.search); const selectedPlatform = ref(props.filters.platform || ''); @@ -130,6 +134,7 @@ const reload = (params: { search?: string; platform?: string }) => { { search: params.search || undefined, platform: params.platform || undefined, + date: props.date || undefined, }, { preserveState: true, preserveScroll: true, replace: true }, ); @@ -158,7 +163,8 @@ const applyTemplate = async (template: PostTemplate) => { applyingSlug.value = template.slug; try { - const data = (await useHttp().post(applyRoute.url(template.slug))) as { + const http = useHttp<{ date: string | null }>({ date: props.date ?? null }); + const data = (await http.post(applyRoute.url(template.slug))) as { post_id: string; redirect_url: string; }; diff --git a/tests/Feature/Ai/PostAiCreateTest.php b/tests/Feature/Ai/PostAiCreateTest.php index 16e37b22..744ca621 100644 --- a/tests/Feature/Ai/PostAiCreateTest.php +++ b/tests/Feature/Ai/PostAiCreateTest.php @@ -23,7 +23,7 @@ // --- POST /posts/ai/create (start) --- test('start requires authentication', function () { - $this->postJson('/posts/ai/create', ['prompt' => 'hello', 'format' => 'x_post']) + $this->postJson(route('app.posts.ai.create'), ['prompt' => 'hello', 'format' => 'x_post']) ->assertStatus(Response::HTTP_UNAUTHORIZED); }); @@ -31,7 +31,7 @@ Bus::fake(); $this->actingAs($this->user) - ->postJson('/posts/ai/create', ['format' => 'x_post']) + ->postJson(route('app.posts.ai.create'), ['format' => 'x_post']) ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) ->assertJsonValidationErrors(['prompt']); }); @@ -40,7 +40,7 @@ Bus::fake(); $this->actingAs($this->user) - ->postJson('/posts/ai/create', ['prompt' => 'hello']) + ->postJson(route('app.posts.ai.create'), ['prompt' => 'hello']) ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) ->assertJsonValidationErrors(['format']); }); @@ -49,7 +49,7 @@ Bus::fake(); $this->actingAs($this->user) - ->postJson('/posts/ai/create', ['prompt' => 'hello', 'format' => 'tiktok_video']) + ->postJson(route('app.posts.ai.create'), ['prompt' => 'hello', 'format' => 'tiktok_video']) ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) ->assertJsonValidationErrors(['format']); }); @@ -64,7 +64,7 @@ ]); $this->actingAs($this->user) - ->postJson('/posts/ai/create', [ + ->postJson(route('app.posts.ai.create'), [ 'prompt' => 'hello', 'format' => 'x_post', 'social_account_id' => $foreignAccount->id, @@ -81,7 +81,7 @@ ]); $response = $this->actingAs($this->user) - ->postJson('/posts/ai/create', [ + ->postJson(route('app.posts.ai.create'), [ 'prompt' => 'Write a post about productivity', 'format' => 'x_post', 'social_account_id' => $account->id, @@ -108,7 +108,7 @@ Bus::fake(); $this->actingAs($this->user) - ->postJson('/posts/ai/create', [ + ->postJson(route('app.posts.ai.create'), [ 'prompt' => 'Write a LinkedIn post', 'format' => 'linkedin_post', ]) @@ -121,13 +121,13 @@ // --- POST /posts/ai/create/{creationId}/finalize --- test('finalize requires authentication', function () { - $this->postJson('/posts/ai/create/fake-id/finalize') + $this->postJson(route('app.posts.ai.create.finalize', 'fake-id')) ->assertStatus(Response::HTTP_UNAUTHORIZED); }); test('finalize returns 404 if creation not found in cache', function () { $this->actingAs($this->user) - ->postJson('/posts/ai/create/nonexistent-id/finalize') + ->postJson(route('app.posts.ai.create.finalize', 'nonexistent-id')) ->assertStatus(Response::HTTP_NOT_FOUND); }); @@ -146,7 +146,7 @@ ], now()->addMinutes(30)); $this->actingAs($this->user) - ->postJson("/posts/ai/create/{$creationId}/finalize") + ->postJson(route('app.posts.ai.create.finalize', $creationId)) ->assertStatus(Response::HTTP_NOT_FOUND); }); @@ -165,7 +165,7 @@ ], now()->addMinutes(30)); $response = $this->actingAs($this->user) - ->postJson("/posts/ai/create/{$creationId}/finalize") + ->postJson(route('app.posts.ai.create.finalize', $creationId)) ->assertStatus(Response::HTTP_OK) ->assertJsonStructure(['post_id', 'redirect_url']); @@ -183,3 +183,75 @@ // Redirect URL should point to the edit page expect($response->json('redirect_url'))->toContain("/posts/{$postId}/edit"); }); + +test('finalize defaults scheduled_at to today when no date is in cache state', function () { + $creationId = 'no-date-creation'; + + Cache::put("ai-creation:{$creationId}", [ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'format' => 'x_post', + 'social_account_id' => null, + 'image_count' => 0, + 'content' => 'hello', + 'created_at' => now()->toIso8601String(), + ], now()->addMinutes(30)); + + $response = $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create.finalize', $creationId)) + ->assertOk(); + + $post = Post::find($response->json('post_id')); + expect($post->scheduled_at->format('Y-m-d'))->toBe(now('UTC')->format('Y-m-d')); +}); + +test('finalize schedules the post on the date stored in cache state', function () { + $creationId = 'with-date-creation'; + + Cache::put("ai-creation:{$creationId}", [ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'format' => 'x_post', + 'social_account_id' => null, + 'image_count' => 0, + 'content' => 'hello', + 'date' => '2026-06-15', + 'created_at' => now()->toIso8601String(), + ], now()->addMinutes(30)); + + $response = $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create.finalize', $creationId)) + ->assertOk(); + + $post = Post::find($response->json('post_id')); + expect($post->scheduled_at->format('Y-m-d'))->toBe('2026-06-15'); +}); + +test('start dispatches the job carrying the date param when provided', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => 'hello', + 'format' => 'x_post', + 'date' => '2026-06-15', + ]) + ->assertAccepted(); + + Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->date === '2026-06-15'); +}); + +test('start rejects invalid date format', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => 'hello', + 'format' => 'x_post', + 'date' => 'not-a-date', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['date']); + + Bus::assertNotDispatched(StreamPostCreation::class); +}); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index e664d2a6..4e61275f 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -150,6 +150,30 @@ expect($post->postPlatforms)->toHaveCount(1); }); +test('store post defaults scheduled_at to today when no date is provided', function () { + $this->actingAs($this->user)->post(route('app.posts.store'))->assertRedirect(); + + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post->scheduled_at->format('Y-m-d'))->toBe(now('UTC')->format('Y-m-d')); +}); + +test('store post schedules draft on the date param when provided', function () { + $this->actingAs($this->user)->post(route('app.posts.store'), [ + 'date' => '2026-06-15', + ])->assertRedirect(); + + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post->scheduled_at->format('Y-m-d'))->toBe('2026-06-15'); +}); + +test('store post rejects invalid date format', function () { + $this->actingAs($this->user) + ->post(route('app.posts.store'), ['date' => 'not-a-date']) + ->assertSessionHasErrors(['date']); + + expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(0); +}); + // Edit tests test('edit post requires authentication', function () { $post = Post::factory()->create([ diff --git a/tests/Feature/PostTemplateControllerTest.php b/tests/Feature/PostTemplateControllerTest.php index a10344a9..be4b34bd 100644 --- a/tests/Feature/PostTemplateControllerTest.php +++ b/tests/Feature/PostTemplateControllerTest.php @@ -123,6 +123,39 @@ ->assertNotFound(); }); +test('apply defaults scheduled_at to today when no date is provided', function () { + Http::fake(['api.unsplash.com/*' => Http::response(['results' => []])]); + + $this->actingAs($this->user) + ->postJson(route('app.post-templates.apply', 'success_story')) + ->assertOk(); + + $post = $this->workspace->posts()->latest()->first(); + expect($post->scheduled_at->format('Y-m-d'))->toBe(now('UTC')->format('Y-m-d')); +}); + +test('apply schedules the post on the date param when provided', function () { + Http::fake(['api.unsplash.com/*' => Http::response(['results' => []])]); + + $this->actingAs($this->user) + ->postJson(route('app.post-templates.apply', 'success_story'), [ + 'date' => '2026-06-15', + ]) + ->assertOk(); + + $post = $this->workspace->posts()->latest()->first(); + expect($post->scheduled_at->format('Y-m-d'))->toBe('2026-06-15'); +}); + +test('apply rejects invalid date format', function () { + $this->actingAs($this->user) + ->postJson(route('app.post-templates.apply', 'success_story'), [ + 'date' => 'not-a-date', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['date']); +}); + test('apply with slides creates post even when image rendering fails', function () { // Unsplash returns no results → generator returns null → no media attached, post still created. Http::fake(['api.unsplash.com/*' => Http::response(['results' => []])]);