prepare( 'SELECT tasks.project FROM tasks INNER JOIN projects ON projects.id = tasks.project WHERE tasks.id = ? LIMIT 1' ); $stmt->execute([$taskId]); $projectId = $stmt->fetchColumn(); if (!$projectId) { jsonResponse([ 'success' => false, 'error' => 'Task not found.' ], 404); } requireProjectAccessForUser($userId, (string)$projectId); } function optionResponse(?array $option): ?array { if (!$option) { return null; } return [ 'id' => (int)$option['id'], 'name' => $option['name'], 'icon' => $option['logo'] !== null ? 'data:image/svg+xml;base64,' . base64_encode($option['logo']) : null ]; } function stateResponse(?array $state): ?array { if (!$state) { return null; } return [ 'id' => (int)$state['id'], 'name' => $state['name'], 'color' => $state['color'] ]; } function customFieldResponse(array $field): array { $storedValue = $field['value'] ?? $field['default_value'] ?? null; if ($storedValue === null) { return [ 'id' => (int)$field['id'], 'task_type' => (int)$field['task_type'], 'name' => $field['name'], 'type' => $field['type'], 'value' => null, 'raw_value' => '' ]; } $decodedValue = json_decode((string)$storedValue, true); $decodeOk = json_last_error() === JSON_ERROR_NONE; $rawValue = (string)$storedValue; if ($decodeOk) { if ($field['type'] === 'json') { $rawValue = json_encode($decodedValue, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } elseif ($field['type'] === 'boolean') { $rawValue = $decodedValue ? 'true' : 'false'; } else { $rawValue = (string)$decodedValue; } } return [ 'id' => (int)$field['id'], 'task_type' => (int)$field['task_type'], 'name' => $field['name'], 'type' => $field['type'], 'value' => $decodeOk ? $decodedValue : null, 'raw_value' => $rawValue ]; } function normalizeCustomFieldValue(mixed $value, string $type): ?string { $rawValue = trim((string)$value); if ($rawValue === '') { return null; } $normalized = match ($type) { 'int' => filter_var($rawValue, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE), 'float' => is_numeric($rawValue) ? (float)$rawValue : null, 'boolean' => match (strtolower($rawValue)) { '1', 'true', 'yes', 'on' => true, '0', 'false', 'no', 'off' => false, default => null }, 'date' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $rawValue) ? $rawValue : null, 'json' => json_decode($rawValue, true), default => $rawValue }; if ($type === 'json' && json_last_error() !== JSON_ERROR_NONE) { jsonResponse(['success' => false, 'error' => 'Custom field JSON value is invalid.'], 400); } if ($normalized === null && $type !== 'json') { jsonResponse(['success' => false, 'error' => "Custom field value does not match {$type}."], 400); } return json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } function ensureCustomTaskFieldsTable(): void { db()->exec( 'CREATE TABLE IF NOT EXISTS custom_task_fields ( id INT NOT NULL AUTO_INCREMENT, task_type INT NOT NULL, name VARCHAR(128) NOT NULL, type VARCHAR(128) NOT NULL, default_value TEXT NULL, PRIMARY KEY (id), INDEX idx_custom_task_fields_task_type (task_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $columns = db()->query('SHOW COLUMNS FROM custom_task_fields')->fetchAll(PDO::FETCH_COLUMN); if (!in_array('default_value', $columns, true)) { db()->exec('ALTER TABLE custom_task_fields ADD COLUMN default_value TEXT NULL'); } if (in_array('value', $columns, true)) { db()->exec('UPDATE custom_task_fields SET default_value = `value` WHERE default_value IS NULL'); } } function ensureCustomFieldValuesTable(): void { db()->exec( 'CREATE TABLE IF NOT EXISTS custom_field_values ( id INT NOT NULL AUTO_INCREMENT, field_id INT NOT NULL, task_id VARCHAR(26) NOT NULL, `value` TEXT NULL, PRIMARY KEY (id), INDEX idx_custom_field_values_task (task_id), INDEX idx_custom_field_values_field (field_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); } function pictureDataUri(?string $picture): ?string { if ($picture === null || $picture === '') { return null; } $info = @getimagesizefromstring($picture); $mime = is_array($info) && isset($info['mime']) ? $info['mime'] : 'image/png'; return 'data:' . $mime . ';base64,' . base64_encode($picture); } function requireTaskOption(string $table, int $id, string $label): void { if ($id <= 0) { jsonResponse([ 'success' => false, 'error' => "{$label} must be valid." ], 400); } $stmt = db()->prepare("SELECT id FROM {$table} WHERE id = ? LIMIT 1"); $stmt->execute([$id]); if (!$stmt->fetch()) { jsonResponse([ 'success' => false, 'error' => "{$label} not found." ], 400); } } function requireUserExists(?int $userId): void { if ($userId === null) { return; } $stmt = db()->prepare('SELECT id FROM users WHERE id = ? LIMIT 1'); $stmt->execute([$userId]); if (!$stmt->fetch()) { jsonResponse([ 'success' => false, 'error' => 'Assignee not found.' ], 400); } } function requireVersionForProject(?int $versionId, string $projectId): void { if ($versionId === null) { return; } $stmt = db()->prepare( 'SELECT id FROM versions WHERE id = ? AND project = ? LIMIT 1' ); $stmt->execute([$versionId, $projectId]); if (!$stmt->fetch()) { jsonResponse([ 'success' => false, 'error' => 'Fix version not found for this project.' ], 400); } } function getTaskProject(string $taskId, int $userId): string { $stmt = db()->prepare( 'SELECT tasks.project FROM tasks INNER JOIN projects ON projects.id = tasks.project WHERE tasks.id = ? LIMIT 1' ); $stmt->execute([$taskId]); $projectId = $stmt->fetchColumn(); if (!$projectId) { jsonResponse([ 'success' => false, 'error' => 'Task not found.' ], 404); } return (string)$projectId; } function requireCommentForTask(int $commentId, string $taskId, int $userId): void { if ($commentId <= 0) { jsonResponse([ 'success' => false, 'error' => 'Parent comment must be valid.' ], 400); } $stmt = db()->prepare( 'SELECT comments.id FROM comments INNER JOIN tasks ON tasks.id = comments.task_id INNER JOIN projects ON projects.id = tasks.project WHERE comments.id = ? AND comments.task_id = ? LIMIT 1' ); $stmt->execute([ $commentId, $taskId ]); if (!$stmt->fetch()) { jsonResponse([ 'success' => false, 'error' => 'Parent comment not found for this task.' ], 400); } } function requireOwnCommentForTask(int $commentId, string $taskId, int $userId): array { if ($commentId <= 0) { jsonResponse([ 'success' => false, 'error' => 'Comment must be valid.' ], 400); } $stmt = db()->prepare( 'SELECT comments.id, comments.response_to, comments.task_id, comments.commenter, comments.comment FROM comments INNER JOIN tasks ON tasks.id = comments.task_id INNER JOIN projects ON projects.id = tasks.project WHERE comments.id = ? AND comments.task_id = ? AND comments.commenter = ? LIMIT 1' ); $stmt->execute([ $commentId, $taskId, $userId ]); $comment = $stmt->fetch(); if (!$comment) { jsonResponse([ 'success' => false, 'error' => 'Comment not found or not your comment.' ], 404); } return $comment; } function generateTaskId(string $projectId): string { $stmt = db()->prepare( 'SELECT id FROM tasks WHERE project = ? AND id LIKE ? AND SUBSTRING(id, ?) REGEXP "^[0-9]+$" ORDER BY CAST(SUBSTRING(id, ?) AS UNSIGNED) DESC LIMIT 1' ); $stmt->execute([ $projectId, $projectId . '-%', strlen($projectId) + 2, strlen($projectId) + 2 ]); $lastId = $stmt->fetchColumn(); if (!$lastId) { return $projectId . '-1'; } $parts = explode('-', $lastId); $lastNumber = (int)end($parts); return $projectId . '-' . ($lastNumber + 1); } function resolveTaskStatus(array $task): ?int { if ($task['status'] !== null) { return (int)$task['status']; } $stmt = db()->prepare( 'SELECT default_state FROM task_types WHERE id = ? LIMIT 1' ); $stmt->execute([(int)$task['type']]); $defaultState = $stmt->fetchColumn(); if (!$defaultState) { return null; } $stateId = (int)$defaultState; $assignedStmt = db()->prepare( 'SELECT id FROM assigned_task_states WHERE task_type = ? AND state = ? LIMIT 1' ); $assignedStmt->execute([(int)$task['type'], $stateId]); if (!$assignedStmt->fetch()) { return null; } db()->prepare('UPDATE tasks SET status = ? WHERE id = ?')->execute([$stateId, $task['id']]); return $stateId; } function getTaskState(?int $stateId): ?array { if ($stateId === null) { return null; } $stmt = db()->prepare( 'SELECT id, name, color FROM task_states WHERE id = ? LIMIT 1' ); $stmt->execute([$stateId]); return stateResponse($stmt->fetch() ?: null); } function getTaskTransitions(int $taskType, ?int $status): array { if ($status === null) { return []; } $stmt = db()->prepare( 'SELECT task_state_transitions.id, task_state_transitions.from_id, task_state_transitions.to_id, task_state_transitions.action_name, to_state.name AS to_name, to_state.color AS to_color FROM task_state_transitions INNER JOIN assigned_task_states from_assignment ON from_assignment.task_type = ? AND from_assignment.state = task_state_transitions.from_id INNER JOIN assigned_task_states to_assignment ON to_assignment.task_type = ? AND to_assignment.state = task_state_transitions.to_id INNER JOIN task_states to_state ON to_state.id = task_state_transitions.to_id WHERE task_state_transitions.from_id = ? ORDER BY task_state_transitions.id ASC' ); $stmt->execute([$taskType, $taskType, $status]); return array_map(static function (array $transition): array { return [ 'id' => (int)$transition['id'], 'from_id' => (int)$transition['from_id'], 'to_id' => (int)$transition['to_id'], 'action_name' => $transition['action_name'], 'to_state' => [ 'id' => (int)$transition['to_id'], 'name' => $transition['to_name'], 'color' => $transition['to_color'] ] ]; }, $stmt->fetchAll()); } function getTaskCustomFields(string $taskId, int $taskType): array { ensureCustomTaskFieldsTable(); ensureCustomFieldValuesTable(); $stmt = db()->prepare( 'SELECT custom_task_fields.id, custom_task_fields.task_type, custom_task_fields.name, custom_task_fields.type, custom_task_fields.default_value, custom_field_values.id AS value_id, custom_field_values.`value`, custom_field_values.id IS NOT NULL AS has_task_value FROM custom_task_fields LEFT JOIN custom_field_values ON custom_field_values.field_id = custom_task_fields.id AND custom_field_values.task_id = ? WHERE custom_task_fields.task_type = ? ORDER BY custom_task_fields.id ASC' ); $stmt->execute([$taskId, $taskType]); return array_map(static function (array $field): array { $response = customFieldResponse([ 'id' => $field['id'], 'task_type' => $field['task_type'], 'name' => $field['name'], 'type' => $field['type'], 'value' => $field['has_task_value'] ? $field['value'] : $field['default_value'] ]); $response['value_id'] = $field['value_id'] !== null ? (int)$field['value_id'] : null; $response['has_task_value'] = (bool)$field['has_task_value']; return $response; }, $stmt->fetchAll()); } function getCustomFieldForTask(int $fieldId, string $taskId): array { ensureCustomTaskFieldsTable(); $stmt = db()->prepare( 'SELECT custom_task_fields.id, custom_task_fields.task_type, custom_task_fields.name, custom_task_fields.type, custom_task_fields.default_value, tasks.id AS task_id FROM custom_task_fields INNER JOIN tasks ON tasks.type = custom_task_fields.task_type WHERE custom_task_fields.id = ? AND tasks.id = ? LIMIT 1' ); $stmt->execute([$fieldId, $taskId]); $field = $stmt->fetch(); if (!$field) { jsonResponse(['success' => false, 'error' => 'Custom field not found for this task.'], 404); } return $field; } function getTaskListPaging(): array { $page = max(1, (int)($_GET['page_number'] ?? $_GET['page'] ?? 1)); $perPage = (int)($_GET['per_page'] ?? 100); $perPage = max(1, min(100, $perPage)); return [$page, $perPage, ($page - 1) * $perPage]; } function getTaskListOrder(): string { $sort = (string)($_GET['sort'] ?? 'id'); $direction = strtolower((string)($_GET['direction'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC'; $columns = [ 'id' => 'tasks.id', 'title' => 'tasks.title', 'type' => 'type_option.name', 'priority' => 'priority_option.name', 'assignee' => 'users.name', 'status' => 'status_state.name' ]; $column = $columns[$sort] ?? $columns['id']; return "{$column} {$direction}, tasks.id ASC"; } function taskListResponse(string $whereSql, array $params): array { [$page, $perPage, $offset] = getTaskListPaging(); $orderBy = getTaskListOrder(); $countStmt = db()->prepare( "SELECT COUNT(*) FROM tasks INNER JOIN projects ON projects.id = tasks.project {$whereSql}" ); $countStmt->execute($params); $total = (int)$countStmt->fetchColumn(); $stmt = db()->prepare( "SELECT tasks.id, tasks.title, tasks.type, tasks.priority, tasks.assignee, users.name AS assignee_name, users.picture AS assignee_picture, COALESCE(tasks.status, task_types.default_state) AS status, status_state.name AS status_name, status_state.color AS status_color FROM tasks INNER JOIN projects ON projects.id = tasks.project INNER JOIN task_types ON task_types.id = tasks.type LEFT JOIN task_types type_option ON type_option.id = tasks.type LEFT JOIN task_priorities priority_option ON priority_option.id = tasks.priority LEFT JOIN users ON users.id = tasks.assignee LEFT JOIN task_states status_state ON status_state.id = COALESCE(tasks.status, task_types.default_state) {$whereSql} ORDER BY {$orderBy} LIMIT {$perPage} OFFSET {$offset}" ); $stmt->execute($params); $tasks = array_map(static function (array $task): array { $task['assignee_picture'] = pictureDataUri($task['assignee_picture']); $task['status'] = $task['status'] !== null ? (int)$task['status'] : null; $task['status_state'] = $task['status'] !== null ? [ 'id' => (int)$task['status'], 'name' => $task['status_name'], 'color' => $task['status_color'] ] : null; unset($task['status_name'], $task['status_color']); return $task; }, $stmt->fetchAll()); return [ 'tasks' => $tasks, 'pagination' => [ 'page' => $page, 'per_page' => $perPage, 'total' => $total, 'total_pages' => max(1, (int)ceil($total / $perPage)) ] ]; } $user = requireApiAuth(); $api = $_GET['api'] ?? ''; switch ($api) { case 'ListTypes': { $stmt = db()->query( 'SELECT id, name, logo FROM task_types ORDER BY id ASC' ); jsonResponse([ 'success' => true, 'types' => array_map('optionResponse', $stmt->fetchAll()) ]); } case 'ListPriorities': { $stmt = db()->query( 'SELECT id, name, logo FROM task_priorities ORDER BY id ASC' ); jsonResponse([ 'success' => true, 'priorities' => array_map('optionResponse', $stmt->fetchAll()) ]); } case 'TaskInfo': { $taskId = strtoupper(trim($_GET['task_id'] ?? '')); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } $stmt = db()->prepare( 'SELECT tasks.id, tasks.title, tasks.description, tasks.project, tasks.created_date, tasks.last_changed, tasks.reporter, tasks.assignee, tasks.fix_version, tasks.type, tasks.priority, tasks.status FROM tasks INNER JOIN projects ON projects.id = tasks.project WHERE tasks.id = ? LIMIT 1' ); $stmt->execute([$taskId]); $task = $stmt->fetch(); if (!$task) { jsonResponse([ 'success' => false, 'error' => 'Task not found.' ], 404); } $task['status'] = resolveTaskStatus($task); jsonResponse([ 'success' => true, 'task' => [ ...$task, 'status_state' => getTaskState($task['status']), 'status_transitions' => getTaskTransitions((int)$task['type'], $task['status']), 'custom_fields' => getTaskCustomFields((string)$task['id'], (int)$task['type']) ] ]); } case 'ListTasksByProject': { $projectId = strtoupper(trim($_GET['project_id'] ?? '')); if ($projectId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing project_id.' ], 400); } requireProjectAccess($projectId, (int)$user['id']); $result = taskListResponse( 'WHERE tasks.project = ?', [$projectId] ); jsonResponse([ 'success' => true, 'tasks' => $result['tasks'], 'pagination' => $result['pagination'] ]); } case 'ListTasksByVersion': { $versionId = (int)($_GET['version_id'] ?? 0); if ($versionId <= 0) { jsonResponse([ 'success' => false, 'error' => 'Missing or invalid version_id.' ], 400); } $versionProjectStmt = db()->prepare( 'SELECT project FROM versions WHERE id = ? LIMIT 1' ); $versionProjectStmt->execute([$versionId]); $versionProject = $versionProjectStmt->fetchColumn(); if (!$versionProject) { jsonResponse([ 'success' => false, 'error' => 'Version not found.' ], 404); } requireProjectAccessForUser((int)$user['id'], (string)$versionProject); $result = taskListResponse( 'WHERE tasks.fix_version = ?', [$versionId] ); jsonResponse([ 'success' => true, 'tasks' => $result['tasks'], 'pagination' => $result['pagination'] ]); } case 'ListComments': { $taskId = strtoupper(trim($_GET['task_id'] ?? '')); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } requireTaskAccess($taskId, (int)$user['id']); $stmt = db()->prepare( 'SELECT comments.id, comments.response_to, comments.task_id, comments.commenter, comments.comment, users.name AS commenter_name, users.email AS commenter_email, users.picture AS commenter_picture FROM comments INNER JOIN users ON users.id = comments.commenter WHERE comments.task_id = ? ORDER BY comments.id ASC' ); $stmt->execute([$taskId]); $comments = array_map(static function (array $comment): array { $comment['id'] = (int)$comment['id']; $comment['response_to'] = $comment['response_to'] !== null ? (int)$comment['response_to'] : null; $comment['commenter'] = (int)$comment['commenter']; $comment['commenter_picture'] = pictureDataUri($comment['commenter_picture']); return $comment; }, $stmt->fetchAll()); jsonResponse([ 'success' => true, 'comments' => $comments ]); } case 'Create': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse([ 'success' => false, 'error' => 'Create requires POST.' ], 405); } $data = getInputData(); $projectId = strtoupper(trim($data['project_id'] ?? $data['project'] ?? '')); $title = trim($data['title'] ?? ''); $description = array_key_exists('description', $data) ? trim((string)$data['description']) : null; $assignee = isset($data['assignee']) && $data['assignee'] !== '' ? (int)$data['assignee'] : null; $fixVersion = isset($data['fix_version']) && $data['fix_version'] !== '' ? (int)$data['fix_version'] : null; $type = (int)($data['type'] ?? 0); $priority = (int)($data['priority'] ?? 0); if ($projectId === '') { jsonResponse([ 'success' => false, 'error' => 'Project id is required.' ], 400); } requireProjectRight((int)$user['id'], $projectId, 'Create Tasks'); if ($title === '') { jsonResponse([ 'success' => false, 'error' => 'Task title is required.' ], 400); } if ($type <= 0) { jsonResponse([ 'success' => false, 'error' => 'Task type is required.' ], 400); } if ($priority <= 0) { jsonResponse([ 'success' => false, 'error' => 'Task priority is required.' ], 400); } requireTaskOption('task_types', $type, 'Task type'); requireTaskOption('task_priorities', $priority, 'Task priority'); requireUserExists($assignee); requireVersionForProject($fixVersion, $projectId); $taskId = generateTaskId($projectId); $stmt = db()->prepare( 'INSERT INTO tasks ( id, title, description, project, created_date, last_changed, reporter, assignee, fix_version, type, priority ) VALUES ( ?, ?, ?, ?, CURDATE(), CURDATE(), ?, ?, ?, ?, ? )' ); $stmt->execute([ $taskId, $title, $description !== '' ? $description : null, $projectId, (int)$user['id'], $assignee, $fixVersion, $type, $priority ]); jsonResponse([ 'success' => true, 'task_id' => $taskId ], 201); } case 'CreateComment': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse([ 'success' => false, 'error' => 'CreateComment requires POST.' ], 405); } $taskId = strtoupper(trim($_GET['task_id'] ?? '')); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } requireTaskAccess($taskId, (int)$user['id']); $data = getInputData(); $comment = trim((string)($data['comment'] ?? '')); $responseTo = isset($data['response_to']) && $data['response_to'] !== '' ? (int)$data['response_to'] : null; if ($comment === '') { jsonResponse([ 'success' => false, 'error' => 'Comment cannot be empty.' ], 400); } if ($responseTo !== null) { requireCommentForTask($responseTo, $taskId, (int)$user['id']); } $stmt = db()->prepare( 'INSERT INTO comments (response_to, task_id, commenter, comment) VALUES (?, ?, ?, ?)' ); $stmt->execute([ $responseTo, $taskId, (int)$user['id'], $comment ]); jsonResponse([ 'success' => true, 'comment_id' => (int)db()->lastInsertId() ], 201); } case 'EditComment': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse([ 'success' => false, 'error' => 'EditComment requires POST.' ], 405); } $taskId = strtoupper(trim($_GET['task_id'] ?? '')); $commentId = (int)($_GET['comment_id'] ?? 0); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } requireOwnCommentForTask($commentId, $taskId, (int)$user['id']); $data = getInputData(); $comment = trim((string)($data['comment'] ?? '')); if ($comment === '') { jsonResponse([ 'success' => false, 'error' => 'Comment cannot be empty.' ], 400); } $stmt = db()->prepare( 'UPDATE comments SET comment = ? WHERE id = ? AND task_id = ? AND commenter = ?' ); $stmt->execute([ $comment, $commentId, $taskId, (int)$user['id'] ]); jsonResponse([ 'success' => true, 'comment_id' => $commentId ]); } case 'DeleteComment': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse([ 'success' => false, 'error' => 'DeleteComment requires POST.' ], 405); } $taskId = strtoupper(trim($_GET['task_id'] ?? '')); $commentId = (int)($_GET['comment_id'] ?? 0); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } $comment = requireOwnCommentForTask($commentId, $taskId, (int)$user['id']); $parentId = $comment['response_to'] !== null ? (int)$comment['response_to'] : null; db()->beginTransaction(); try { $childStmt = db()->prepare( 'UPDATE comments SET response_to = ? WHERE task_id = ? AND response_to = ?' ); $childStmt->execute([ $parentId, $taskId, $commentId ]); $deleteStmt = db()->prepare( 'DELETE FROM comments WHERE id = ? AND task_id = ? AND commenter = ?' ); $deleteStmt->execute([ $commentId, $taskId, (int)$user['id'] ]); db()->commit(); } catch (Throwable $e) { db()->rollBack(); jsonResponse([ 'success' => false, 'error' => 'Could not delete comment.' ], 500); } jsonResponse([ 'success' => true, 'comment_id' => $commentId ]); } case 'SetCustomFieldValue': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse([ 'success' => false, 'error' => 'SetCustomFieldValue requires POST.' ], 405); } $taskId = strtoupper(trim($_GET['task_id'] ?? '')); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } requireTaskAccess($taskId, (int)$user['id']); $taskProject = getTaskProject($taskId, (int)$user['id']); requireProjectRight((int)$user['id'], $taskProject, 'Edit Tasks'); $data = getInputData(); $fieldId = (int)($data['field_id'] ?? 0); $field = getCustomFieldForTask($fieldId, $taskId); $value = normalizeCustomFieldValue($data['value'] ?? '', (string)$field['type']); ensureCustomFieldValuesTable(); $existingStmt = db()->prepare( 'SELECT id FROM custom_field_values WHERE field_id = ? AND task_id = ? ORDER BY id ASC LIMIT 1' ); $existingStmt->execute([$fieldId, $taskId]); $existingId = $existingStmt->fetchColumn(); if ($existingId) { $stmt = db()->prepare('UPDATE custom_field_values SET `value` = ? WHERE id = ?'); $stmt->execute([$value, $existingId]); } else { $stmt = db()->prepare( 'INSERT INTO custom_field_values (field_id, task_id, `value`) VALUES (?, ?, ?)' ); $stmt->execute([$fieldId, $taskId, $value]); } db()->prepare('UPDATE tasks SET last_changed = CURDATE() WHERE id = ?')->execute([$taskId]); jsonResponse([ 'success' => true, 'field_id' => $fieldId, 'task_id' => $taskId ]); } case 'TransitionStatus': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse([ 'success' => false, 'error' => 'TransitionStatus requires POST.' ], 405); } $taskId = strtoupper(trim($_GET['task_id'] ?? '')); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } requireTaskAccess($taskId, (int)$user['id']); $stmt = db()->prepare( 'SELECT id, type, assignee, status FROM tasks WHERE id = ? LIMIT 1' ); $stmt->execute([$taskId]); $task = $stmt->fetch(); if (!$task) { jsonResponse(['success' => false, 'error' => 'Task not found.'], 404); } $canTransition = userIsAdmin((int)$user['id']) || userHasRight((int)$user['id'], 'Edit Tasks') || (int)$task['assignee'] === (int)$user['id']; if (!$canTransition) { jsonResponse(['success' => false, 'error' => 'Only the current assignee or users with Edit Tasks can transition this task.'], 403); } $currentStatus = resolveTaskStatus($task); if ($currentStatus === null) { jsonResponse(['success' => false, 'error' => 'Task has no current status.'], 400); } $data = getInputData(); $transitionId = (int)($data['transition_id'] ?? 0); if ($transitionId <= 0) { jsonResponse(['success' => false, 'error' => 'Transition must be valid.'], 400); } $transitionStmt = db()->prepare( 'SELECT task_state_transitions.id, task_state_transitions.from_id, task_state_transitions.to_id FROM task_state_transitions INNER JOIN assigned_task_states from_assignment ON from_assignment.task_type = ? AND from_assignment.state = task_state_transitions.from_id INNER JOIN assigned_task_states to_assignment ON to_assignment.task_type = ? AND to_assignment.state = task_state_transitions.to_id WHERE task_state_transitions.id = ? AND task_state_transitions.from_id = ? LIMIT 1' ); $transitionStmt->execute([ (int)$task['type'], (int)$task['type'], $transitionId, $currentStatus ]); $transition = $transitionStmt->fetch(); if (!$transition) { jsonResponse(['success' => false, 'error' => 'Transition is not valid for this task.'], 400); } db()->prepare('UPDATE tasks SET status = ?, last_changed = CURDATE() WHERE id = ?')->execute([ (int)$transition['to_id'], $taskId ]); jsonResponse([ 'success' => true, 'task_id' => $taskId, 'status' => (int)$transition['to_id'] ]); } case 'Edit': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse([ 'success' => false, 'error' => 'Edit requires POST.' ], 405); } $taskId = strtoupper(trim($_GET['task_id'] ?? '')); if ($taskId === '') { jsonResponse([ 'success' => false, 'error' => 'Missing task_id.' ], 400); } $taskProject = getTaskProject($taskId, (int)$user['id']); requireProjectRight((int)$user['id'], $taskProject, 'Edit Tasks'); $data = getInputData(); $fields = []; $values = []; if (array_key_exists('title', $data)) { $title = trim((string)$data['title']); if ($title === '') { jsonResponse([ 'success' => false, 'error' => 'Task title cannot be empty.' ], 400); } $fields[] = 'title = ?'; $values[] = $title; } if (array_key_exists('description', $data)) { $description = trim((string)$data['description']); $fields[] = 'description = ?'; $values[] = $description !== '' ? $description : null; } if (array_key_exists('assignee', $data)) { $assignee = $data['assignee'] !== '' && $data['assignee'] !== null ? (int)$data['assignee'] : null; requireUserExists($assignee); $fields[] = 'assignee = ?'; $values[] = $assignee; } if (array_key_exists('fix_version', $data)) { $fixVersion = $data['fix_version'] !== '' && $data['fix_version'] !== null ? (int)$data['fix_version'] : null; requireVersionForProject($fixVersion, $taskProject); $fields[] = 'fix_version = ?'; $values[] = $fixVersion; } if (array_key_exists('type', $data)) { $type = (int)$data['type']; if ($type <= 0) { jsonResponse([ 'success' => false, 'error' => 'Task type must be valid.' ], 400); } requireTaskOption('task_types', $type, 'Task type'); $defaultStateStmt = db()->prepare( 'SELECT default_state FROM task_types WHERE id = ? LIMIT 1' ); $defaultStateStmt->execute([$type]); $defaultState = $defaultStateStmt->fetchColumn(); $fields[] = 'type = ?'; $values[] = $type; $fields[] = 'status = ?'; $values[] = $defaultState ? (int)$defaultState : null; ensureCustomFieldValuesTable(); db()->prepare('DELETE FROM custom_field_values WHERE task_id = ?')->execute([$taskId]); } if (array_key_exists('priority', $data)) { $priority = (int)$data['priority']; if ($priority <= 0) { jsonResponse([ 'success' => false, 'error' => 'Task priority must be valid.' ], 400); } requireTaskOption('task_priorities', $priority, 'Task priority'); $fields[] = 'priority = ?'; $values[] = $priority; } if (empty($fields)) { jsonResponse([ 'success' => false, 'error' => 'No editable fields provided.' ], 400); } $fields[] = 'last_changed = CURDATE()'; $values[] = $taskId; $stmt = db()->prepare( 'UPDATE tasks SET ' . implode(', ', $fields) . ' WHERE id = ?' ); $stmt->execute($values); jsonResponse([ 'success' => true, 'task_id' => $taskId ]); } case 'GetType': { $typeId = (int)($_GET['type_id'] ?? 0); if ($typeId <= 0) { jsonResponse([ 'success' => false, 'error' => 'Missing or invalid type_id.' ], 400); } $stmt = db()->prepare( 'SELECT id, name, logo FROM task_types WHERE id = ? LIMIT 1' ); $stmt->execute([$typeId]); $type = $stmt->fetch(); if (!$type) { jsonResponse([ 'success' => false, 'error' => 'Task type not found.' ], 404); } jsonResponse([ 'success' => true, 'type' => optionResponse($type) ]); } case 'GetPriority': { $priorityId = (int)($_GET['priority_id'] ?? 0); if ($priorityId <= 0) { jsonResponse([ 'success' => false, 'error' => 'Missing or invalid priority_id.' ], 400); } $stmt = db()->prepare( 'SELECT id, name, logo FROM task_priorities WHERE id = ? LIMIT 1' ); $stmt->execute([$priorityId]); $priority = $stmt->fetch(); if (!$priority) { jsonResponse([ 'success' => false, 'error' => 'Task priority not found.' ], 404); } jsonResponse([ 'success' => true, 'priority' => optionResponse($priority) ]); } default: { jsonResponse([ 'success' => false, 'error' => 'Unknown API action.' ], 404); } }