1092 lines
38 KiB
Markdown
1092 lines
38 KiB
Markdown
# 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 <command>` 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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
use Illuminate\Database\Migrations\Migration;
|
||
|
|
use Illuminate\Database\Schema\Blueprint;
|
||
|
|
use Illuminate\Support\Facades\Schema;
|
||
|
|
|
||
|
|
return new class extends Migration
|
||
|
|
{
|
||
|
|
public function up(): void
|
||
|
|
{
|
||
|
|
Schema::create('posts', function (Blueprint $table) {
|
||
|
|
$table->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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
use App\Models\Post;
|
||
|
|
use App\Models\Site;
|
||
|
|
use App\Models\SocialAccount;
|
||
|
|
|
||
|
|
test('a post can be created with required fields only', function () {
|
||
|
|
$site = Site::factory()->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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Models;
|
||
|
|
|
||
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
|
|
use Illuminate\Database\Eloquent\Model;
|
||
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
|
|
||
|
|
class Post extends Model
|
||
|
|
{
|
||
|
|
use HasFactory;
|
||
|
|
|
||
|
|
public const TYPES = [
|
||
|
|
'twitter', 'instagram', 'facebook', 'reddit',
|
||
|
|
'tiktok', 'linkedin', 'youtube', 'blog', 'other',
|
||
|
|
];
|
||
|
|
|
||
|
|
public const STATUSES = ['draft', 'scheduled', 'published', 'failed'];
|
||
|
|
|
||
|
|
protected $fillable = [
|
||
|
|
'site_id',
|
||
|
|
'social_account_id',
|
||
|
|
'type',
|
||
|
|
'title',
|
||
|
|
'body',
|
||
|
|
'status',
|
||
|
|
'scheduled_at',
|
||
|
|
];
|
||
|
|
|
||
|
|
protected $casts = [
|
||
|
|
'scheduled_at' => '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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace Database\Factories;
|
||
|
|
|
||
|
|
use App\Models\Post;
|
||
|
|
use App\Models\Site;
|
||
|
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||
|
|
|
||
|
|
class PostFactory extends Factory
|
||
|
|
{
|
||
|
|
protected $model = Post::class;
|
||
|
|
|
||
|
|
public function definition(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'site_id' => 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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
use App\Livewire\Posts\Index;
|
||
|
|
use App\Models\Post;
|
||
|
|
use App\Models\Site;
|
||
|
|
use App\Models\SocialAccount;
|
||
|
|
use Livewire\Livewire;
|
||
|
|
|
||
|
|
test('posts index page lists existing posts', function () {
|
||
|
|
$site = Site::factory()->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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Livewire\Posts;
|
||
|
|
|
||
|
|
use App\Models\Post;
|
||
|
|
use App\Models\Site;
|
||
|
|
use App\Models\SocialAccount;
|
||
|
|
use Livewire\Attributes\Layout;
|
||
|
|
use Livewire\Component;
|
||
|
|
|
||
|
|
#[Layout('components.layouts.app')]
|
||
|
|
class Index extends Component
|
||
|
|
{
|
||
|
|
public bool $showForm = false;
|
||
|
|
|
||
|
|
public ?int $editingId = null;
|
||
|
|
|
||
|
|
public string $siteId = '';
|
||
|
|
|
||
|
|
public string $socialAccountId = '';
|
||
|
|
|
||
|
|
public string $type = '';
|
||
|
|
|
||
|
|
public string $title = '';
|
||
|
|
|
||
|
|
public string $body = '';
|
||
|
|
|
||
|
|
public string $status = 'draft';
|
||
|
|
|
||
|
|
public string $scheduledAt = '';
|
||
|
|
|
||
|
|
public function render()
|
||
|
|
{
|
||
|
|
return view('livewire.posts.index', [
|
||
|
|
'posts' => 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
|
||
|
|
<div>
|
||
|
|
<div class="flex justify-between items-center mb-4">
|
||
|
|
<h1 class="text-xl font-semibold">Posts</h1>
|
||
|
|
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Post</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<table class="w-full bg-white border border-gray-200 rounded">
|
||
|
|
<thead>
|
||
|
|
<tr class="text-left border-b border-gray-200">
|
||
|
|
<th class="p-3">Site</th>
|
||
|
|
<th class="p-3">Type</th>
|
||
|
|
<th class="p-3">Title</th>
|
||
|
|
<th class="p-3">Status</th>
|
||
|
|
<th class="p-3">Scheduled</th>
|
||
|
|
<th class="p-3">Account</th>
|
||
|
|
<th class="p-3"></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
@foreach ($posts as $post)
|
||
|
|
<tr wire:key="post-{{ $post->id }}" class="border-b border-gray-100">
|
||
|
|
<td class="p-3">{{ $post->site->name }}</td>
|
||
|
|
<td class="p-3">{{ ucfirst($post->type) }}</td>
|
||
|
|
<td class="p-3">{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}</td>
|
||
|
|
<td class="p-3">{{ ucfirst($post->status) }}</td>
|
||
|
|
<td class="p-3">{{ $post->scheduled_at?->format('Y-m-d H:i') ?? '—' }}</td>
|
||
|
|
<td class="p-3">{{ $post->socialAccount?->username ?? '—' }}</td>
|
||
|
|
<td class="p-3 text-right space-x-2">
|
||
|
|
<button wire:click="edit({{ $post->id }})" class="text-blue-600">Edit</button>
|
||
|
|
<button wire:click="delete({{ $post->id }})" wire:confirm="Delete this post?" class="text-red-600">Delete</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
@endforeach
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
|
||
|
|
@if ($showForm)
|
||
|
|
<div class="fixed inset-0 bg-black/30 flex items-center justify-center">
|
||
|
|
<div class="bg-white p-6 rounded w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto">
|
||
|
|
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Post' : 'Add Post' }}</h2>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium">Site</label>
|
||
|
|
<select wire:model="siteId" class="w-full border rounded p-2">
|
||
|
|
<option value="">Select site</option>
|
||
|
|
@foreach ($sites as $site)
|
||
|
|
<option value="{{ $site->id }}">{{ $site->name }}</option>
|
||
|
|
@endforeach
|
||
|
|
</select>
|
||
|
|
@error('siteId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium">Social Account (optional)</label>
|
||
|
|
<select wire:model="socialAccountId" class="w-full border rounded p-2">
|
||
|
|
<option value="">None</option>
|
||
|
|
@foreach ($accounts as $account)
|
||
|
|
<option value="{{ $account->id }}">{{ $account->username }} ({{ ucfirst($account->network) }})</option>
|
||
|
|
@endforeach
|
||
|
|
</select>
|
||
|
|
@error('socialAccountId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium">Type</label>
|
||
|
|
<select wire:model="type" class="w-full border rounded p-2">
|
||
|
|
<option value="">Select type</option>
|
||
|
|
@foreach ($types as $t)
|
||
|
|
<option value="{{ $t }}">{{ ucfirst($t) }}</option>
|
||
|
|
@endforeach
|
||
|
|
</select>
|
||
|
|
@error('type') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium">Title (optional)</label>
|
||
|
|
<input type="text" wire:model="title" class="w-full border rounded p-2">
|
||
|
|
@error('title') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium">Body</label>
|
||
|
|
<textarea wire:model="body" rows="4" class="w-full border rounded p-2"></textarea>
|
||
|
|
@error('body') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium">Status</label>
|
||
|
|
<select wire:model="status" class="w-full border rounded p-2">
|
||
|
|
@foreach ($statuses as $s)
|
||
|
|
<option value="{{ $s }}">{{ ucfirst($s) }}</option>
|
||
|
|
@endforeach
|
||
|
|
</select>
|
||
|
|
@error('status') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium">Scheduled At (optional)</label>
|
||
|
|
<input type="datetime-local" wire:model="scheduledAt" class="w-full border rounded p-2">
|
||
|
|
@error('scheduledAt') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex justify-end gap-2">
|
||
|
|
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||
|
|
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
@endif
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Add the nav link to the shared layout**
|
||
|
|
|
||
|
|
In `resources/views/components/layouts/app.blade.php`, change:
|
||
|
|
|
||
|
|
```blade
|
||
|
|
<nav class="bg-white border-b border-gray-200 px-6 py-4 flex gap-6">
|
||
|
|
<a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
|
||
|
|
<a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
|
||
|
|
</nav>
|
||
|
|
```
|
||
|
|
|
||
|
|
to:
|
||
|
|
|
||
|
|
```blade
|
||
|
|
<nav class="bg-white border-b border-gray-200 px-6 py-4 flex gap-6">
|
||
|
|
<a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
|
||
|
|
<a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
|
||
|
|
<a href="{{ route('posts.index') }}" class="font-medium hover:text-blue-600">Posts</a>
|
||
|
|
</nav>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Register the route**
|
||
|
|
|
||
|
|
In `routes/web.php`, change:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
use App\Livewire\Sites\Index as SitesIndex;
|
||
|
|
use App\Livewire\SocialAccounts\Index as SocialAccountsIndex;
|
||
|
|
use Illuminate\Support\Facades\Route;
|
||
|
|
|
||
|
|
Route::get('/', function () {
|
||
|
|
return view('welcome');
|
||
|
|
});
|
||
|
|
|
||
|
|
Route::get('/sites', SitesIndex::class)->name('sites.index');
|
||
|
|
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');
|
||
|
|
```
|
||
|
|
|
||
|
|
to:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
|
||
|
|
use App\Livewire\Posts\Index as PostsIndex;
|
||
|
|
use App\Livewire\Sites\Index as SitesIndex;
|
||
|
|
use App\Livewire\SocialAccounts\Index as SocialAccountsIndex;
|
||
|
|
use Illuminate\Support\Facades\Route;
|
||
|
|
|
||
|
|
Route::get('/', function () {
|
||
|
|
return view('welcome');
|
||
|
|
});
|
||
|
|
|
||
|
|
Route::get('/sites', SitesIndex::class)->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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
use App\Models\Post;
|
||
|
|
use App\Models\Site;
|
||
|
|
use App\Models\SocialAccount;
|
||
|
|
|
||
|
|
test('it lists all posts', function () {
|
||
|
|
Post::factory()->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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Http\Requests;
|
||
|
|
|
||
|
|
use App\Models\Post;
|
||
|
|
use Illuminate\Foundation\Http\FormRequest;
|
||
|
|
|
||
|
|
class StorePostRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function authorize(): bool
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'site_id' => ['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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Http\Requests;
|
||
|
|
|
||
|
|
use App\Models\Post;
|
||
|
|
use Illuminate\Foundation\Http\FormRequest;
|
||
|
|
|
||
|
|
class UpdatePostRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function authorize(): bool
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'site_id' => ['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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Http\Resources;
|
||
|
|
|
||
|
|
use Illuminate\Http\Request;
|
||
|
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||
|
|
|
||
|
|
class PostResource extends JsonResource
|
||
|
|
{
|
||
|
|
public function toArray(Request $request): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'id' => $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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Http\Controllers\Api;
|
||
|
|
|
||
|
|
use App\Http\Controllers\Controller;
|
||
|
|
use App\Http\Requests\StorePostRequest;
|
||
|
|
use App\Http\Requests\UpdatePostRequest;
|
||
|
|
use App\Http\Resources\PostResource;
|
||
|
|
use App\Models\Post;
|
||
|
|
use Illuminate\Http\Request;
|
||
|
|
|
||
|
|
class PostController extends Controller
|
||
|
|
{
|
||
|
|
public function index(Request $request)
|
||
|
|
{
|
||
|
|
$query = Post::query();
|
||
|
|
|
||
|
|
if ($request->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
|
||
|
|
<?php
|
||
|
|
|
||
|
|
use App\Http\Controllers\Api\PostController;
|
||
|
|
use App\Http\Controllers\Api\SiteController;
|
||
|
|
use App\Http\Controllers\Api\SocialAccountController;
|
||
|
|
use Illuminate\Support\Facades\Route;
|
||
|
|
|
||
|
|
Route::get('/sites', [SiteController::class, 'index']);
|
||
|
|
Route::get('/sites/{site}', [SiteController::class, 'show']);
|
||
|
|
|
||
|
|
Route::get('/accounts', [SocialAccountController::class, 'index']);
|
||
|
|
Route::get('/accounts/{socialAccount}', [SocialAccountController::class, 'show']);
|
||
|
|
|
||
|
|
Route::apiResource('posts', PostController::class);
|
||
|
|
```
|
||
|
|
|
||
|
|
`Route::apiResource` registers all five REST actions (`index`, `store`, `show`, `update`, `destroy`) under `/posts`, matching the `{post}` route-model-binding parameter name the controller expects.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Run test to verify it passes**
|
||
|
|
|
||
|
|
Run: `docker compose exec laravel.test php artisan test tests/Feature/Api/PostsApiTest.php`
|
||
|
|
Expected: PASS (13 tests)
|
||
|
|
|
||
|
|
- [ ] **Step 8: Run the full test suite**
|
||
|
|
|
||
|
|
Run: `docker compose exec laravel.test php artisan test`
|
||
|
|
Expected: PASS — all tests across the app (Sites, SocialAccounts, Posts — UI and API) pass with no regressions.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add app/Http/Controllers/Api/PostController.php app/Http/Resources/PostResource.php app/Http/Requests/StorePostRequest.php app/Http/Requests/UpdatePostRequest.php routes/api.php tests/Feature/Api/PostsApiTest.php
|
||
|
|
git commit -m "feat: add full CRUD Posts API"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Self-Review Notes
|
||
|
|
|
||
|
|
- **Spec coverage:** Post model with required `site_id`/`type`/`body`, optional `social_account_id`/`title`/`scheduled_at`, defaulted `status` (Task 1); cascade/null-on-delete relationship behavior (Task 1, migration + tests); independent `type` vocabulary reusing `SocialAccount::NETWORKS` plus `blog` (Task 1); Livewire CRUD UI as a new top-level nav section (Task 2); full read/write API with `status`/`site_id`/`social_account_id`/`due` filters (Task 3) — all spec requirements are covered.
|
||
|
|
- **Placeholder scan:** No TBD/TODO markers; every step has concrete, complete code.
|
||
|
|
- **Type consistency:** `Post` fillable fields (`site_id`, `social_account_id`, `type`, `title`, `body`, `status`, `scheduled_at`) and the `TYPES`/`STATUSES` constants are used consistently across the model, factory, Livewire component (camelCase property names map to snake_case DB columns explicitly in `save()`/`edit()`), Form Requests, and API resource/controller. `Post::TYPES` and `Post::STATUSES` naming mirrors the existing `SocialAccount::NETWORKS`/`SocialAccount::TYPES` convention.
|