'Method not allowed.'], 405 ); } } catch (InvalidArgumentException $exception) { jsonResponse( ['error' => $exception->getMessage()], 422 ); } catch (Throwable $exception) { error_log($exception->__toString()); jsonResponse( ['error' => 'An internal server error occurred.'], 500 ); } function getTasks(PDO $pdo): never { $id = filter_input( INPUT_GET, 'id', FILTER_VALIDATE_INT ); if ($id !== null && $id !== false) { $task = findTask($pdo, $id); if ($task === null) { jsonResponse( ['error' => 'Task not found.'], 404 ); } jsonResponse(['task' => $task]); } $statement = $pdo->query( <<<'SQL' SELECT * FROM kanban_tasks ORDER BY column_id ASC, position ASC, id ASC SQL ); $tasks = array_map( 'formatTask', $statement->fetchAll() ); jsonResponse(['tasks' => $tasks]); } function createTask(PDO $pdo): never { $input = getJsonInput(); $columnId = requireInteger( $input, 'columnId', 1, 6 ); $content = trim((string) ($input['content'] ?? '')); if ($content === '') { throw new InvalidArgumentException( 'Task name is required.' ); } $pdo->beginTransaction(); try { $positionStatement = $pdo->prepare( <<<'SQL' SELECT COALESCE(MAX(position), -1) + 1 FROM kanban_tasks WHERE column_id = :column_id SQL ); $positionStatement->execute([ 'column_id' => $columnId, ]); $position = (int) $positionStatement->fetchColumn(); $statement = $pdo->prepare( <<<'SQL' INSERT INTO kanban_tasks ( column_id, position, assign_to, content, description, tags, color, due_at, start_date, priority, complexity, estimate_hours, time_spent_hours ) VALUES ( :column_id, :position, :assign_to, :content, :description, :tags, :color, :due_at, :start_date, :priority, :complexity, :estimate_hours, :time_spent_hours ) SQL ); $statement->execute([ 'column_id' => $columnId, 'position' => $position, 'assign_to' => cleanString( $input['assign'] ?? 'me', 'me' ), 'content' => $content, 'description' => nullableString( $input['description'] ?? null ), 'tags' => nullableString( $input['tags'] ?? null ), 'color' => cleanString( $input['color'] ?? 'yellow', 'yellow' ), 'due_at' => nullableString( $input['due'] ?? null ), 'start_date' => nullableString( $input['start'] ?? null ), 'priority' => boundedInteger( $input['priority'] ?? 0, 0, 10 ), 'complexity' => boundedInteger( $input['complexity'] ?? 0, 0, 10 ), 'estimate_hours' => nullableNumber( $input['estimate'] ?? null ), 'time_spent_hours' => nullableNumber( $input['timeSpent'] ?? null ), ]); $taskId = (int) $pdo->lastInsertId(); $pdo->commit(); jsonResponse( ['task' => findTask($pdo, $taskId)], 201 ); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } function updateTask(PDO $pdo): never { $input = getJsonInput(); $taskId = requireInteger( $input, 'id', 1 ); $pdo->beginTransaction(); try { $currentTask = findRawTask($pdo, $taskId); if ($currentTask === null) { $pdo->rollBack(); jsonResponse( ['error' => 'Task not found.'], 404 ); } $currentColumn = (int) $currentTask['column_id']; $currentPosition = (int) $currentTask['position']; $targetColumn = array_key_exists('columnId', $input) ? boundedInteger($input['columnId'], 1, 6) : $currentColumn; $targetPosition = array_key_exists('position', $input) ? max(0, (int) $input['position']) : $currentPosition; if ( $targetColumn !== $currentColumn || $targetPosition !== $currentPosition ) { moveTask( $pdo, $taskId, $currentColumn, $currentPosition, $targetColumn, $targetPosition ); } $fields = []; $parameters = [ 'id' => $taskId, ]; addField( $fields, $parameters, $input, 'content', 'content', static function (mixed $value): string { $content = trim((string) $value); if ($content === '') { throw new InvalidArgumentException( 'Task name is required.' ); } return $content; } ); addField( $fields, $parameters, $input, 'assign', 'assign_to', static fn(mixed $value): string => cleanString($value, 'me') ); addField( $fields, $parameters, $input, 'description', 'description', 'nullableString' ); addField( $fields, $parameters, $input, 'tags', 'tags', 'nullableString' ); addField( $fields, $parameters, $input, 'color', 'color', static fn(mixed $value): string => cleanString($value, 'yellow') ); addField( $fields, $parameters, $input, 'due', 'due_at', 'nullableString' ); addField( $fields, $parameters, $input, 'start', 'start_date', 'nullableString' ); addField( $fields, $parameters, $input, 'priority', 'priority', static fn(mixed $value): int => boundedInteger($value, 0, 10) ); addField( $fields, $parameters, $input, 'complexity', 'complexity', static fn(mixed $value): int => boundedInteger($value, 0, 10) ); addField( $fields, $parameters, $input, 'estimate', 'estimate_hours', 'nullableNumber' ); addField( $fields, $parameters, $input, 'timeSpent', 'time_spent_hours', 'nullableNumber' ); if ($fields !== []) { $fields[] = 'updated_at = CURRENT_TIMESTAMP'; $sql = sprintf( 'UPDATE kanban_tasks SET %s WHERE id = :id', implode(', ', $fields) ); $statement = $pdo->prepare($sql); $statement->execute($parameters); } $pdo->commit(); jsonResponse([ 'task' => findTask($pdo, $taskId), ]); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } function moveTask( PDO $pdo, int $taskId, int $currentColumn, int $currentPosition, int $targetColumn, int $targetPosition ): void { /* * First remove the task from its current ordering. */ $closeGap = $pdo->prepare( <<<'SQL' UPDATE kanban_tasks SET position = position - 1 WHERE column_id = :column_id AND position > :position AND id != :id SQL ); $closeGap->execute([ 'column_id' => $currentColumn, 'position' => $currentPosition, 'id' => $taskId, ]); /* * When moving downward in the same column, removing the * task causes the requested position to decrease by one. */ if ( $targetColumn === $currentColumn && $targetPosition > $currentPosition ) { $targetPosition--; } $countStatement = $pdo->prepare( <<<'SQL' SELECT COUNT(*) FROM kanban_tasks WHERE column_id = :column_id AND id != :id SQL ); $countStatement->execute([ 'column_id' => $targetColumn, 'id' => $taskId, ]); $numberOfTasks = (int) $countStatement->fetchColumn(); $targetPosition = min( max(0, $targetPosition), $numberOfTasks ); /* * Create room in the destination column. */ $openGap = $pdo->prepare( <<<'SQL' UPDATE kanban_tasks SET position = position + 1 WHERE column_id = :column_id AND position >= :position AND id != :id SQL ); $openGap->execute([ 'column_id' => $targetColumn, 'position' => $targetPosition, 'id' => $taskId, ]); $moveStatement = $pdo->prepare( <<<'SQL' UPDATE kanban_tasks SET column_id = :column_id, position = :position, updated_at = CURRENT_TIMESTAMP WHERE id = :id SQL ); $moveStatement->execute([ 'column_id' => $targetColumn, 'position' => $targetPosition, 'id' => $taskId, ]); } function deleteTask(PDO $pdo): never { $input = getJsonInput(); $taskId = isset($input['id']) ? (int) $input['id'] : 0; if ($taskId < 1) { throw new InvalidArgumentException( 'A valid task ID is required.' ); } $pdo->beginTransaction(); try { $task = findRawTask($pdo, $taskId); if ($task === null) { $pdo->rollBack(); jsonResponse( ['error' => 'Task not found.'], 404 ); } $statement = $pdo->prepare( 'DELETE FROM kanban_tasks WHERE id = :id' ); $statement->execute([ 'id' => $taskId, ]); $reorderStatement = $pdo->prepare( <<<'SQL' UPDATE kanban_tasks SET position = position - 1 WHERE column_id = :column_id AND position > :position SQL ); $reorderStatement->execute([ 'column_id' => (int) $task['column_id'], 'position' => (int) $task['position'], ]); $pdo->commit(); jsonResponse([ 'success' => true, 'id' => $taskId, ]); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } function findTask(PDO $pdo, int $id): ?array { $task = findRawTask($pdo, $id); return $task === null ? null : formatTask($task); } function findRawTask(PDO $pdo, int $id): ?array { $statement = $pdo->prepare( 'SELECT * FROM kanban_tasks WHERE id = :id' ); $statement->execute([ 'id' => $id, ]); $task = $statement->fetch(); return $task === false ? null : $task; } function formatTask(array $task): array { return [ 'id' => (int) $task['id'], 'columnId' => (int) $task['column_id'], 'status' => (int) $task['column_id'], 'position' => (int) $task['position'], 'assign' => $task['assign_to'], 'content' => $task['content'], 'description' => $task['description'] ?? '', 'tags' => $task['tags'] ?? '', 'color' => $task['color'], 'due' => $task['due_at'] ?? '', 'start' => $task['start_date'] ?? '', 'priority' => (int) $task['priority'], 'complexity' => (int) $task['complexity'], 'estimate' => $task['estimate_hours'] === null ? null : (float) $task['estimate_hours'], 'timeSpent' => $task['time_spent_hours'] === null ? null : (float) $task['time_spent_hours'], ]; } function addField( array &$fields, array &$parameters, array $input, string $inputName, string $columnName, callable $filter ): void { if (!array_key_exists($inputName, $input)) { return; } $parameterName = 'field_' . $inputName; $fields[] = sprintf( '%s = :%s', $columnName, $parameterName ); $parameters[$parameterName] = $filter( $input[$inputName] ); } function getJsonInput(): array { $body = file_get_contents('php://input'); if ($body === false || trim($body) === '') { return []; } try { $input = json_decode( $body, true, 512, JSON_THROW_ON_ERROR ); } catch (JsonException) { throw new InvalidArgumentException( 'Invalid JSON request body.' ); } if (!is_array($input)) { throw new InvalidArgumentException( 'The JSON body must be an object.' ); } return $input; } function requireInteger( array $input, string $name, int $minimum, ?int $maximum = null ): int { if (!array_key_exists($name, $input)) { throw new InvalidArgumentException( sprintf('%s is required.', $name) ); } return boundedInteger( $input[$name], $minimum, $maximum ); } function boundedInteger( mixed $value, int $minimum, ?int $maximum = null ): int { if ( filter_var( $value, FILTER_VALIDATE_INT ) === false ) { throw new InvalidArgumentException( 'An integer value was expected.' ); } $number = (int) $value; if ($number < $minimum) { throw new InvalidArgumentException( 'The integer value is too small.' ); } if ($maximum !== null && $number > $maximum) { throw new InvalidArgumentException( 'The integer value is too large.' ); } return $number; } function nullableString(mixed $value): ?string { if ($value === null) { return null; } $value = trim((string) $value); return $value === '' ? null : $value; } function cleanString( mixed $value, string $default ): string { $value = trim((string) $value); return $value === '' ? $default : $value; } function nullableNumber(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (!is_numeric($value)) { throw new InvalidArgumentException( 'A numeric value was expected.' ); } $number = (float) $value; if ($number < 0) { throw new InvalidArgumentException( 'The number cannot be negative.' ); } return $number; } function jsonResponse( array $data, int $status = 200 ): never { http_response_code($status); echo json_encode( $data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; }