From 1db50bc69aa0fc4da6f3b7e043b5870cb331788b Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Thu, 28 May 2026 20:37:53 +0200 Subject: [PATCH] follow --- app/Broadcasting/ExpoPushChannel.php | 1 - app/Enums/FollowStatus.php | 11 +++ .../Schemas/CreditProductForm.php | 2 +- app/Http/Controllers/BillingController.php | 3 +- app/Http/Controllers/FollowController.php | 86 +++++++++++++++++++ .../MealImageAnalysisController.php | 3 +- app/Http/Controllers/MealPostController.php | 2 +- .../Controllers/WorkoutSessionController.php | 2 +- app/Http/Requests/FollowRequest.php | 28 ++++++ app/Http/Resources/FollowResource.php | 24 ++++++ .../GrantCreditsFromStripeWebhook.php | 2 +- app/Models/Follow.php | 33 +++++++ app/Models/User.php | 14 +++ app/Providers/AppServiceProvider.php | 2 +- ...2026_05_28_181009_create_follows_table.php | 34 ++++++++ lang/en/api.php | 8 ++ lang/fr/api.php | 8 ++ tests/Feature/MealPostListTest.php | 1 - 18 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 app/Enums/FollowStatus.php create mode 100644 app/Http/Controllers/FollowController.php create mode 100644 app/Http/Requests/FollowRequest.php create mode 100644 app/Http/Resources/FollowResource.php create mode 100644 app/Models/Follow.php create mode 100644 database/migrations/2026_05_28_181009_create_follows_table.php diff --git a/app/Broadcasting/ExpoPushChannel.php b/app/Broadcasting/ExpoPushChannel.php index 906f005..0f728e9 100644 --- a/app/Broadcasting/ExpoPushChannel.php +++ b/app/Broadcasting/ExpoPushChannel.php @@ -110,7 +110,6 @@ class ExpoPushChannel 'tokens_count' => $tokens->count(), ]); - return; } diff --git a/app/Enums/FollowStatus.php b/app/Enums/FollowStatus.php new file mode 100644 index 0000000..0ee7945 --- /dev/null +++ b/app/Enums/FollowStatus.php @@ -0,0 +1,11 @@ +is_active, 404); abort_unless(filled($creditProduct->stripe_price_id), 404); diff --git a/app/Http/Controllers/FollowController.php b/app/Http/Controllers/FollowController.php new file mode 100644 index 0000000..d11ea56 --- /dev/null +++ b/app/Http/Controllers/FollowController.php @@ -0,0 +1,86 @@ +user(); + + if ($authUser->id === $user->id) { + return response()->json([ + 'message' => __('api.follows.cannot_follow_self') + ], 422); + } + + // Default to Accepted as is_private doesn't exist in the DB yet + $status = FollowStatus::Accepted; + + $authUser->following()->syncWithPivotValues([$user->id], ['status' => $status], false); + + return response()->json([ + 'message' => __('api.follows.followed'), + 'status' => $status, + ]); + } + + public function unfollow(FollowRequest $request, User $user): JsonResponse + { + $request->user()->following()->detach($user->id); + + return response()->json([ + 'message' => __('api.follows.unfollowed'), + ]); + } + + public function accept(FollowRequest $request, User $user): JsonResponse + { + $updated = $request->user()->followers()->updateExistingPivot($user->id, [ + 'status' => FollowStatus::Accepted, + ]); + + return response()->json([ + 'message' => $updated ? __('api.follows.accepted') : __('api.follows.not_found'), + ]); + } + + public function reject(FollowRequest $request, User $user): JsonResponse + { + $detached = $request->user()->followers()->detach($user->id); + + return response()->json([ + 'message' => $detached ? __('api.follows.rejected') : __('api.follows.not_found'), + ]); + } + + public function followers(Request $request, User $user): AnonymousResourceCollection + { + $perPage = max(1, min($request->integer('per_page', 15), 100)); + + $followers = $user->followers() + ->wherePivot('status', FollowStatus::Accepted) + ->paginate($perPage); + + return FollowResource::collection($followers); + } + + public function following(Request $request, User $user): AnonymousResourceCollection + { + $perPage = max(1, min($request->integer('per_page', 15), 100)); + + $following = $user->following() + ->wherePivot('status', FollowStatus::Accepted) + ->paginate($perPage); + + return FollowResource::collection($following); + } +} diff --git a/app/Http/Controllers/MealImageAnalysisController.php b/app/Http/Controllers/MealImageAnalysisController.php index e7f41d1..8fd2eca 100644 --- a/app/Http/Controllers/MealImageAnalysisController.php +++ b/app/Http/Controllers/MealImageAnalysisController.php @@ -22,8 +22,7 @@ class MealImageAnalysisController extends Controller MealImageAnalysisRequest $request, AiCreditService $aiCredits, AiUsageRecorder $aiUsageRecorder, - ): JsonResponse - { + ): JsonResponse { $user = $request->user(); $image = $request->file('image'); $input = $this->analysisInput($image); diff --git a/app/Http/Controllers/MealPostController.php b/app/Http/Controllers/MealPostController.php index bff3315..dc2c3fc 100644 --- a/app/Http/Controllers/MealPostController.php +++ b/app/Http/Controllers/MealPostController.php @@ -231,7 +231,7 @@ class MealPostController extends Controller $data = $request->validated(); if ($request->hasFile('image')) { - $path = $request->file('image')->store('meal-posts', ); + $path = $request->file('image')->store('meal-posts'); $data['image_url'] = $path; } diff --git a/app/Http/Controllers/WorkoutSessionController.php b/app/Http/Controllers/WorkoutSessionController.php index 658018d..7ca7b1f 100644 --- a/app/Http/Controllers/WorkoutSessionController.php +++ b/app/Http/Controllers/WorkoutSessionController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers; -use App\Http\Requests\WorkoutSessionsRequest; use App\Http\Requests\WorkoutSessionsCalendarRequest; +use App\Http\Requests\WorkoutSessionsRequest; use App\Http\Resources\WorkoutSessionsResource; use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; diff --git a/app/Http/Requests/FollowRequest.php b/app/Http/Requests/FollowRequest.php new file mode 100644 index 0000000..be992c5 --- /dev/null +++ b/app/Http/Requests/FollowRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + // No specific body rules for now as we use route model binding + ]; + } +} diff --git a/app/Http/Resources/FollowResource.php b/app/Http/Resources/FollowResource.php new file mode 100644 index 0000000..daa3fce --- /dev/null +++ b/app/Http/Resources/FollowResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, + ]; + } +} diff --git a/app/Listeners/GrantCreditsFromStripeWebhook.php b/app/Listeners/GrantCreditsFromStripeWebhook.php index f065979..9565abb 100644 --- a/app/Listeners/GrantCreditsFromStripeWebhook.php +++ b/app/Listeners/GrantCreditsFromStripeWebhook.php @@ -4,8 +4,8 @@ namespace App\Listeners; use App\Enums\CreditLedgerEntryType; use App\Enums\CreditProductType; -use App\Models\CreditProduct; use App\Models\CreditLedgerEntry; +use App\Models\CreditProduct; use App\Models\User; use App\Services\AiCreditService; use App\Services\PaymentInvoiceEmailer; diff --git a/app/Models/Follow.php b/app/Models/Follow.php new file mode 100644 index 0000000..502ce48 --- /dev/null +++ b/app/Models/Follow.php @@ -0,0 +1,33 @@ + FollowStatus::class, + ]; + } + + public function follower(): BelongsTo + { + return $this->belongsTo(User::class, 'follower_id'); + } + + public function following(): BelongsTo + { + return $this->belongsTo(User::class, 'following_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d412d3c..2209064 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -149,6 +149,20 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->morphMany(Report::class, 'reportable'); } + public function followers(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + return $this->belongsToMany(self::class, 'follows', 'following_id', 'follower_id') + ->withPivot('status') + ->withTimestamps(); + } + + public function following(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + return $this->belongsToMany(self::class, 'follows', 'follower_id', 'following_id') + ->withPivot('status') + ->withTimestamps(); + } + public function moderationCase(): MorphOne { return $this->morphOne(ModerationCase::class, 'caseable'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cc79196..cf79923 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,8 +11,8 @@ use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; diff --git a/database/migrations/2026_05_28_181009_create_follows_table.php b/database/migrations/2026_05_28_181009_create_follows_table.php new file mode 100644 index 0000000..5ad61dc --- /dev/null +++ b/database/migrations/2026_05_28_181009_create_follows_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignUlid('follower_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('following_id')->constrained('users')->cascadeOnDelete(); + $table->string('status')->default('accepted'); + $table->timestampsTz(); + $table->unique([ + 'follower_id', + 'following_id', + ]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('follows'); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index f9edfc6..82e3bf1 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -90,6 +90,14 @@ return [ 'missing_activity_scope' => 'The Strava connection must allow the activity:read or activity:read_all scope.', 'disconnect_failed' => 'Unable to revoke Strava access right now. Please try again in a moment.', ], + 'follows' => [ + 'cannot_follow_self' => 'You cannot follow yourself.', + 'followed' => 'Follow request recorded.', + 'unfollowed' => 'Unfollowed successfully.', + 'accepted' => 'Follow request accepted.', + 'rejected' => 'Follow request rejected.', + 'not_found' => 'No request found.', + ], 'validation' => [ 'image_required' => 'Add an image to analyze.', 'image_file' => 'The file must be an image.', diff --git a/lang/fr/api.php b/lang/fr/api.php index b549355..7d4c35b 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -90,6 +90,14 @@ return [ 'missing_activity_scope' => 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.', 'disconnect_failed' => "Impossible de retirer l'accès Strava pour le moment. Réessaie dans quelques instants.", ], + 'follows' => [ + 'cannot_follow_self' => 'Vous ne pouvez pas vous suivre vous-même.', + 'followed' => 'Abonnement enregistré.', + 'unfollowed' => 'Désabonnement effectué.', + 'accepted' => 'Demande d’abonnement acceptée.', + 'rejected' => 'Demande d’abonnement refusée.', + 'not_found' => 'Aucune demande trouvée.', + ], 'validation' => [ 'image_required' => 'Ajoute une image à analyser.', 'image_file' => 'Le fichier doit être une image.', diff --git a/tests/Feature/MealPostListTest.php b/tests/Feature/MealPostListTest.php index a7ef202..d1594c3 100644 --- a/tests/Feature/MealPostListTest.php +++ b/tests/Feature/MealPostListTest.php @@ -34,4 +34,3 @@ it('returns only the authenticated user meal posts', function () { 'id' => $otherMealPost->id, ]); }); -