Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .cursor/rules/laravel-patterns.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,9 @@ If you hit `Illuminate\Foundation\ViteException: Unable to locate file in Vite m
## Deployment

Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/) — the fastest path to production for Laravel apps.

## AI agents (`app/Ai/Agents`)

- Do not write prompts inside agent classes (no heredocs, no long strings in `instructions()`).
- Store prompt copy in `resources/views/prompts/**/*.blade.php` and render with `view('prompts....', $vars)->render()`.
- Follow `PostContentStreamer`, `PostContentReviewer`, or `BrandAnalyzer` for structure.
2 changes: 2 additions & 0 deletions .cursor/rules/project-context.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ This project has domain-specific skills in `.claude/skills/` (e.g. `pest-testing
## Conventions

- Follow existing code conventions. Check sibling files for structure, approach and naming before creating or editing.
- In Vue `<DialogFooter>`, primary action button first in markup, then cancel/secondary (see `vue-typescript.mdc` and `CLAUDE.md`).
- AI agents: prompts live in `resources/views/prompts/`; `instructions()` uses `view(...)->render()` only — never heredocs or inline prompt strings in `app/Ai/Agents/` (see `CLAUDE.md`).
- Use descriptive names (`isRegisteredForDiscounts`, not `discount()`).
- Reuse existing components before writing new ones.
- Stick to existing directory structure. Do not create new base folders without approval.
Expand Down
15 changes: 15 additions & 0 deletions .cursor/rules/vue-typescript.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`
- Import routes from `@/routes/...` — e.g. `import { store } from '@/routes/login'`.
- Import controller actions from `@/actions/...`.

## Dialogs

In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel.

`DialogFooter` (`resources/js/components/ui/dialog/DialogFooter.vue`) uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop — the first child is the leftmost action on larger screens.

```vue
<DialogFooter>
<Button @click="submit">{{ $t('...submit') }}</Button>
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
</DialogFooter>
```

Check sibling dialogs in the same feature before inventing a new footer layout.

## Form validation

NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation.
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,17 @@ Vue components must have a single root element.

- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.

## Dialogs

- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
- Match sibling dialogs in the same feature area before inventing a new footer layout.

## AI agents (`app/Ai/Agents`)

- **Never** embed prompts in PHP (`<<<PROMPT`, heredocs, or long string literals in `instructions()`).
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator`, `prompts.post_image.regenerator`).
- In `instructions()`, return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.

## Icons (@tabler/icons-vue)

- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.
Expand Down
5 changes: 2 additions & 3 deletions app/Actions/Post/UpdatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\Workspace;
use App\Support\PostStatusRules;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
Expand All @@ -20,9 +21,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 (PostStatusRules::blocksEditing($post)) {
return ['post' => $post, 'action' => PostAction::Finalized];
}

Expand Down
64 changes: 64 additions & 0 deletions app/Ai/Agents/PostImageRegenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace App\Ai\Agents;

use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;

#[Temperature(0.25)]
class PostImageRegenerator implements Agent, HasStructuredOutput
{
use Promptable;

public function __construct(
public Workspace $workspace,
) {}

public function instructions(): string
{
return view('prompts.post_image.regenerator', [
'content_language' => $this->workspace->content_language ?: 'en',
])->render();
}

public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()
->description('Updated short title for the image (max ~120 chars).')
->required(),
'body' => $schema->string()
->description('Updated supporting text for the image (max ~240 chars).')
->required(),
'keywords' => $schema->array()
->items($schema->string())
->description('3-10 short keywords for image generation context.')
->required(),
'change_mode' => $schema->string()
->enum(['image_only', 'text_only', 'both'])
->description('Set to image_only (change visual only), text_only (change text only), or both (change visual and text).')
->required(),
];
}

public function provider(): Lab
{
return match (config('ai.default')) {
'openai' => Lab::OpenAI,
'anthropic' => Lab::Anthropic,
default => Lab::Gemini,
};
}

public function model(): string
{
return config('ai.default_text_model');
}
}
15 changes: 15 additions & 0 deletions app/Broadcasting/UserAiMediaRegenerationChannel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Broadcasting;

use App\Models\User;

class UserAiMediaRegenerationChannel
{
public function join(User $user, User $owner, string $regenerationId): bool
{
return $user->is($owner);
}
}
55 changes: 55 additions & 0 deletions app/Events/Ai/PostMediaRegenerated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace App\Events\Ai;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class PostMediaRegenerated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;

/**
* @param array<string, mixed>|null $media
*/
public function __construct(
public string $userId,
public string $regenerationId,
public string $postId,
public ?array $media = null,
public ?string $error = null,
) {}

public function broadcastAs(): string
{
return 'ai.media.regenerated';
}

public function broadcastOn(): PrivateChannel
{
return new PrivateChannel("user.{$this->userId}.ai-media.{$this->regenerationId}");
}

/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'regeneration_id' => $this->regenerationId,
'post_id' => $this->postId,
'media' => $this->media,
'error' => $this->error,
];
}

public function broadcastQueue(): string
{
return 'broadcasts';
}
}
3 changes: 2 additions & 1 deletion app/Http/Controllers/Api/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Services\Post\MediaAttacher;
use App\Support\PostStatusRules;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
Expand Down Expand Up @@ -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' => PostStatusRules::editBlockedMessage()],
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
Expand Down
4 changes: 1 addition & 3 deletions app/Http/Controllers/App/PostAiGenerateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
67 changes: 67 additions & 0 deletions app/Http/Controllers/App/PostAiRegenerateMediaController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\App;

use App\Enums\Media\Source;
use App\Http\Requests\App\Ai\RegeneratePostMediaImageRequest;
use App\Jobs\Ai\RegeneratePostMediaImage;
use App\Models\Post;
use App\Support\PostStatusRules;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class PostAiRegenerateMediaController extends Controller
{
public function regenerate(RegeneratePostMediaImageRequest $request, Post $post, string $mediaId): JsonResponse
{
$this->authorize('update', $post);

$workspace = $request->user()->currentWorkspace;

if (PostStatusRules::blocksEditing($post)) {
return response()->json([
'message' => PostStatusRules::editBlockedMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}

$gate = Gate::inspect('useAi', $workspace->account);
if ($gate->denied()) {
return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED);
}

$mediaItem = collect($post->media ?? [])
->first(fn ($item) => data_get($item, 'id') === $mediaId);

if (! is_array($mediaItem)) {
return response()->json([
'message' => __('posts.ai.image_regenerate.errors.media_not_found'),
], Response::HTTP_NOT_FOUND);
}

if (data_get($mediaItem, 'source') !== Source::Ai->value) {
return response()->json([
'message' => __('posts.ai.image_regenerate.errors.not_ai_media'),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}

$regenerationId = (string) Str::uuid();

RegeneratePostMediaImage::dispatch(
workspaceId: $workspace->id,
postId: $post->id,
userId: $request->user()->id,
mediaId: $mediaId,
regenerationId: $regenerationId,
instruction: $request->string('instruction')->toString(),
);

return response()->json([
'regeneration_id' => $regenerationId,
'channel' => "user.{$request->user()->id}.ai-media.{$regenerationId}",
], Response::HTTP_ACCEPTED);
}
}
4 changes: 1 addition & 3 deletions app/Http/Controllers/App/PostAiReviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
5 changes: 3 additions & 2 deletions app/Http/Controllers/App/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use App\Services\Post\PostMetricsFetcher;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\TikTokCreatorInfo;
use App\Support\PostStatusRules;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
Expand Down Expand Up @@ -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 (PostStatusRules::blocksEditing($post)) {
return redirect()->route('app.posts.show', $post);
}

Expand Down Expand Up @@ -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 (PostStatusRules::blocksDeletion($post)) {
session()->flash('flash.banner', __('posts.flash.cannot_delete_published'));
session()->flash('flash.bannerStyle', 'danger');

Expand Down
34 changes: 34 additions & 0 deletions app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\App\Ai;

use Illuminate\Foundation\Http\FormRequest;

class RegeneratePostMediaImageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'instruction' => ['required', 'string', 'max:1000'],
];
}

protected function prepareForValidation(): void
{
if ($this->has('instruction')) {
$this->merge([
'instruction' => trim((string) $this->input('instruction')),
]);
}
}
}
3 changes: 3 additions & 0 deletions app/Http/Requests/App/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Http\Requests\App\Post;

use App\Enums\Media\Source;
use App\Enums\Post\Status;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
Expand Down Expand Up @@ -49,6 +50,8 @@ public function rules(): array
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.size' => ['sometimes', 'nullable', 'integer'],
'media.*.meta' => ['sometimes', 'nullable', 'array'],
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
'scheduled_at' => [
'sometimes',
'nullable',
Expand Down
Loading
Loading