diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php new file mode 100644 index 0000000..d59f142 --- /dev/null +++ b/app/Http/Controllers/CommentController.php @@ -0,0 +1,147 @@ +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'); + } + } + } +} diff --git a/app/Http/Controllers/RatingController.php b/app/Http/Controllers/RatingController.php new file mode 100644 index 0000000..e1c2d37 --- /dev/null +++ b/app/Http/Controllers/RatingController.php @@ -0,0 +1,151 @@ +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'); + } + } + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php index ae16986..d629b8c 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -8,7 +8,8 @@ class Comment extends Model { protected $fillable = [ 'user_id', - 'media_id', + 'commentable_id', + 'commentable_type', 'content', ]; @@ -17,8 +18,20 @@ public function user() return $this->belongsTo(User::class); } + public function commentable() + { + return $this->morphTo(); + } + + // For backward compatibility with 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); } } diff --git a/app/Models/Media.php b/app/Models/Media.php index db37198..3445d44 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; - +use App\Traits\HasRatings; +use App\Traits\HasComments; class Media extends Model { + use HasRatings, HasComments; protected $fillable = [ 'user_id', 'image_id', @@ -21,7 +23,9 @@ class Media extends Model ]; protected $appends = [ 'average_rating', - 'user_rating', + 'user_rating', + 'ratings_count', + 'comments_count' ]; public function image() { @@ -68,27 +72,4 @@ public function getIsSavedAttribute() ->where('user_id', auth()->id()) ->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); -} } diff --git a/app/Models/Music.php b/app/Models/Music.php index b372274..cf13e10 100644 --- a/app/Models/Music.php +++ b/app/Models/Music.php @@ -7,9 +7,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; // ← Add this use Illuminate\Database\Eloquent\Relations\BelongsToMany; // ← 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 { - use HasFactory; + use HasFactory, HasRatings, HasComments; protected $table = 'music'; @@ -39,7 +43,38 @@ public function tags(): BelongsToMany 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 public function getUrlAttribute() { diff --git a/app/Models/Rating.php b/app/Models/Rating.php index 699305f..864f0b9 100644 --- a/app/Models/Rating.php +++ b/app/Models/Rating.php @@ -8,17 +8,30 @@ class Rating extends Model { protected $fillable = [ 'user_id', - 'media_id', + 'rateable_id', + 'rateable_type', 'stars', ]; - public function media() + public function rateable() { - return $this->belongsTo(Media::class); + return $this->morphTo(); } public function user() { 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); + } } \ No newline at end of file diff --git a/app/Traits/HasComments.php b/app/Traits/HasComments.php new file mode 100644 index 0000000..d3acc5b --- /dev/null +++ b/app/Traits/HasComments.php @@ -0,0 +1,23 @@ +morphMany(Comment::class, 'commentable'); + } + + public function getLatestCommentsAttribute() + { + return $this->comments()->latest()->limit(5)->get(); + } + + public function getCommentsCountAttribute() + { + return $this->comments()->count(); + } +} \ No newline at end of file diff --git a/app/Traits/HasRatings.php b/app/Traits/HasRatings.php new file mode 100644 index 0000000..a38557c --- /dev/null +++ b/app/Traits/HasRatings.php @@ -0,0 +1,32 @@ +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(); + } +} \ No newline at end of file diff --git a/database/migrations/2026_05_20_061423_make_comments_polymorphic.php b/database/migrations/2026_05_20_061423_make_comments_polymorphic.php new file mode 100644 index 0000000..877f4e2 --- /dev/null +++ b/database/migrations/2026_05_20_061423_make_comments_polymorphic.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_05_20_070900_make_ratings_polymorphic.php b/database/migrations/2026_05_20_070900_make_ratings_polymorphic.php new file mode 100644 index 0000000..1fe4270 --- /dev/null +++ b/database/migrations/2026_05_20_070900_make_ratings_polymorphic.php @@ -0,0 +1,65 @@ +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']); + }); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index eeeee92..264cb51 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,6 +17,9 @@ use App\Http\Controllers\MediaController; use App\Http\Controllers\MusicCategoryController; use App\Http\Controllers\MusicPlaylistController; +use App\Http\Controllers\RatingController; +use App\Http\Controllers\CommentController; + Route::get('/test-hash', function() { $plain = 'amnk1380'; @@ -169,4 +172,23 @@ Route::delete('music/{musicId}/remove-from-playlist', [MusicController::class, 'removeFromPlaylist']); Route::post('music/update-order', [MusicController::class, 'updateOrder']); 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']); + }); }); \ No newline at end of file