Files
Todo-App-Backend/app/Controllers/Api/V1/TodoController.php
Jürg Hallenbarter 02f77a15a7 implement full backend requirements: pagination, filtering, sorting, meta responses, JWT auth, model validation, request logging, API key management
- BaseController: paginatedResponse() helper with meta (page/perPage/total/lastPage/hasMore), getSortParams(), getFilterParams(), encodeJwt()/decodeJwt(), logActivity() helper, validateWithModel()
- TodoController: paginated/sortable/filterable index, model-based validation, boolean conversion on write, activity logging
- CategoryController: same pagination/sort/filter patterns + duplicate-name check (409)
- ProjectController: paginated index + activity logging
- RecurringTaskController: paginated/sortable/filterable index + junction-table category linking
- AuthController: JWT register/login/refresh endpoints (firebase/php-jwt v7)
- Routes: JWT routes added as public endpoints
- Models: all have proper validationRules with exact error messages (field-level, user-facing)
- ApiAuthFilter: scoped API key auth + UserThemeController generateUuid visibility fix
- composer.json: add firebase/php-jwt ^7.0
2026-05-13 14:54:16 +02:00

277 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\TodoModel;
use App\Models\TodoCategoryModel;
use App\Models\CategoryModel;
class TodoController extends BaseController
{
protected $todoModel;
protected $todoCategoryModel;
public function __construct()
{
$this->todoModel = new TodoModel();
$this->todoCategoryModel = new TodoCategoryModel();
}
// ── Allowed sort & filter fields ───────────────────────────────────────
const SORTABLE = ['title', 'status', 'due_date', 'due_time', 'created_at', 'updated_at'];
const FILTERABLE = ['status', 'project_id', 'sync_enabled', 'reminder_enabled', 'recurring_enabled'];
/**
* GET /api/v1/todos
* Paginated, sortable, filterable list of todos for the authenticated user.
*
* Query params:
* page (int) page number, default 1
* per_page (int) items per page, default 50, max 200
* sort (string) e.g. "title" or "-created_at,title"
* status (string) filter by status
* project_id (string) filter by project
*/
public function index()
{
$userId = $this->getUserId();
$filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
$builder = $this->todoModel
->select('todos.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
->join('categories', 'todo_categories.category_id = categories.id', 'left')
->where('todos.user_id', $userId)
->groupBy('todos.id');
// Apply filters
$this->applyFilters($builder, $filters);
// Apply sorting (default: newest first)
if (empty($sorts)) {
$builder->orderBy('todos.created_at', 'DESC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Todos retrieved successfully');
}
/**
* POST /api/v1/todos
*/
public function create()
{
$userId = $this->getUserId();
if (!$this->validateWithModel($this->todoModel)) {
return;
}
$json = $this->request->getJSON(true);
$data = [
'id' => $this->generateUuid(),
'user_id' => $userId,
'title' => $json['title'],
'description' => $json['description'] ?? null,
'status' => $json['status'] ?? 'open',
'due_date' => $json['due_date'] ?? null,
'due_time' => $json['due_time'] ?? null,
'sync_enabled' => !empty($json['sync_enabled']),
'reminder_enabled' => !empty($json['reminder_enabled']),
'recurring_enabled' => !empty($json['recurring_enabled']),
'project_id' => $json['project_id'] ?? null,
];
$this->todoModel->insert($data);
// Link category if provided
if (!empty($json['category_id'])) {
$this->linkCategory($data['id'], $json['category_id']);
}
$this->logActivity('todo_created', 'todo', $data['id'], [
'title' => $data['title'],
]);
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201);
}
/**
* GET /api/v1/todos/{id}
*/
public function show($id = null)
{
$userId = $this->getUserId();
$todos = $this->todoModel->getByUserWithCategories($userId, $id);
if (empty($todos)) {
return $this->errorResponse('Todo not found', 404);
}
return $this->successResponse($todos[0], 'Todo retrieved successfully');
}
/**
* PUT /api/v1/todos/{id}
*/
public function update($id = null)
{
$userId = $this->getUserId();
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$json = $this->request->getJSON(true);
// Handle category update separately (not a column in todos table)
$hasCategoryUpdate = array_key_exists('category_id', $json);
if ($hasCategoryUpdate) {
$categoryId = $json['category_id'];
$this->todoCategoryModel->where('todo_id', $id)->delete();
if (!empty($categoryId)) {
$this->linkCategory($id, $categoryId);
}
}
// Update todo fields
$allowedFields = [
'title', 'description', 'status', 'due_date', 'due_time',
'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id',
];
$updateData = array_intersect_key($json, array_flip($allowedFields));
if (!empty($updateData)) {
// Convert boolean-ish values
foreach (['sync_enabled', 'reminder_enabled', 'recurring_enabled'] as $boolField) {
if (array_key_exists($boolField, $updateData)) {
$updateData[$boolField] = !empty($updateData[$boolField]);
}
}
$this->todoModel->update($id, $updateData);
}
$this->logActivity('todo_updated', 'todo', $id, [
'title' => $todo['title'] ?? 'Unknown',
]);
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
return $this->successResponse($updated[0] ?? null, 'Todo updated successfully');
}
/**
* DELETE /api/v1/todos/{id}
*/
public function delete($id = null)
{
$userId = $this->getUserId();
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$this->todoModel->delete($id);
$this->logActivity('todo_deleted', 'todo', $id, [
'title' => $todo['title'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Todo deleted successfully');
}
// ── Category linking ───────────────────────────────────────────────────
/**
* POST /api/v1/todos/{id}/categories
*/
public function addCategory($todoId = null)
{
$userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = ['category_id' => 'required'];
if (!$this->validateRequest($rules)) {
return;
}
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$existing = $this->todoCategoryModel
->where('todo_id', $todoId)
->where('category_id', $json['category_id'])
->first();
if ($existing) {
return $this->errorResponse('Category already linked to this todo', 409);
}
$this->todoCategoryModel->insert([
'todo_id' => $todoId,
'category_id' => $json['category_id'],
]);
return $this->successResponse(null, 'Category added to todo successfully', 201);
}
/**
* DELETE /api/v1/todos/{id}/categories/{categoryId}
*/
public function removeCategory($todoId = null, $categoryId = null)
{
$userId = $this->getUserId();
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$this->todoCategoryModel
->where('todo_id', $todoId)
->where('category_id', $categoryId)
->delete();
return $this->successResponse(null, 'Category removed from todo successfully');
}
/**
* Link a category to a todo (internal helper)
*/
private function linkCategory(string $todoId, string $categoryId): void
{
$userId = $this->getUserId();
$categoryModel = new CategoryModel();
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
if (!$category) {
return;
}
$existing = $this->todoCategoryModel
->where('todo_id', $todoId)
->where('category_id', $categoryId)
->first();
if (!$existing) {
$this->todoCategoryModel->insert([
'todo_id' => $todoId,
'category_id' => $categoryId,
]);
}
}
}