refactor(posts): centralize post editing status checks with PostStatusGuard
- Replaced direct status checks in multiple controllers and actions with the PostStatusGuard utility, improving code readability and maintainability. - Updated error messages to utilize a centralized method for consistency across the application. - Removed the BrandImagePalette class, consolidating color resolution logic into the AiImageClient for better organization and type safety.
This commit is contained in:
parent
4d8452dbc8
commit
7854596579
16 changed files with 165 additions and 128 deletions
51
app/Actions/Post/PostStatusGuard.php
Normal file
51
app/Actions/Post/PostStatusGuard.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Post;
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Models\Post;
|
||||
|
||||
class PostStatusGuard
|
||||
{
|
||||
private const EDIT_BLOCKED_MESSAGE_KEY = 'posts.cannot_edit_finalized';
|
||||
|
||||
/**
|
||||
* Statuses where the post can no longer be edited.
|
||||
*
|
||||
* @var array<int, PostStatus>
|
||||
*/
|
||||
private const EDIT_BLOCKED_STATUSES = [
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
PostStatus::Failed,
|
||||
PostStatus::Publishing,
|
||||
];
|
||||
|
||||
/**
|
||||
* Statuses where the post can no longer be deleted.
|
||||
*
|
||||
* @var array<int, PostStatus>
|
||||
*/
|
||||
private const DELETE_BLOCKED_STATUSES = [
|
||||
PostStatus::Publishing,
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
];
|
||||
|
||||
public static function blocksEditing(Post $post): bool
|
||||
{
|
||||
return in_array($post->status, self::EDIT_BLOCKED_STATUSES, true);
|
||||
}
|
||||
|
||||
public static function blocksDeletion(Post $post): bool
|
||||
{
|
||||
return in_array($post->status, self::DELETE_BLOCKED_STATUSES, true);
|
||||
}
|
||||
|
||||
public static function editBlockedMessage(): string
|
||||
{
|
||||
return __(self::EDIT_BLOCKED_MESSAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,9 +20,7 @@ class UpdatePost
|
|||
*/
|
||||
public static function execute(Workspace $workspace, Post $post, array $data): array
|
||||
{
|
||||
$terminalStatuses = [PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing];
|
||||
|
||||
if (in_array($post->status, $terminalStatuses, true)) {
|
||||
if (PostStatusGuard::blocksEditing($post)) {
|
||||
return ['post' => $post, 'action' => PostAction::Finalized];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Actions\Post\CreatePost;
|
||||
use App\Actions\Post\DeletePost;
|
||||
use App\Actions\Post\PostStatusGuard;
|
||||
use App\Actions\Post\UpdatePost;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Enums\Post\Action as PostAction;
|
||||
|
|
@ -69,7 +70,7 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
|
|||
|
||||
if (data_get($result, 'action') === PostAction::Finalized) {
|
||||
return response()->json(
|
||||
['message' => 'Cannot edit a post in a terminal state.'],
|
||||
['message' => PostStatusGuard::editBlockedMessage()],
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ public function generate(GeneratePostContentRequest $request, Post $post): JsonR
|
|||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
$this->authorize('update', $post);
|
||||
|
||||
$gate = Gate::inspect('useAi', $workspace->account);
|
||||
if ($gate->denied()) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\Post\PostStatusGuard;
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Http\Requests\App\Ai\RegeneratePostMediaImageRequest;
|
||||
use App\Jobs\Ai\RegeneratePostMediaImage;
|
||||
use App\Models\Post;
|
||||
|
|
@ -22,16 +22,9 @@ public function regenerate(RegeneratePostMediaImageRequest $request, Post $post,
|
|||
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$terminalStatuses = [
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
PostStatus::Failed,
|
||||
PostStatus::Publishing,
|
||||
];
|
||||
|
||||
if (in_array($post->status, $terminalStatuses, true)) {
|
||||
if (PostStatusGuard::blocksEditing($post)) {
|
||||
return response()->json([
|
||||
'message' => __('posts.cannot_edit_finalized'),
|
||||
'message' => PostStatusGuard::editBlockedMessage(),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ public function review(ReviewPostContentRequest $request, Post $post): JsonRespo
|
|||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
$this->authorize('update', $post);
|
||||
|
||||
$gate = Gate::inspect('useAi', $workspace->account);
|
||||
if ($gate->denied()) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Actions\Post\CreatePost;
|
||||
use App\Actions\Post\DeletePost;
|
||||
use App\Actions\Post\DuplicatePost;
|
||||
use App\Actions\Post\PostStatusGuard;
|
||||
use App\Actions\Post\SyncPostPlatforms;
|
||||
use App\Actions\Post\UpdatePost;
|
||||
use App\Enums\Post\Action as PostAction;
|
||||
|
|
@ -219,7 +220,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
|
||||
$this->authorize('update', $post);
|
||||
|
||||
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed], true)) {
|
||||
if (PostStatusGuard::blocksEditing($post)) {
|
||||
return redirect()->route('app.posts.show', $post);
|
||||
}
|
||||
|
||||
|
|
@ -313,7 +314,7 @@ public function destroy(Request $request, Post $post): RedirectResponse
|
|||
|
||||
$this->authorize('delete', $post);
|
||||
|
||||
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
|
||||
if (PostStatusGuard::blocksDeletion($post)) {
|
||||
session()->flash('flash.banner', __('posts.flash.cannot_delete_published'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Mcp\Tools\Post;
|
||||
|
||||
use App\Actions\Post\PostStatusGuard;
|
||||
use App\Actions\Post\UpdatePost;
|
||||
use App\Enums\Post\Action as PostAction;
|
||||
use App\Enums\Post\Status;
|
||||
|
|
@ -48,7 +49,7 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
]);
|
||||
|
||||
if (data_get($result, 'action') === PostAction::Finalized) {
|
||||
return Response::error('Post is already published or in a terminal state.');
|
||||
return Response::error(PostStatusGuard::editBlockedMessage());
|
||||
}
|
||||
|
||||
/** @var Post $updated */
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Mcp\Tools\Post;
|
||||
|
||||
use App\Actions\Post\PostStatusGuard;
|
||||
use App\Actions\Post\UpdatePost;
|
||||
use App\Enums\Post\Action as PostAction;
|
||||
use App\Enums\Post\Status;
|
||||
|
|
@ -55,7 +56,7 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
$result = UpdatePost::execute($workspace, $post, $payload);
|
||||
|
||||
if (data_get($result, 'action') === PostAction::Finalized) {
|
||||
return Response::error('Cannot edit a post in a terminal state.');
|
||||
return Response::error(PostStatusGuard::editBlockedMessage());
|
||||
}
|
||||
|
||||
/** @var Post $updated */
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use App\Support\BrandImagePalette;
|
||||
use App\Support\HexColorName;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Ai\Image;
|
||||
use Throwable;
|
||||
|
|
@ -39,11 +39,7 @@ public function generate(
|
|||
return null;
|
||||
}
|
||||
|
||||
$palette = new BrandImagePalette(
|
||||
brandColor: $brandColor,
|
||||
backgroundColor: $backgroundColor,
|
||||
textColor: $textColor,
|
||||
);
|
||||
$palette = $this->buildPaletteContext($brandColor, $backgroundColor, $textColor);
|
||||
|
||||
$brandContext = null;
|
||||
if ($brandDescription !== null) {
|
||||
|
|
@ -59,10 +55,10 @@ public function generate(
|
|||
'style' => $style->value,
|
||||
'scene' => implode(', ', $clean),
|
||||
'language_name' => $this->languageName($language),
|
||||
'has_brand_palette' => $palette->isDefined(),
|
||||
'brand_color_name' => $palette->brandColorName,
|
||||
'background_color_name' => $palette->backgroundColorName,
|
||||
'text_color_name' => $palette->textColorName,
|
||||
'has_brand_palette' => data_get($palette, 'is_defined', false),
|
||||
'brand_color_name' => data_get($palette, 'brand_color_name'),
|
||||
'background_color_name' => data_get($palette, 'background_color_name'),
|
||||
'text_color_name' => data_get($palette, 'text_color_name'),
|
||||
'brand_context' => $brandContext,
|
||||
])->render();
|
||||
|
||||
|
|
@ -99,4 +95,38 @@ private function languageName(string $code): string
|
|||
default => 'English',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* is_defined: bool,
|
||||
* brand_color_name: ?string,
|
||||
* background_color_name: ?string,
|
||||
* text_color_name: ?string
|
||||
* }
|
||||
*/
|
||||
private function buildPaletteContext(
|
||||
?string $brandColor,
|
||||
?string $backgroundColor,
|
||||
?string $textColor,
|
||||
): array {
|
||||
$brandColorName = $this->resolveColorName($brandColor);
|
||||
$backgroundColorName = $this->resolveColorName($backgroundColor);
|
||||
$textColorName = $this->resolveColorName($textColor);
|
||||
|
||||
return [
|
||||
'is_defined' => $brandColorName !== null || $backgroundColorName !== null || $textColorName !== null,
|
||||
'brand_color_name' => $brandColorName,
|
||||
'background_color_name' => $backgroundColorName,
|
||||
'text_color_name' => $textColorName,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveColorName(?string $hex): ?string
|
||||
{
|
||||
if ($hex === null || trim($hex) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return HexColorName::approximate($hex);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Resolves workspace brand hex colours into human-readable names for
|
||||
* image-generation prompts. Models follow named colours more reliably
|
||||
* than raw hex codes.
|
||||
*/
|
||||
final readonly class BrandImagePalette
|
||||
{
|
||||
public ?string $brandColorName;
|
||||
|
||||
public ?string $backgroundColorName;
|
||||
|
||||
public ?string $textColorName;
|
||||
|
||||
public function __construct(
|
||||
?string $brandColor = null,
|
||||
?string $backgroundColor = null,
|
||||
?string $textColor = null,
|
||||
) {
|
||||
$this->brandColorName = self::resolveName($brandColor);
|
||||
$this->backgroundColorName = self::resolveName($backgroundColor);
|
||||
$this->textColorName = self::resolveName($textColor);
|
||||
}
|
||||
|
||||
public function isDefined(): bool
|
||||
{
|
||||
return $this->brandColorName !== null
|
||||
|| $this->backgroundColorName !== null
|
||||
|| $this->textColorName !== null;
|
||||
}
|
||||
|
||||
private static function resolveName(?string $hex): ?string
|
||||
{
|
||||
if ($hex === null || trim($hex) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return HexColorName::approximate($hex);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,14 +22,14 @@
|
|||
});
|
||||
|
||||
test('endpoint requires authentication', function () {
|
||||
$this->postJson("/posts/{$this->post->id}/ai/generate", ['prompt' => 'hi'])
|
||||
$this->postJson(route('app.posts.ai.generate', $this->post), ['prompt' => 'hi'])
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('endpoint validates prompt is required', function () {
|
||||
Bus::fake();
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/generate", [])
|
||||
->postJson(route('app.posts.ai.generate', $this->post), [])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJsonValidationErrors(['prompt']);
|
||||
});
|
||||
|
|
@ -40,15 +40,15 @@
|
|||
$foreignPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$foreignPost->id}/ai/generate", ['prompt' => 'hi'])
|
||||
->assertStatus(Response::HTTP_FORBIDDEN);
|
||||
->postJson(route('app.posts.ai.generate', $foreignPost), ['prompt' => 'hi'])
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('endpoint dispatches StreamPostContent and returns generation id and channel', function () {
|
||||
Bus::fake();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/generate", [
|
||||
->postJson(route('app.posts.ai.generate', $this->post), [
|
||||
'prompt' => 'Write a post about Mondays',
|
||||
'current_content' => 'Old content',
|
||||
])
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@
|
|||
});
|
||||
|
||||
test('endpoint requires authentication', function () {
|
||||
$this->postJson("/posts/{$this->post->id}/ai/review", ['content' => 'hi'])
|
||||
$this->postJson(route('app.posts.ai.review', $this->post), ['content' => 'hi'])
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('endpoint validates content required', function () {
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/review", [])
|
||||
->postJson(route('app.posts.ai.review', $this->post), [])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJsonValidationErrors(['content']);
|
||||
});
|
||||
|
|
@ -37,8 +37,8 @@
|
|||
$foreignPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$foreignPost->id}/ai/review", ['content' => 'hi'])
|
||||
->assertStatus(Response::HTTP_FORBIDDEN);
|
||||
->postJson(route('app.posts.ai.review', $foreignPost), ['content' => 'hi'])
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('endpoint returns suggestions array', function () {
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/review", ['content' => 'i was here'])
|
||||
->postJson(route('app.posts.ai.review', $this->post), ['content' => 'i was here'])
|
||||
->assertOk();
|
||||
|
||||
expect($response->json('suggestions'))->toBeArray()->toHaveCount(1);
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdatePostTool::class, ['post_id' => $post->id, 'content' => 'x']);
|
||||
|
||||
$response->assertHasErrors(['Cannot edit a post in a terminal state.']);
|
||||
$response->assertHasErrors([__('posts.cannot_edit_finalized')]);
|
||||
})->with([
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
|
|
@ -277,7 +277,7 @@
|
|||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(PublishPostTool::class, ['post_id' => $post->id]);
|
||||
|
||||
$response->assertHasErrors(['Post is already published or in a terminal state.']);
|
||||
$response->assertHasErrors([__('posts.cannot_edit_finalized')]);
|
||||
})->with([
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
|
|
|
|||
47
tests/Unit/Actions/Post/PostStatusGuardTest.php
Normal file
47
tests/Unit/Actions/Post/PostStatusGuardTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Post\PostStatusGuard;
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Models\Post;
|
||||
|
||||
test('blocks editing for terminal statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusGuard::blocksEditing($post))->toBeTrue();
|
||||
})->with([
|
||||
PostStatus::Publishing,
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
PostStatus::Failed,
|
||||
]);
|
||||
|
||||
test('allows editing for non terminal statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusGuard::blocksEditing($post))->toBeFalse();
|
||||
})->with([
|
||||
PostStatus::Draft,
|
||||
PostStatus::Scheduled,
|
||||
]);
|
||||
|
||||
test('blocks deletion for published statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusGuard::blocksDeletion($post))->toBeTrue();
|
||||
})->with([
|
||||
PostStatus::Publishing,
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
]);
|
||||
|
||||
test('allows deletion for draft, scheduled and failed statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusGuard::blocksDeletion($post))->toBeFalse();
|
||||
})->with([
|
||||
PostStatus::Draft,
|
||||
PostStatus::Scheduled,
|
||||
PostStatus::Failed,
|
||||
]);
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\BrandImagePalette;
|
||||
|
||||
test('isDefined is false when all colours are missing', function () {
|
||||
$palette = new BrandImagePalette;
|
||||
|
||||
expect($palette->isDefined())->toBeFalse();
|
||||
});
|
||||
|
||||
test('resolves all three workspace colours to approximate names', function () {
|
||||
$palette = new BrandImagePalette(
|
||||
brandColor: '#facc15',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#0f172a',
|
||||
);
|
||||
|
||||
expect($palette->isDefined())->toBeTrue()
|
||||
->and($palette->brandColorName)->toBe('golden yellow')
|
||||
->and($palette->backgroundColorName)->toBe('off-white')
|
||||
->and($palette->textColorName)->toContain('blue');
|
||||
});
|
||||
|
||||
test('ignores malformed hex values', function () {
|
||||
$palette = new BrandImagePalette(
|
||||
brandColor: 'not-a-hex',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '',
|
||||
);
|
||||
|
||||
expect($palette->brandColorName)->toBeNull()
|
||||
->and($palette->backgroundColorName)->toBe('off-white')
|
||||
->and($palette->textColorName)->toBeNull()
|
||||
->and($palette->isDefined())->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in a new issue