A light weight PHP PDO SQLite3 KanBan Board.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
mykanban2/api/tasks.php

787 lines
17 KiB

<?php
declare(strict_types=1);
require_once __DIR__ . '/database.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
try {
$pdo = getDatabase();
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
getTasks($pdo);
break;
case 'POST':
createTask($pdo);
break;
case 'PUT':
case 'PATCH':
updateTask($pdo);
break;
case 'DELETE':
deleteTask($pdo);
break;
default:
jsonResponse(
['error' => '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;
}