Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.56% covered (warning)
60.56%
152 / 251
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TsumegoIssuesController
60.40% covered (warning)
60.40%
151 / 250
33.33% covered (danger)
33.33%
2 / 6
122.01
0.00% covered (danger)
0.00%
0 / 1
 api
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 index
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 create
75.00% covered (warning)
75.00%
54 / 72
0.00% covered (danger)
0.00%
0 / 1
7.77
 close
37.14% covered (danger)
37.14%
13 / 35
0.00% covered (danger)
0.00%
0 / 1
19.17
 reopen
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 moveComment
61.29% covered (warning)
61.29%
57 / 93
0.00% covered (danger)
0.00%
0 / 1
20.35
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     * API endpoint to fetch issues list with pagination and filtering.
15     *
16     * Query params:
17     * - status: 'opened', 'closed', 'all' (default: 'opened')
18     * - page: int (for pagination)
19     *
20     * @return CakeResponse|null
21     */
22    public function api()
23    {
24        $this->loadModel('TsumegoIssue');
25
26        $statusFilter = $this->request->query('status') ?: 'opened';
27        $page = (int) ($this->request->query('page') ?: 1);
28        $perPage = 20;
29
30        $issues = $this->TsumegoIssue->findForIndex($statusFilter, $perPage, $page);
31        $counts = $this->TsumegoIssue->getIndexCounts();
32
33        $totalCount = match ($statusFilter)
34        {
35            'opened' => $counts['open'],
36            'closed' => $counts['closed'],
37            default => $counts['open'] + $counts['closed'],
38        };
39        $totalPages = (int) ceil($totalCount / $perPage);
40
41        $this->response->type('json');
42        $this->response->body(json_encode([
43            'issues' => $issues,
44            'counts' => $counts,
45            'totalPages' => $totalPages,
46            'currentPage' => $page,
47        ]));
48        return $this->response;
49    }
50
51    /**
52     * List all issues page
53     *
54     * Query params:
55     * - status: 'opened', 'closed', 'all' (default: 'opened')
56     * - page: int (for pagination)
57     *
58     * @return void
59     */
60    public function index()
61    {
62        $this->set('_title', 'Tsumego Hero - Issues');
63        $this->set('_page', 'issues');
64
65        // Get filter and pagination params from URL (used for initial state)
66        $statusFilter = $this->request->query('status') ?: 'opened';
67        $page = (int) ($this->request->query('page') ?: 1);
68
69        $this->set(compact('statusFilter'));
70        $this->set('currentPage', $page);
71    }
72
73    /**
74     * Create a new issue with an initial comment.
75     *
76     * POST data:
77     * - Issue.tsumego_id: int
78     * - Issue.message: string (first comment)
79     * - Issue.position: string|null (board position for first comment)
80     *
81     * @return CakeResponse|null
82     */
83    public function create()
84    {
85        if (!$this->request->is('post'))
86            throw new MethodNotAllowedException();
87
88        if (!Auth::isLoggedIn())
89        {
90            $this->response->statusCode(401);
91            $this->response->type('json');
92            $this->response->body(json_encode(['error' => 'You must be logged in to report an issue']));
93            return $this->response;
94        }
95
96        // Parse JSON request body
97        $input = json_decode($this->request->input(), true);
98        $tsumegoId = $input['tsumego_id'] ?? null;
99        $message = $input['text'] ?? null;
100        $position = $input['position'] ?? null;
101
102        if (empty($tsumegoId) || empty($message))
103        {
104            $this->response->statusCode(400);
105            $this->response->type('json');
106            $this->response->body(json_encode(['error' => 'Tsumego ID and message are required']));
107            return $this->response;
108        }
109
110        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
111        $TsumegoComment = ClassRegistry::init('TsumegoComment');
112
113        // Create the issue
114        $issue = [
115            'tsumego_id' => $tsumegoId,
116            'user_id' => Auth::getUserID(),
117            'tsumego_issue_status_id' => TsumegoIssue::$OPENED_STATUS,
118        ];
119
120        $TsumegoIssue->create();
121        if (!$TsumegoIssue->save($issue))
122        {
123            $this->response->statusCode(500);
124            $this->response->type('json');
125            $this->response->body(json_encode(['error' => 'Failed to create issue']));
126            return $this->response;
127        }
128
129        $issueId = $TsumegoIssue->getLastInsertID();
130
131        // Create the first comment attached to this issue
132        $comment = [
133            'tsumego_id' => $tsumegoId,
134            'tsumego_issue_id' => $issueId,
135            'message' => $message,
136            'position' => $position,
137            'user_id' => Auth::getUserID(),
138        ];
139
140        $TsumegoComment->create();
141        if (!$TsumegoComment->save($comment))
142        {
143            // Rollback: delete the issue if comment fails
144            $TsumegoIssue->delete($issueId);
145            $this->response->statusCode(422);
146            $this->response->type('json');
147            $this->response->body(json_encode(['error' => 'Failed to create issue']));
148            return $this->response;
149        }
150
151        // Return full issue data for initial state
152        $User = ClassRegistry::init('User');
153        $user = $User->findById(Auth::getUserID());
154
155        $issueData = [
156            'id' => $issueId,
157            'tsumego_issue_status_id' => TsumegoIssue::$OPENED_STATUS,
158            'created' => date('Y-m-d H:i:s'),
159            'user_id' => Auth::getUserID(),
160            'user_name' => $user['User']['name'],
161            'user_picture' => $user['User']['picture'],
162            'user_rating' => $user['User']['rating'],
163            'user_external_id' => $user['User']['externalId'],
164            'isAdmin' => Auth::isAdmin(),
165            'comments' => [[
166                'id' => $TsumegoComment->getLastInsertID(),
167                'text' => $message,
168                'user_id' => Auth::getUserID(),
169                'user_name' => $user['User']['name'],
170                'user_picture' => $user['User']['picture'],
171                'user_rating' => $user['User']['rating'],
172                'user_external_id' => $user['User']['externalId'],
173                'isAdmin' => Auth::isAdmin(),
174                'created' => date('Y-m-d H:i:s'),
175                'position' => $position,
176            ]],
177        ];
178
179        $this->response->type('json');
180        $this->response->body(json_encode(['success' => true, 'issue' => $issueData]));
181        return $this->response;
182    }
183
184    /**
185     * Close an issue.
186     *
187     * Only admin or issue author can close.
188     * Optionally add a closing comment.
189     *
190     * @param int $id Issue ID
191     * @return CakeResponse|null
192     */
193    public function close($id)
194    {
195        if (!$this->request->is('post'))
196            throw new MethodNotAllowedException();
197
198        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
199        $issue = $TsumegoIssue->findById($id);
200
201        if (!$issue)
202        {
203            $this->response->statusCode(404);
204            $this->response->type('json');
205            $this->response->body(json_encode(['error' => 'Issue not found']));
206            return $this->response;
207        }
208
209        // Only admin or issue author can close
210        $isOwner = $issue['TsumegoIssue']['user_id'] === Auth::getUserID();
211        if (!Auth::isAdmin() && !$isOwner)
212        {
213            $this->response->statusCode(403);
214            $this->response->type('json');
215            $this->response->body(json_encode(['error' => 'You are not authorized to close this issue']));
216            return $this->response;
217        }
218
219        // Update status to closed
220        $TsumegoIssue->id = $id;
221        if (!$TsumegoIssue->saveField('tsumego_issue_status_id', TsumegoIssue::$CLOSED_STATUS))
222        {
223            $this->response->statusCode(500);
224            $this->response->type('json');
225            $this->response->body(json_encode(['error' => 'Failed to close issue']));
226            return $this->response;
227        }
228
229        // Add closing comment if provided
230        $closingMessage = $this->request->data('Issue.message');
231        if (!empty($closingMessage))
232        {
233            $TsumegoComment = ClassRegistry::init('TsumegoComment');
234            $comment = [
235                'tsumego_id' => $issue['TsumegoIssue']['tsumego_id'],
236                'tsumego_issue_id' => $id,
237                'message' => $closingMessage,
238                'user_id' => Auth::getUserID(),
239            ];
240            $TsumegoComment->create();
241            $TsumegoComment->save($comment);
242        }
243
244        $this->response->type('json');
245        $this->response->body(json_encode(['success' => true]));
246        return $this->response;
247    }
248
249    /**
250     * Reopen a closed issue.
251     *
252     * Admin only.
253     *
254     * @param int $id Issue ID
255     * @return CakeResponse|null
256     */
257    public function reopen($id)
258    {
259        if (!$this->request->is('post'))
260            throw new MethodNotAllowedException();
261
262        if (!Auth::isAdmin())
263        {
264            $this->response->statusCode(403);
265            $this->response->type('json');
266            $this->response->body(json_encode(['error' => 'Only admins can reopen issues']));
267            return $this->response;
268        }
269
270        $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
271        $issue = $TsumegoIssue->findById($id);
272
273        if (!$issue)
274        {
275            $this->response->statusCode(404);
276            $this->response->type('json');
277            $this->response->body(json_encode(['error' => 'Issue not found']));
278            return $this->response;
279        }
280
281        $TsumegoIssue->id = $id;
282        if (!$TsumegoIssue->saveField('tsumego_issue_status_id', TsumegoIssue::$OPENED_STATUS))
283        {
284            $this->response->statusCode(500);
285            $this->response->type('json');
286            $this->response->body(json_encode(['error' => 'Failed to reopen issue']));
287            return $this->response;
288        }
289
290        $this->response->type('json');
291        $this->response->body(json_encode(['success' => true]));
292        return $this->response;
293    }
294
295    /**
296     * Move an existing comment into an issue (new or existing), or make it standalone.
297     *
298     * Admin only.
299     *
300     * POST data:
301     * - Comment.tsumego_issue_id: 'standalone' | 'new' | int (issue ID)
302     *
303     * @param int $commentId Comment ID to move
304     * @return CakeResponse|null
305     */
306    public function moveComment($commentId)
307    {
308        if (!$this->request->is('post'))
309            throw new MethodNotAllowedException();
310
311        if (!Auth::isAdmin())
312        {
313            $this->response->statusCode(403);
314            $this->response->type('json');
315            $this->response->body(json_encode(['error' => 'Only admins can move comments']));
316            return $this->response;
317        }
318
319        $TsumegoComment = ClassRegistry::init('TsumegoComment');
320        $comment = $TsumegoComment->findById($commentId);
321
322        if (!$comment)
323        {
324            $this->response->statusCode(404);
325            $this->response->type('json');
326            $this->response->body(json_encode(['error' => 'Comment not found']));
327            return $this->response;
328        }
329
330        $targetIssueId = $this->request->data('Comment.tsumego_issue_id');
331        $currentIssueId = $comment['TsumegoComment']['tsumego_issue_id'];
332
333        // Handle 'standalone' - remove from issue
334        if ($targetIssueId === 'standalone')
335        {
336            if (empty($currentIssueId))
337            {
338                $this->response->type('json');
339                $this->response->body(json_encode(['success' => true]));
340                return $this->response;
341            }
342
343            $TsumegoComment->id = $commentId;
344            if ($TsumegoComment->saveField('tsumego_issue_id', null))
345            {
346                // Check if issue is now empty and delete it
347                $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
348                $TsumegoIssue->deleteIfEmpty($currentIssueId);
349                $this->response->type('json');
350                $this->response->body(json_encode(['success' => true]));
351                return $this->response;
352            }
353
354            $this->response->statusCode(500);
355            $this->response->type('json');
356            $this->response->body(json_encode(['error' => 'Failed to remove comment from issue']));
357            return $this->response;
358        }
359
360        // Handle 'new' - create new issue
361        if ($targetIssueId === 'new')
362        {
363            $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
364            $User = ClassRegistry::init('User');
365
366            $issue = [
367                'tsumego_id' => $comment['TsumegoComment']['tsumego_id'],
368                'user_id' => $comment['TsumegoComment']['user_id'], // Original comment author becomes issue author
369                'tsumego_issue_status_id' => TsumegoIssue::$OPENED_STATUS,
370            ];
371
372            $TsumegoIssue->create();
373            if (!$TsumegoIssue->save($issue))
374            {
375                $this->response->statusCode(500);
376                $this->response->type('json');
377                $this->response->body(json_encode(['error' => 'Failed to create new issue']));
378                return $this->response;
379            }
380            $targetIssueId = $TsumegoIssue->getLastInsertID();
381
382            // Move comment to this new issue
383            $TsumegoComment->id = $commentId;
384            $TsumegoComment->saveField('tsumego_issue_id', $targetIssueId);
385
386            $issueUser = $User->findById($comment['TsumegoComment']['user_id']);
387            $commentUser = $User->findById($comment['TsumegoComment']['user_id']);
388
389            $issueData = [
390                'id' => $targetIssueId,
391                'status' => 'open',
392                'created' => date('Y-m-d H:i:s'),
393                'user_id' => $comment['TsumegoComment']['user_id'],
394                'user_name' => $issueUser['User']['name'],
395                'user_picture' => $issueUser['User']['picture'],
396                'user_rating' => $issueUser['User']['rating'],
397                'user_external_id' => $issueUser['User']['externalId'],
398                'isAdmin' => ($issueUser['User']['isAdmin'] == 1),
399                'comments' => [[
400                    'id' => $commentId,
401                    'text' => $comment['TsumegoComment']['message'],
402                    'user_id' => $comment['TsumegoComment']['user_id'],
403                    'user_name' => $commentUser['User']['name'],
404                    'user_picture' => $commentUser['User']['picture'],
405                    'user_rating' => $commentUser['User']['rating'],
406                    'user_external_id' => $commentUser['User']['externalId'],
407                    'isAdmin' => ($commentUser['User']['isAdmin'] == 1),
408                    'created' => $comment['TsumegoComment']['created'],
409                    'position' => $comment['TsumegoComment']['position'],
410                ]],
411            ];
412
413            $this->response->type('json');
414            $this->response->body(json_encode(['success' => true, 'issue' => $issueData, 'comment_id' => $commentId]));
415            return $this->response;
416        }
417
418        // Check if moving to same issue
419        if ($currentIssueId == $targetIssueId)
420        {
421            $this->response->type('json');
422            $this->response->body(json_encode(['success' => true]));
423            return $this->response;
424        }
425
426        // Move the comment to the target issue
427        $TsumegoComment->id = $commentId;
428        if ($TsumegoComment->saveField('tsumego_issue_id', $targetIssueId))
429        {
430            // Check if old issue is now empty and delete it
431            if (!empty($currentIssueId))
432            {
433                $TsumegoIssue = ClassRegistry::init('TsumegoIssue');
434                $TsumegoIssue->deleteIfEmpty($currentIssueId);
435            }
436            $this->response->type('json');
437            $this->response->body(json_encode(['success' => true]));
438            return $this->response;
439        }
440
441        $this->response->statusCode(500);
442        $this->response->type('json');
443        $this->response->body(json_encode(['error' => 'Failed to move comment']));
444        return $this->response;
445    }
446}