(int)$option['id'], 'name' => $option['name'], 'icon' => $option['logo'] !== null ? 'data:image/svg+xml;base64,' . base64_encode($option['logo']) : null ]; } function customFieldResponse(array $field): array { if ($field['default_value'] === 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)$field['default_value'], true); $decodeOk = json_last_error() === JSON_ERROR_NONE; $rawValue = (string)$field['default_value']; 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 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 userResponse(array $user): array { return [ 'id' => (int)$user['id'], 'name' => $user['name'], 'email' => $user['email'], 'picture' => pictureDataUri($user['picture'] ?? null) ]; } function rightResponse(array $right): array { return [ 'id' => (int)$right['id'], 'name' => $right['name'] ]; } function projectResponse(array $project): array { return [ 'id' => $project['id'], 'name' => $project['name'], 'owner' => (int)$project['owner'] ]; } function optionTable(string $kind): string { return match ($kind) { 'type' => 'task_types', 'priority' => 'task_priorities', default => jsonResponse([ 'success' => false, 'error' => 'Option kind must be type or priority.' ], 400) }; } function optionLabel(string $kind): string { return $kind === 'type' ? 'Task type' : 'Task priority'; } function normalizeName(mixed $name, string $label, int $maxLength = 128): string { $name = trim((string)$name); if ($name === '') { jsonResponse(['success' => false, 'error' => "{$label} name is required."], 400); } if (strlen($name) > $maxLength) { jsonResponse(['success' => false, 'error' => "{$label} name is too long."], 400); } return $name; } function normalizeCustomFieldType(mixed $type): string { $type = strtolower(trim((string)$type)); $allowedTypes = ['string', 'int', 'float', 'date', 'boolean', 'text', 'json']; if (!in_array($type, $allowedTypes, true)) { jsonResponse([ 'success' => false, 'error' => 'Custom field type must be string, int, float, date, boolean, text, or json.' ], 400); } return $type; } 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 uploadedLogo(): ?string { if (!isset($_FILES['logo']) || !is_array($_FILES['logo'])) { return null; } $file = $_FILES['logo']; if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { return null; } if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { jsonResponse(['success' => false, 'error' => 'Logo upload failed.'], 400); } if (($file['size'] ?? 0) > 1024 * 1024) { jsonResponse(['success' => false, 'error' => 'Logo must be 1 MB or smaller.'], 400); } $content = file_get_contents($file['tmp_name']); if ($content === false || trim($content) === '') { jsonResponse(['success' => false, 'error' => 'Logo file is empty.'], 400); } if (!preg_match('/ false, 'error' => 'Logo must be an SVG file.'], 400); } return $content; } function requireOption(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."], 404); } } function preventDeleteIfUsed(string $kind, int $id): void { if ($kind === 'type') { $taskStmt = db()->prepare('SELECT COUNT(*) FROM tasks WHERE type = ?'); $taskStmt->execute([$id]); $assignmentStmt = db()->prepare('SELECT COUNT(*) FROM assigned_task_states WHERE task_type = ?'); $assignmentStmt->execute([$id]); if ((int)$taskStmt->fetchColumn() > 0 || (int)$assignmentStmt->fetchColumn() > 0) { jsonResponse([ 'success' => false, 'error' => 'Task type is still used by tasks or workflow assignments.' ], 409); } return; } $stmt = db()->prepare('SELECT COUNT(*) FROM tasks WHERE priority = ?'); $stmt->execute([$id]); if ((int)$stmt->fetchColumn() > 0) { jsonResponse([ 'success' => false, 'error' => 'Task priority is still used by tasks.' ], 409); } } function requireCustomField(int $fieldId): array { if ($fieldId <= 0) { jsonResponse(['success' => false, 'error' => 'Custom field must be valid.'], 400); } ensureCustomTaskFieldsTable(); $stmt = db()->prepare( 'SELECT id, task_type, name, type, default_value FROM custom_task_fields WHERE id = ? LIMIT 1' ); $stmt->execute([$fieldId]); $field = $stmt->fetch(); if (!$field) { jsonResponse(['success' => false, 'error' => 'Custom field not found.'], 404); } return $field; } function requireUserId(int $userId): void { if ($userId <= 0) { jsonResponse(['success' => false, 'error' => 'User must be valid.'], 400); } $stmt = db()->prepare('SELECT id FROM users WHERE id = ? LIMIT 1'); $stmt->execute([$userId]); if (!$stmt->fetch()) { jsonResponse(['success' => false, 'error' => 'User not found.'], 404); } } function requireRightId(int $rightId): void { if ($rightId <= 0) { jsonResponse(['success' => false, 'error' => 'Right must be valid.'], 400); } $stmt = db()->prepare('SELECT id FROM rights WHERE id = ? LIMIT 1'); $stmt->execute([$rightId]); if (!$stmt->fetch()) { jsonResponse(['success' => false, 'error' => 'Right not found.'], 404); } } function requireProjectId(string $projectId): void { if ($projectId === '') { jsonResponse(['success' => false, 'error' => 'Project must be valid.'], 400); } $stmt = db()->prepare('SELECT id FROM projects WHERE id = ? LIMIT 1'); $stmt->execute([$projectId]); if (!$stmt->fetch()) { jsonResponse(['success' => false, 'error' => 'Project not found.'], 404); } } function rightName(int $rightId): ?string { $stmt = db()->prepare('SELECT name FROM rights WHERE id = ? LIMIT 1'); $stmt->execute([$rightId]); $name = $stmt->fetchColumn(); return $name !== false ? (string)$name : null; } function optionsData(): array { $types = array_map('optionResponse', db()->query( 'SELECT id, name, logo FROM task_types ORDER BY id ASC' )->fetchAll()); $priorities = array_map('optionResponse', db()->query( 'SELECT id, name, logo FROM task_priorities ORDER BY id ASC' )->fetchAll()); try { ensureCustomTaskFieldsTable(); $customFields = array_map('customFieldResponse', db()->query( 'SELECT id, task_type, name, type, default_value FROM custom_task_fields ORDER BY task_type ASC, id ASC' )->fetchAll()); } catch (Throwable $e) { $customFields = []; } $users = array_map('userResponse', db()->query( 'SELECT id, name, email, picture FROM users ORDER BY name ASC, id ASC' )->fetchAll()); $rights = array_map('rightResponse', db()->query( 'SELECT id, name FROM rights ORDER BY id ASC' )->fetchAll()); $userRights = db()->query( 'SELECT user_id, right_id FROM user_rights ORDER BY user_id ASC, right_id ASC' )->fetchAll(); $projects = array_map('projectResponse', db()->query( 'SELECT id, name, owner FROM projects ORDER BY name ASC, id ASC' )->fetchAll()); $userAccess = db()->query( 'SELECT id, user_id, project_id FROM user_access ORDER BY user_id ASC, project_id ASC' )->fetchAll(); return [ 'types' => $types, 'priorities' => $priorities, 'custom_fields' => $customFields, 'users' => $users, 'rights' => $rights, 'user_rights' => array_map(static fn (array $row): array => [ 'user_id' => (int)$row['user_id'], 'right_id' => (int)$row['right_id'] ], $userRights), 'projects' => $projects, 'user_access' => array_map(static fn (array $row): array => [ 'id' => (int)$row['id'], 'user_id' => (int)$row['user_id'], 'project_id' => $row['project_id'] ], $userAccess) ]; } $user = requireApiAuth(); requireUserRight((int)$user['id'], 'Admin'); $api = $_GET['api'] ?? ''; switch ($api) { case 'OptionsData': { jsonResponse([ 'success' => true, 'options' => optionsData() ]); } case 'CreateOption': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'CreateOption requires POST.'], 405); } $data = getInputData(); $kind = (string)($data['kind'] ?? ''); $table = optionTable($kind); $label = optionLabel($kind); $name = normalizeName($data['name'] ?? '', $label, $kind === 'type' ? 20 : 128); $logo = uploadedLogo(); $stmt = db()->prepare("INSERT INTO {$table} (name, logo) VALUES (?, ?)"); $stmt->execute([$name, $logo]); jsonResponse([ 'success' => true, 'option_id' => (int)db()->lastInsertId() ], 201); } case 'UpdateOption': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'UpdateOption requires POST.'], 405); } $data = getInputData(); $kind = (string)($data['kind'] ?? ''); $table = optionTable($kind); $label = optionLabel($kind); $id = (int)($data['id'] ?? 0); $name = normalizeName($data['name'] ?? '', $label, $kind === 'type' ? 20 : 128); $logo = uploadedLogo(); $removeLogo = !empty($data['remove_logo']); requireOption($table, $id, $label); if ($logo !== null) { $stmt = db()->prepare("UPDATE {$table} SET name = ?, logo = ? WHERE id = ?"); $stmt->execute([$name, $logo, $id]); } elseif ($removeLogo) { $stmt = db()->prepare("UPDATE {$table} SET name = ?, logo = NULL WHERE id = ?"); $stmt->execute([$name, $id]); } else { $stmt = db()->prepare("UPDATE {$table} SET name = ? WHERE id = ?"); $stmt->execute([$name, $id]); } jsonResponse([ 'success' => true, 'option_id' => $id ]); } case 'DeleteOption': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'DeleteOption requires POST.'], 405); } $kind = (string)($_GET['kind'] ?? ''); $table = optionTable($kind); $label = optionLabel($kind); $id = (int)($_GET['id'] ?? 0); requireOption($table, $id, $label); preventDeleteIfUsed($kind, $id); if ($kind === 'type') { ensureCustomTaskFieldsTable(); ensureCustomFieldValuesTable(); db()->prepare( 'DELETE custom_field_values FROM custom_field_values INNER JOIN custom_task_fields ON custom_task_fields.id = custom_field_values.field_id WHERE custom_task_fields.task_type = ?' )->execute([$id]); db()->prepare('DELETE FROM custom_task_fields WHERE task_type = ?')->execute([$id]); } $stmt = db()->prepare("DELETE FROM {$table} WHERE id = ?"); $stmt->execute([$id]); jsonResponse([ 'success' => true, 'option_id' => $id ]); } case 'CreateCustomField': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'CreateCustomField requires POST.'], 405); } $data = getInputData(); $taskType = (int)($data['task_type'] ?? 0); $name = normalizeName($data['name'] ?? '', 'Custom field'); $type = normalizeCustomFieldType($data['type'] ?? ''); $value = normalizeCustomFieldValue($data['value'] ?? '', $type); requireOption('task_types', $taskType, 'Task type'); ensureCustomTaskFieldsTable(); $stmt = db()->prepare( 'INSERT INTO custom_task_fields (task_type, name, type, default_value) VALUES (?, ?, ?, ?)' ); $stmt->execute([$taskType, $name, $type, $value]); jsonResponse([ 'success' => true, 'field_id' => (int)db()->lastInsertId() ], 201); } case 'UpdateCustomField': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'UpdateCustomField requires POST.'], 405); } $data = getInputData(); $fieldId = (int)($data['id'] ?? 0); $field = requireCustomField($fieldId); $name = normalizeName($data['name'] ?? '', 'Custom field'); $type = normalizeCustomFieldType($data['type'] ?? ''); $value = normalizeCustomFieldValue($data['value'] ?? '', $type); ensureCustomTaskFieldsTable(); $stmt = db()->prepare( 'UPDATE custom_task_fields SET name = ?, type = ?, default_value = ? WHERE id = ?' ); $stmt->execute([$name, $type, $value, $field['id']]); jsonResponse([ 'success' => true, 'field_id' => (int)$field['id'] ]); } case 'DeleteCustomField': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'DeleteCustomField requires POST.'], 405); } $fieldId = (int)($_GET['field_id'] ?? 0); $field = requireCustomField($fieldId); ensureCustomTaskFieldsTable(); $stmt = db()->prepare('DELETE FROM custom_task_fields WHERE id = ?'); $stmt->execute([$field['id']]); jsonResponse([ 'success' => true, 'field_id' => (int)$field['id'] ]); } case 'SetUserRight': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'SetUserRight requires POST.'], 405); } $data = getInputData(); $userId = (int)($data['user_id'] ?? 0); $rightId = (int)($data['right_id'] ?? 0); $enabled = !empty($data['enabled']); requireUserId($userId); requireRightId($rightId); if (!$enabled && $userId === (int)$user['id'] && rightName($rightId) === 'Admin') { jsonResponse(['success' => false, 'error' => 'You cannot remove your own Admin right.'], 400); } if ($enabled) { $stmt = db()->prepare( 'SELECT id FROM user_rights WHERE user_id = ? AND right_id = ? LIMIT 1' ); $stmt->execute([$userId, $rightId]); if (!$stmt->fetch()) { db()->prepare( 'INSERT INTO user_rights (user_id, right_id) VALUES (?, ?)' )->execute([$userId, $rightId]); } } else { db()->prepare( 'DELETE FROM user_rights WHERE user_id = ? AND right_id = ?' )->execute([$userId, $rightId]); } jsonResponse([ 'success' => true, 'user_id' => $userId, 'right_id' => $rightId, 'enabled' => $enabled ]); } case 'GrantProjectAccess': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'GrantProjectAccess requires POST.'], 405); } $data = getInputData(); $userId = (int)($data['user_id'] ?? 0); $projectId = strtoupper(trim((string)($data['project_id'] ?? ''))); requireUserId($userId); requireProjectId($projectId); $stmt = db()->prepare( 'SELECT id FROM user_access WHERE user_id = ? AND project_id = ? LIMIT 1' ); $stmt->execute([$userId, $projectId]); if (!$stmt->fetch()) { db()->prepare( 'INSERT INTO user_access (user_id, project_id) VALUES (?, ?)' )->execute([$userId, $projectId]); } jsonResponse([ 'success' => true, 'user_id' => $userId, 'project_id' => $projectId ], 201); } case 'RevokeProjectAccess': { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonResponse(['success' => false, 'error' => 'RevokeProjectAccess requires POST.'], 405); } $accessId = (int)($_GET['access_id'] ?? 0); if ($accessId <= 0) { jsonResponse(['success' => false, 'error' => 'Project access must be valid.'], 400); } db()->prepare('DELETE FROM user_access WHERE id = ?')->execute([$accessId]); jsonResponse([ 'success' => true, 'access_id' => $accessId ]); } default: jsonResponse([ 'success' => false, 'error' => 'Unknown admin options API action.' ], 400); }