feat: add rating and comement

This commit is contained in:
2026-05-20 12:19:10 +03:30
parent e446de79fd
commit 6f5224a5d9
11 changed files with 547 additions and 32 deletions
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CommentController extends Controller
{
/**
* Add comment to any model
*/
public function addComment(Request $request, $type, $id)
{
$request->validate([
'content' => 'required|string|max:1000',
]);
$model = $this->getModel($type, $id);
$this->checkAccess($model);
$comment = $model->comments()->create([
'user_id' => auth()->id(),
'content' => $request->content,
]);
return response()->json([
'message' => 'Comment added successfully',
'comment' => $comment->load('user'),
], 201);
}
/**
* Get all comments for a model
*/
public function getComments($type, $id)
{
$model = $this->getModel($type, $id);
$this->checkAccess($model);
$comments = $model->comments()
->with('user')
->latest()
->paginate(20);
return response()->json($comments);
}
/**
* Update comment
*/
public function updateComment(Request $request, $type, $id, $commentId)
{
$request->validate([
'content' => 'required|string|max:1000',
]);
$comment = Comment::where('id', $commentId)
->where('user_id', auth()->id())
->where('commentable_id', $id)
->where('commentable_type', $this->getModelClass($type))
->firstOrFail();
$comment->update([
'content' => $request->content,
]);
return response()->json([
'message' => 'Comment updated successfully',
'comment' => $comment->load('user'),
]);
}
/**
* Delete comment
*/
public function deleteComment($type, $id, $commentId)
{
$comment = Comment::where('id', $commentId)
->where('user_id', auth()->id())
->where('commentable_id', $id)
->where('commentable_type', $this->getModelClass($type))
->firstOrFail();
$comment->delete();
return response()->json([
'message' => 'Comment deleted successfully',
]);
}
/**
* Get most commented items
*/
public function mostCommented($type)
{
$modelClass = $this->getModelClass($type);
$userId = auth()->id();
$items = $modelClass::with(['image', 'user'])
->withCount('comments')
->where(function($query) use ($modelClass, $userId) {
if (property_exists($modelClass, 'type')) {
$query->where('type', 'public')
->orWhere(function($q) use ($userId) {
$q->where('type', 'private')
->where('user_id', $userId);
});
}
})
->orderBy('comments_count', 'desc')
->limit(10)
->get();
return response()->json($items);
}
private function getModel($type, $id)
{
$modelClass = $this->getModelClass($type);
$model = $modelClass::findOrFail($id);
return $model;
}
private function getModelClass($type)
{
$models = [
'music' => \App\Models\Music::class,
'media' => \App\Models\Media::class,
];
if (!isset($models[$type])) {
abort(404, 'Invalid model type');
}
return $models[$type];
}
private function checkAccess($model)
{
if (property_exists($model, 'type') && $model->type === 'private') {
if (auth()->id() !== $model->user_id) {
abort(403, 'You do not have access to this item');
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class RatingController extends Controller
{
/**
* Add or update rating for any model
*
* @param Request $request
* @param string $type (music, media, etc.)
* @param int $id
*/
public function rate(Request $request, $type, $id)
{
$request->validate([
'stars' => 'required|integer|min:1|max:5',
]);
$model = $this->getModel($type, $id);
$this->checkAccess($model);
$rating = $model->ratings()->updateOrCreate(
['user_id' => auth()->id()],
['stars' => $request->stars]
);
return response()->json([
'message' => 'Rating submitted successfully',
'rating' => $rating,
'average_rating' => $model->fresh()->average_rating,
'user_rating' => $rating->stars,
'total_ratings' => $model->fresh()->ratings_count,
]);
}
/**
* Get user's rating for a model
*/
public function getUserRating($type, $id)
{
$model = $this->getModel($type, $id);
$rating = $model->ratings()
->where('user_id', auth()->id())
->first();
return response()->json([
'rating' => $rating ? $rating->stars : null,
]);
}
/**
* Delete user's rating
*/
public function deleteRating($type, $id)
{
$model = $this->getModel($type, $id);
$deleted = $model->ratings()
->where('user_id', auth()->id())
->delete();
return response()->json([
'message' => $deleted ? 'Rating deleted successfully' : 'No rating found',
]);
}
/**
* Get all ratings for a model
*/
public function getRatings($type, $id)
{
$model = $this->getModel($type, $id);
$this->checkAccess($model);
$ratings = $model->ratings()
->with('user')
->latest()
->paginate(20);
return response()->json([
'average' => $model->average_rating,
'total' => $model->ratings_count,
'ratings' => $ratings,
]);
}
/**
* Get top rated items
*/
public function topRated($type)
{
$modelClass = $this->getModelClass($type);
$userId = auth()->id();
$items = $modelClass::with(['image', 'user'])
->withAvg('ratings', 'stars')
->where(function($query) use ($modelClass, $userId) {
if (method_exists($modelClass, 'isAccessible')) {
// Use model-specific access logic
} elseif (property_exists($modelClass, 'type')) {
$query->where('type', 'public')
->orWhere(function($q) use ($userId) {
$q->where('type', 'private')
->where('user_id', $userId);
});
}
})
->having('ratings_avg_stars', '>', 0)
->orderBy('ratings_avg_stars', 'desc')
->limit(10)
->get();
return response()->json($items);
}
private function getModel($type, $id)
{
$modelClass = $this->getModelClass($type);
$model = $modelClass::findOrFail($id);
return $model;
}
private function getModelClass($type)
{
$models = [
'music' => \App\Models\Music::class,
'media' => \App\Models\Media::class,
];
if (!isset($models[$type])) {
abort(404, 'Invalid model type');
}
return $models[$type];
}
private function checkAccess($model)
{
// Check if model has 'type' property (public/private)
if (property_exists($model, 'type') && $model->type === 'private') {
if (auth()->id() !== $model->user_id) {
abort(403, 'You do not have access to this item');
}
}
}
}
+15 -2
View File
@@ -8,7 +8,8 @@ class Comment extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'media_id', 'commentable_id',
'commentable_type',
'content', 'content',
]; ];
@@ -17,8 +18,20 @@ public function user()
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function commentable()
{
return $this->morphTo();
}
// For backward compatibility with Media
public function media() public function media()
{ {
return $this->belongsTo(Media::class); return $this->belongsTo(Media::class, 'commentable_id')->where('commentable_type', Media::class);
}
// For Music
public function music()
{
return $this->belongsTo(Music::class, 'commentable_id')->where('commentable_type', Music::class);
} }
} }
+5 -24
View File
@@ -3,9 +3,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\HasRatings;
use App\Traits\HasComments;
class Media extends Model class Media extends Model
{ {
use HasRatings, HasComments;
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'image_id', 'image_id',
@@ -22,6 +24,8 @@ class Media extends Model
protected $appends = [ protected $appends = [
'average_rating', 'average_rating',
'user_rating', 'user_rating',
'ratings_count',
'comments_count'
]; ];
public function image() public function image()
{ {
@@ -68,27 +72,4 @@ public function getIsSavedAttribute()
->where('user_id', auth()->id()) ->where('user_id', auth()->id())
->exists(); ->exists();
} }
public function ratings()
{
return $this->hasMany(Rating::class);
}
public function getAverageRatingAttribute()
{
return round($this->ratings()->avg('stars'), 1);
}
public function getUserRatingAttribute()
{
if (!auth()->check()) return null;
return $this->ratings()
->where('user_id', auth()->id())
->value('stars');
}
public function comments()
{
return $this->hasMany(Comment::class);
}
} }
+37 -2
View File
@@ -7,9 +7,13 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo; // ← Add this use Illuminate\Database\Eloquent\Relations\BelongsTo; // ← Add this
use Illuminate\Database\Eloquent\Relations\BelongsToMany; // ← Add this use Illuminate\Database\Eloquent\Relations\BelongsToMany; // ← Add this
use Illuminate\Database\Eloquent\Relations\HasMany; // ← Add this use Illuminate\Database\Eloquent\Relations\HasMany; // ← Add this
use Illuminate\Database\Eloquent\Relations\MorphMany; // ← Add this
use App\Traits\HasRatings;
use App\Traits\HasComments;
class Music extends Model class Music extends Model
{ {
use HasFactory; use HasFactory, HasRatings, HasComments;
protected $table = 'music'; protected $table = 'music';
@@ -39,7 +43,38 @@ public function tags(): BelongsToMany
return $this->belongsToMany(Tag::class, 'music_tags'); return $this->belongsToMany(Tag::class, 'music_tags');
} }
protected $appends = ['url', 'image_url']; protected $appends = ['url', 'image_url' , 'average_rating', 'user_rating', 'comments_count'];
// New rating and comment relationships
public function ratings(): MorphMany
{
return $this->morphMany(Rating::class, 'rateable');
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
// Accessors for ratings
public function getAverageRatingAttribute(): float
{
return round($this->ratings()->avg('stars'), 1);
}
public function getUserRatingAttribute(): ?int
{
if (!auth()->check()) return null;
return $this->ratings()
->where('user_id', auth()->id())
->value('stars');
}
public function getCommentsCountAttribute(): int
{
return $this->comments()->count();
}
// Accessor for full URL // Accessor for full URL
public function getUrlAttribute() public function getUrlAttribute()
{ {
+16 -3
View File
@@ -8,17 +8,30 @@ class Rating extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'media_id', 'rateable_id',
'rateable_type',
'stars', 'stars',
]; ];
public function media() public function rateable()
{ {
return $this->belongsTo(Media::class); return $this->morphTo();
} }
public function user() public function user()
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
// For backward compatibility with Media
public function media()
{
return $this->belongsTo(Media::class, 'rateable_id')->where('rateable_type', Media::class);
}
// For Music
public function music()
{
return $this->belongsTo(Music::class, 'rateable_id')->where('rateable_type', Music::class);
}
} }
+23
View File
@@ -0,0 +1,23 @@
<?php
// app/Traits/HasComments.php
namespace App\Traits;
use App\Models\Comment;
trait HasComments
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
public function getLatestCommentsAttribute()
{
return $this->comments()->latest()->limit(5)->get();
}
public function getCommentsCountAttribute()
{
return $this->comments()->count();
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
// app/Traits/HasRatings.php
namespace App\Traits;
use App\Models\Rating;
trait HasRatings
{
public function ratings()
{
return $this->morphMany(Rating::class, 'rateable');
}
public function getAverageRatingAttribute()
{
return round($this->ratings()->avg('stars'), 1);
}
public function getUserRatingAttribute()
{
if (!auth()->check()) return null;
return $this->ratings()
->where('user_id', auth()->id())
->value('stars');
}
public function getRatingsCountAttribute()
{
return $this->ratings()->count();
}
}
@@ -0,0 +1,33 @@
<?php
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::table('comments', function (Blueprint $table) {
$table->dropForeign(['media_id']);
$table->dropColumn('media_id');
// Add polymorphic columns
$table->morphs('commentable');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->dropMorphs('commentable');
$table->foreignId('media_id')->constrained()->onDelete('cascade');
});
}
};
@@ -0,0 +1,65 @@
<?php
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::dropIfExists('ratings');
Schema::create('ratings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
// polymorphic relation
$table->morphs('rateable');
$table->tinyInteger('stars'); // 1-5
$table->timestamps();
// one rating per user per item
$table->unique([
'user_id',
'rateable_id',
'rateable_type'
], 'ratings_user_rateable_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ratings');
// optional: rollback old structure (if you still need it)
Schema::create('ratings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('media_id')
->constrained()
->cascadeOnDelete();
$table->tinyInteger('stars');
$table->timestamps();
$table->unique(['user_id', 'media_id']);
});
}
};
+22
View File
@@ -17,6 +17,9 @@
use App\Http\Controllers\MediaController; use App\Http\Controllers\MediaController;
use App\Http\Controllers\MusicCategoryController; use App\Http\Controllers\MusicCategoryController;
use App\Http\Controllers\MusicPlaylistController; use App\Http\Controllers\MusicPlaylistController;
use App\Http\Controllers\RatingController;
use App\Http\Controllers\CommentController;
Route::get('/test-hash', function() { Route::get('/test-hash', function() {
$plain = 'amnk1380'; $plain = 'amnk1380';
@@ -169,4 +172,23 @@
Route::delete('music/{musicId}/remove-from-playlist', [MusicController::class, 'removeFromPlaylist']); Route::delete('music/{musicId}/remove-from-playlist', [MusicController::class, 'removeFromPlaylist']);
Route::post('music/update-order', [MusicController::class, 'updateOrder']); Route::post('music/update-order', [MusicController::class, 'updateOrder']);
Route::apiResource('music', MusicController::class); Route::apiResource('music', MusicController::class);
// Generic Rating Routes (works for both music and media)
Route::prefix('ratings')->group(function () {
Route::post('{type}/{id}', [RatingController::class, 'rate']);
Route::get('{type}/{id}/user', [RatingController::class, 'getUserRating']);
Route::delete('{type}/{id}', [RatingController::class, 'deleteRating']);
Route::get('{type}/{id}', [RatingController::class, 'getRatings']);
Route::get('top/{type}', [RatingController::class, 'topRated']);
});
// Generic Comment Routes (works for both music and media)
Route::prefix('comments')->group(function () {
Route::post('{type}/{id}', [CommentController::class, 'addComment']);
Route::get('{type}/{id}', [CommentController::class, 'getComments']);
Route::put('{type}/{id}/{commentId}', [CommentController::class, 'updateComment']);
Route::delete('{type}/{id}/{commentId}', [CommentController::class, 'deleteComment']);
Route::get('most/{type}', [CommentController::class, 'mostCommented']);
});
}); });