Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.50% covered (warning)
52.50%
105 / 200
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TsumegoIssuesController
52.26% covered (warning)
52.26%
104 / 199
28.57% covered (danger)
28.57%
2 / 7
276.21
0.00% covered (danger)
0.00%
0 / 1
 index
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 create
61.90% covered (warning)
61.90%
26 / 42
0.00% covered (danger)
0.00%
0 / 1
9.71
 close
39.39% covered (danger)
39.39%
13 / 33
0.00% covered (danger)
0.00%
0 / 1
27.03
 reopen
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 moveComment
60.00% covered (warning)
60.00%
30 / 50
0.00% covered (danger)
0.00%
0 / 1
21.22
 _renderIssuesSection
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 _renderCommentsSection
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3App::uses('TsumegoIssue', 'Model');
4
5/**
6 * Controller for managing tsumego issues.
7 *
8 * Issues are reports about problems with tsumego solutions (missing moves, wrong answers, etc.).
9 * Each issue contains one or more comments discussing the problem.
10 */
11class TsumegoIssuesController extends AppController
12{
13    /**
14     * List all issues with optional filtering.
15     *
16     * Query params:
17     * - status: 'opened', 'closed', 'all' (default: 'opened')
18     * - page: int (for pagination)
19     *
20     * @return void
21     */
22    public function index()
23    {
24        $this->loadModel('TsumegoIssue');
25
26        $this->set('_title', 'Tsumego Hero - Issues');
27        $this->set('_page', 'issues');
28
29        // Get filter and pagination params
30        $statusFilter = $this->request->query('status') ?: 'opened';
31        $page = (int) ($this->request->query('page') ?: 1);
32        $perPage = 20;
33
34        // Single optimized query using model method
35        $issues = $this->TsumegoIssue->findForIndex($statusFilter, $perPage, $page);
36
37        // Get tab counts (also used for pagination)
38        $counts = $this->TsumegoIssue->getIndexCounts();
39        $openCount = $counts['open'];
40        $closedCount = $counts['closed'];
41
42        // Calculate total for pagination based on current filter
43        $totalCount = match ($statusFilter)
44        {
45            'opened' => $openCount,
46            'closed' => $closedCount,
47            default => $openCount + $closedCount, // 'all'
48        };
49        $totalPages = (int) ceil($totalCount / $perPage);
50
51        $this->set(compact('issues', 'statusFilter', 'openCount', 'closedCount', 'totalPages', 'perPage'));
52        $this->set('currentPage', $page);
53
54        // htmx requests get just the issues-section element (idiomorph handles the diff)
55        if ($this->isHtmxRequest())
56        {
57            $this->layout = false;
58            $this->render('/Elements/TsumegoIssues/issues-section');
59        }
60        // Regular requests get the full page with layout
61    }
62
63    /**
64     * Create a new issue with an initial comment.
65     *
66     * POST data:
67     * - Issue.tsumego_id: int
68     * - Issue.message: string (first comment)
69     * - Issue.position: string|null (board position for first comment)
70     *
71     * @return CakeResponse|null
72     */
73    public function create()
74    {
75        if (!$this->request->is('post'))
76            throw new MethodNotAllowedException();
77
78        if (!Auth::isLoggedIn())
79        {
80            $this->response->statusCode(401);
81            $this->response->body('You must be logged in to report an issue.');
82            return $this->response;
83        }
84
85        $tsumegoId = $this->request->data('Issue.tsumego_id');
86        $message = $this->request->data('Issue.message');
87        $position = $this->request->data('Issue.position');
88
89        if (empty($tsumegoId) || empty($message))
90        {
91            $this->response->statusCode(400);
92            $this->response->body('Tsumego ID and message are required.');
93            return $this->response;
94        }
95
96        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
97        $TsumegoComment = ClassRegistry::init('TsumegoComment');
98
99        // Create the issue
100        $issue = [
101            'tsumego_id' => $tsumegoId,
102            'user_id' => Auth::getUserID(),
103            'tsumego_issue_status_id' => TsumegoIssue::$OPENED_STATUS,
104        ];
105
106        $TsumegoIssue->create();
107        if (!$TsumegoIssue->save($issue))
108        {
109            $this->response->statusCode(500);
110            $this->response->body('Failed to create issue.');
111            return $this->response;
112        }
113
114        $issueId = $TsumegoIssue->getLastInsertID();
115
116        // Create the first comment attached to this issue
117        $comment = [
118            'tsumego_id' => $tsumegoId,
119            'tsumego_issue_id' => $issueId,
120            'message' => $message,
121            'position' => $position,
122            'user_id' => Auth::getUserID(),
123        ];
124
125        $TsumegoComment->create();
126        if (!$TsumegoComment->save($comment))
127        {
128            // Rollback: delete the issue if comment fails
129            $TsumegoIssue->delete($issueId);
130            $this->response->statusCode(422);
131            $this->layout = false;
132            $this->autoRender = false;
133            $this->response->body('<div class="alert alert--error">Failed to create issue.</div>');
134            return $this->response;
135        }
136
137        // Return the full comments section (idiomorph handles the diff)
138        return $this->_renderCommentsSection($tsumegoId);
139    }
140
141    /**
142     * Close an issue.
143     *
144     * Only admin or issue author can close.
145     * Optionally add a closing comment.
146     *
147     * @param int $id Issue ID
148     * @return CakeResponse|null
149     */
150    public function close($id)
151    {
152        if (!$this->request->is('post'))
153            throw new MethodNotAllowedException();
154
155        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
156        $issue = $TsumegoIssue->findById($id);
157
158        if (!$issue)
159        {
160            $this->response->statusCode(404);
161            $this->response->body('Issue not found.');
162            return $this->response;
163        }
164
165        // Only admin or issue author can close
166        $isOwner = $issue['TsumegoIssue']['user_id'] === Auth::getUserID();
167        if (!Auth::isAdmin() && !$isOwner)
168        {
169            $this->response->statusCode(403);
170            $this->response->body('You are not authorized to close this issue.');
171            return $this->response;
172        }
173
174        // Update status to closed
175        $TsumegoIssue->id = $id;
176        if (!$TsumegoIssue->saveField('tsumego_issue_status_id', TsumegoIssue::$CLOSED_STATUS))
177        {
178            $this->response->statusCode(500);
179            $this->response->body('Failed to close issue.');
180            return $this->response;
181        }
182
183        // Add closing comment if provided
184        $closingMessage = $this->request->data('Issue.message');
185        if (!empty($closingMessage))
186        {
187            $TsumegoComment = ClassRegistry::init('TsumegoComment');
188            $comment = [
189                'tsumego_id' => $issue['TsumegoIssue']['tsumego_id'],
190                'tsumego_issue_id' => $id,
191                'message' => $closingMessage,
192                'user_id' => Auth::getUserID(),
193            ];
194            $TsumegoComment->create();
195            $TsumegoComment->save($comment);
196        }
197
198        // Return updated content
199        $source = $this->request->data('source') ?: 'list';
200
201        // Play page source - return full comments section (idiomorph handles the diff)
202        if ($source === 'play')
203            return $this->_renderCommentsSection($issue['TsumegoIssue']['tsumego_id']);
204
205        // Issues list page - render with pagination support
206        return $this->_renderIssuesSection();
207    }
208
209    /**
210     * Reopen a closed issue.
211     *
212     * Admin only.
213     *
214     * @param int $id Issue ID
215     * @return CakeResponse|null
216     */
217    public function reopen($id)
218    {
219        if (!$this->request->is('post'))
220            throw new MethodNotAllowedException();
221
222        if (!Auth::isAdmin())
223        {
224            $this->response->statusCode(403);
225            $this->response->body('Only admins can reopen issues.');
226            return $this->response;
227        }
228
229        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
230        $issue = $TsumegoIssue->findById($id);
231
232        if (!$issue)
233        {
234            $this->response->statusCode(404);
235            $this->response->body('Issue not found.');
236            return $this->response;
237        }
238
239        $TsumegoIssue->id = $id;
240        if (!$TsumegoIssue->saveField('tsumego_issue_status_id', TsumegoIssue::$OPENED_STATUS))
241        {
242            $this->response->statusCode(500);
243            $this->response->body('Failed to reopen issue.');
244            return $this->response;
245        }
246
247        // Return updated content
248        $source = $this->request->data('source') ?: 'list';
249
250        // Play page source - return full comments section (idiomorph handles the diff)
251        if ($source === 'play')
252            return $this->_renderCommentsSection($issue['TsumegoIssue']['tsumego_id']);
253
254        // Issues list page - render with pagination support
255        return $this->_renderIssuesSection();
256    }
257
258    /**
259     * Move an existing comment into an issue (new or existing), or make it standalone.
260     *
261     * Admin only.
262     *
263     * POST data:
264     * - Comment.tsumego_issue_id: 'standalone' | 'new' | int (issue ID)
265     *
266     * @param int $commentId Comment ID to move
267     * @return CakeResponse|null
268     */
269    public function moveComment($commentId)
270    {
271        if (!$this->request->is('post'))
272            throw new MethodNotAllowedException();
273
274        if (!Auth::isAdmin())
275        {
276            $this->response->statusCode(403);
277            $this->response->body('Only admins can move comments.');
278            return $this->response;
279        }
280
281        $TsumegoComment = ClassRegistry::init('TsumegoComment');
282        $comment = $TsumegoComment->findById($commentId);
283
284        if (!$comment)
285        {
286            $this->response->statusCode(404);
287            $this->response->body('Comment not found.');
288            return $this->response;
289        }
290
291        $tsumegoId = $comment['TsumegoComment']['tsumego_id'];
292        $targetIssueId = $this->request->data('Comment.tsumego_issue_id');
293        $currentIssueId = $comment['TsumegoComment']['tsumego_issue_id'];
294
295        // Handle 'standalone' - remove from issue
296        if ($targetIssueId === 'standalone')
297        {
298            if (empty($currentIssueId))
299                return $this->_renderCommentsSection($tsumegoId);
300
301            $TsumegoComment->id = $commentId;
302            if ($TsumegoComment->saveField('tsumego_issue_id', null))
303            {
304                // Check if issue is now empty and delete it
305                $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
306                $TsumegoIssue->deleteIfEmpty($currentIssueId);
307                return $this->_renderCommentsSection($tsumegoId);
308            }
309
310            $this->response->statusCode(500);
311            $this->response->body('Failed to remove comment from issue.');
312            return $this->response;
313        }
314
315        // Handle 'new' - create new issue
316        if ($targetIssueId === 'new')
317        {
318            $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
319            $issue = [
320                'tsumego_id' => $comment['TsumegoComment']['tsumego_id'],
321                'user_id' => $comment['TsumegoComment']['user_id'], // Original comment author becomes issue author
322                'tsumego_issue_status_id' => TsumegoIssue::$OPENED_STATUS,
323            ];
324
325            $TsumegoIssue->create();
326            if (!$TsumegoIssue->save($issue))
327            {
328                $this->response->statusCode(500);
329                $this->response->body('Failed to create new issue.');
330                return $this->response;
331            }
332            $targetIssueId = $TsumegoIssue->getLastInsertID();
333        }
334
335        // Check if moving to same issue
336        if ($currentIssueId == $targetIssueId)
337            return $this->_renderCommentsSection($tsumegoId);
338
339        // Move the comment to the target issue
340        $TsumegoComment->id = $commentId;
341        if ($TsumegoComment->saveField('tsumego_issue_id', $targetIssueId))
342        {
343            // Check if old issue is now empty and delete it
344            if (!empty($currentIssueId))
345            {
346                $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
347                $TsumegoIssue->deleteIfEmpty($currentIssueId);
348            }
349            return $this->_renderCommentsSection($tsumegoId);
350        }
351
352        $this->response->statusCode(500);
353        $this->response->body('Failed to move comment.');
354        return $this->response;
355    }
356
357    /**
358     * Helper method to render the issues section for htmx responses.
359     *
360     * Extracts filter/page from request data and renders the issues-section element.
361     *
362     * @return CakeResponse
363     */
364    protected function _renderIssuesSection(): CakeResponse
365    {
366        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
367
368        $filter = $this->request->data('filter') ?: 'all';
369        $page = (int) ($this->request->data('page') ?: 1);
370        if ($page < 1)
371            $page = 1;
372
373        $perPage = 20;
374        $issues = $TsumegoIssue->findForIndex($filter, $perPage, $page);
375        $counts = $TsumegoIssue->getIndexCounts();
376
377        $totalCount = $filter === 'opened' ? $counts['open'] : ($filter === 'closed' ? $counts['closed'] : $counts['open'] + $counts['closed']);
378        $totalPages = max(1, (int) ceil($totalCount / $perPage));
379
380        $this->set('issues', $issues);
381        $this->set('statusFilter', $filter);
382        $this->set('openCount', $counts['open']);
383        $this->set('closedCount', $counts['closed']);
384        $this->set('currentPage', $page);
385        $this->set('totalPages', $totalPages);
386
387        $this->layout = false;
388        return $this->render('/Elements/TsumegoIssues/issues-section');
389    }
390
391    /**
392     * Render the morphable comments section content for htmx morph responses (play page).
393     *
394     * Loads all comments data and renders just the inner content element.
395     *
396     * @param int $tsumegoId The tsumego ID
397     * @return CakeResponse
398     */
399    protected function _renderCommentsSection(int $tsumegoId): CakeResponse
400    {
401        $Tsumego = ClassRegistry::init('Tsumego');
402        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
403
404        $commentsData = $Tsumego->loadCommentsData($tsumegoId);
405        $counts = $TsumegoIssue->getCommentSectionCounts($tsumegoId);
406
407        $this->set('tsumegoId', $tsumegoId);
408        $this->set('issues', $commentsData['issues']);
409        $this->set('plainComments', $commentsData['plainComments']);
410        $this->set('totalCount', $counts['total']);
411        $this->set('commentCount', $counts['comments']);
412        $this->set('issueCount', $counts['issues']);
413        $this->set('openIssueCount', $counts['openIssues']);
414
415        $this->layout = false;
416        return $this->render('/Elements/TsumegoComments/section-content');
417    }
418}