feat: pass and persist post dates through AI creation wizard and template application flows

This commit is contained in:
Paulo Castellano 2026-05-06 17:21:30 -03:00
parent 3e642ef520
commit 1d116cfbb2
15 changed files with 262 additions and 29 deletions

View file

@ -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/<Group>/`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
- Naming: `<Verb><Resource>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'`.

View file

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

View file

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

View file

@ -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([

View file

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

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Post;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'date' => ['nullable', 'date_format:Y-m-d'],
'media' => ['nullable', 'array'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\PostTemplate;
use Illuminate\Foundation\Http\FormRequest;
class ApplyPostTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'social_account_id' => ['nullable', 'uuid'],
'date' => ['nullable', 'date_format:Y-m-d'],
];
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\PostTemplate;
use Illuminate\Foundation\Http\FormRequest;
class IndexPostTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'platform' => ['nullable', 'string'],
'search' => ['nullable', 'string', 'max:120'],
'date' => ['nullable', 'date_format:Y-m-d'],
];
}
}

View file

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

View file

@ -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<Props>();
const props = withDefaults(defineProps<Props>(), {
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 };

View file

@ -112,7 +112,7 @@ const stepHeader = computed(() => {
</button>
<Link
:href="templatesIndex.url()"
:href="templatesIndex.url({ query: { date: props.date } })"
class="group flex flex-col items-start gap-4 rounded-2xl border-2 border-foreground bg-card p-5 text-left shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<div class="inline-flex size-12 -rotate-1 items-center justify-center rounded-2xl border-2 border-foreground bg-emerald-200 shadow-2xs transition-transform group-hover:rotate-0">
@ -132,6 +132,7 @@ const stepHeader = computed(() => {
<AiPostWizard
v-else-if="view === 'ai'"
:social-accounts="socialAccounts"
:date="props.date"
@update:step-header="aiHeader = $event"
@cancel="view = 'choice'; aiHeader = null"
/>

View file

@ -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<Props>();
const props = withDefaults(defineProps<Props>(), {
date: null,
});
const searchQuery = ref(props.filters.search);
const selectedPlatform = ref<string>(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;
};

View file

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

View file

@ -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([

View file

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