From 8e2ced8bed6ca399d06c8b8a0df306164ef37319 Mon Sep 17 00:00:00 2001 From: kushal-saha Date: Tue, 28 Apr 2026 12:37:50 +0000 Subject: [PATCH] Implement new JSON:API Resource for the api responses. Implement Authentication States, show logout options in auth store and services. Make DB and controllers to make messages grouped by chats. Refactor backend code to use DTO. --- .../app/Actions/Chats/CreateChatAction.php | 16 ++++ .../Actions/Chats/StoreChatMessageAction.php | 18 +++++ backend/app/Data/ChatResponseDto.php | 28 +++++++ .../app/Data/SocialMediaPostResponseDto.php | 4 + backend/app/Enums/Chats/ChatRoles.php | 9 +++ .../app/Http/Controllers/ChatController.php | 20 +++++ .../Controllers/ChatMessageController.php | 19 +++++ .../Controllers/SocialMediaPostController.php | 28 ------- .../app/Http/Requests/CreateChatRequest.php | 20 +++++ ...ostRequest.php => GeneratePostRequest.php} | 2 +- .../Http/Resources/ChatResponseResource.php | 32 ++++++++ .../Http/Resources/GeneratedPostResource.php | 32 ++++++++ backend/app/Models/Chat.php | 22 +++++ backend/app/Models/ChatMessage.php | 16 ++++ backend/app/Models/User.php | 6 ++ backend/app/Services/SocialMediaService.php | 25 ++++-- .../2026_04_28_055031_create_chats_table.php | 30 +++++++ ...4_28_055044_create_chat_messages_table.php | 31 +++++++ backend/routes/api.php | 6 +- frontend/src/app/chat/chat-service.spec.ts | 16 ++++ frontend/src/app/chat/chat-service.ts | 22 +++++ frontend/src/app/chat/chat.store.ts | 80 ++++++++++++------- frontend/src/app/chat/chat.types.ts | 30 +++++++ .../src/app/core/layout/header/header.html | 10 ++- frontend/src/app/core/layout/header/header.ts | 7 +- frontend/src/app/core/types/api.ts | 9 +++ 26 files changed, 466 insertions(+), 72 deletions(-) create mode 100644 backend/app/Actions/Chats/CreateChatAction.php create mode 100644 backend/app/Actions/Chats/StoreChatMessageAction.php create mode 100644 backend/app/Data/ChatResponseDto.php create mode 100644 backend/app/Enums/Chats/ChatRoles.php create mode 100644 backend/app/Http/Controllers/ChatController.php create mode 100644 backend/app/Http/Controllers/ChatMessageController.php delete mode 100644 backend/app/Http/Controllers/SocialMediaPostController.php create mode 100644 backend/app/Http/Requests/CreateChatRequest.php rename backend/app/Http/Requests/{SocialMediaPostRequest.php => GeneratePostRequest.php} (93%) create mode 100644 backend/app/Http/Resources/ChatResponseResource.php create mode 100644 backend/app/Http/Resources/GeneratedPostResource.php create mode 100644 backend/app/Models/Chat.php create mode 100644 backend/app/Models/ChatMessage.php create mode 100644 backend/database/migrations/2026_04_28_055031_create_chats_table.php create mode 100644 backend/database/migrations/2026_04_28_055044_create_chat_messages_table.php create mode 100644 frontend/src/app/chat/chat-service.spec.ts create mode 100644 frontend/src/app/chat/chat-service.ts create mode 100644 frontend/src/app/chat/chat.types.ts create mode 100644 frontend/src/app/core/types/api.ts diff --git a/backend/app/Actions/Chats/CreateChatAction.php b/backend/app/Actions/Chats/CreateChatAction.php new file mode 100644 index 0000000..a158d5e --- /dev/null +++ b/backend/app/Actions/Chats/CreateChatAction.php @@ -0,0 +1,16 @@ +chats()->create(['title' => $title]); + + return ChatResponseDto::fromModel($chat); + } +} diff --git a/backend/app/Actions/Chats/StoreChatMessageAction.php b/backend/app/Actions/Chats/StoreChatMessageAction.php new file mode 100644 index 0000000..53e8a89 --- /dev/null +++ b/backend/app/Actions/Chats/StoreChatMessageAction.php @@ -0,0 +1,18 @@ +messages()->create([ + 'role' => $role->value, + 'content' => $message, + ]); + } +} diff --git a/backend/app/Data/ChatResponseDto.php b/backend/app/Data/ChatResponseDto.php new file mode 100644 index 0000000..7823672 --- /dev/null +++ b/backend/app/Data/ChatResponseDto.php @@ -0,0 +1,28 @@ +id, + createdAt: $chat->created_at, + updatedAt: $chat->updated_at, + userId: $chat->user_id, + title: $chat->title ?? null, + ); + } +} diff --git a/backend/app/Data/SocialMediaPostResponseDto.php b/backend/app/Data/SocialMediaPostResponseDto.php index 952aaf2..920ef28 100644 --- a/backend/app/Data/SocialMediaPostResponseDto.php +++ b/backend/app/Data/SocialMediaPostResponseDto.php @@ -2,10 +2,14 @@ namespace App\Data; +use Carbon\CarbonInterface; + class SocialMediaPostResponseDto { public function __construct( + public string $id, public string $post, public string $image, + public CarbonInterface $createdAt ) {} } diff --git a/backend/app/Enums/Chats/ChatRoles.php b/backend/app/Enums/Chats/ChatRoles.php new file mode 100644 index 0000000..e3de5a8 --- /dev/null +++ b/backend/app/Enums/Chats/ChatRoles.php @@ -0,0 +1,9 @@ +create($request->user(), $request->input('title', null)) + ); + } +} diff --git a/backend/app/Http/Controllers/ChatMessageController.php b/backend/app/Http/Controllers/ChatMessageController.php new file mode 100644 index 0000000..ca664b0 --- /dev/null +++ b/backend/app/Http/Controllers/ChatMessageController.php @@ -0,0 +1,19 @@ +generatePostWithImage($request->input('prompt'), $chat) + ); + } +} diff --git a/backend/app/Http/Controllers/SocialMediaPostController.php b/backend/app/Http/Controllers/SocialMediaPostController.php deleted file mode 100644 index aa708a6..0000000 --- a/backend/app/Http/Controllers/SocialMediaPostController.php +++ /dev/null @@ -1,28 +0,0 @@ -input('prompt'); - $response = $socialMediaService->generatePostWithImage($prompt); - - return response()->json([ - 'post' => $response->post, - 'image_prompt' => $response->image, - ]); - } -} diff --git a/backend/app/Http/Requests/CreateChatRequest.php b/backend/app/Http/Requests/CreateChatRequest.php new file mode 100644 index 0000000..a5e07cf --- /dev/null +++ b/backend/app/Http/Requests/CreateChatRequest.php @@ -0,0 +1,20 @@ + 'nullable|string|max:255', + ]; + } + + public function authorize(): bool + { + return true; + } +} diff --git a/backend/app/Http/Requests/SocialMediaPostRequest.php b/backend/app/Http/Requests/GeneratePostRequest.php similarity index 93% rename from backend/app/Http/Requests/SocialMediaPostRequest.php rename to backend/app/Http/Requests/GeneratePostRequest.php index 5aa215f..0ba6d8b 100644 --- a/backend/app/Http/Requests/SocialMediaPostRequest.php +++ b/backend/app/Http/Requests/GeneratePostRequest.php @@ -4,7 +4,7 @@ use Illuminate\Foundation\Http\FormRequest; -class SocialMediaPostRequest extends FormRequest +class GeneratePostRequest extends FormRequest { public function rules(): array { diff --git a/backend/app/Http/Resources/ChatResponseResource.php b/backend/app/Http/Resources/ChatResponseResource.php new file mode 100644 index 0000000..3b3e2d3 --- /dev/null +++ b/backend/app/Http/Resources/ChatResponseResource.php @@ -0,0 +1,32 @@ +resource->id; + } + + public function toAttributes(Request $request): array + { + return [ + 'title' => $this->resource->title, + 'createdAt' => $this->resource->createdAt->toIso8601String(), + 'updatedAt' => $this->resource->updatedAt->toIso8601String(), + ]; + } +} diff --git a/backend/app/Http/Resources/GeneratedPostResource.php b/backend/app/Http/Resources/GeneratedPostResource.php new file mode 100644 index 0000000..d9f7b45 --- /dev/null +++ b/backend/app/Http/Resources/GeneratedPostResource.php @@ -0,0 +1,32 @@ +resource->id; + } + + public function toAttributes(Request $request): array + { + return [ + 'post' => $this->resource->post, + 'image' => $this->resource->image, + 'createdAt' => $this->resource->createdAt->toIso8601String(), + ]; + } +} diff --git a/backend/app/Models/Chat.php b/backend/app/Models/Chat.php new file mode 100644 index 0000000..cd344b1 --- /dev/null +++ b/backend/app/Models/Chat.php @@ -0,0 +1,22 @@ +belongsTo(User::class); + } + + public function messages(): HasMany + { + return $this->hasMany(ChatMessage::class); + } +} diff --git a/backend/app/Models/ChatMessage.php b/backend/app/Models/ChatMessage.php new file mode 100644 index 0000000..626a239 --- /dev/null +++ b/backend/app/Models/ChatMessage.php @@ -0,0 +1,16 @@ +belongsTo(Chat::class); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 4e71ebe..2d0857b 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -30,4 +31,9 @@ protected function casts(): array 'password' => 'hashed', ]; } + + public function chats(): HasMany + { + return $this->hasMany(Chat::class); + } } diff --git a/backend/app/Services/SocialMediaService.php b/backend/app/Services/SocialMediaService.php index 3d01943..8149fc3 100644 --- a/backend/app/Services/SocialMediaService.php +++ b/backend/app/Services/SocialMediaService.php @@ -2,25 +2,40 @@ namespace App\Services; +use App\Actions\Chats\StoreChatMessageAction; use App\Ai\Agents\ContentWriterAgent; use App\Ai\Agents\CreativeDirectorAgent; use App\Data\SocialMediaPostResponseDto; +use App\Enums\Chats\ChatRoles; +use App\Models\Chat; +use App\Models\ChatMessage; -class SocialMediaService +readonly class SocialMediaService { public function __construct( - private readonly ContentWriterAgent $contentWriterAgent, - private readonly CreativeDirectorAgent $creativeDirectorAgent, + private ContentWriterAgent $contentWriterAgent, + private CreativeDirectorAgent $creativeDirectorAgent, + private StoreChatMessageAction $chatMessage, ) {} - public function generatePostWithImage(string $prompt): SocialMediaPostResponseDto + /** + * Generate a social media post with an image. + * We are not using database transactions here because we do not want to delete the user prompt + * if any of the ai agents fails. + */ + public function generatePostWithImage(string $prompt, Chat $chat): SocialMediaPostResponseDto { + $this->chatMessage->store($chat, ChatRoles::USER, $prompt); + $socialMediaResponse = $this->contentWriterAgent->prompt($prompt); $postText = $socialMediaResponse->text; + /* @var ChatMessage $aiChat */ + $aiChat = $this->chatMessage->store($chat, ChatRoles::AI, $postText); + $imagePromptResponse = $this->creativeDirectorAgent->prompt($postText); $imagePrompt = $imagePromptResponse->text; - return new SocialMediaPostResponseDto($postText, $imagePrompt); + return new SocialMediaPostResponseDto($aiChat->id, $postText, $imagePrompt, now()); } } diff --git a/backend/database/migrations/2026_04_28_055031_create_chats_table.php b/backend/database/migrations/2026_04_28_055031_create_chats_table.php new file mode 100644 index 0000000..134b217 --- /dev/null +++ b/backend/database/migrations/2026_04_28_055031_create_chats_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(User::class, 'user_id')->constrained()->cascadeOnDelete(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('chats'); + } +}; diff --git a/backend/database/migrations/2026_04_28_055044_create_chat_messages_table.php b/backend/database/migrations/2026_04_28_055044_create_chat_messages_table.php new file mode 100644 index 0000000..90ed1ea --- /dev/null +++ b/backend/database/migrations/2026_04_28_055044_create_chat_messages_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Chat::class, 'chat_id')->constrained()->cascadeOnDelete(); + $table->string('role'); + $table->text('content'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('chat_messages'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 1a3929f..f78dced 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ user(); })->middleware('auth:sanctum'); -Route::post('/social-media/generate', [SocialMediaPostController::class, 'generate']); +Route::apiResource('chats', ChatController::class); +Route::apiResource('chats.messages', ChatMessageController::class); diff --git a/frontend/src/app/chat/chat-service.spec.ts b/frontend/src/app/chat/chat-service.spec.ts new file mode 100644 index 0000000..830afa2 --- /dev/null +++ b/frontend/src/app/chat/chat-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ChatService } from './chat-service'; + +describe('ChatService', () => { + let service: ChatService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ChatService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/chat/chat-service.ts b/frontend/src/app/chat/chat-service.ts new file mode 100644 index 0000000..396a443 --- /dev/null +++ b/frontend/src/app/chat/chat-service.ts @@ -0,0 +1,22 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { API_URL } from '../core/tokens/api-urls'; +import { MessageResponse, NewChatResponse } from './chat.types'; + +@Injectable({ + providedIn: 'root', +}) +export class ChatService { + private http = inject(HttpClient); + private apiUrl = inject(API_URL); + + public newChat() { + return this.http.post(`${this.apiUrl}/chats`, {}); + } + + public sendMessage(id: string, message: string) { + return this.http.post(`${this.apiUrl}/chats/${id}/messages`, { + prompt: message, + }); + } +} diff --git a/frontend/src/app/chat/chat.store.ts b/frontend/src/app/chat/chat.store.ts index 1860b49..d9129fb 100644 --- a/frontend/src/app/chat/chat.store.ts +++ b/frontend/src/app/chat/chat.store.ts @@ -1,41 +1,62 @@ import { signalStore, withState, withMethods, patchState } from '@ngrx/signals'; - -export type Message = { - id: string; - role: 'user' | 'ai'; - content: string; - timestamp: Date; -}; - -type ChatState = { - messages: Message[]; - isLoading: boolean; -}; +import { inject } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChatService } from './chat-service'; +import { ChatState, Message } from './chat.types'; +import { lastValueFrom } from 'rxjs'; const initialState: ChatState = { messages: [ { id: 'welcome', role: 'ai', - content: "What\'s you want to post today ?", - timestamp: new Date() - } + content: "What's you want to post today?", + timestamp: new Date(), + }, ], isLoading: false, + id: null, }; -import { inject } from '@angular/core'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { lastValueFrom } from 'rxjs'; - export const ChatStore = signalStore( { providedIn: 'root' }, withState(initialState), withMethods((store) => { - const http = inject(HttpClient); + const chatService = inject(ChatService); + + const newChat = async () => { + try { + const response = await lastValueFrom(chatService.newChat()); + patchState(store, { id: response.data.id }); + return response.data.id; + } catch (error) { + console.error('Failed to create a new chat:', error); + throw error; + } + }; return { + newChat, + sendMessage: async (content: string) => { + if (!store.id()) { + try { + await newChat(); + } catch (error) { + // If chat creation fails, append an error message and stop execution + const errorMessage: Message = { + id: Date.now().toString(), + role: 'ai', + content: 'Failed to initialize a new chat session. Please try again.', + timestamp: new Date(), + }; + patchState(store, (state) => ({ + messages: [...state.messages, errorMessage], + })); + return; + } + } + // Add user message const userMessage: Message = { id: Date.now().toString(), @@ -46,31 +67,30 @@ export const ChatStore = signalStore( patchState(store, (state) => ({ messages: [...state.messages, userMessage], - isLoading: true + isLoading: true, })); - try { const response = await lastValueFrom( - http.post<{post: string, imagePrompt?: string}>('http://localhost:8000/api/social-media/generate', { prompt: content }) + chatService.sendMessage(store.id(), content), ); const aiMessage: Message = { id: (Date.now() + 1).toString(), role: 'ai', - content: response.post, - timestamp: new Date(), + content: response.data.attributes.post, + timestamp: new Date(response.data.attributes.createdAt), }; patchState(store, (state) => ({ messages: [...state.messages, aiMessage], - isLoading: false + isLoading: false, })); } catch (error: any) { let errorText = 'Sorry, I encountered an error while communicating with the server.'; if (error instanceof HttpErrorResponse && error.status === 422) { errorText = error.error?.message || 'Validation error.'; - + // Extract the first error message from the 'errors' object if available if (error.error?.errors) { const firstErrorKey = Object.keys(error.error.errors)[0]; @@ -89,10 +109,10 @@ export const ChatStore = signalStore( patchState(store, (state) => ({ messages: [...state.messages, errorMessage], - isLoading: false + isLoading: false, })); } - } + }, }; - }) + }), ); diff --git a/frontend/src/app/chat/chat.types.ts b/frontend/src/app/chat/chat.types.ts new file mode 100644 index 0000000..938959e --- /dev/null +++ b/frontend/src/app/chat/chat.types.ts @@ -0,0 +1,30 @@ +import { JsonApiDocument, JsonApiResource } from '../core/types/api'; + +export interface NewChatAttributes { + id: string; + title: string | null; + createdAt: string; + updatedAt: string; +} + +export type NewChatResponse = JsonApiDocument; + +export interface MessageAttributes { + post: string; + image: string; + createdAt: string; +} +export type MessageResponse = JsonApiDocument; + +export type Message = { + id: string; + role: 'user' | 'ai'; + content: string; + timestamp: Date; +}; + +export type ChatState = { + messages: Message[]; + isLoading: boolean; + id: string | null; +}; diff --git a/frontend/src/app/core/layout/header/header.html b/frontend/src/app/core/layout/header/header.html index 1ec8b7c..383b77f 100644 --- a/frontend/src/app/core/layout/header/header.html +++ b/frontend/src/app/core/layout/header/header.html @@ -20,18 +20,22 @@ Login + class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-semibold rounded-lg text-white bg-linear-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-0.5"> Register } @else{ -
+
+ {{ authStore.user()?.name }}
diff --git a/frontend/src/app/core/layout/header/header.ts b/frontend/src/app/core/layout/header/header.ts index eb4a806..557d802 100644 --- a/frontend/src/app/core/layout/header/header.ts +++ b/frontend/src/app/core/layout/header/header.ts @@ -1,6 +1,7 @@ -import { Component, inject} from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; -import {AuthStore} from '../../../auth/auth.store'; +import { AuthStore } from '../../../auth/auth.store'; +import { ChatStore } from '../../../chat/chat.store'; @Component({ selector: 'app-header', @@ -10,5 +11,5 @@ import {AuthStore} from '../../../auth/auth.store'; }) export class Header { protected readonly authStore = inject(AuthStore); - + protected readonly chatStore = inject(ChatStore); } diff --git a/frontend/src/app/core/types/api.ts b/frontend/src/app/core/types/api.ts new file mode 100644 index 0000000..7e12782 --- /dev/null +++ b/frontend/src/app/core/types/api.ts @@ -0,0 +1,9 @@ +export interface JsonApiResource { + id: string; + type: string; + attributes: T; +} + +export interface JsonApiDocument { + data: JsonApiResource; +}