Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.73% covered (warning)
70.73%
58 / 82
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
JokeController
70.73% covered (warning)
70.73%
58 / 82
0.00% covered (danger)
0.00%
0 / 8
55.09
0.00% covered (danger)
0.00%
0 / 1
 index
72.73% covered (warning)
72.73%
16 / 22
0.00% covered (danger)
0.00%
0 / 1
7.99
 store
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 show
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 update
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 destroy
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 random
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 restore
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 forceDelete
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Http\Controllers\Api\v1;
4
5use App\Http\Controllers\Controller;
6use App\Http\Requests\StoreJokeRequest;
7use App\Http\Requests\UpdateJokeRequest;
8use App\Models\Joke;
9use App\Models\Category;
10use App\Responses\ApiResponse;
11use Illuminate\Http\Request;
12use Illuminate\Http\JsonResponse;
13use Illuminate\Support\Facades\Gate;
14
15/**
16 * JokeController
17 * 
18 * Handles all joke-related API operations including CRUD operations, search,
19 * filtering, and random joke retrieval. Implements ownership-based permissions
20 * where users can manage their own jokes, while staff+ can manage any joke.
21 * 
22 * @package App\Http\Controllers\Api\v1
23 * @author Ting Liu
24 * @version 1.0.0
25 * @since 2025-10-04
26 * 
27 * @method JsonResponse index(Request $request) Display a listing of jokes with pagination and filtering
28 * @method JsonResponse store(StoreJokeRequest $request) Store a newly created joke
29 * @method JsonResponse show(string $id) Display the specified joke
30 * @method JsonResponse update(UpdateJokeRequest $request, string $id) Update the specified joke
31 * @method JsonResponse destroy(string $id) Remove the specified joke (soft delete)
32 * @method JsonResponse random() Get a random joke (public endpoint for guests)
33 * @method JsonResponse restore(string $id) Restore a soft deleted joke
34 * @method JsonResponse forceDelete(string $id) Permanently delete a joke
35 * 
36 * @see \App\Policies\JokePolicy For authorization logic
37 * @see \App\Http\Requests\StoreJokeRequest For creation validation
38 * @see \App\Http\Requests\UpdateJokeRequest For update validation
39 * @see \App\Responses\ApiResponse For standardized JSON responses
40 */
41class JokeController extends Controller
42{
43    /**
44     * Display a listing of jokes with pagination and filtering.
45     * 
46     * Retrieves a paginated list of jokes with optional search and filtering capabilities.
47     * Supports filtering by category, user (admin/staff only), and text search across
48     * title and content fields. Returns 15 jokes per page by default.
49     * 
50     * @param Request $request The HTTP request containing optional query parameters:
51     *                        - q (string): Search term for title/content
52     *                        - category_id (int): Filter by category ID
53     *                        - user_id (int): Filter by user ID (admin/staff only)
54     * 
55     * @return JsonResponse Standardized JSON response containing:
56     *                     - success: boolean indicating operation success
57     *                     - message: Human-readable status message
58     *                     - data: Object containing paginated jokes with user and category relationships
59     * 
60     * @throws \Illuminate\Http\Exceptions\HttpResponseException When user lacks view permissions
61     * 
62     * @api GET /api/v1/jokes
63     * @permission jokes.browse (User level 100+)
64     * @permission jokes.search (User level 100+) - for search functionality
65     */
66    public function index(Request $request): JsonResponse
67    {
68        // Check if user can view jokes (User level 100+)
69        if (!Gate::allows('viewAny', Joke::class)) {
70            return ApiResponse::error(null, 'Unauthorized to view jokes', 403);
71        }
72
73        $query = Joke::with(['user', 'categories']);
74
75                // For regular clients, filter out jokes with unknown/empty categories or soft deleted categories
76                if ($request->user()->hasRole('client')) {
77            $query->whereHas('categories', function ($q) {
78                $q->whereNull('deleted_at'); // Only jokes that have at least one non-deleted category
79            });
80        }
81
82        // Check if user can search and apply search filter
83        if ($search = $request->string('q')->toString()) {
84            if (!Gate::allows('search', Joke::class)) {
85                return ApiResponse::error(null, 'Unauthorized to search jokes', 403);
86            }
87            $query->where(function ($q) use ($search) {
88                $q->where('title', 'like', "%{$search}%")
89                  ->orWhere('content', 'like', "%{$search}%");
90            });
91        }
92
93        // Filter by category if provided
94        if ($categoryId = $request->integer('category_id')) {
95            $query->whereHas('categories', function ($q) use ($categoryId) {
96                $q->where('categories.id', $categoryId);
97            });
98        }
99
100        // Filter by user if provided (for admin/staff)
101        if ($userId = $request->integer('user_id')) {
102            $query->where('user_id', $userId);
103        }
104
105        $jokes = $query->orderBy('created_at', 'desc')->paginate(15);
106
107        return ApiResponse::success(['jokes' => $jokes], 'Jokes retrieved');
108    }
109
110    /**
111     * Store a newly created joke in storage.
112     * 
113     * Creates a new joke with the provided data and assigns it to the authenticated user.
114     * Automatically handles category relationships if provided. The joke is created with
115     * ownership tracking for permission-based access control.
116     * 
117     * @param StoreJokeRequest $request Validated request containing:
118     *                                  - title (required): Joke title (max 255 chars)
119     *                                  - content (required): Joke content (min 10 chars)
120     *                                  - reference (optional): Source reference (max 255 chars)
121     *                                  - published_at (optional): Publication date
122     *                                  - categories (optional): Array of category IDs
123     * 
124     * @return JsonResponse Standardized JSON response containing:
125     *                     - success: boolean indicating operation success
126     *                     - message: Human-readable status message
127     *                     - data: Object containing the created joke with relationships
128     * 
129     * @throws \Illuminate\Http\Exceptions\HttpResponseException When user lacks create permissions
130     * @throws \Illuminate\Validation\ValidationException When validation fails
131     * 
132     * @api POST /api/v1/jokes
133     * @permission jokes.create (User level 100+)
134     */
135    public function store(StoreJokeRequest $request): JsonResponse
136    {
137        // Check if user can create jokes (User level 100+)
138        if (!Gate::allows('create', Joke::class)) {
139            return ApiResponse::error(null, 'Unauthorized to create jokes', 403);
140        }
141
142        $data = $request->validated();
143        $data['user_id'] = $request->user()->id;
144
145        $joke = Joke::create($data);
146
147        // Sync categories if provided
148        if ($request->has('categories')) {
149            $joke->categories()->sync($request->categories);
150        }
151
152        $joke->load(['user', 'categories']);
153
154        return ApiResponse::success(['joke' => $joke], 'Joke created', 201);
155    }
156
157    /**
158     * Display the specified joke.
159     * 
160     * Retrieves a single joke by ID with all related data including user information,
161     * categories, and votes. Implements ownership-based access control where users
162     * can view any joke, but only manage their own jokes.
163     * 
164     * @param string $id The joke ID to retrieve
165     * 
166     * @return JsonResponse Standardized JSON response containing:
167     *                     - success: boolean indicating operation success
168     *                     - message: Human-readable status message
169     *                     - data: Object containing the joke with user, categories, and votes
170     * 
171     * @throws \Illuminate\Http\Exceptions\HttpResponseException When joke not found or user lacks view permissions
172     * 
173     * @api GET /api/v1/jokes/{id}
174     * @permission jokes.read (User level 100+)
175     */
176    public function show(string $id): JsonResponse
177    {
178        $joke = Joke::with(['user', 'categories', 'votes'])->find($id);
179
180        if (!$joke) {
181            return ApiResponse::error(null, "Joke not found", 404);
182        }
183
184        // Check if user can view this joke (User level 100+)
185        if (!Gate::allows('view', $joke)) {
186            return ApiResponse::error(null, 'Unauthorized to view this joke', 403);
187        }
188
189                // For regular clients, check if joke has valid categories (not soft deleted)
190                if (request()->user()->hasRole('client')) {
191            $hasValidCategories = $joke->categories()->whereNull('deleted_at')->exists();
192            if (!$hasValidCategories) {
193                return ApiResponse::error(null, "Joke not found", 404);
194            }
195        }
196
197        return ApiResponse::success(['joke' => $joke], 'Joke retrieved');
198    }
199
200    /**
201     * Update the specified joke in storage.
202     * 
203     * Updates an existing joke with the provided data. Implements ownership-based
204     * permissions where users can only update their own jokes, while staff+ can
205     * update any joke. Supports partial updates and category relationship management.
206     * 
207     * @param UpdateJokeRequest $request Validated request containing:
208     *                                   - title (optional): Joke title (max 255 chars)
209     *                                   - content (optional): Joke content (min 10 chars)
210     *                                   - reference (optional): Source reference (max 255 chars)
211     *                                   - published_at (optional): Publication date
212     *                                   - categories (optional): Array of category IDs
213     * @param string $id The joke ID to update
214     * 
215     * @return JsonResponse Standardized JSON response containing:
216     *                     - success: boolean indicating operation success
217     *                     - message: Human-readable status message
218     *                     - data: Object containing the updated joke with relationships
219     * 
220     * @throws \Illuminate\Http\Exceptions\HttpResponseException When joke not found or user lacks update permissions
221     * @throws \Illuminate\Validation\ValidationException When validation fails
222     * 
223     * @api PUT /api/v1/jokes/{id}
224     * @permission jokes.update (User level 100+ for own jokes, Staff+ for any joke)
225     */
226    public function update(UpdateJokeRequest $request, string $id): JsonResponse
227    {
228        $joke = Joke::find($id);
229
230        if (!$joke) {
231            return ApiResponse::error(null, "Joke not found", 404);
232        }
233
234        // Check if user can update this joke (ownership or Staff+)
235        if (!Gate::allows('update', $joke)) {
236            return ApiResponse::error(null, 'Unauthorized to update this joke', 403);
237        }
238
239        $joke->update($request->validated());
240
241        // Sync categories if provided
242        if ($request->has('categories')) {
243            $joke->categories()->sync($request->categories);
244        }
245
246        $joke->load(['user', 'categories']);
247
248        return ApiResponse::success(['joke' => $joke], 'Joke updated');
249    }
250
251    /**
252     * Remove the specified joke from storage (soft delete).
253     * 
254     * Performs a soft delete on the specified joke, making it unavailable to users
255     * but preserving the data for potential restoration. Implements ownership-based
256     * permissions where users can only delete their own jokes, while staff+ can
257     * delete any joke.
258     * 
259     * @param string $id The joke ID to delete
260     * 
261     * @return JsonResponse Standardized JSON response containing:
262     *                     - success: boolean indicating operation success
263     *                     - message: Human-readable status message
264     *                     - data: null (joke is soft deleted)
265     * 
266     * @throws \Illuminate\Http\Exceptions\HttpResponseException When joke not found or user lacks delete permissions
267     * 
268     * @api DELETE /api/v1/jokes/{id}
269     * @permission jokes.delete (User level 100+ for own jokes, Staff+ for any joke)
270     */
271    public function destroy(string $id): JsonResponse
272    {
273        $joke = Joke::find($id);
274
275        if (!$joke) {
276            return ApiResponse::error(null, "Joke not found", 404);
277        }
278
279        // Check if user can delete this joke (ownership or Staff+)
280        if (!Gate::allows('delete', $joke)) {
281            return ApiResponse::error(null, 'Unauthorized to delete this joke', 403);
282        }
283
284        $joke->delete();
285
286        return ApiResponse::success(null, 'Joke deleted');
287    }
288
289    /**
290     * Get a random joke (public endpoint for guests).
291     * 
292     * Retrieves a single random joke that has categories assigned. This endpoint
293     * is publicly accessible without authentication and is specifically designed
294     * for guest users. Excludes jokes without categories (unknown category jokes)
295     * to ensure quality content is presented to guests.
296     * 
297     * @return JsonResponse Standardized JSON response containing:
298     *                     - success: boolean indicating operation success
299     *                     - message: Human-readable status message
300     *                     - data: Object containing the random joke with user and category relationships
301     * 
302     * @throws \Illuminate\Http\Exceptions\HttpResponseException When no jokes with categories are available
303     * 
304     * @api GET /api/v1/jokes/random
305     * @permission None (public endpoint)
306     * @note Only returns jokes that have categories assigned
307     */
308    public function random(): JsonResponse
309    {
310        $joke = Joke::with(['user', 'categories'])
311            ->whereHas('categories') // Only jokes with categories (no unknown category jokes)
312            ->inRandomOrder()
313            ->first();
314
315        if (!$joke) {
316            return ApiResponse::error(null, "No jokes available", 404);
317        }
318
319        return ApiResponse::success(['joke' => $joke], 'Random joke retrieved');
320    }
321
322    /**
323     * Restore a soft deleted joke.
324     * 
325     * Restores a previously soft deleted joke, making it available again to users.
326     * This operation is restricted to staff+ level users and can only be performed
327     * on jokes that are currently soft deleted.
328     * 
329     * @param string $id The joke ID to restore
330     * 
331     * @return JsonResponse Standardized JSON response containing:
332     *                     - success: boolean indicating operation success
333     *                     - message: Human-readable status message
334     *                     - data: Object containing the restored joke with relationships
335     * 
336     * @throws \Illuminate\Http\Exceptions\HttpResponseException When joke not found, not deleted, or user lacks restore permissions
337     * 
338     * @api POST /api/v1/jokes/{id}/restore
339     * @permission jokes.restore (Staff level 500+)
340     */
341    public function restore(string $id): JsonResponse
342    {
343        $joke = Joke::withTrashed()->find($id);
344
345        if (!$joke) {
346            return ApiResponse::error(null, "Joke not found", 404);
347        }
348
349        // Check if user can restore this joke (Staff level 500+)
350        if (!Gate::allows('restore', $joke)) {
351            return ApiResponse::error(null, 'Unauthorized to restore this joke', 403);
352        }
353
354        if (!$joke->trashed()) {
355            return ApiResponse::error(null, "Joke is not deleted", 400);
356        }
357
358        $joke->restore();
359        $joke->load(['user', 'categories']);
360
361        return ApiResponse::success(['joke' => $joke], 'Joke restored');
362    }
363
364    /**
365     * Permanently delete a joke.
366     * 
367     * Permanently removes a joke from the database, including soft deleted jokes.
368     * This operation is irreversible and is restricted to admin+ level users.
369     * Use with extreme caution as this action cannot be undone.
370     * 
371     * @param string $id The joke ID to permanently delete
372     * 
373     * @return JsonResponse Standardized JSON response containing:
374     *                     - success: boolean indicating operation success
375     *                     - message: Human-readable status message
376     *                     - data: null (joke is permanently removed)
377     * 
378     * @throws \Illuminate\Http\Exceptions\HttpResponseException When joke not found or user lacks force delete permissions
379     * 
380     * @api DELETE /api/v1/jokes/{id}/force
381     * @permission jokes.force-delete (Admin level 750+)
382     * @warning This action is irreversible
383     */
384    public function forceDelete(string $id): JsonResponse
385    {
386        $joke = Joke::withTrashed()->find($id);
387
388        if (!$joke) {
389            return ApiResponse::error(null, "Joke not found", 404);
390        }
391
392        // Check if user can permanently delete this joke (Admin level 750+)
393        if (!Gate::allows('forceDelete', $joke)) {
394            return ApiResponse::error(null, 'Unauthorized to permanently delete this joke', 403);
395        }
396
397        $joke->forceDelete();
398
399        return ApiResponse::success(null, 'Joke permanently removed');
400    }
401}