# 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
Site Type Title Status Scheduled Account
{{ $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