From 4293475ce1dfa44df010592e1d271c79d328039f Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 18 Jul 2026 16:53:18 +0300 Subject: [PATCH] docs: add implementation plan for Scheduled Posts feature Co-Authored-By: Claude Sonnet 5 --- docs/superpowers/plans/2026-07-18-posts.md | 1091 ++++++++++++++++++++ 1 file changed, 1091 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-posts.md diff --git a/docs/superpowers/plans/2026-07-18-posts.md b/docs/superpowers/plans/2026-07-18-posts.md new file mode 100644 index 0000000..a34b6f8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-posts.md @@ -0,0 +1,1091 @@ +# Scheduled Posts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `Post` resource — a content calendar linking a required `Site`, an optional `SocialAccount`, a `type` enum, title/body content, a lifecycle `status`, and an optional `scheduled_at` — with a Livewire CRUD UI and a full read/write JSON API, so an AI agent can draft, schedule, and report on posts it publishes externally. + +**Architecture:** Follows the exact pattern established by the existing Sites/SocialAccounts feature in this codebase: one Eloquent model (`Post`) with two `belongsTo` relationships, one migration, one factory, a Livewire full-page CRUD component (index + inline modal form), and a REST API controller + API Resource. The only structural difference from the prior feature is that the Posts API is full CRUD (`apiResource`) rather than read-only, since the AI drives the post lifecycle end-to-end. + +**Tech Stack:** Laravel 13, PHP 8.4, Livewire 4.3.3, Pest 4 (`pestphp/pest-plugin-laravel`), MySQL, Tailwind 4. Same stack as the existing feature — no new dependencies. + +## Global Constraints + +- No authentication/authorization anywhere (UI or API) — local network only, per spec. +- Posts API is full CRUD (index, show, store, update, destroy) — this is the one resource in the app that is writable via API, unlike the read-only Sites/Accounts API. +- `type` enum values: `twitter`, `instagram`, `facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other` (same network vocabulary as `SocialAccount::NETWORKS`, plus `blog`). +- `status` enum values: `draft`, `scheduled`, `published`, `failed`; defaults to `draft`. +- `site_id` is required; deleting a `Site` cascades and deletes its posts (`cascadeOnDelete()`). +- `social_account_id` is nullable; deleting a `SocialAccount` nulls out the post's link, post survives (`nullOnDelete()`). +- No validation ties `type` to the linked account's `network` — these are independent fields, by design. +- This app does not publish anything itself — no queue job or scheduler calling external platforms. The `?due=1` API filter exists so the AI can poll for what needs publishing; the AI does the actual publishing and reports back via `PATCH`. + +--- + +## File Structure + +- `database/migrations/xxxx_create_posts_table.php` — new +- `app/Models/Post.php` — new +- `database/factories/PostFactory.php` — new +- `app/Livewire/Posts/Index.php` + `resources/views/livewire/posts/index.blade.php` — new (list + create/edit/delete UI) +- `resources/views/components/layouts/app.blade.php` — modify (add "Posts" nav link) +- `routes/web.php` — modify (add Posts Livewire page route) +- `app/Http/Controllers/Api/PostController.php` — new (full CRUD, unlike the existing read-only API controllers) +- `app/Http/Resources/PostResource.php` — new +- `app/Http/Requests/StorePostRequest.php` — new (validation rules shared conceptually between store/update, kept as two small Form Request classes per Laravel convention) +- `app/Http/Requests/UpdatePostRequest.php` — new +- `routes/api.php` — modify (add Posts API routes) +- `tests/Feature/Livewire/PostsTest.php` — new +- `tests/Feature/Api/PostsApiTest.php` — new +- `tests/Feature/PostModelTest.php` — new + +--- + +## Task 1: Post model, migration, and factory + +**Files:** +- Create: `database/migrations/xxxx_create_posts_table.php` (via `php artisan make:migration`) +- Create: `app/Models/Post.php` +- Create: `database/factories/PostFactory.php` +- Test: `tests/Feature/PostModelTest.php` + +**Interfaces:** +- Produces: `Post` model with fillable `site_id`, `social_account_id`, `type`, `title`, `body`, `status`, `scheduled_at`; public constants `Post::TYPES` and `Post::STATUSES`; relationship methods `site(): BelongsTo` and `socialAccount(): BelongsTo`; `PostFactory` producing valid random posts (with a `Site` and, optionally, a `SocialAccount` created via factory relationships). + +- [ ] **Step 1: Generate the migration** + +Run: `docker compose exec laravel.test php artisan make:migration create_posts_table` + +(This project runs inside Docker via Laravel Sail. All `php artisan`/`composer`/`npm` commands in this plan must be run as `docker compose exec laravel.test ` from the project root `/home/liquidsky/Projects/muninn`, not as bare host commands.) + +- [ ] **Step 2: Write the migration** + +Edit the generated file in `database/migrations/`: + +```php +id(); + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); + $table->foreignId('social_account_id')->nullable()->constrained()->nullOnDelete(); + $table->enum('type', [ + 'twitter', 'instagram', 'facebook', 'reddit', + 'tiktok', 'linkedin', 'youtube', 'blog', 'other', + ]); + $table->string('title')->nullable(); + $table->text('body'); + $table->enum('status', ['draft', 'scheduled', 'published', 'failed'])->default('draft'); + $table->timestamp('scheduled_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; +``` + +- [ ] **Step 3: Write the failing model test** + +Create `tests/Feature/PostModelTest.php`: + +```php +create(); + + $post = Post::create([ + 'site_id' => $site->id, + 'type' => 'blog', + 'body' => 'Hello world', + ]); + + $fresh = $post->fresh(); + + expect($fresh) + ->site_id->toBe($site->id) + ->type->toBe('blog') + ->body->toBe('Hello world') + ->status->toBe('draft') + ->social_account_id->toBeNull() + ->title->toBeNull() + ->scheduled_at->toBeNull(); +}); + +test('a post can be created with an account, title, status, and schedule', function () { + $site = Site::factory()->create(); + $account = SocialAccount::factory()->create(); + + $post = Post::create([ + 'site_id' => $site->id, + 'social_account_id' => $account->id, + 'type' => 'twitter', + 'title' => 'Launch day', + 'body' => 'We just launched!', + 'status' => 'scheduled', + 'scheduled_at' => '2026-08-01 09:00:00', + ]); + + $fresh = $post->fresh(); + + expect($fresh) + ->social_account_id->toBe($account->id) + ->title->toBe('Launch day') + ->status->toBe('scheduled') + ->scheduled_at->not->toBeNull(); +}); + +test('a post belongs to a site', function () { + $site = Site::factory()->create(); + $post = Post::factory()->create(['site_id' => $site->id]); + + expect($post->site)->toBeInstanceOf(Site::class); + expect($post->site->id)->toBe($site->id); +}); + +test('a post can belong to a social account', function () { + $account = SocialAccount::factory()->create(); + $post = Post::factory()->create(['social_account_id' => $account->id]); + + expect($post->socialAccount)->toBeInstanceOf(SocialAccount::class); + expect($post->socialAccount->id)->toBe($account->id); +}); + +test('deleting a site cascades and deletes its posts', function () { + $site = Site::factory()->create(); + $post = Post::factory()->create(['site_id' => $site->id]); + + $site->delete(); + + expect(Post::find($post->id))->toBeNull(); +}); + +test('deleting a social account nulls out the post link but keeps the post', function () { + $account = SocialAccount::factory()->create(); + $post = Post::factory()->create(['social_account_id' => $account->id]); + + $account->delete(); + + expect($post->fresh()) + ->not->toBeNull() + ->social_account_id->toBeNull(); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `docker compose exec laravel.test php artisan test tests/Feature/PostModelTest.php` +Expected: FAIL — `Post` class not found, or `posts` table doesn't exist. + +- [ ] **Step 5: Create the Post model** + +Create `app/Models/Post.php`: + +```php + 'datetime', + ]; + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } +} +``` + +- [ ] **Step 6: Create the factory** + +Create `database/factories/PostFactory.php`: + +```php + Site::factory(), + 'social_account_id' => null, + 'type' => fake()->randomElement(Post::TYPES), + 'title' => fake()->optional()->sentence(), + 'body' => fake()->paragraph(), + 'status' => fake()->randomElement(Post::STATUSES), + 'scheduled_at' => fake()->optional()->dateTimeBetween('now', '+1 month'), + ]; + } +} +``` + +- [ ] **Step 7: Run migrations and test to verify it passes** + +Run: `docker compose exec laravel.test php artisan migrate` +Run: `docker compose exec laravel.test php artisan test tests/Feature/PostModelTest.php` +Expected: PASS (6 tests) + +- [ ] **Step 8: Commit** + +```bash +git add database/migrations database/factories/PostFactory.php app/Models/Post.php tests/Feature/PostModelTest.php +git commit -m "feat: add Post model, migration, and factory" +``` + +--- + +## Task 2: Posts Livewire CRUD + +**Files:** +- Create: `app/Livewire/Posts/Index.php` +- Create: `resources/views/livewire/posts/index.blade.php` +- Modify: `resources/views/components/layouts/app.blade.php` +- Modify: `routes/web.php` +- Test: `tests/Feature/Livewire/PostsTest.php` + +**Interfaces:** +- Consumes: `App\Models\Post` (Task 1, incl. `TYPES`/`STATUSES` constants), `App\Models\Site`, `App\Models\SocialAccount`, `components.layouts.app` layout. +- Produces: route named `posts.index` at `/posts`; Livewire component `App\Livewire\Posts\Index` with public properties `$siteId`, `$socialAccountId`, `$type`, `$title`, `$body`, `$status`, `$scheduledAt`, `$editingId`, `$showForm`, and methods `create()`, `edit(int $id)`, `save()`, `delete(int $id)`, `resetForm()`. + +- [ ] **Step 1: Write the failing feature test** + +Create `tests/Feature/Livewire/PostsTest.php`: + +```php +create(['name' => 'My Blog']); + Post::factory()->create(['site_id' => $site->id, 'title' => 'Hello World Post']); + + $this->get(route('posts.index')) + ->assertOk() + ->assertSee('Hello World Post') + ->assertSee('My Blog'); +}); + +test('a new post can be created with required fields only', function () { + $site = Site::factory()->create(); + + Livewire::test(Index::class) + ->set('siteId', $site->id) + ->set('type', 'blog') + ->set('body', 'New post body') + ->call('save'); + + $post = Post::where('body', 'New post body')->first(); + expect($post)->not->toBeNull(); + expect($post->site_id)->toBe($site->id); + expect($post->status)->toBe('draft'); +}); + +test('a new post can be created with an account, title, status, and schedule', function () { + $site = Site::factory()->create(); + $account = SocialAccount::factory()->create(); + + Livewire::test(Index::class) + ->set('siteId', $site->id) + ->set('socialAccountId', $account->id) + ->set('type', 'twitter') + ->set('title', 'Launch') + ->set('body', 'We launched') + ->set('status', 'scheduled') + ->set('scheduledAt', '2026-08-01T09:00') + ->call('save'); + + $post = Post::where('title', 'Launch')->first(); + expect($post->social_account_id)->toBe($account->id); + expect($post->status)->toBe('scheduled'); + expect($post->scheduled_at)->not->toBeNull(); +}); + +test('site, type, and body are required to create a post', function () { + Livewire::test(Index::class) + ->set('siteId', '') + ->set('type', '') + ->set('body', '') + ->call('save') + ->assertHasErrors(['siteId', 'type', 'body']); +}); + +test('an existing post can be edited', function () { + $site = Site::factory()->create(); + $post = Post::factory()->create(['site_id' => $site->id, 'title' => 'Old Title']); + + Livewire::test(Index::class) + ->call('edit', $post->id) + ->set('title', 'New Title') + ->call('save'); + + expect($post->fresh()->title)->toBe('New Title'); +}); + +test('a post can be deleted', function () { + $post = Post::factory()->create(); + + Livewire::test(Index::class)->call('delete', $post->id); + + expect(Post::find($post->id))->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec laravel.test php artisan test tests/Feature/Livewire/PostsTest.php` +Expected: FAIL — route `posts.index` not defined / component class not found. + +- [ ] **Step 3: Create the Livewire component** + +Run: `docker compose exec laravel.test php artisan make:livewire Posts/Index --class --no-interaction` + +(This project uses Livewire 4.3.3, where `make:livewire` defaults to a single-file component unless `--class` is passed. Use `--class` to get separate `app/Livewire/Posts/Index.php` and `resources/views/livewire/posts/index.blade.php` files, matching the existing Sites/SocialAccounts pattern.) + +Replace `app/Livewire/Posts/Index.php` with: + +```php + Post::with(['site', 'socialAccount'])->orderByDesc('created_at')->get(), + 'sites' => Site::orderBy('name')->get(), + 'accounts' => SocialAccount::orderBy('username')->get(), + 'types' => Post::TYPES, + 'statuses' => Post::STATUSES, + ]); + } + + public function create(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function edit(int $id): void + { + $post = Post::findOrFail($id); + $this->editingId = $post->id; + $this->siteId = (string) $post->site_id; + $this->socialAccountId = $post->social_account_id ? (string) $post->social_account_id : ''; + $this->type = $post->type; + $this->title = (string) $post->title; + $this->body = $post->body; + $this->status = $post->status; + $this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? ''; + $this->showForm = true; + } + + public function save(): void + { + $validated = $this->validate([ + 'siteId' => ['required', 'integer', 'exists:sites,id'], + 'socialAccountId' => ['nullable', 'integer', 'exists:social_accounts,id'], + 'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)], + 'title' => ['nullable', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)], + 'scheduledAt' => ['nullable', 'date'], + ]); + + Post::updateOrCreate(['id' => $this->editingId], [ + 'site_id' => $validated['siteId'], + 'social_account_id' => $validated['socialAccountId'] ?: null, + 'type' => $validated['type'], + 'title' => $validated['title'] ?: null, + 'body' => $validated['body'], + 'status' => $validated['status'], + 'scheduled_at' => $validated['scheduledAt'] ?: null, + ]); + + $this->resetForm(); + $this->showForm = false; + } + + public function delete(int $id): void + { + Post::findOrFail($id)->delete(); + } + + public function resetForm(): void + { + $this->editingId = null; + $this->siteId = ''; + $this->socialAccountId = ''; + $this->type = ''; + $this->title = ''; + $this->body = ''; + $this->status = 'draft'; + $this->scheduledAt = ''; + $this->resetValidation(); + } +} +``` + +- [ ] **Step 4: Create the Blade view** + +Create `resources/views/livewire/posts/index.blade.php`: + +```blade +
+
+

Posts

+ +
+ + + + + + + + + + + + + + + @foreach ($posts as $post) + + + + + + + + + + @endforeach + +
SiteTypeTitleStatusScheduledAccount
{{ $post->site->name }}{{ ucfirst($post->type) }}{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}{{ ucfirst($post->status) }}{{ $post->scheduled_at?->format('Y-m-d H:i') ?? '—' }}{{ $post->socialAccount?->username ?? '—' }} + + +
+ + @if ($showForm) +
+
+

{{ $editingId ? 'Edit Post' : 'Add Post' }}

+ +
+ + + @error('siteId') {{ $message }} @enderror +
+ +
+ + + @error('socialAccountId') {{ $message }} @enderror +
+ +
+ + + @error('type') {{ $message }} @enderror +
+ +
+ + + @error('title') {{ $message }} @enderror +
+ +
+ + + @error('body') {{ $message }} @enderror +
+ +
+ + + @error('status') {{ $message }} @enderror +
+ +
+ + + @error('scheduledAt') {{ $message }} @enderror +
+ +
+ + +
+
+
+ @endif +
+``` + +- [ ] **Step 5: Add the nav link to the shared layout** + +In `resources/views/components/layouts/app.blade.php`, change: + +```blade + +``` + +to: + +```blade + +``` + +- [ ] **Step 6: Register the route** + +In `routes/web.php`, change: + +```php +name('sites.index'); +Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index'); +``` + +to: + +```php +name('sites.index'); +Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index'); +Route::get('/posts', PostsIndex::class)->name('posts.index'); +``` + +- [ ] **Step 7: Run test to verify it passes** + +Run: `docker compose exec laravel.test php artisan test tests/Feature/Livewire/PostsTest.php` +Expected: PASS (6 tests) + +- [ ] **Step 8: Run the full test suite to confirm no regressions in Sites/SocialAccounts pages (their nav now includes the new Posts link)** + +Run: `docker compose exec laravel.test php artisan test` +Expected: PASS — all existing tests plus the 6 new ones remain green. + +- [ ] **Step 9: Commit** + +```bash +git add app/Livewire/Posts resources/views/livewire/posts resources/views/components/layouts/app.blade.php routes/web.php tests/Feature/Livewire/PostsTest.php +git commit -m "feat: add Posts Livewire CRUD page" +``` + +--- + +## Task 3: Posts API (full CRUD) + +**Files:** +- Create: `app/Http/Controllers/Api/PostController.php` +- Create: `app/Http/Resources/PostResource.php` +- Create: `app/Http/Requests/StorePostRequest.php` +- Create: `app/Http/Requests/UpdatePostRequest.php` +- Modify: `routes/api.php` +- Test: `tests/Feature/Api/PostsApiTest.php` + +**Interfaces:** +- Consumes: `App\Models\Post` (Task 1, incl. `TYPES`/`STATUSES` constants), `App\Models\Site`, `App\Models\SocialAccount`. +- Produces: `GET /api/posts` (list; filters `?status=`, `?site_id=`, `?social_account_id=`, `?due=1`), `GET /api/posts/{post}` (show), `POST /api/posts` (create), `PUT/PATCH /api/posts/{post}` (update), `DELETE /api/posts/{post}` (delete). JSON shape: `{id, site_id, social_account_id, type, title, body, status, scheduled_at, created_at, updated_at}`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/Feature/Api/PostsApiTest.php`: + +```php +count(3)->create(); + + $response = $this->getJson('/api/posts'); + + $response->assertOk()->assertJsonCount(3, 'data'); +}); + +test('it filters posts by status', function () { + Post::factory()->create(['status' => 'draft']); + Post::factory()->create(['status' => 'published']); + + $response = $this->getJson('/api/posts?status=published'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.status', 'published'); +}); + +test('it filters posts by site_id', function () { + $site = Site::factory()->create(); + Post::factory()->create(['site_id' => $site->id]); + Post::factory()->create(); + + $response = $this->getJson("/api/posts?site_id={$site->id}"); + + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +test('it filters posts by social_account_id', function () { + $account = SocialAccount::factory()->create(); + Post::factory()->create(['social_account_id' => $account->id]); + Post::factory()->create(['social_account_id' => null]); + + $response = $this->getJson("/api/posts?social_account_id={$account->id}"); + + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +test('it filters posts that are due for publishing', function () { + Post::factory()->create(['status' => 'scheduled', 'scheduled_at' => now()->subHour()]); + Post::factory()->create(['status' => 'scheduled', 'scheduled_at' => now()->addDay()]); + Post::factory()->create(['status' => 'draft', 'scheduled_at' => now()->subHour()]); + + $response = $this->getJson('/api/posts?due=1'); + + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +test('it shows a single post', function () { + $post = Post::factory()->create(['title' => 'Solo Post']); + + $response = $this->getJson("/api/posts/{$post->id}"); + + $response->assertOk()->assertJsonPath('data.title', 'Solo Post'); +}); + +test('it returns 404 for a missing post', function () { + $this->getJson('/api/posts/999')->assertNotFound(); +}); + +test('it creates a post with required fields only', function () { + $site = Site::factory()->create(); + + $response = $this->postJson('/api/posts', [ + 'site_id' => $site->id, + 'type' => 'blog', + 'body' => 'New content', + ]); + + $response->assertCreated() + ->assertJsonPath('data.site_id', $site->id) + ->assertJsonPath('data.type', 'blog') + ->assertJsonPath('data.body', 'New content') + ->assertJsonPath('data.status', 'draft'); + + expect(Post::where('body', 'New content')->exists())->toBeTrue(); +}); + +test('it rejects creating a post without a valid site_id', function () { + $response = $this->postJson('/api/posts', [ + 'type' => 'blog', + 'body' => 'New content', + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors(['site_id']); +}); + +test('it rejects creating a post without a valid type', function () { + $site = Site::factory()->create(); + + $response = $this->postJson('/api/posts', [ + 'site_id' => $site->id, + 'type' => 'not-a-real-type', + 'body' => 'New content', + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors(['type']); +}); + +test('it rejects creating a post without a body', function () { + $site = Site::factory()->create(); + + $response = $this->postJson('/api/posts', [ + 'site_id' => $site->id, + 'type' => 'blog', + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors(['body']); +}); + +test('it updates a post status', function () { + $post = Post::factory()->create(['status' => 'scheduled']); + + $response = $this->patchJson("/api/posts/{$post->id}", [ + 'status' => 'published', + ]); + + $response->assertOk()->assertJsonPath('data.status', 'published'); + expect($post->fresh()->status)->toBe('published'); +}); + +test('it deletes a post', function () { + $post = Post::factory()->create(); + + $response = $this->deleteJson("/api/posts/{$post->id}"); + + $response->assertNoContent(); + expect(Post::find($post->id))->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec laravel.test php artisan test tests/Feature/Api/PostsApiTest.php` +Expected: FAIL — routes not found. + +- [ ] **Step 3: Create the Form Request classes** + +Run: `docker compose exec laravel.test php artisan make:request StorePostRequest` +Run: `docker compose exec laravel.test php artisan make:request UpdatePostRequest` + +Replace `app/Http/Requests/StorePostRequest.php` with: + +```php + ['required', 'integer', 'exists:sites,id'], + 'social_account_id' => ['nullable', 'integer', 'exists:social_accounts,id'], + 'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)], + 'title' => ['nullable', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], + 'scheduled_at' => ['nullable', 'date'], + ]; + } +} +``` + +Replace `app/Http/Requests/UpdatePostRequest.php` with: + +```php + ['sometimes', 'required', 'integer', 'exists:sites,id'], + 'social_account_id' => ['nullable', 'integer', 'exists:social_accounts,id'], + 'type' => ['sometimes', 'required', 'string', 'in:' . implode(',', Post::TYPES)], + 'title' => ['nullable', 'string', 'max:255'], + 'body' => ['sometimes', 'required', 'string'], + 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], + 'scheduled_at' => ['nullable', 'date'], + ]; + } +} +``` + +`authorize()` returns `true` in both — consistent with the project's no-auth-anywhere constraint (there is no user/permission system to check against). + +- [ ] **Step 4: Create the API Resource** + +Create `app/Http/Resources/PostResource.php`: + +```php + $this->id, + 'site_id' => $this->site_id, + 'social_account_id' => $this->social_account_id, + 'type' => $this->type, + 'title' => $this->title, + 'body' => $this->body, + 'status' => $this->status, + 'scheduled_at' => $this->scheduled_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} +``` + +- [ ] **Step 5: Create the controller** + +Create `app/Http/Controllers/Api/PostController.php`: + +```php +filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('site_id')) { + $query->where('site_id', $request->integer('site_id')); + } + + if ($request->filled('social_account_id')) { + $query->where('social_account_id', $request->integer('social_account_id')); + } + + if ($request->boolean('due')) { + $query->where('status', 'scheduled') + ->where('scheduled_at', '<=', now()); + } + + return PostResource::collection($query->orderByDesc('created_at')->get()); + } + + public function show(Post $post) + { + return new PostResource($post); + } + + public function store(StorePostRequest $request) + { + $post = Post::create($request->validated()); + + return (new PostResource($post))->response()->setStatusCode(201); + } + + public function update(UpdatePostRequest $request, Post $post) + { + $post->update($request->validated()); + + return new PostResource($post); + } + + public function destroy(Post $post) + { + $post->delete(); + + return response()->noContent(); + } +} +``` + +- [ ] **Step 6: Add routes to `routes/api.php`** + +Update `routes/api.php` to: + +```php +