Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.17% covered (success)
99.17%
119 / 120
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
TsumegoIssue
99.15% covered (success)
99.15%
116 / 117
80.00% covered (warning)
80.00%
4 / 5
21
0.00% covered (danger)
0.00%
0 / 1
 statusName
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 deleteIfEmpty
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 findForIndex
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
14
 getIndexCounts
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getCommentSectionCounts
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3App::uses('TsumegoComment', 'Model');
4App::uses('User', 'Model');
5App::uses('TsumegosController', 'Controller');
6
7/**
8 * TsumegoIssue Model
9 *
10 * Represents an issue/problem report for a tsumego, which can contain multiple comments.
11 *
12 * Table: tsumego_issue
13 * Columns:
14 *   - id (INT, PK, AUTO_INCREMENT)
15 *   - tsumego_issue_status_id (INT, FK -> tsumego_issue_status)
16 *   - tsumego_id (INT, FK -> tsumego)
17 *   - user_id (INT, FK -> user) - author of the issue
18 *   - created (DATETIME)
19 *   - deleted (TINYINT) - soft delete flag
20 *
21 * Statuses (tsumego_issue_status table):
22 *   - 1 = opened
23 *   - 2 = closed
24 *
25 */
26class TsumegoIssue extends AppModel
27{
28    public $useTable = 'tsumego_issue';
29    public $actsAs = ['Containable'];
30
31    public $belongsTo = [
32        'Tsumego',
33        'User',
34    ];
35
36    public $hasMany = [
37        'TsumegoComment' => [
38            'foreignKey' => 'tsumego_issue_id',
39            'dependent' => true,
40        ],
41    ];
42
43    public static int $OPENED_STATUS = 1;
44    public static int $CLOSED_STATUS = 2;
45
46    /**
47     * Get the human-readable name for a status.
48     *
49     * @param int $status The status ID
50     * @return string The status name
51     * @throws \Exception If the status is invalid
52     */
53    public static function statusName(int $status): string
54    {
55        if ($status === self::$OPENED_STATUS)
56            return 'Opened';
57        if ($status === self::$CLOSED_STATUS)
58            return 'Closed';
59        throw new \Exception("Invalid issue status: $status");
60    }
61
62    /**
63     * Delete an issue if it has no non-deleted comments.
64     *
65     * Should be called after a comment is deleted or removed from an issue
66     * to clean up empty issues automatically.
67     *
68     * @param int $issueId The issue ID to check and potentially delete
69     * @return bool True if issue was deleted, false if it still has comments or doesn't exist
70     */
71    public function deleteIfEmpty(int $issueId): bool
72    {
73        /** @var TsumegoComment $commentModel */
74        $commentModel = ClassRegistry::init('TsumegoComment');
75
76        $count = $commentModel->find('count', [
77            'conditions' => [
78                'tsumego_issue_id' => $issueId,
79                'deleted' => false,
80            ],
81        ]);
82
83        if ($count === 0)
84            return $this->delete($issueId);
85
86        return false;
87    }
88
89    /**
90     * Find issues for the global issues index page.
91     *
92     * Returns issues with all their comments, formatted to be compatible with
93     * the TsumegoIssues/issue.ctp element (same format as Tsumego::loadCommentsData).
94     *
95     * @param string $status 'opened', 'closed', or 'all' (default: 'opened')
96     * @param int $limit Number of results per page (default: 20)
97     * @param int $page Page number for pagination (default: 1)
98     * @return array Formatted issues ready for the view (compatible with issue.ctp element)
99     */
100    public function findForIndex(string $status = 'opened', int $limit = 20, int $page = 1): array
101    {
102        $offset = ($page - 1) * $limit;
103
104        // Build status condition
105        $statusCondition = '';
106        if ($status === 'opened')
107            $statusCondition = 'AND tsumego_issue_status_id = ' . self::$OPENED_STATUS;
108        elseif ($status === 'closed')
109            $statusCondition = 'AND tsumego_issue_status_id = ' . self::$CLOSED_STATUS;
110
111        // Query 1: Get paginated issue IDs
112        $idsSql = "
113            SELECT id
114            FROM tsumego_issue
115            WHERE deleted = 0
116            {$statusCondition}
117            ORDER BY created DESC, id DESC
118            LIMIT {$limit} OFFSET {$offset}
119        ";
120        $idsResult = $this->query($idsSql) ?: [];
121        $issueIds = array_column(array_column($idsResult, 'tsumego_issue'), 'id');
122
123        if (empty($issueIds))
124            return [];
125
126        $issueIdsStr = implode(',', array_map('intval', $issueIds));
127
128        // Query 2: Get full data for those issues only
129        $sql = "
130            SELECT
131                ti.id AS issue_id,
132                ti.tsumego_issue_status_id,
133                ti.tsumego_id,
134                ti.user_id AS issue_user_id,
135                ti.created AS issue_created,
136                u.name AS issue_author_name,
137                tc.id AS comment_id,
138                tc.message AS comment_text,
139                tc.created AS comment_created,
140                tc.user_id AS comment_user_id,
141                cu.id AS comment_author_id,
142                cu.name AS comment_author_name,
143                cu.external_id AS comment_author_external_id,
144                cu.picture AS comment_author_picture,
145                cu.rating AS comment_author_rating,
146                cu.isAdmin AS comment_author_isAdmin,
147                s.id AS set_id,
148                s.title AS set_title,
149                sc.num AS tsumego_num
150            FROM tsumego_issue ti
151            LEFT JOIN user u ON u.id = ti.user_id
152            LEFT JOIN tsumego_comment tc ON tc.tsumego_issue_id = ti.id AND tc.deleted = 0
153            LEFT JOIN user cu ON cu.id = tc.user_id
154            LEFT JOIN (
155                SELECT sc1.tsumego_id, sc1.num, sc1.set_id
156                FROM set_connection sc1
157                INNER JOIN (
158                    SELECT tsumego_id, MIN(id) as min_id
159                    FROM set_connection
160                    GROUP BY tsumego_id
161                ) sc2 ON sc1.id = sc2.min_id
162            ) sc ON sc.tsumego_id = ti.tsumego_id
163            LEFT JOIN `set` s ON s.id = sc.set_id
164            WHERE ti.id IN ({$issueIdsStr})
165            ORDER BY ti.created DESC, ti.id DESC, tc.created ASC
166        ";
167
168        $rows = $this->query($sql) ?: [];
169
170        // Group results by issue, maintaining order from first query
171        $issuesMap = [];
172
173        foreach ($rows as $row)
174        {
175            $issueId = $row['ti']['issue_id'];
176
177            if (!isset($issuesMap[$issueId]))
178            {
179                $issuesMap[$issueId] = [
180                    'issue' => [
181                        'id' => $issueId,
182                        'tsumego_issue_status_id' => $row['ti']['tsumego_issue_status_id'],
183                        'tsumego_id' => $row['ti']['tsumego_id'],
184                        'user_id' => $row['ti']['issue_user_id'],
185                        'created' => $row['ti']['issue_created'],
186                    ],
187                    'comments' => [],
188                    'author' => $row['u']['issue_author_name']
189                        ? ['name' => $row['u']['issue_author_name']]
190                        : ['name' => '[deleted user]'],
191                    'tsumegoId' => $row['ti']['tsumego_id'],
192                    'Set' => $row['s']['set_id'] ? [
193                        'id' => $row['s']['set_id'],
194                        'title' => $row['s']['set_title'],
195                    ] : null,
196                    'TsumegoNum' => $row['sc']['tsumego_num'] ?? null,
197                ];
198            }
199
200            // Add comment if exists
201            if (!empty($row['tc']['comment_id']))
202            {
203                $issuesMap[$issueId]['comments'][] = [
204                    'id' => $row['tc']['comment_id'],
205                    'message' => $row['tc']['comment_text'],
206                    'created' => $row['tc']['comment_created'],
207                    'user_id' => $row['tc']['comment_user_id'],
208                    'user' => $row['cu']['comment_author_name']
209                        ? [
210                            'id' => $row['cu']['comment_author_id'],
211                            'name' => $row['cu']['comment_author_name'],
212                            'external_id' => $row['cu']['comment_author_external_id'],
213                            'picture' => $row['cu']['comment_author_picture'],
214                            'rating' => $row['cu']['comment_author_rating'],
215                            'isAdmin' => $row['cu']['comment_author_isAdmin'],
216                        ]
217                        : ['name' => '[deleted user]', 'isAdmin' => 0],
218                ];
219            }
220        }
221
222        // Build result in original order (from first query)
223        $result = [];
224        foreach ($issueIds as $index => $issueId)
225            if (isset($issuesMap[$issueId]))
226            {
227                $issueData = $issuesMap[$issueId];
228                $issueData['issueNumber'] = $offset + $index + 1;
229                $result[] = $issueData;
230            }
231
232        return $result;
233    }
234
235    /**
236     * Get counts for the issues index tabs.
237     *
238     * @return array{open: int, closed: int}
239     */
240    public function getIndexCounts(): array
241    {
242        return [
243            'open' => $this->find('count', [
244                'conditions' => [
245                    'tsumego_issue_status_id' => self::$OPENED_STATUS,
246                    'deleted' => false,
247                ],
248            ]),
249            'closed' => $this->find('count', [
250                'conditions' => [
251                    'tsumego_issue_status_id' => self::$CLOSED_STATUS,
252                    'deleted' => false,
253                ],
254            ]),
255        ];
256    }
257
258    /**
259     * Get comment section counts for a specific tsumego.
260     *
261     * Used for updating the comment tabs (ALL/COMMENTS/ISSUES) via htmx OOB.
262     *
263     * @param int $tsumegoId The tsumego ID
264     * @return array{total: int, comments: int, issues: int, openIssues: int}
265     */
266    public function getCommentSectionCounts(int $tsumegoId): array
267    {
268        $TsumegoComment = ClassRegistry::init('TsumegoComment');
269
270        // Count standalone comments (not in any issue)
271        $commentCount = $TsumegoComment->find('count', [
272            'conditions' => [
273                'TsumegoComment.tsumego_id' => $tsumegoId,
274                'TsumegoComment.tsumego_issue_id IS NULL',
275                'TsumegoComment.deleted' => false,
276            ],
277        ]);
278
279        // Count issues for this tsumego
280        $issueCount = $this->find('count', [
281            'conditions' => [
282                'TsumegoIssue.tsumego_id' => $tsumegoId,
283            ],
284        ]);
285
286        // Count open issues for this tsumego
287        $openIssueCount = $this->find('count', [
288            'conditions' => [
289                'TsumegoIssue.tsumego_id' => $tsumegoId,
290                'TsumegoIssue.tsumego_issue_status_id' => self::$OPENED_STATUS,
291            ],
292        ]);
293
294        return [
295            'total' => $commentCount + $issueCount,
296            'comments' => $commentCount,
297            'issues' => $issueCount,
298            'openIssues' => $openIssueCount,
299        ];
300    }
301}