feat: add full CRUD Posts API
Adds JSON API for Posts (index/show/store/update/destroy) so an AI
agent can draft posts, read what's due for publishing, and report
back publish/fail status. Unlike the existing read-only Sites/Accounts
APIs, this is full CRUD since the app itself never publishes anything.
- PostController with status/site_id/social_account_id/due filters
- StorePostRequest/UpdatePostRequest validate against Post::TYPES/STATUSES
- PostResource shapes the JSON response
- Route::apiResource('posts', ...) registered with ->names('api.posts')
to avoid colliding with the existing web posts.index route name
- store() calls ->fresh() after create() so the response reflects the
DB-level default status ('draft') instead of a stale in-memory null
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
be8e6e997f
commit
e0b8f2f924
@@ -0,0 +1,63 @@
|
|||||||
|
<?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())->fresh();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\PostController;
|
||||||
use App\Http\Controllers\Api\SiteController;
|
use App\Http\Controllers\Api\SiteController;
|
||||||
use App\Http\Controllers\Api\SocialAccountController;
|
use App\Http\Controllers\Api\SocialAccountController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -9,3 +10,5 @@ Route::get('/sites/{site}', [SiteController::class, 'show']);
|
|||||||
|
|
||||||
Route::get('/accounts', [SocialAccountController::class, 'index']);
|
Route::get('/accounts', [SocialAccountController::class, 'index']);
|
||||||
Route::get('/accounts/{socialAccount}', [SocialAccountController::class, 'show']);
|
Route::get('/accounts/{socialAccount}', [SocialAccountController::class, 'show']);
|
||||||
|
|
||||||
|
Route::apiResource('posts', PostController::class)->names('api.posts');
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user