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.
This commit is contained in:
kushal-saha 2026-04-28 12:37:50 +00:00
parent 66e858e386
commit 8e2ced8bed
26 changed files with 466 additions and 72 deletions

View File

@ -0,0 +1,16 @@
<?php
namespace App\Actions\Chats;
use App\Data\ChatResponseDto;
use App\Models\User;
class CreateChatAction
{
public function create(User $user, ?string $title = null)
{
$chat = $user->chats()->create(['title' => $title]);
return ChatResponseDto::fromModel($chat);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Actions\Chats;
use App\Enums\Chats\ChatRoles;
use App\Models\Chat;
use Illuminate\Database\Eloquent\Model;
final readonly class StoreChatMessageAction
{
public function store(Chat $chat, ChatRoles $role, string $message): Model
{
return $chat->messages()->create([
'role' => $role->value,
'content' => $message,
]);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Data;
use App\Models\Chat;
use Carbon\CarbonInterface;
final readonly class ChatResponseDto
{
public function __construct(
public string $id,
public CarbonInterface $createdAt,
public CarbonInterface $updatedAt,
public string $userId,
public ?string $title = null,
) {}
public static function fromModel(Chat $chat)
{
return new self(
id: $chat->id,
createdAt: $chat->created_at,
updatedAt: $chat->updated_at,
userId: $chat->user_id,
title: $chat->title ?? null,
);
}
}

View File

@ -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
) {}
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums\Chats;
enum ChatRoles: string
{
case USER = 'user';
case AI = 'ai';
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Chats\CreateChatAction;
use App\Http\Requests\CreateChatRequest;
use App\Http\Resources\ChatResponseResource;
use Illuminate\Http\Request;
class ChatController extends Controller
{
public function index(Request $request) {}
public function store(CreateChatRequest $request, CreateChatAction $createChatAction)
{
return new ChatResponseResource(
$createChatAction->create($request->user(), $request->input('title', null))
);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\GeneratePostRequest;
use App\Http\Resources\GeneratedPostResource;
use App\Models\Chat;
use App\Services\SocialMediaService;
class ChatMessageController extends Controller
{
public function store(GeneratePostRequest $request, Chat $chat, SocialMediaService $socialMediaService)
{
return new GeneratedPostResource(
$socialMediaService
->generatePostWithImage($request->input('prompt'), $chat)
);
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SocialMediaPostRequest;
use App\Services\SocialMediaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Middleware('auth:sanctum')]
class SocialMediaPostController extends Controller
{
/**
* Generate a social media post and a matching image from the given prompt.
*/
public function generate(
SocialMediaPostRequest $request,
SocialMediaService $socialMediaService,
): JsonResponse {
$prompt = $request->input('prompt');
$response = $socialMediaService->generatePostWithImage($prompt);
return response()->json([
'post' => $response->post,
'image_prompt' => $response->image,
]);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateChatRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => 'nullable|string|max:255',
];
}
public function authorize(): bool
{
return true;
}
}

View File

@ -4,7 +4,7 @@
use Illuminate\Foundation\Http\FormRequest;
class SocialMediaPostRequest extends FormRequest
class GeneratePostRequest extends FormRequest
{
public function rules(): array
{

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use App\Data\ChatResponseDto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
/**
* @property ChatResponseDto $resource
*/
class ChatResponseResource extends JsonApiResource
{
public function type(): string
{
return 'chats';
}
public function resolveResourceIdentifier($request): string
{
return (string) $this->resource->id;
}
public function toAttributes(Request $request): array
{
return [
'title' => $this->resource->title,
'createdAt' => $this->resource->createdAt->toIso8601String(),
'updatedAt' => $this->resource->updatedAt->toIso8601String(),
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use App\Data\SocialMediaPostResponseDto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
/**
* @property SocialMediaPostResponseDto $resource
*/
class GeneratedPostResource extends JsonApiResource
{
public function type(): string
{
return 'generated-posts';
}
public function resolveResourceIdentifier($request): string
{
return (string) $this->resource->id;
}
public function toAttributes(Request $request): array
{
return [
'post' => $this->resource->post,
'image' => $this->resource->image,
'createdAt' => $this->resource->createdAt->toIso8601String(),
];
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['user_id', 'title'])]
class Chat extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function messages(): HasMany
{
return $this->hasMany(ChatMessage::class);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['chat_id', 'role', 'content', 'user_id'])]
class ChatMessage extends Model
{
public function chat(): BelongsTo
{
return $this->belongsTo(Chat::class);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,30 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('chats', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,31 @@
<?php
use App\Models\Chat;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('chat_messages', function (Blueprint $table) {
$table->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');
}
};

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\SocialMediaPostController;
use App\Http\Controllers\ChatController;
use App\Http\Controllers\ChatMessageController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@ -8,4 +9,5 @@
return $request->user();
})->middleware('auth:sanctum');
Route::post('/social-media/generate', [SocialMediaPostController::class, 'generate']);
Route::apiResource('chats', ChatController::class);
Route::apiResource('chats.messages', ChatMessageController::class);

View File

@ -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();
});
});

View File

@ -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<NewChatResponse>(`${this.apiUrl}/chats`, {});
}
public sendMessage(id: string, message: string) {
return this.http.post<MessageResponse>(`${this.apiUrl}/chats/${id}/messages`, {
prompt: message,
});
}
}

View File

@ -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,24 +67,23 @@ 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(<string>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.';
@ -89,10 +109,10 @@ export const ChatStore = signalStore(
patchState(store, (state) => ({
messages: [...state.messages, errorMessage],
isLoading: false
isLoading: false,
}));
}
}
},
};
})
}),
);

View File

@ -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<NewChatAttributes>;
export interface MessageAttributes {
post: string;
image: string;
createdAt: string;
}
export type MessageResponse = JsonApiDocument<MessageAttributes>;
export type Message = {
id: string;
role: 'user' | 'ai';
content: string;
timestamp: Date;
};
export type ChatState = {
messages: Message[];
isLoading: boolean;
id: string | null;
};

View File

@ -20,18 +20,22 @@
Login
</a>
<a routerLink="/user/register"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-semibold rounded-lg text-white bg-gradient-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">
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
</a>
</div>
}
@else{
<div class="flex font-normal items-center space-x-3">
<div class="flex font-normal items-center space-x-6">
<button (click)="chatStore.newChat()"
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 shadow-md hover:shadow-lg transition-all duration-200 transform hover:-translate-y-0.5">
New Chat
</button>
<span class="text-sm text-gray-200 dark:text-gray-300">
{{ authStore.user()?.name }}
</span>
<button (click)="authStore.logout()"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-semibold rounded-lg text-gray-400 bg-gray-200/9 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">
class="text-sm font-semibold rounded-lg text-gray-400 transition-all duration-200 transform hover:text-red-400">
Logout
</button>
</div>

View File

@ -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);
}

View File

@ -0,0 +1,9 @@
export interface JsonApiResource<T> {
id: string;
type: string;
attributes: T;
}
export interface JsonApiDocument<T> {
data: JsonApiResource<T>;
}