9 Commits

7 changed files with 740 additions and 148 deletions

View File

@@ -35,7 +35,7 @@ COMMENT ON TABLE municipalities IS 'Configuration Per Municipality (Tenant) usin
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 3: Table "contributions" -- Block 3: Table "contributions"
-- Aitizen and Administration Contributions as Points, Lines, and -- Citizen and Administration Contributions as Points, Lines, and
-- Polygons stored together in one mixed-geometry Column. -- Polygons stored together in one mixed-geometry Column.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TABLE contributions ( CREATE TABLE contributions (

View File

@@ -5,8 +5,8 @@
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 1: Tasks Table -- Block 1: Tasks Table
-- Stores community Tasks with Geometry, Moderation and Completion. -- Stores Tasks with Geometry, Moderation and Completion.
-- Status Flow: pending rejected | open → completed verified -- Status Flow from pending to rejected or approved to completed to verified
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
task_id SERIAL PRIMARY KEY, task_id SERIAL PRIMARY KEY,
@@ -21,17 +21,17 @@ CREATE TABLE IF NOT EXISTS tasks (
browser_id VARCHAR(36), browser_id VARCHAR(36),
photo_path VARCHAR(255), photo_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending' status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'rejected', 'open', 'completed', 'verified')), CHECK (status IN ('pending', 'rejected', 'approved', 'completed', 'verified')),
address VARCHAR(255), address VARCHAR(255),
-- Completion Fields (NULL until completed) -- Completion Fields empty before completed
completed_by_name VARCHAR(100), completed_by_name VARCHAR(100),
completed_by_browser VARCHAR(36), completed_by_browser VARCHAR(36),
completion_photo VARCHAR(255), completion_photo VARCHAR(255),
completion_comment TEXT, completion_comment TEXT,
completed_at TIMESTAMP, completed_at TIMESTAMP,
-- Counters (updated via Triggers) -- Counters updated via Triggers
likes_count INTEGER NOT NULL DEFAULT 0, likes_count INTEGER NOT NULL DEFAULT 0,
dislikes_count INTEGER NOT NULL DEFAULT 0, dislikes_count INTEGER NOT NULL DEFAULT 0,
comment_count INTEGER NOT NULL DEFAULT 0, comment_count INTEGER NOT NULL DEFAULT 0,
@@ -46,8 +46,8 @@ CREATE INDEX idx_tasks_category ON tasks(category);
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 2: User Points Table -- Block 2: Citizen Points Table
-- One Entry per verified Task Completion. Leaderboard via SUM/GROUP BY. -- One Row per Completion. Leaderboard via SUM and GROUP BY.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS user_points ( CREATE TABLE IF NOT EXISTS user_points (
points_id SERIAL PRIMARY KEY, points_id SERIAL PRIMARY KEY,
@@ -63,8 +63,8 @@ CREATE INDEX idx_user_points_user ON user_points(user_name);
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 3: Extends Votes Table for Tasks -- Block 3: Adapts Votes Table for Tasks
-- Either contribution_id OR task_id is set, not both. -- Either contribution_id OR task_id
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
ALTER TABLE votes ALTER TABLE votes
ADD COLUMN task_id INTEGER REFERENCES tasks(task_id) ON DELETE CASCADE; ADD COLUMN task_id INTEGER REFERENCES tasks(task_id) ON DELETE CASCADE;
@@ -78,8 +78,8 @@ ALTER TABLE votes
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 4: Extends Comments Table for Tasks -- Block 4: Adapts Comments Table for Tasks
-- Either contribution_id OR task_id is set, not both. -- Either contribution_id OR task_id
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
ALTER TABLE comments ALTER TABLE comments
ADD COLUMN task_id INTEGER REFERENCES tasks(task_id) ON DELETE CASCADE; ADD COLUMN task_id INTEGER REFERENCES tasks(task_id) ON DELETE CASCADE;
@@ -88,7 +88,7 @@ CREATE INDEX idx_comments_task ON comments(task_id);
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 5: Trigger — updated_at Timestamp for Tasks -- Block 5: Trigger Updated Timestamp for Tasks
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TRIGGER set_tasks_updated_at CREATE TRIGGER set_tasks_updated_at
BEFORE UPDATE ON tasks BEFORE UPDATE ON tasks
@@ -97,8 +97,8 @@ CREATE TRIGGER set_tasks_updated_at
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 6: Trigger Vote Counts for Tasks -- Block 6: Trigger Vote Counts for Tasks
-- Mirrors the Pattern from Contributions. -- Mirrors Pattern from Contributions.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_task_vote_counts() CREATE OR REPLACE FUNCTION update_task_vote_counts()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
@@ -123,16 +123,17 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_update_task_vote_counts ON votes;
CREATE TRIGGER trigger_update_task_vote_counts CREATE TRIGGER trigger_update_task_vote_counts
AFTER INSERT OR DELETE OR UPDATE ON votes AFTER INSERT OR DELETE OR UPDATE ON votes
FOR EACH ROW FOR EACH ROW
WHEN (NEW.task_id IS NOT NULL OR OLD.task_id IS NOT NULL)
EXECUTE FUNCTION update_task_vote_counts(); EXECUTE FUNCTION update_task_vote_counts();
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 7: Trigger Comment Count for Tasks -- Block 7: Trigger Comment Count for Tasks
-- Only counts approved Comments. Mirrors Contribution Pattern. -- Mirrors Pattern from Contributions.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_task_comment_count() CREATE OR REPLACE FUNCTION update_task_comment_count()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
@@ -161,15 +162,16 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_update_task_comment_count ON comments;
CREATE TRIGGER trigger_update_task_comment_count CREATE TRIGGER trigger_update_task_comment_count
AFTER INSERT OR DELETE OR UPDATE OF status ON comments AFTER INSERT OR DELETE OR UPDATE OF status ON comments
FOR EACH ROW FOR EACH ROW
WHEN (NEW.task_id IS NOT NULL OR OLD.task_id IS NOT NULL)
EXECUTE FUNCTION update_task_comment_count(); EXECUTE FUNCTION update_task_comment_count();
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 8: Views for QGIS (optional) -- Block 8: Views for QGIS
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE OR REPLACE VIEW tasks_points AS CREATE OR REPLACE VIEW tasks_points AS
SELECT * FROM tasks WHERE geom_type = 'point'; SELECT * FROM tasks WHERE geom_type = 'point';

View File

@@ -17,7 +17,7 @@ require_once __DIR__ . '/db.php';
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Read Action Parameter and Route to correct Handler // Reads Action Parameter and Routes to correct Handler
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
$input = get_input(); $input = get_input();
$action = $input['action'] ?? ''; $action = $input['action'] ?? '';
@@ -59,6 +59,27 @@ switch ($action) {
case 'update_comment': case 'update_comment':
handle_update_comment($input); handle_update_comment($input);
break; break;
case 'read_tasks':
handle_read_tasks($input);
break;
case 'create_task':
handle_create_task($input);
break;
case 'update_task':
handle_update_task($input);
break;
case 'delete_task':
handle_delete_task($input);
break;
case 'complete_task':
handle_complete_task($input);
break;
case 'verify_task':
handle_verify_task($input);
break;
case 'read_leaderboard':
handle_read_leaderboard($input);
break;
default: default:
error_response('Unknown Action. Supported Actions are read, create, update, delete, vote.'); error_response('Unknown Action. Supported Actions are read, create, update, delete, vote.');
} }
@@ -335,8 +356,8 @@ function handle_delete($input) {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// VOTE: Likes or Dislikes a Contribution // VOTE: Likes or Dislikes Contributions or Tasks
// Required: contribution_id, voter_name, vote_type // Required: contribution_id or task_id, voter_name, vote_type
// Database Trigger automatically updates Likes and Dislikes Count // Database Trigger automatically updates Likes and Dislikes Count
// UNIQUE Constraint prevents duplicate Votes per Voter. // UNIQUE Constraint prevents duplicate Votes per Voter.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -344,7 +365,7 @@ function handle_vote($input) {
$pdo = get_db(); $pdo = get_db();
// Validates Input // Validates Input
$missing = validate_required($input, ['contribution_id', 'voter_name', 'vote_type']); $missing = validate_required($input, ['voter_name', 'vote_type']);
if (!empty($missing)) { if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing)); error_response('Missing Fields: ' . implode(', ', $missing));
} }
@@ -355,13 +376,6 @@ function handle_vote($input) {
error_response('Invalid vote_type. Must be: ' . implode(', ', $valid_vote_types)); error_response('Invalid vote_type. Must be: ' . implode(', ', $valid_vote_types));
} }
// 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);
}
// Prepared SQL Statement // Prepared SQL Statement
try { try {
// Checks if Voter already voted on this Contribution // Checks if Voter already voted on this Contribution
@@ -370,11 +384,39 @@ function handle_vote($input) {
error_response('Browser ID required for Voting.'); error_response('Browser ID required for Voting.');
} }
$stmt = $pdo->prepare(" // Determines Vote Type
SELECT vote_id, vote_type FROM votes $is_task = isset($input['task_id']) && $input['task_id'] !== '';
WHERE contribution_id = :cid AND browser_id = :bid
"); if ($is_task) {
$stmt->execute([':cid' => $input['contribution_id'], ':bid' => $browser_id]); // Checks for Tasks
$stmt = $pdo->prepare("SELECT task_id FROM tasks WHERE task_id = :id");
$stmt->execute([':id' => $input['task_id']]);
if (!$stmt->fetch()) {
error_response('Task not found.', 404);
}
// Checks if Browser already voted on Task
$stmt = $pdo->prepare("
SELECT vote_id, vote_type FROM votes
WHERE task_id = :id AND browser_id = :bid
");
$stmt->execute([':id' => $input['task_id'], ':bid' => $browser_id]);
} else {
// Checks for Contributions
$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);
}
// Checks if Browser already voted on Contribution
$stmt = $pdo->prepare("
SELECT vote_id, vote_type FROM votes
WHERE contribution_id = :id AND browser_id = :bid
");
$stmt->execute([':id' => $input['contribution_id'], ':bid' => $browser_id]);
}
$existing = $stmt->fetch(); $existing = $stmt->fetch();
if ($existing) { if ($existing) {
@@ -384,35 +426,47 @@ function handle_vote($input) {
$stmt->execute([':vid' => $existing['vote_id']]); $stmt->execute([':vid' => $existing['vote_id']]);
json_response(['message' => 'Vote removed.', 'action' => 'removed']); json_response(['message' => 'Vote removed.', 'action' => 'removed']);
} else { } else {
// Different Vote Type — Switches Vote // Different Vote Type — Removes old Vote before Inserting new one
$stmt = $pdo->prepare("DELETE FROM votes WHERE vote_id = :vid"); $stmt = $pdo->prepare("DELETE FROM votes WHERE vote_id = :vid");
$stmt->execute([':vid' => $existing['vote_id']]); $stmt->execute([':vid' => $existing['vote_id']]);
$this_insert = true;
$stmt = $pdo->prepare("
INSERT INTO votes (contribution_id, voter_name, vote_type, browser_id)
VALUES (:cid, :voter, :vtype, :bid)
");
$stmt->execute([
':cid' => $input['contribution_id'],
':voter' => $input['voter_name'],
':vtype' => $input['vote_type'],
':bid' => $browser_id
]);
json_response(['message' => 'Vote changed.', 'action' => 'changed'], 200);
} }
} else { } else {
// No existing Vote — Inserts Vote // No existing Vote — Inserts Vote
$stmt = $pdo->prepare(" $this_insert = true;
INSERT INTO votes (contribution_id, voter_name, vote_type, browser_id) }
VALUES (:cid, :voter, :vtype, :bid)
if (!empty($this_insert)) {
if ($is_task) {
$stmt = $pdo->prepare("
INSERT INTO votes (task_id, voter_name, vote_type, browser_id)
VALUES (:id, :voter, :vtype, :bid)
"); ");
$stmt->execute([ $stmt->execute([
':cid' => $input['contribution_id'], ':id' => $input['task_id'],
':voter' => $input['voter_name'], ':voter' => $input['voter_name'],
':vtype' => $input['vote_type'], ':vtype' => $input['vote_type'],
':bid' => $browser_id ':bid' => $browser_id
]); ]);
json_response(['message' => 'Vote recorded.', 'action' => 'created'], 201); } else {
$stmt = $pdo->prepare("
INSERT INTO votes (contribution_id, voter_name, vote_type, browser_id)
VALUES (:id, :voter, :vtype, :bid)
");
$stmt->execute([
':id' => $input['contribution_id'],
':voter' => $input['voter_name'],
':vtype' => $input['vote_type'],
':bid' => $browser_id
]);
}
// Returns changed or created
if ($existing) {
json_response(['message' => 'Vote changed.', 'action' => 'changed'], 200);
} else {
json_response(['message' => 'Vote recorded.', 'action' => 'created'], 201);
}
} }
} catch (PDOException $e) { } catch (PDOException $e) {
@@ -565,26 +619,40 @@ function handle_photo_upload($file) {
// ===================================================================== // =====================================================================
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// READ COMMENTS: Loads Comments for a Contribution // READ COMMENTS: Loads Comments for Contributions or Tasks
// Returns Comments sorted by Date (newest first) // Returns Comments sorted by Date (oldest first)
// Required: contribution_id // Required: contribution_id or task_id
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
function handle_read_comments($input) { function handle_read_comments($input) {
$pdo = get_db(); $pdo = get_db();
$missing = validate_required($input, ['contribution_id']); // Checks for contribution_id or task_id
if (!empty($missing)) { if (empty($input['contribution_id']) && empty($input['task_id'])) {
error_response('Missing Fields: ' . implode(', ', $missing)); error_response('Either contribution_id or task_id is required.');
} }
// Determines Vote Type
$is_task = isset($input['task_id']) && $input['task_id'] !== '';
try { try {
$stmt = $pdo->prepare(" if ($is_task) {
SELECT comment_id, contribution_id, author_name, browser_id, content, status, created_at $stmt = $pdo->prepare("
FROM comments SELECT comment_id, task_id, author_name, browser_id, content, status, created_at
WHERE contribution_id = :cid AND status = 'approved' FROM comments
ORDER BY created_at ASC WHERE task_id = :id AND status = 'approved'
"); ORDER BY created_at ASC
$stmt->execute([':cid' => $input['contribution_id']]); ");
} else {
$stmt = $pdo->prepare("
SELECT comment_id, contribution_id, author_name, browser_id, content, status, created_at
FROM comments
WHERE contribution_id = :id AND status = 'approved'
ORDER BY created_at ASC
");
}
// Prepared Statement
$stmt->execute([':id' => $is_task ? $input['task_id'] : $input['contribution_id']]);
$comments = $stmt->fetchAll(); $comments = $stmt->fetchAll();
json_response(['comments' => $comments, 'count' => count($comments)]); json_response(['comments' => $comments, 'count' => count($comments)]);
@@ -596,37 +664,56 @@ function handle_read_comments($input) {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// CREATE COMMENT: Adds Comments to Contributions // CREATE COMMENT: Adds Comments Contributions or Tasks
// Required: contribution_id, author_name, content // Required: author_name, content, contribution_id or task_id
// Optional: browser_id // Optional: browser_id
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
function handle_create_comment($input) { function handle_create_comment($input) {
$pdo = get_db(); $pdo = get_db();
$missing = validate_required($input, ['contribution_id', 'author_name', 'content']); $missing = validate_required($input, ['author_name', 'content']);
if (!empty($missing)) { if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing)); error_response('Missing Fields: ' . implode(', ', $missing));
} }
// Validates Content Length // Checks for contribution_id or task_id
if (empty($input['contribution_id']) && empty($input['task_id'])) {
error_response('Either contribution_id or task_id is required.');
}
// Validates Length
if (strlen($input['content']) > 1000) { if (strlen($input['content']) > 1000) {
error_response('Comment too long. Maximum 1000 Characters.'); error_response('Comment too long. Maximum 1000 Characters.');
} }
// Checks if Contribution exists // Determines Comment Type
$stmt = $pdo->prepare("SELECT contribution_id FROM contributions WHERE contribution_id = :id"); $is_task = isset($input['task_id']) && $input['task_id'] !== '';
$stmt->execute([':id' => $input['contribution_id']]);
if (!$stmt->fetch()) {
error_response('Contribution not found.', 404);
}
if ($is_task) {
// Checks for Tasks
$stmt = $pdo->prepare("SELECT task_id FROM tasks WHERE task_id = :id");
$stmt->execute([':id' => $input['task_id']]);
if (!$stmt->fetch()) {
error_response('Task not found.', 404);
}
} else {
// Checks for Contributions
$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);
}
}
// Prepared Statement
try { try {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO comments (contribution_id, author_name, browser_id, content) INSERT INTO comments (contribution_id, task_id, author_name, browser_id, content)
VALUES (:cid, :author, :bid, :content) VALUES (:cid, :tid, :author, :bid, :content)
"); ");
$stmt->execute([ $stmt->execute([
':cid' => $input['contribution_id'], ':cid' => $is_task ? null : $input['contribution_id'],
':tid' => $is_task ? $input['task_id'] : null,
':author' => $input['author_name'], ':author' => $input['author_name'],
':bid' => $input['browser_id'] ?? null, ':bid' => $input['browser_id'] ?? null,
':content' => $input['content'] ':content' => $input['content']
@@ -709,4 +796,387 @@ function handle_update_comment($input) {
} catch (PDOException $e) { } catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500); error_response('Database Error: ' . $e->getMessage(), 500);
} }
} }
// =====================================================================
// Action Handlers for Tasks
// =====================================================================
// ---------------------------------------------------------------------
// READ TASKS: Loads Tasks as GeoJSON FeatureCollection
// Required: municipality_id
// Optional: status, browser_id
// ---------------------------------------------------------------------
function handle_read_tasks($input) {
$pdo = get_db();
$missing = validate_required($input, ['municipality_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$sql = "SELECT *, ST_AsGeoJSON(geom) AS geojson
FROM tasks
WHERE municipality_id = :mid";
$params = [':mid' => $input['municipality_id']];
// Status Filter
$status = $input['status'] ?? 'visible';
if ($status === 'visible') {
$sql .= " AND status IN ('open', 'completed', 'verified')";
} elseif ($status !== 'all') {
$sql .= " AND status = :status";
$params[':status'] = $status;
}
$sql .= " ORDER BY created_at DESC";
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
// Builds GeoJSON FeatureCollection
$features = [];
foreach ($rows as $row) {
$geometry = json_decode($row['geojson']);
unset($row['geom'], $row['geojson']);
$features[] = [
'type' => 'Feature',
'geometry' => $geometry,
'properties' => $row
];
}
$result = [
'type' => 'FeatureCollection',
'features' => $features
];
// User Votes for Tasks
$browser_id = $input['browser_id'] ?? '';
if ($browser_id !== '') {
$stmt = $pdo->prepare("
SELECT task_id, vote_type FROM votes
WHERE browser_id = :bid AND task_id IS NOT NULL
");
$stmt->execute([':bid' => $browser_id]);
$user_votes = [];
foreach ($stmt->fetchAll() as $v) {
$user_votes[$v['task_id']] = $v['vote_type'];
}
$result['user_votes'] = $user_votes;
}
json_response($result);
}
// ---------------------------------------------------------------------
// CREATE TASK: Inserts new Task with optional Photo
// Required: municipality_id, geom, geom_type, category, title, author_name
// Optional: description, browser_id, photo
// ---------------------------------------------------------------------
function handle_create_task($input) {
$pdo = get_db();
$missing = validate_required($input, [
'municipality_id', 'geom', 'geom_type', 'category', 'title', 'author_name'
]);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$valid_geom_types = ['point', 'line', 'polygon'];
if (!in_array($input['geom_type'], $valid_geom_types)) {
error_response('Invalid Geometry Type.');
}
$geojson = json_decode($input['geom']);
if (!$geojson || !isset($geojson->type)) {
error_response('Invalid GeoJSON.');
}
// Handles optional 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.');
}
}
try {
$stmt = $pdo->prepare("
INSERT INTO tasks
(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, :photo_path)
");
$stmt->execute([
':mid' => $input['municipality_id'],
':geom' => $input['geom'],
':geom_type' => $input['geom_type'],
':category' => $input['category'],
':title' => $input['title'],
':description' => $input['description'] ?? '',
':author_name' => $input['author_name'],
':browser_id' => $input['browser_id'] ?? null,
':photo_path' => $photo_path
]);
json_response([
'message' => 'Task created successfully.',
'task_id' => (int) $pdo->lastInsertId()
], 201);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// UPDATE TASK: Updates existing Tasks or Status
// Required: task_id
// Optional: category, title, description, status, address
// ---------------------------------------------------------------------
function handle_update_task($input) {
$pdo = get_db();
$missing = validate_required($input, ['task_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$updatable = ['category', 'title', 'description', 'status', 'address'];
$set = [];
$params = [':id' => $input['task_id']];
foreach ($updatable as $field) {
if (isset($input[$field]) && $input[$field] !== '') {
$set[] = "$field = :$field";
$params[":$field"] = $input[$field];
}
}
if (empty($set)) {
error_response('No Fields to update.');
}
if (isset($params[':status'])) {
$valid = ['pending', 'rejected', 'open', 'completed', 'verified'];
if (!in_array($params[':status'], $valid)) {
error_response('Invalid Status.');
}
}
try {
$stmt = $pdo->prepare("UPDATE tasks SET " . implode(', ', $set) . " WHERE task_id = :id");
$stmt->execute($params);
json_response(['message' => 'Task updated successfully.']);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// DELETE TASK: Removes existing Tasks
// Required: task_id
// ---------------------------------------------------------------------
function handle_delete_task($input) {
$pdo = get_db();
$missing = validate_required($input, ['task_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
try {
$stmt = $pdo->prepare("DELETE FROM tasks WHERE task_id = :id");
$stmt->execute([':id' => $input['task_id']]);
json_response(['message' => 'Task deleted successfully.']);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// COMPLETE TASK: Completes existing Tasks with Photo Proof
// Required: task_id, author_name, browser_id
// Required File: completion_photo
// Optional: completion_comment
// ---------------------------------------------------------------------
function handle_complete_task($input) {
$pdo = get_db();
$missing = validate_required($input, ['task_id', 'author_name', 'browser_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
// Checks if Task exists and is open
$stmt = $pdo->prepare("SELECT task_id, status FROM tasks WHERE task_id = :id");
$stmt->execute([':id' => $input['task_id']]);
$task = $stmt->fetch();
if (!$task) {
error_response('Task not found.', 404);
}
if ($task['status'] !== 'open') {
error_response('Task is not available for Completion.');
}
// Handles required Completion Photo
if (!isset($_FILES['completion_photo']) || $_FILES['completion_photo']['error'] !== UPLOAD_ERR_OK) {
error_response('Completion Photo is required.');
}
$photo_path = handle_photo_upload($_FILES['completion_photo']);
if (!$photo_path) {
error_response('Photo Upload failed. JPG, PNG, GIF and WebP up to 5 MB.');
}
try {
$stmt = $pdo->prepare("
UPDATE tasks SET
status = 'completed',
completed_by_name = :name,
completed_by_browser = :browser,
completion_photo = :photo,
completion_comment = :comment,
completed_at = NOW()
WHERE task_id = :id
");
$stmt->execute([
':id' => $input['task_id'],
':name' => $input['author_name'],
':browser' => $input['browser_id'],
':photo' => $photo_path,
':comment' => $input['completion_comment'] ?? ''
]);
json_response(['message' => 'Task Completion submitted for Review.']);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// VERIFY TASK: Moderator confirms or rejects Completions
// Required: task_id, action
// Awards Points and sets Status if verified
// Clears Completion Fields, resets Status if rejected
// ---------------------------------------------------------------------
function handle_verify_task($input) {
$pdo = get_db();
$missing = validate_required($input, ['task_id', 'verify_action']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
// Loads Task
$stmt = $pdo->prepare("SELECT * FROM tasks WHERE task_id = :id");
$stmt->execute([':id' => $input['task_id']]);
$task = $stmt->fetch();
if (!$task) {
error_response('Task not found.', 404);
}
if ($task['status'] !== 'completed') {
error_response('Task is not in completed State.');
}
try {
if ($input['verify_action'] === 'verify') {
// Accepts Completion and Awards Points
$stmt = $pdo->prepare("UPDATE tasks SET status = 'verified' WHERE task_id = :id");
$stmt->execute([':id' => $input['task_id']]);
// Awards Points to User
$stmt = $pdo->prepare("
INSERT INTO user_points (municipality_id, user_name, points, task_id)
VALUES (:mid, :name, :points, :tid)
");
$stmt->execute([
':mid' => $task['municipality_id'],
':name' => $task['completed_by_name'],
':points' => $task['points_reward'],
':tid' => $input['task_id']
]);
json_response(['message' => 'Task verified. Points awarded.']);
} elseif ($input['verify_action'] === 'reject') {
// Rejects Completion and Clears Fields
$stmt = $pdo->prepare("
UPDATE tasks SET
status = 'open',
completed_by_name = NULL,
completed_by_browser = NULL,
completion_photo = NULL,
completion_comment = NULL,
completed_at = NULL
WHERE task_id = :id
");
$stmt->execute([':id' => $input['task_id']]);
json_response(['message' => 'Completion rejected. Task is open again.']);
} else {
error_response('Invalid Action. Must be: verify or reject.');
}
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}
// ---------------------------------------------------------------------
// READ LEADERBOARD: Returns Citizen Leaderboard
// Required: municipality_id
// Optional: limit
// ---------------------------------------------------------------------
function handle_read_leaderboard($input) {
$pdo = get_db();
$missing = validate_required($input, ['municipality_id']);
if (!empty($missing)) {
error_response('Missing Fields: ' . implode(', ', $missing));
}
$limit = min((int)($input['limit'] ?? 10), 50);
try {
$stmt = $pdo->prepare("
SELECT user_name,
SUM(points) AS total_points,
COUNT(*) AS tasks_completed
FROM user_points
WHERE municipality_id = :mid
GROUP BY user_name
ORDER BY total_points DESC
LIMIT :lim
");
$stmt->bindValue(':mid', $input['municipality_id'], PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
json_response(['leaderboard' => $stmt->fetchAll()]);
} catch (PDOException $e) {
error_response('Database Error: ' . $e->getMessage(), 500);
}
}

View File

@@ -110,4 +110,22 @@ function get_categories() {
'industry' => ['label' => 'Industrie', 'faIcon' => 'fa-industry', 'color' => '#7030A0'], 'industry' => ['label' => 'Industrie', 'faIcon' => 'fa-industry', 'color' => '#7030A0'],
'other' => ['label' => 'Sonstiges', 'faIcon' => 'fa-thumbtack', 'color' => '#7F7F7F'], 'other' => ['label' => 'Sonstiges', 'faIcon' => 'fa-thumbtack', 'color' => '#7F7F7F'],
]; ];
}
// ---------------------------------------------------------------------
// Task Category Definitions
// Returns associative Array of Task Category Keys to Labels, Icons,
// and Colors. Shared between Citizen Participation Portal and
// Moderation Page.
// ToDo: Move to Database Table.
// ---------------------------------------------------------------------
function get_task_categories() {
return [
'repair' => ['label' => 'Reparatur', 'faIcon' => 'fa-wrench', 'color' => '#C00000'],
'social' => ['label' => 'Nachbarschaft', 'faIcon' => 'fa-people-group', 'color' => '#E65100'],
'safety' => ['label' => 'Sicherheit', 'faIcon' => 'fa-shield-halved', 'color' => '#FFC000'],
'greenery' => ['label' => 'Grünpflege', 'faIcon' => 'fa-leaf', 'color' => '#92D050'],
'cleanup' => ['label' => 'Sauberkeit', 'faIcon' => 'fa-broom', 'color' => '#0070C0'],
'other_task' => ['label' => 'Sonstiges', 'faIcon' => 'fa-clipboard-check','color' => '#7F7F7F'],
];
} }

View File

@@ -130,35 +130,71 @@ $news_items = $stmt->fetchAll();
<!-- Sidebar Tab Icons --> <!-- Sidebar Tab Icons -->
<div class="leaflet-sidebar-tabs"> <div class="leaflet-sidebar-tabs">
<ul role="tablist"> <ul role="tablist">
<li><a href="#tab-home" role="tab"><i class="fa-solid fa-house"></i></a></li> <li><a href="#tab-contributions" role="tab" title="Hinweise"><i class="fa-solid fa-clipboard-list"></i></a></li>
<li><a href="#tab-help" role="tab"><i class="fa-solid fa-circle-question"></i></a></li> <li><a href="#tab-tasks" role="tab" title="Aufgaben"><i class="fa-solid fa-clipboard-check"></i></a></li>
<li><a href="#tab-list" role="tab"><i class="fa-solid fa-list"></i></a></li> <li><a href="#tab-list" role="tab" title="Beiträge"><i class="fa-solid fa-list"></i></a></li>
<li><a href="#tab-news" role="tab"><i class="fa-solid fa-newspaper"></i></a></li> <li><a href="#tab-news" role="tab" title="Neuigkeiten"><i class="fa-solid fa-newspaper"></i></a></li>
<li><a href="#tab-help" role="tab" title="Hilfe"><i class="fa-solid fa-circle-question"></i></a></li>
</ul> </ul>
</div> </div>
<!-- Sidebar Tab Content --> <!-- Sidebar Tab Content -->
<div class="leaflet-sidebar-content"> <div class="leaflet-sidebar-content">
<!-- Home Tab --> <!-- Contributions Tab -->
<div class="leaflet-sidebar-pane" id="tab-home"> <div class="leaflet-sidebar-pane" id="tab-contributions">
<h2 class="leaflet-sidebar-header"> <h2 class="leaflet-sidebar-header">
Start Hinweise
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span> <span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2> </h2>
<div class="sidebar-body"> <div class="sidebar-body">
<p>Willkommen beim Bürgerbeteiligungsportal <strong><?= htmlspecialchars($municipality['name']) ?></strong>.</p> <p>Verwenden Sie die Karte, um <strong>Hinweise</strong> für die Stadtverwaltung hinzuzufügen oder bestehende Hinweise zu betrachten, bewerten und kommentieren</p>
<p>Verwenden Sie die Karte, um Hinweise und Aufgaben für die Stadtverwaltung hinzuzufügen oder bestehende Beiträge der Bürgerschaft zu betrachten.</p>
<h3>Kategorien</h3> <h3>Kategorien</h3>
<div id="category-filter"> <div id="category-filter">
<!-- Category Filter Checkboxes — populated by app.js --> <!-- populated by app.js -->
</div>
<p id="stats-container"></p>
<!-- populated by app.js -->
</div>
</div>
<!-- Tasks Tab -->
<div class="leaflet-sidebar-pane" id="tab-tasks">
<h2 class="leaflet-sidebar-header">
Aufgaben
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<p>Verwenden Sie die Karte, um <strong>Aufgaben</strong> für die Gemeinschaft hinzuzufügen oder bestehende Aufgaben zu betrachten, bewerten und kommentieren.</p>
<h3>Kategorien</h3>
<div id="task-category-filter">
<!-- populated by app.js -->
</div>
<p id="task-stats-container">
<!-- populated by app.js -->
</p>
<div class="task-filter-row">
<select id="task-status-filter" class="form-input" onchange="updateTasksList()" style="margin-bottom:8px;">
<option value="open">Offene Aufgaben</option>
<option value="all">Alle Aufgaben</option>
<option value="completed">Wartend auf Prüfung</option>
<option value="verified">Erledigte Aufgaben</option>
</select>
</div> </div>
<h3>Statistik</h3> <!-- Leaderboard -->
<div id="stats-container"> <div id="leaderboard-container" class="leaderboard-box">
<!-- Contribution Statistics — populated by app.js --> <h3>Rangliste</h3>
<div id="leaderboard-list"></div>
<button class="btn btn-secondary leaderboard-more-btn" onclick="showFullLeaderboard()">
Vollständige Rangliste
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -173,7 +209,39 @@ $news_items = $stmt->fetchAll();
<input type="text" id="list-search-input" placeholder="Beiträge durchsuchen..." class="form-input"> <input type="text" id="list-search-input" placeholder="Beiträge durchsuchen..." class="form-input">
</div> </div>
<div id="contributions-list"> <div id="contributions-list">
<!-- Contribution Cards — populated by app.js --> <!-- populated by app.js -->
</div>
</div>
</div>
<!-- News Tab -->
<div class="leaflet-sidebar-pane" id="tab-news">
<h2 class="leaflet-sidebar-header">
Neuigkeiten
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<div class="list-search">
<input type="text" id="news-search-input" placeholder="Neuigkeiten durchsuchen..." class="form-input" oninput="filterNews()">
</div>
<div id="news-list">
<?php if (empty($news_items)): ?>
<p style="text-align:center;color:#999;padding:20px;">Noch keine Neuigkeiten veröffentlicht.</p>
<?php else: ?>
<?php foreach ($news_items as $news): ?>
<div class="news-item"
data-title="<?= htmlspecialchars(strtolower($news['title'])) ?>"
data-content="<?= htmlspecialchars(strtolower($news['content'])) ?>"
data-author="<?= htmlspecialchars(strtolower($news['author_name'])) ?>">
<h3><?= htmlspecialchars($news['title']) ?></h3>
<p><?= nl2br(htmlspecialchars($news['content'])) ?></p>
<span class="news-date">
<?= htmlspecialchars($news['author_name']) ?>
· <?= date('d.m.Y', strtotime($news['published_at'])) ?>
</span>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -205,49 +273,16 @@ $news_items = $stmt->fetchAll();
<h3><i class="fa-solid fa-comments"></i> Kommentieren</h3> <h3><i class="fa-solid fa-comments"></i> Kommentieren</h3>
<p>Gerne können Sie Ihre Meinung zu bestehenden Beiträgen auch durch die Kommentarfunktion äußern.</p> <p>Gerne können Sie Ihre Meinung zu bestehenden Beiträgen auch durch die Kommentarfunktion äußern.</p>
<h3><i class="fa-solid fa-clipboard-check"></i> Aufgaben erledigen</h3>
<p>Klicken Sie auf eine offene Aufgabe und melden Sie die Erledigung mit einem Foto-Nachweis.</p>
<h3><i class="fa-solid fa-magnifying-glass"></i> Suchen</h3> <h3><i class="fa-solid fa-magnifying-glass"></i> Suchen</h3>
<p>Verwenden Sie die Adresssuche rechts, um schnell den richtigen Ort auf der Mitmachkarte zu finden.</p> <p>Verwenden Sie die Adresssuche rechts, um schnell den richtigen Ort auf der Mitmachkarte zu finden.</p>
</div>
</div>
<!-- News Tab -->
<div class="leaflet-sidebar-pane" id="tab-news">
<h2 class="leaflet-sidebar-header">
Neuigkeiten
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<!-- News Search -->
<div class="list-search">
<input type="text" id="news-search-input" placeholder="Neuigkeiten durchsuchen..." class="form-input" oninput="filterNews()">
</div>
<!-- News Items Container -->
<div id="news-list">
<?php if (empty($news_items)): ?>
<p style="text-align:center;color:#999;padding:20px;">Noch keine Neuigkeiten veröffentlicht.</p>
<?php else: ?>
<?php foreach ($news_items as $news): ?>
<div class="news-item"
data-title="<?= htmlspecialchars(strtolower($news['title'])) ?>"
data-content="<?= htmlspecialchars(strtolower($news['content'])) ?>"
data-author="<?= htmlspecialchars(strtolower($news['author_name'])) ?>">
<h3><?= htmlspecialchars($news['title']) ?></h3>
<p><?= nl2br(htmlspecialchars($news['content'])) ?></p>
<span class="news-date">
<?= htmlspecialchars($news['author_name']) ?>
· <?= date('d.m.Y', strtotime($news['published_at'])) ?>
</span>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Leaflet Map --> <!-- Leaflet Map -->
@@ -404,6 +439,7 @@ $news_items = $stmt->fetchAll();
// Category Definitions from Database // Category Definitions from Database
const CATEGORIES = <?= json_encode(get_categories(), JSON_UNESCAPED_UNICODE) ?>; const CATEGORIES = <?= json_encode(get_categories(), JSON_UNESCAPED_UNICODE) ?>;
const TASK_CATEGORIES = <?= json_encode(get_task_categories(), JSON_UNESCAPED_UNICODE) ?>;
// Admin Status from PHP Session // Admin Status from PHP Session
const IS_ADMIN = <?= (function_exists('is_admin') && is_admin()) ? 'true' : 'false' ?>; const IS_ADMIN = <?= (function_exists('is_admin') && is_admin()) ? 'true' : 'false' ?>;

View File

@@ -898,24 +898,24 @@ function updateStatistics() {
const total = contributionsData.length; const total = contributionsData.length;
// Counts per Category // Counts per Category
const counts = {}; // const counts = {};
contributionsData.forEach(function (f) { // contributionsData.forEach(function (f) {
const cat = f.properties.category; // const cat = f.properties.category;
counts[cat] = (counts[cat] || 0) + 1; // counts[cat] = (counts[cat] || 0) + 1;
}); // });
let html = '<p style="font-size:0.9rem;"><strong>' + total + '</strong> Beiträge insgesamt</p>'; let html = '<p style="font-size:0.8rem;"><strong>' + total + '</strong> Hinweise insgesamt</p>';
for (const key in CATEGORIES) { // for (const key in CATEGORIES) {
const cat = CATEGORIES[key]; // const cat = CATEGORIES[key];
const count = counts[key] || 0; // const count = counts[key] || 0;
if (count > 0) { // if (count > 0) {
html += '<div style="display:flex;align-items:center;gap:8px;margin:4px 0;font-size:0.85rem;">' + // html += '<div style="display:flex;align-items:center;gap:8px;margin:4px 0;font-size:0.8rem;">' +
categoryIcon(cat) + ' ' + // categoryIcon(cat) + ' ' +
cat.label + ': ' + count + // cat.label + ': ' + count +
'</div>'; // '</div>';
} // }
} // }
container.innerHTML = html; container.innerHTML = html;
} }

View File

@@ -881,6 +881,72 @@ select.form-input { cursor: pointer; }
} }
/* -----------------------------------------------------------------
4.10 Create Type Toggle (Contribution or Task)
----------------------------------------------------------------- */
.create-type-toggle {
display: flex;
gap: 8px;
}
.create-type-btn {
flex: 1;
padding: 10px;
border: 2px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
cursor: pointer;
font-family: var(--font-body);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.create-type-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.create-type-btn.active { border-color: var(--color-primary); background: var(--color-primary-light); color: var(--color-primary); }
/* -----------------------------------------------------------------
4.11 Leaderboard (Sidebar Tasks Tab)
----------------------------------------------------------------- */
.leaderboard-box {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: var(--space-md);
margin-bottom: var(--space-md);
}
.leaderboard-box h3 { margin-top: 0 !important; }
.leaderboard-entry {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 0;
font-size: 0.85rem;
border-bottom: 1px solid #f0f0f0;
}
.leaderboard-entry:last-child { border-bottom: none; }
.leaderboard-rank { font-size: 1rem; width: 28px; text-align: center; }
.leaderboard-name { flex: 1; font-weight: 600; }
.leaderboard-points { color: var(--color-primary); font-weight: 600; }
.leaderboard-more-btn {
width: 100%;
margin-top: var(--space-sm);
font-size: 0.8rem !important;
min-height: 32px !important;
padding: 4px 12px !important;
}
/* ================================================================= /* =================================================================
SECTION 5: Admin-specific Styles (admin.php) SECTION 5: Admin-specific Styles (admin.php)
================================================================= */ ================================================================= */