Files
Projectkiln/ProjectKiln/api/task.php

1491 lines
41 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../db.php';
require_once __DIR__ . '/../auth.php';
header('Content-Type: application/json; charset=utf-8');
function jsonResponse(array $data, int $status = 200): void
{
http_response_code($status);
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
function getInputData(): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
return $_POST;
}
function requireProjectAccess(string $projectId, int $userId): void
{
requireProjectAccessForUser($userId, $projectId);
}
function requireTaskAccess(string $taskId, int $userId): void
{
$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);
}
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);
}
}