topicId = $topicId; $this->name = $name; } public function getId(): int { return $this->topicId; } public function getName(): string { return $this->name; } } // -- Post Data Models (as previously defined, with setReadCount in Post) -- class Post { private Space $space; private Author $author; private int $postTypeId; private ?string $title; private ?string $slug; private int $readCount; private ?DateTime $createdAt; private ?DateTime $updatedAt; private ?DateTime $status; private bool $published; public function __construct($postArray) { $this->postId = $postArray['postId']; $this->author = $postArray['authorId']; $this->postTypeId = $postArray['postType']; $this->title = $postArray['title']; $this->slug = $postArray['slug']; $this->readCount = 0; $this->status = $postArray['status']; } public function getPostId(): int { return $this->postId; } public function getAuthor(): Author { return $this->author; } public function getPostTypeId(): int { return $this->postTypeId; } public function getTitle(): ?string { return $this->title; } public function getSlug(): ?string { return $this->slug; } public function getReadCount(): int { return $this->readCount; } public function getCreatedAt(): ?DateTime { return $this->createdAt; } public function getUpdatedAt(): ?DateTime { return $this->updatedAt; } public function getStatus(): ?DateTime { return $this->status; } public function isPublished(): bool { return $this->published; } public function incrementReadCount(): void { $this->readCount++; } public function setSlug(?string $slug): void { $this->slug = $slug; } public function setStatus(?string $status): void { $this->status = $status; } public function setPublished(bool $published): void { $this->published = $published; } public function setReadCount(int $count): void { $this->readCount = $count; } } class Essay extends Post { private ?string $markdown; private ?string $excerpt; private ?array $files; public function __construct(int $postId, Author $author, ?string $title = null, ?string $slug = null, ?string $markdown = null, ?string $excerpt = null, ?array $files = null, ?DateTime $createdAt = null, ?DateTime $updatedAt = null, ?DateTime $publishedAt = null, bool $published = true) { parent::__construct($postId, $author, 1, $title, $slug, $createdAt, $updatedAt, $publishedAt, $published); $this->markdown = $markdown; $this->excerpt = $excerpt; $this->files = $files; } public function getMarkdown(): ?string { return $this->markdown; } public function getExcerpt(): ?string { return $this->excerpt; } public function getFiles(): ?array { return $this->files; } public function setMarkdown(?string $markdown): void { $this->markdown = $markdown; } public function setExcerpt(?string $excerpt): void { $this->excerpt = $excerpt; } public function setFiles(?array $files): void { $this->files = $files; } } class Moment extends Post { private ?string $caption; private ?array $images; public function __construct(int $postId, Author $author, ?string $caption = null, ?array $images = null, ?DateTime $createdAt = null, ?DateTime $updatedAt = null, ?DateTime $publishedAt = null, bool $published = true) { parent::__construct($postId, $author, 2, null, null, $createdAt, $updatedAt, $publishedAt, $published); $this->caption = $caption; $this->images = $images; } public function getCaption(): ?string { return $this->caption; } public function getImages(): ?array { return $this->images; } public function setCaption(?string $caption): void { $this->caption = $caption; } public function setImages(?array $images): void { $this->images = $images; } } class Thought extends Post { private ?string $thought; public function __construct(int $postId, Author $author, ?string $thought = null, ?DateTime $createdAt = null, ?DateTime $updatedAt = null, ?DateTime $publishedAt = null, bool $published = true) { parent::__construct($postId, $author, 3, null, null, $createdAt, $updatedAt, $publishedAt, $published); $this->thought = $thought; } public function getThought(): ?string { return $this->thought; } public function setThought(?string $thought): void { $this->thought = $thought; } } // -- Repository for Topics -- class TopicRepository { // No $conn property needed as exec_stmt handles connection public function __construct() { // Constructor is now empty } public function getAllTopics(): array { $result = exec_stmt('SELECT topic_id, topic FROM topics ORDER BY topic ASC', ''); // No params $topics = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) { $topics[] = new Topic((int) $row['topic_id'], $row['topic']); } $result->free(); } else { // exec_stmt for SELECT should return mysqli_result. If not, it's an issue with exec_stmt or query. error_log('TopicRepository::getAllTopics expected mysqli_result, got something else.'); } return $topics; } public function findById(int $topicId): ?Topic { $result = exec_stmt('SELECT topic_id, topic FROM topics WHERE topic_id = ?', 'i', $topicId); if ($result instanceof mysqli_result) { if ($row = $result->fetch_assoc()) { $result->free(); return new Topic((int) $row['topic_id'], $row['topic']); } $result->free(); } return null; } public function findByName(string $name): ?Topic { $result = exec_stmt('SELECT topic_id, topic FROM topics WHERE topic = ?', 's', $name); if ($result instanceof mysqli_result) { if ($row = $result->fetch_assoc()) { $result->free(); return new Topic((int) $row['topic_id'], $row['topic']); } $result->free(); } return null; } public function findOrCreate(string $name): Topic { $topic = $this->findByName($name); if ($topic) { return $topic; } // topics.topic_id is AUTO_INCREMENT, so exec_stmt should return the new ID. $newTopicId = exec_stmt('INSERT INTO topics (topic) VALUES (?)', 's', $name); if (is_numeric($newTopicId) && $newTopicId > 0) { return new Topic((int) $newTopicId, $name); } // If $newTopicId is 0 or not numeric, insert failed or exec_stmt behavior is unexpected. throw new \RuntimeException('Failed to create topic or retrieve its ID: ' . $name); } } // -- Repository for Posts -- class PostRepository { private TopicRepository $topicRepository; private string $essayContentPath; public function __construct(TopicRepository $topicRepository, string $essayContentPath) { $this->topicRepository = $topicRepository; $this->essayContentPath = rtrim($essayContentPath, '/') . '/'; } private function getAuthorFromDb(int $authorId): ?Author { $result = exec_stmt('SELECT user_id, username, profile_picture, bio, website FROM user WHERE user_id = ?', 'i', $authorId); if ($result instanceof mysqli_result) { $authorData = $result->fetch_assoc(); $result->free(); if ($authorData) { return new Author( (int) $authorData['user_id'], $authorData['username'], $authorData['profile_picture'], $authorData['bio'], $authorData['website'] ); } } else { error_log('PostRepository::getAuthorFromDb expected mysqli_result for author ID: ' . $authorId); } error_log('Author not found with ID: ' . $authorId); return null; } private function hydratePost(array $row): ?Post { $author = $this->getAuthorFromDb((int) $row['author_id']); if (!$author) return null; $postId = (int) $row['post_id']; $postTypeId = (int) $row['post_type']; $title = $row['title']; $slug = $row['slug']; $readCount = (int) $row['read_count']; $createdAt = $row['created_at'] ? new DateTime($row['created_at']) : null; $updatedAt = $row['updated_at'] ? new DateTime($row['updated_at']) : null; $publishedAt = $row['published_at'] ? new DateTime($row['published_at']) : null; $published = (bool) $row['published']; $post = null; switch ($postTypeId) { case 1: // Essay $markdown = $this->getEssayMarkdown($postId); $post = new Essay($postId, $author, $title, $slug, $markdown, $row['excerpt'], null, $createdAt, $updatedAt, $publishedAt, $published); break; case 2: // Moment $post = new Moment($postId, $author, $row['excerpt'], null, $createdAt, $updatedAt, $publishedAt, $published); break; case 3: // Thought $post = new Thought($postId, $author, $row['excerpt'], $createdAt, $updatedAt, $publishedAt, $published); break; default: error_log('Unknown post type ID: ' . $postTypeId); return null; } if ($post) $post->setReadCount($readCount); return $post; } // File operations remain unchanged as they don't use the DB connection directly private function getEssayMarkdown(int $postId): ?string { /* ... same as before ... */ $filePath = $this->essayContentPath . $postId . '/article.md'; if (file_exists($filePath) && is_readable($filePath)) { return file_get_contents($filePath); } return null; } private function saveEssayMarkdown(int $postId, string $markdownContent): bool { /* ... same as before ... */ $dirPath = $this->essayContentPath . $postId . '/'; if (!is_dir($dirPath)) { if (!mkdir($dirPath, 0755, true)) { error_log('Failed to create directory: ' . $dirPath); return false; } } $filePath = $dirPath . 'article.md'; if (file_put_contents($filePath, $markdownContent) === false) { error_log('Failed to write markdown file: ' . $filePath); return false; } return true; } private function deleteEssayContentDirectory(int $postId): bool { /* ... same as before ... */ $dirPath = $this->essayContentPath . $postId . '/'; if (!is_dir($dirPath)) return true; $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dirPath, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($files as $fileinfo) { $todo = ($fileinfo->isDir() ? 'rmdir' : 'unlink'); if (!@$todo($fileinfo->getRealPath())) { error_log("Failed to {$todo} {$fileinfo->getRealPath()}"); return false; } } if (!@rmdir($dirPath)) { error_log("Failed to remove main directory {$dirPath}"); return false; } return true; } public function findById(int $postId): ?Post { $result = exec_stmt('SELECT * FROM posts WHERE post_id = ?', 'i', $postId); if ($result instanceof mysqli_result) { $row = $result->fetch_assoc(); $result->free(); return $row ? $this->hydratePost($row) : null; } error_log('PostRepository::findById expected mysqli_result for post ID: ' . $postId); return null; } public function getPostIds(string $orderBy = 'published_at', string $direction = 'DESC'): array { // Basic validation for orderBy and direction $orderBy = preg_replace('/[^a-zA-Z0-9_]/', '', $orderBy); // Basic sanitization $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT post_id FROM posts ORDER BY $orderBy $direction"; $result = exec_stmt($sql, ''); $ids = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) $ids[] = (int) $row['post_id']; $result->free(); } else { error_log('Error in getPostIds: Expected mysqli_result.'); } return $ids; } public function getAllPosts(string $orderBy = 'published_at', string $direction = 'DESC'): array { $orderBy = preg_replace('/[^a-zA-Z0-9_]/', '', $orderBy); $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT * FROM posts ORDER BY $orderBy $direction"; $result = exec_stmt($sql, ''); $posts = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) { if ($postObject = $this->hydratePost($row)) $posts[] = $postObject; } $result->free(); } else { error_log('Error in getAllPosts: Expected mysqli_result.'); } return $posts; } public function getPostIdsByAuthor(int $authorId, string $orderBy = 'published_at', string $direction = 'DESC'): array { $orderBy = preg_replace('/[^a-zA-Z0-9_]/', '', $orderBy); $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT post_id FROM posts WHERE author_id = ? ORDER BY $orderBy $direction"; $result = exec_stmt($sql, 'i', $authorId); $ids = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) $ids[] = (int) $row['post_id']; $result->free(); } else { error_log('Error in getPostIdsByAuthor: Expected mysqli_result.'); } return $ids; } public function getPostsByAuthor(int $authorId, string $orderBy = 'published_at', string $direction = 'DESC'): array { $orderBy = preg_replace('/[^a-zA-Z0-9_.]/', '', $orderBy); // Allow dot for aliased columns $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT * FROM posts WHERE author_id = ? ORDER BY $orderBy $direction"; $result = exec_stmt($sql, 'i', $authorId); $posts = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) { if ($postObject = $this->hydratePost($row)) $posts[] = $postObject; } $result->free(); } else { error_log('Error in getPostsByAuthor: Expected mysqli_result.'); } return $posts; } public function getPostIdsByTopicName(string $topicName, string $orderBy = 'p.published_at', string $direction = 'DESC'): array { $orderBy = preg_replace('/[^a-zA-Z0-9_.]/', '', $orderBy); $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT p.post_id FROM posts p INNER JOIN post_topics pt ON p.post_id = pt.post_id INNER JOIN topics t ON pt.topic_id = t.topic_id WHERE t.topic = ? ORDER BY $orderBy $direction"; $result = exec_stmt($sql, 's', $topicName); $ids = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) $ids[] = (int) $row['post_id']; $result->free(); } else { error_log('Error in getPostIdsByTopicName: Expected mysqli_result.'); } return $ids; } public function getPostsByTopicName(string $topicName, string $orderBy = 'p.published_at', string $direction = 'DESC'): array { $orderBy = preg_replace('/[^a-zA-Z0-9_.]/', '', $orderBy); $direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT p.* FROM posts p INNER JOIN post_topics pt ON p.post_id = pt.post_id INNER JOIN topics t ON pt.topic_id = t.topic_id WHERE t.topic = ? ORDER BY $orderBy $direction"; $result = exec_stmt($sql, 's', $topicName); $posts = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) { if ($postObject = $this->hydratePost($row)) $posts[] = $postObject; } $result->free(); } else { error_log('Error in getPostsByTopicName: Expected mysqli_result.'); } return $posts; } private function updatePostTopics(int $postId, array $topicNames): void { // Delete existing topics for the post // exec_stmt returns 0 for successful DELETE. No direct success check here. exec_stmt('DELETE FROM post_topics WHERE post_id = ?', 'i', $postId); if (empty($topicNames)) return; // Batch insert is harder with this exec_stmt. Doing one by one. // This is inefficient but simpler with the current exec_stmt. foreach ($topicNames as $name) { $topicObj = $this->topicRepository->findOrCreate(trim($name)); // post_topics has no auto-increment, exec_stmt will return 0 for success. exec_stmt('INSERT INTO post_topics (post_id, topic_id) VALUES (?, ?)', 'ii', $postId, $topicObj->getId()); } } public function createPost(Post $post, array $topicNames = []): ?Post { // WARNING: No transaction possible with the provided exec_stmt for multi-step operations. // Each exec_stmt is its own transaction. try { $sql = 'INSERT INTO posts (post_id, author_id, post_type, title, slug, read_count, created_at, updated_at, published_at, published, excerpt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; $postIdVal = $post->getPostId(); $authorId = $post->getAuthor()->getUserId(); $postTypeId = $post->getPostTypeId(); $title = $post->getTitle(); $slug = $post->getSlug(); $readCount = $post->getReadCount(); $createdAtDb = $post->getCreatedAt() ? $post->getCreatedAt()->format('Y-m-d H:i:s') : (new DateTime())->format('Y-m-d H:i:s'); $updatedAtDb = $post->getUpdatedAt() ? $post->getUpdatedAt()->format('Y-m-d H:i:s') : (new DateTime())->format('Y-m-d H:i:s'); $publishedAtDb = $post->getPublishedAt() ? $post->getPublishedAt()->format('Y-m-d H:i:s') : null; $published = (int) $post->isPublished(); $excerpt = null; if ($post instanceof Essay) $excerpt = $post->getExcerpt(); elseif ($post instanceof Moment) $excerpt = $post->getCaption(); elseif ($post instanceof Thought) $excerpt = $post->getThought(); // For INSERT on `posts` (no auto-increment PK), exec_stmt returns 0 on success. $insertResult = exec_stmt( $sql, 'iiississsis', $postIdVal, $authorId, $postTypeId, $title, $slug, $readCount, $createdAtDb, $updatedAtDb, $publishedAtDb, $published, $excerpt ); // We can't reliably check $insertResult for success of this specific insert. // Assume success if no PHP error/exception was thrown by exec_stmt (though it doesn't throw). if ($post instanceof Essay && $post->getMarkdown() !== null) { if (!$this->saveEssayMarkdown($postIdVal, $post->getMarkdown())) { // Rollback is not possible. Log error. error_log('Failed to save essay markdown for post ID: ' . $postIdVal . '. DB insert was separate.'); // Potentially delete the created post record if consistency is critical, but that's complex. } } $this->updatePostTopics($postIdVal, $topicNames); // Also not part of a transaction return $this->findById($postIdVal); } catch (\Exception $e) { // Catch any exceptions from our code, not exec_stmt error_log("Error creating post (ID: {$post->getPostId()}): " . $e->getMessage()); return null; } } public function updatePost(Post $post, array $topicNames = []): ?Post { // WARNING: No transaction possible. try { $sql = 'UPDATE posts SET author_id = ?, post_type = ?, title = ?, slug = ?, updated_at = CURRENT_TIMESTAMP, published_at = ?, published = ?, excerpt = ? WHERE post_id = ?'; $authorId = $post->getAuthor()->getUserId(); $postTypeId = $post->getPostTypeId(); $title = $post->getTitle(); $slug = $post->getSlug(); $publishedAtDb = $post->getPublishedAt() ? $post->getPublishedAt()->format('Y-m-d H:i:s') : null; $published = (int) $post->isPublished(); $postIdVal = $post->getPostId(); $excerpt = null; if ($post instanceof Essay) $excerpt = $post->getExcerpt(); elseif ($post instanceof Moment) $excerpt = $post->getCaption(); elseif ($post instanceof Thought) $excerpt = $post->getThought(); // exec_stmt returns 0 for successful UPDATE. exec_stmt( $sql, 'iisssisi', $authorId, $postTypeId, $title, $slug, $publishedAtDb, $published, $excerpt, $postIdVal ); if ($post instanceof Essay && $post->getMarkdown() !== null) { if (!$this->saveEssayMarkdown($postIdVal, $post->getMarkdown())) { error_log('Failed to save essay markdown for post ID: ' . $postIdVal . ' during update.'); } } $this->updatePostTopics($postIdVal, $topicNames); return $this->findById($postIdVal); } catch (\Exception $e) { error_log("Error updating post (ID: {$post->getPostId()}): " . $e->getMessage()); return null; } } /** * Deletes a post. * WARNING: Due to exec_stmt, this is not a transactional operation. * Success of individual DB deletions is not reliably checkable. * This method attempts the operations and logs errors. * Consider changing return type to void as boolean success is ambiguous. */ public function deletePost(int $postId): void { try { $postDataResult = exec_stmt('SELECT post_type FROM posts WHERE post_id = ?', 'i', $postId); $postType = null; if ($postDataResult instanceof mysqli_result && $row = $postDataResult->fetch_assoc()) { $postType = (int) $row['post_type']; $postDataResult->free(); } exec_stmt('DELETE FROM post_topics WHERE post_id = ?', 'i', $postId); exec_stmt('DELETE FROM posts WHERE post_id = ?', 'i', $postId); // Cannot reliably check if the above deletes were successful or affected rows. if ($postType === 1) { // Essay if (!$this->deleteEssayContentDirectory($postId)) { error_log('Failed to delete essay content directory for post ID: ' . $postId); } } } catch (\Exception $e) { error_log("Error during deletePost for ID $postId: " . $e->getMessage()); // Depending on desired behavior, re-throw or handle } } /** * Deletes posts by author. * WARNING: Not transactional. Returns void as count of deleted posts cannot be reliably obtained from exec_stmt. */ public function deletePostsByAuthor(int $authorId): void { try { $result = exec_stmt('SELECT post_id, post_type FROM posts WHERE author_id = ?', 'i', $authorId); $postsToDelete = []; if ($result instanceof mysqli_result) { while ($row = $result->fetch_assoc()) $postsToDelete[] = $row; $result->free(); } else { error_log("Failed to fetch posts for deletion by author ID: $authorId"); return; // Exit if we can't get the list of posts } foreach ($postsToDelete as $postInfo) { if ((int) $postInfo['post_type'] === 1) { // Essay if (!$this->deleteEssayContentDirectory((int) $postInfo['post_id'])) { error_log('Failed to delete essay content for post ' . $postInfo['post_id'] . ' during mass delete by author.'); } } // Handle Moment image deletion if any } // Must delete from child table `post_topics` first if no ON DELETE CASCADE, or if ensuring order. // This is more complex with joins if exec_stmt doesn't handle it well. // Simpler: iterate and delete topics for each post_id, or a broader delete. foreach ($postsToDelete as $postInfo) { exec_stmt('DELETE FROM post_topics WHERE post_id = ?', 'i', (int) $postInfo['post_id']); } exec_stmt('DELETE FROM posts WHERE author_id = ?', 'i', $authorId); // Cannot get actual deleted count. } catch (\Exception $e) { error_log("Error deleting posts by author (ID: $authorId): " . $e->getMessage()); } } /** * Increments read count for a post. * WARNING: Success of the UPDATE is not reliably checkable with current exec_stmt. */ public function incrementReadCount(int $postId): void { // exec_stmt returns 0 for successful UPDATE. exec_stmt('UPDATE posts SET read_count = read_count + 1 WHERE post_id = ?', 'i', $postId); // No easy way to confirm success with current exec_stmt. } } // Example Usage (Illustrative - this would go in your controller/logic files) /* * $topicRepo = new TopicRepository(); * // Ensure $_ENV['ESSAY_CONTENT_PATH'] is set in your environment/config * $essayPath = $_ENV['ESSAY_CONTENT_PATH'] ?? '/path/to/your/site/public/essays'; * $postRepo = new PostRepository($topicRepo, $essayPath); * * // --- Fetching Posts --- * // $allPosts = $postRepo->getAllPosts('created_at', 'DESC'); * // $post123 = $postRepo->findById(123); * // if ($post123 instanceof Essay) { * // echo "Essay Title: " . $post123->getTitle() . "\n"; * // } * * // --- Creating a new Essay --- * // $author = $userRepo->findAuthorById(1); // Assuming a UserRepo or similar for authors * // if ($author) { * // $newEssayId = 1001; // Must be unique, not auto-incremented in schema * // $newEssay = new Essay( * // $newEssayId, $author, "My New OOP Essay", "my-new-oop-essay", * // "# Hello World\n\nThis is markdown content.", "An excerpt about the essay." * // ); * // $createdPost = $postRepo->createPost($newEssay, ['PHP', 'OOP', 'Refactoring']); * // if ($createdPost) { * // echo "Created Post ID: " . $createdPost->getPostId() . "\n"; * // } else { * // echo "Failed to create post.\n"; * // } * // } * * // No explicit $dbConnection->close(); needed as exec_stmt handles its own connections. */