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.
787 lines
17 KiB
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;
|
|
}
|
|
|