Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Article
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 8
1806
0.00% covered (danger)
0.00%
0 / 1
 save
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
182
 update
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 publish
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 archive
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 incrementViewCount
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 toArray
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 fromArray
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3namespace BuyerKiosk\Support;
4
5use \PDO;
6use \PDOException;
7
8/**
9 * Article - Support article model
10 *
11 * Manages support articles with markdown content, categories, authors,
12 * view tracking, and featured status for the knowledge base system.
13 */
14class Article
15{
16    /**
17     * @var int|null Article ID
18     */
19    public $id;
20
21    /**
22     * @var int Category ID
23     */
24    public $categoryId;
25
26    /**
27     * @var string Article title
28     */
29    public $title;
30
31    /**
32     * @var string URL-friendly slug
33     */
34    public $slug;
35
36    /**
37     * @var string Markdown content
38     */
39    public $contentMd;
40
41    /**
42     * @var string|null Rendered HTML content
43     */
44    public $contentHtml;
45
46    /**
47     * @var int|null Author employee ID
48     */
49    public $authorId;
50
51    /**
52     * @var string|null Author name (for display)
53     */
54    public $authorName;
55
56    /**
57     * @var string Status (draft, published, archived)
58     */
59    public $status;
60
61    /**
62     * @var int View count
63     */
64    public $viewCount;
65
66    /**
67     * @var bool Whether article is featured
68     */
69    public $isFeatured;
70
71    /**
72     * @var string Created timestamp
73     */
74    public $createdAt;
75
76    /**
77     * @var string|null Updated timestamp
78     */
79    public $updatedAt;
80
81    /**
82     * @var string|null Published timestamp
83     */
84    public $publishedAt;
85
86    /**
87     * @var array|null Aggregated reaction counts by type
88     */
89    public $reactionCounts;
90
91    /**
92     * @var int|null Comment count (aggregated)
93     */
94    public $commentCount;
95
96    /**
97     * @var Category|null Category object (joined)
98     */
99    public $category;
100
101    /**
102     * Save article (insert or update)
103     *
104     * Uses INSERT or UPDATE based on whether ID is set
105     *
106     * @param PDO $db Database connection
107     * @return bool True if successful
108     */
109    public function save(PDO $db): bool
110    {
111        if (!isset($this->categoryId) || !isset($this->title) || !isset($this->slug) || !isset($this->contentMd)) {
112            error_log("Article::save error: categoryId, title, slug, and contentMd are required");
113            return false;
114        }
115
116        // Set defaults
117        if (!isset($this->status)) {
118            $this->status = 'draft';
119        }
120        if (!isset($this->viewCount)) {
121            $this->viewCount = 0;
122        }
123        if (!isset($this->isFeatured)) {
124            $this->isFeatured = false;
125        }
126
127        try {
128            if ($this->id) {
129                // Update existing article
130                $stmt = $db->prepare("
131                    UPDATE support_articles
132                    SET category_id = :categoryId,
133                        title = :title,
134                        slug = :slug,
135                        content_md = :contentMd,
136                        content_html = :contentHtml,
137                        author_id = :authorId,
138                        status = :status,
139                        is_featured = :isFeatured,
140                        published_at = :publishedAt
141                    WHERE id = :id
142                ");
143
144                return $stmt->execute([
145                    ':id' => $this->id,
146                    ':categoryId' => $this->categoryId,
147                    ':title' => $this->title,
148                    ':slug' => $this->slug,
149                    ':contentMd' => $this->contentMd,
150                    ':contentHtml' => $this->contentHtml,
151                    ':authorId' => $this->authorId,
152                    ':status' => $this->status,
153                    ':isFeatured' => $this->isFeatured ? 1 : 0,
154                    ':publishedAt' => $this->publishedAt
155                ]);
156            } else {
157                // Insert new article
158                $stmt = $db->prepare("
159                    INSERT INTO support_articles
160                    (category_id, title, slug, content_md, content_html, author_id, status, view_count, is_featured, published_at)
161                    VALUES
162                    (:categoryId, :title, :slug, :contentMd, :contentHtml, :authorId, :status, :viewCount, :isFeatured, :publishedAt)
163                ");
164
165                $result = $stmt->execute([
166                    ':categoryId' => $this->categoryId,
167                    ':title' => $this->title,
168                    ':slug' => $this->slug,
169                    ':contentMd' => $this->contentMd,
170                    ':contentHtml' => $this->contentHtml,
171                    ':authorId' => $this->authorId,
172                    ':status' => $this->status,
173                    ':viewCount' => $this->viewCount,
174                    ':isFeatured' => $this->isFeatured ? 1 : 0,
175                    ':publishedAt' => $this->publishedAt
176                ]);
177
178                if ($result) {
179                    $this->id = (int) $db->lastInsertId();
180                }
181
182                return $result;
183            }
184        } catch (PDOException $e) {
185            error_log("Article::save error: " . $e->getMessage());
186            return false;
187        }
188    }
189
190    /**
191     * Update article
192     *
193     * @param PDO $db Database connection
194     * @return bool True if successful
195     */
196    public function update(PDO $db): bool
197    {
198        return $this->save($db);
199    }
200
201    /**
202     * Delete article and all related data
203     *
204     * Note: This will cascade to reactions, comments, and edit suggestions
205     * in store databases via application logic
206     *
207     * @param PDO $db Database connection
208     * @return bool True if successful
209     */
210    public function delete(PDO $db): bool
211    {
212        if (!isset($this->id)) {
213            error_log("Article::delete error: id is required");
214            return false;
215        }
216
217        try {
218            $stmt = $db->prepare("DELETE FROM support_articles WHERE id = :id");
219            return $stmt->execute([':id' => $this->id]);
220        } catch (PDOException $e) {
221            error_log("Article::delete error: " . $e->getMessage());
222            return false;
223        }
224    }
225
226    /**
227     * Publish article
228     *
229     * Sets status to 'published' and sets publishedAt timestamp
230     *
231     * @param PDO $db Database connection
232     * @return bool True if successful
233     */
234    public function publish(PDO $db): bool
235    {
236        if (!isset($this->id)) {
237            error_log("Article::publish error: id is required");
238            return false;
239        }
240
241        try {
242            $this->status = 'published';
243            $this->publishedAt = date('Y-m-d H:i:s');
244
245            $stmt = $db->prepare("
246                UPDATE support_articles
247                SET status = 'published',
248                    published_at = :publishedAt
249                WHERE id = :id
250            ");
251
252            return $stmt->execute([
253                ':id' => $this->id,
254                ':publishedAt' => $this->publishedAt
255            ]);
256        } catch (PDOException $e) {
257            error_log("Article::publish error: " . $e->getMessage());
258            return false;
259        }
260    }
261
262    /**
263     * Archive article
264     *
265     * Sets status to 'archived'
266     *
267     * @param PDO $db Database connection
268     * @return bool True if successful
269     */
270    public function archive(PDO $db): bool
271    {
272        if (!isset($this->id)) {
273            error_log("Article::archive error: id is required");
274            return false;
275        }
276
277        try {
278            $this->status = 'archived';
279
280            $stmt = $db->prepare("
281                UPDATE support_articles
282                SET status = 'archived'
283                WHERE id = :id
284            ");
285
286            return $stmt->execute([':id' => $this->id]);
287        } catch (PDOException $e) {
288            error_log("Article::archive error: " . $e->getMessage());
289            return false;
290        }
291    }
292
293    /**
294     * Increment view count
295     *
296     * @param PDO $db Database connection
297     * @return bool True if successful
298     */
299    public function incrementViewCount(PDO $db): bool
300    {
301        if (!isset($this->id)) {
302            error_log("Article::incrementViewCount error: id is required");
303            return false;
304        }
305
306        try {
307            $stmt = $db->prepare("
308                UPDATE support_articles
309                SET view_count = view_count + 1
310                WHERE id = :id
311            ");
312
313            $result = $stmt->execute([':id' => $this->id]);
314
315            if ($result) {
316                $this->viewCount++;
317            }
318
319            return $result;
320        } catch (PDOException $e) {
321            error_log("Article::incrementViewCount error: " . $e->getMessage());
322            return false;
323        }
324    }
325
326    /**
327     * Convert Article to array for API responses
328     *
329     * @return array Associative array representation
330     */
331    public function toArray(): array
332    {
333        $result = [
334            'id' => $this->id,
335            'categoryId' => $this->categoryId,
336            'title' => $this->title,
337            'slug' => $this->slug,
338            'contentMd' => $this->contentMd,
339            'contentHtml' => $this->contentHtml,
340            'authorId' => $this->authorId,
341            'authorName' => $this->authorName,
342            'status' => $this->status,
343            'viewCount' => $this->viewCount,
344            'isFeatured' => (bool) $this->isFeatured,
345            'createdAt' => $this->createdAt,
346            'updatedAt' => $this->updatedAt,
347            'publishedAt' => $this->publishedAt
348        ];
349
350        // Include aggregated data if available
351        if (isset($this->reactionCounts)) {
352            $result['reactionCounts'] = $this->reactionCounts;
353        }
354        if (isset($this->commentCount)) {
355            $result['commentCount'] = $this->commentCount;
356        }
357        if (isset($this->category)) {
358            $result['category'] = $this->category->toArray();
359        }
360
361        return $result;
362    }
363
364    /**
365     * Create an Article object from an array
366     *
367     * Supports both snake_case (from database) and camelCase (from API) keys
368     *
369     * @param array $data Associative array of article data
370     * @return self
371     */
372    public static function fromArray(array $data): self
373    {
374        $article = new self();
375        $article->id = isset($data['id']) ? (int) $data['id'] : null;
376        $article->categoryId = (int) ($data['category_id'] ?? $data['categoryId'] ?? 0);
377        $article->title = $data['title'];
378        $article->slug = $data['slug'];
379        $article->contentMd = $data['content_md'] ?? $data['contentMd'] ?? '';
380        $article->contentHtml = $data['content_html'] ?? $data['contentHtml'] ?? null;
381        $article->authorId = isset($data['author_id']) ? (int) $data['author_id'] : (isset($data['authorId']) ? (int) $data['authorId'] : null);
382        $article->authorName = $data['author_name'] ?? $data['authorName'] ?? null;
383        $article->status = $data['status'] ?? 'draft';
384        $article->viewCount = isset($data['view_count']) ? (int) $data['view_count'] : (isset($data['viewCount']) ? (int) $data['viewCount'] : 0);
385        $article->isFeatured = isset($data['is_featured']) ? filter_var($data['is_featured'], FILTER_VALIDATE_BOOLEAN) : (isset($data['isFeatured']) ? filter_var($data['isFeatured'], FILTER_VALIDATE_BOOLEAN) : false);
386        $article->createdAt = $data['created_at'] ?? $data['createdAt'] ?? null;
387        $article->updatedAt = $data['updated_at'] ?? $data['updatedAt'] ?? null;
388        $article->publishedAt = $data['published_at'] ?? $data['publishedAt'] ?? null;
389
390        // Aggregated data
391        if (isset($data['reactionCounts'])) {
392            $article->reactionCounts = is_array($data['reactionCounts']) ? $data['reactionCounts'] : json_decode($data['reactionCounts'], true);
393        }
394        if (isset($data['commentCount'])) {
395            $article->commentCount = (int) $data['commentCount'];
396        }
397
398        return $article;
399    }
400}