db = Database::connection(); } public function dashboardCounts(): array { return [ 'posts' => (int) $this->db->query("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL")->fetchColumn(), 'published' => (int) $this->db->query("SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL")->fetchColumn(), 'inquiries' => (int) $this->db->query("SELECT COUNT(*) FROM inquiries WHERE deleted_at IS NULL")->fetchColumn(), 'pending' => (int) $this->db->query("SELECT COUNT(*) FROM inquiries WHERE status IN ('pending','in_progress') AND deleted_at IS NULL")->fetchColumn(), ]; } public function adminCount(): int { return (int) $this->db->query('SELECT COUNT(*) FROM admins')->fetchColumn(); } public function createAdmin(string $username, string $displayName, string $password): void { $statement = $this->db->prepare( 'INSERT INTO admins (username, password_hash, display_name) VALUES (:username, :password_hash, :display_name)' ); $statement->execute([ 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'display_name' => $displayName, ]); } public function posts(): array { return $this->db->query( "SELECT id, title, slug, category, status, published_at, updated_at FROM posts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 100" )->fetchAll(); } public function inquiries(): array { return $this->db->query( "SELECT id, ticket, name, phone, inquiry_type, title, status, created_at FROM inquiries WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 100" )->fetchAll(); } public function findPost(int $id): ?array { $statement = $this->db->prepare( 'SELECT posts.*, (SELECT file_path FROM post_images WHERE post_id = posts.id AND caption NOT LIKE \'@gtsit:inline:%\' ORDER BY sort_order, id LIMIT 1) AS image_path, (SELECT alt_text FROM post_images WHERE post_id = posts.id AND caption NOT LIKE \'@gtsit:inline:%\' ORDER BY sort_order, id LIMIT 1) AS image_alt FROM posts WHERE posts.id = :id AND posts.deleted_at IS NULL LIMIT 1' ); $statement->execute(['id' => $id]); $post = $statement->fetch(); if (!$post) { return null; } $media = $this->postMedia($id); $post['gallery_images'] = $media['gallery']; $post['inline_images'] = $media['inline']; return $post; } public function postMedia(int $postId): array { $statement = $this->db->prepare( 'SELECT id, file_path, alt_text, caption, sort_order FROM post_images WHERE post_id = :post_id ORDER BY sort_order, id' ); $statement->execute(['post_id' => $postId]); $gallery = []; $inline = []; foreach ($statement->fetchAll() as $image) { if (preg_match('/^@gtsit:inline:(\d+)$/', (string) $image['caption'], $matches) === 1) { $image['token'] = (int) $matches[1]; $inline[] = $image; continue; } $gallery[] = $image; } return ['gallery' => $gallery, 'inline' => $inline]; } public function savePost(array $data, ?int $id = null): int { $publishedAt = $data['status'] === 'published' ? ($data['published_at'] ?: date('Y-m-d H:i:s')) : null; if ($id === null) { $temporarySlug = 'pending-' . bin2hex(random_bytes(12)); $statement = $this->db->prepare( 'INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at) VALUES (:title, :slug, :category, :excerpt, :body, :author, :status, :published_at)' ); $statement->execute([ 'title'=>$data['title'],'slug'=>$temporarySlug,'category'=>$data['category'], 'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'], 'status'=>$data['status'],'published_at'=>$publishedAt, ]); $postId = (int) $this->db->lastInsertId(); $updateSlug = $this->db->prepare('UPDATE posts SET slug = :slug WHERE id = :id'); $updateSlug->execute(['slug' => 'post-' . $postId, 'id' => $postId]); return $postId; } $statement = $this->db->prepare( 'UPDATE posts SET title=:title, slug=:slug, category=:category, excerpt=:excerpt, body=:body, author=:author, status=:status, published_at=:published_at WHERE id=:id AND deleted_at IS NULL' ); $statement->execute([ 'title'=>$data['title'],'slug'=>'post-' . $id,'category'=>$data['category'], 'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'], 'status'=>$data['status'],'published_at'=>$publishedAt,'id'=>$id, ]); return $id; } public function savePostWithImage(array $data, ?int $id, ?string $imagePath, string $imageAlt): array { $oldPaths = []; $this->db->beginTransaction(); try { $postId = $this->savePost($data, $id); if ($imagePath !== null) { $select = $this->db->prepare('SELECT file_path FROM post_images WHERE post_id = :post_id FOR UPDATE'); $select->execute(['post_id' => $postId]); $oldPaths = array_column($select->fetchAll(), 'file_path'); $delete = $this->db->prepare('DELETE FROM post_images WHERE post_id = :post_id'); $delete->execute(['post_id' => $postId]); $insert = $this->db->prepare( 'INSERT INTO post_images (post_id, file_path, alt_text, sort_order) VALUES (:post_id, :file_path, :alt_text, 0)' ); $insert->execute(['post_id'=>$postId, 'file_path'=>$imagePath, 'alt_text'=>$imageAlt]); } elseif ($id !== null) { $update = $this->db->prepare( 'UPDATE post_images SET alt_text = :alt_text WHERE post_id = :post_id AND sort_order = 0' ); $update->execute(['post_id'=>$postId, 'alt_text'=>$imageAlt]); } $this->db->commit(); return ['id' => $postId, 'old_paths' => $oldPaths]; } catch (\Throwable $exception) { if ($this->db->inTransaction()) { $this->db->rollBack(); } throw $exception; } } public function savePostWithMedia( array $data, ?int $id, array $galleryImages, array $inlineImages, array $deleteImageIds, array $imageAlts ): array { $oldPaths = []; $this->db->beginTransaction(); try { $postId = $this->savePost($data, $id); $existing = $this->db->prepare( 'SELECT id, file_path, caption FROM post_images WHERE post_id = :post_id FOR UPDATE' ); $existing->execute(['post_id' => $postId]); $existingImages = $existing->fetchAll(); $existingIds = array_map('intval', array_column($existingImages, 'id')); foreach ($imageAlts as $imageId => $altText) { $imageId = (int) $imageId; if (!in_array($imageId, $existingIds, true)) continue; $update = $this->db->prepare( 'UPDATE post_images SET alt_text = :alt_text WHERE id = :id AND post_id = :post_id' ); $update->execute(['alt_text' => $altText, 'id' => $imageId, 'post_id' => $postId]); } $deleteIds = array_values(array_intersect($existingIds, array_map('intval', $deleteImageIds))); if ($deleteIds !== []) { foreach ($existingImages as $existingImage) { if (in_array((int) $existingImage['id'], $deleteIds, true)) { $oldPaths[] = (string) $existingImage['file_path']; } } $placeholders = implode(',', array_fill(0, count($deleteIds), '?')); $delete = $this->db->prepare("DELETE FROM post_images WHERE post_id = ? AND id IN ({$placeholders})"); $delete->execute([$postId, ...$deleteIds]); } $galleryOrder = 0; foreach ($existingImages as $existingImage) { if (!in_array((int) $existingImage['id'], $deleteIds, true) && !str_starts_with((string) $existingImage['caption'], '@gtsit:inline:')) { $galleryOrder++; } } $insert = $this->db->prepare( 'INSERT INTO post_images (post_id, file_path, alt_text, caption, sort_order) VALUES (:post_id, :file_path, :alt_text, :caption, :sort_order)' ); foreach ($galleryImages as $image) { $insert->execute([ 'post_id' => $postId, 'file_path' => $image['path'], 'alt_text' => $image['alt'], 'caption' => '@gtsit:gallery', 'sort_order' => $galleryOrder++, ]); } foreach ($inlineImages as $image) { $insert->execute([ 'post_id' => $postId, 'file_path' => $image['path'], 'alt_text' => $image['alt'], 'caption' => '@gtsit:inline:' . $image['token'], 'sort_order' => 1000 + (int) $image['token'], ]); } $this->db->commit(); return ['id' => $postId, 'old_paths' => $oldPaths]; } catch (\Throwable $exception) { if ($this->db->inTransaction()) { $this->db->rollBack(); } throw $exception; } } public function deletePost(int $id): void { $statement = $this->db->prepare('UPDATE posts SET deleted_at = NOW() WHERE id = :id'); $statement->execute(['id' => $id]); } public function findInquiry(int $id): ?array { $statement = $this->db->prepare('SELECT * FROM inquiries WHERE id = :id AND deleted_at IS NULL LIMIT 1'); $statement->execute(['id' => $id]); $inquiry = $statement->fetch(); return $inquiry ?: null; } public function updateInquiry(int $id, string $status, string $reply): void { $statement = $this->db->prepare( 'UPDATE inquiries SET status = :new_status, reply = :reply, replied_at = CASE WHEN :has_reply = 1 THEN NOW() ELSE replied_at END, closed_at = CASE WHEN :closed_status = \'closed\' AND closed_at IS NULL THEN NOW() WHEN :open_status <> \'closed\' THEN NULL ELSE closed_at END WHERE id = :id AND deleted_at IS NULL' ); $statement->execute([ 'new_status' => $status, 'closed_status' => $status, 'open_status' => $status, 'reply' => $reply === '' ? null : $reply, 'has_reply' => $reply === '' ? 0 : 1, 'id' => $id, ]); } }