diff --git a/.gitignore b/.gitignore index e89a32f..8f04f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .env .vscode/ *.log -scripts \ No newline at end of file +scripts + +public/uploads/photos/* +!public/uploads/photos/.gitkeep \ No newline at end of file diff --git a/migrations/006_comments_and_photos.sql b/migrations/006_comments_and_photos.sql new file mode 100644 index 0000000..97366f6 --- /dev/null +++ b/migrations/006_comments_and_photos.sql @@ -0,0 +1,35 @@ +-- ===================================================================== +-- Migration 006: Comments Table and Photo Support +-- ===================================================================== + + +-- --------------------------------------------------------------------- +-- Block 1: Creates Table "comments" +-- Stores Comments on Contributions. Comments is linked to +-- Contributions and identified by browser_id. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS comments ( + comment_id SERIAL PRIMARY KEY, + contribution_id INTEGER NOT NULL REFERENCES contributions(contribution_id) ON DELETE CASCADE, + author_name VARCHAR(100) NOT NULL, + browser_id VARCHAR(36) DEFAULT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + + +-- --------------------------------------------------------------------- +-- Block 2: Indexes for fast Comment Queries +-- --------------------------------------------------------------------- +CREATE INDEX idx_comments_contribution ON comments(contribution_id); +CREATE INDEX idx_comments_browser ON comments(browser_id); + + +-- --------------------------------------------------------------------- +-- Block 3: Adds Photo Path Column to Contributions +-- Stores relative Path to uploaded Photo File. +-- --------------------------------------------------------------------- +ALTER TABLE contributions + ADD COLUMN photo_path VARCHAR(255) DEFAULT NULL; + +COMMENT ON COLUMN contributions.photo_path IS 'Relative Path to uploaded Photo. NULL = no Photo.'; \ No newline at end of file diff --git a/public/api/contributions.php b/public/api/contributions.php index 80aadb3..2c1620f 100644 --- a/public/api/contributions.php +++ b/public/api/contributions.php @@ -47,13 +47,22 @@ switch ($action) { case 'delete_news': handle_delete_news($input); break; + case 'read_comments': + handle_read_comments($input); + break; + case 'create_comment': + handle_create_comment($input); + break; + case 'delete_comment': + handle_delete_comment($input); + break; default: error_response('Unknown Action. Supported Actions are read, create, update, delete, vote.'); } // ===================================================================== -// Action Handlers +// Action Handlers for Contributions // ===================================================================== @@ -152,6 +161,11 @@ function handle_read($input) { // Required: municipality_id, geom, geom_type, category, title, author_name // Optional: description // --------------------------------------------------------------------- +// --------------------------------------------------------------------- +// CREATE: Inserts new Contributions with optional Photo Upload +// Required: municipality_id, geom, geom_type, category, title, author_name +// Optional: description, browser_id, photo (File Upload) +// --------------------------------------------------------------------- function handle_create($input) { $pdo = get_db(); @@ -175,14 +189,23 @@ function handle_create($input) { error_response('Invalid GeoJSON in Geometry Field.'); } + // Handles Photo Upload + $photo_path = null; + if (isset($_FILES['photo']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) { + $photo_path = handle_photo_upload($_FILES['photo']); + if (!$photo_path) { + error_response('Photo Upload failed. JPG, PNG, GIF and WebP up to 5 MB are allowed.'); + } + } + // Prepared SQL Statement try { $stmt = $pdo->prepare(" INSERT INTO contributions - (municipality_id, geom, geom_type, category, title, description, author_name, browser_id) + (municipality_id, geom, geom_type, category, title, description, author_name, browser_id, photo_path) VALUES (:mid, ST_SetSRID(ST_GeomFromGeoJSON(:geom), 4326), :geom_type, - :category, :title, :description, :author_name, :browser_id) + :category, :title, :description, :author_name, :browser_id, :photo_path) "); $stmt->execute([ @@ -193,7 +216,8 @@ function handle_create($input) { ':title' => $input['title'], ':description' => $input['description'] ?? '', ':author_name' => $input['author_name'], - ':browser_id' => $input['browser_id'] ?? null + ':browser_id' => $input['browser_id'] ?? null, + ':photo_path' => $photo_path ]); json_response([ @@ -394,6 +418,10 @@ function handle_vote($input) { } +// ===================================================================== +// Action Handlers for News +// ===================================================================== + // --------------------------------------------------------------------- // CREATE NEWS: Inserts new News Entry // Required: municipality_id, title, content @@ -475,4 +503,162 @@ function handle_delete_news($input) { } catch (PDOException $e) { error_response('Database Error: ' . $e->getMessage(), 500); } +} + + +// ===================================================================== +// Action Handlers for Photos +// ===================================================================== + +// --------------------------------------------------------------------- +// PHOTO UPLOAD: Validates and Saves uploaded Photo Files +// Returns relative Path on Success, null on Failure. +// Allowed: JPG, PNG, GIF, WebP. with maximum Size of 5 MB. +// --------------------------------------------------------------------- +function handle_photo_upload($file) { + // Validates File Size + $max_size = 5 * 1024 * 1024; + if ($file['size'] > $max_size) { + return null; + } + + // Validates MIME Type + $allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!in_array($mime, $allowed_types)) { + return null; + } + + // Generates unique Filename + $ext = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp' + ][$mime]; + + $filename = uniqid('photo_', true) . '.' . $ext; + $upload_dir = __DIR__ . '/../uploads/photos/'; + $target_path = $upload_dir . $filename; + + // Creates Upload Directory + if (!is_dir($upload_dir)) { + mkdir($upload_dir, 0755, true); + } + + // Moves uploaded File + if (move_uploaded_file($file['tmp_name'], $target_path)) { + return 'uploads/photos/' . $filename; + } + + return null; +} + +// ===================================================================== +// Action Handlers for Comments +// ===================================================================== + +// --------------------------------------------------------------------- +// READ COMMENTS: Loads Comments for a Contribution +// Returns Comments sorted by Date (newest first) +// Required: contribution_id +// --------------------------------------------------------------------- +function handle_read_comments($input) { + $pdo = get_db(); + + $missing = validate_required($input, ['contribution_id']); + if (!empty($missing)) { + error_response('Missing Fields: ' . implode(', ', $missing)); + } + + try { + $stmt = $pdo->prepare(" + SELECT comment_id, contribution_id, author_name, browser_id, content, created_at + FROM comments + WHERE contribution_id = :cid + ORDER BY created_at ASC + "); + $stmt->execute([':cid' => $input['contribution_id']]); + $comments = $stmt->fetchAll(); + + json_response(['comments' => $comments, 'count' => count($comments)]); + + } catch (PDOException $e) { + error_response('Database Error: ' . $e->getMessage(), 500); + } +} + + +// --------------------------------------------------------------------- +// CREATE COMMENT: Adds Comments to Contributions +// Required: contribution_id, author_name, content +// Optional: browser_id +// --------------------------------------------------------------------- +function handle_create_comment($input) { + $pdo = get_db(); + + $missing = validate_required($input, ['contribution_id', 'author_name', 'content']); + if (!empty($missing)) { + error_response('Missing Fields: ' . implode(', ', $missing)); + } + + // Validates Content Length + if (strlen($input['content']) > 1000) { + error_response('Comment too long. Maximum 1000 Characters.'); + } + + // Checks if Contribution exists + $stmt = $pdo->prepare("SELECT contribution_id FROM contributions WHERE contribution_id = :id"); + $stmt->execute([':id' => $input['contribution_id']]); + if (!$stmt->fetch()) { + error_response('Contribution not found.', 404); + } + + try { + $stmt = $pdo->prepare(" + INSERT INTO comments (contribution_id, author_name, browser_id, content) + VALUES (:cid, :author, :bid, :content) + "); + $stmt->execute([ + ':cid' => $input['contribution_id'], + ':author' => $input['author_name'], + ':bid' => $input['browser_id'] ?? null, + ':content' => $input['content'] + ]); + + json_response([ + 'message' => 'Comment created successfully.', + 'comment_id' => (int) $pdo->lastInsertId() + ], 201); + + } catch (PDOException $e) { + error_response('Database Error: ' . $e->getMessage(), 500); + } +} + + +// --------------------------------------------------------------------- +// DELETE COMMENT: Removes a Comment +// Required: comment_id +// --------------------------------------------------------------------- +function handle_delete_comment($input) { + $pdo = get_db(); + + $missing = validate_required($input, ['comment_id']); + if (!empty($missing)) { + error_response('Missing Fields: ' . implode(', ', $missing)); + } + + try { + $stmt = $pdo->prepare("DELETE FROM comments WHERE comment_id = :id"); + $stmt->execute([':id' => $input['comment_id']]); + + json_response(['message' => 'Comment deleted successfully.']); + + } catch (PDOException $e) { + error_response('Database Error: ' . $e->getMessage(), 500); + } } \ No newline at end of file diff --git a/public/index.php b/public/index.php index f122214..833b6be 100644 --- a/public/index.php +++ b/public/index.php @@ -321,6 +321,15 @@ $news_items = $stmt->fetchAll(); + +