diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 6a9c6e0..6c166fc 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -33,6 +33,11 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => ' // Marketplace - Public access $routes->get('marketplace/themes', 'MarketplaceController::index'); $routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1'); + + // JWT Authentication + $routes->post('auth/jwt/register', 'AuthController::jwtRegister'); + $routes->post('auth/jwt/login', 'AuthController::jwtLogin'); + $routes->post('auth/jwt/refresh', 'AuthController::jwtRefresh'); }); // Protected endpoints (API key authentication required) diff --git a/app/Controllers/Api/BaseController.php b/app/Controllers/Api/BaseController.php index ebe85da..3b35b62 100644 --- a/app/Controllers/Api/BaseController.php +++ b/app/Controllers/Api/BaseController.php @@ -23,35 +23,138 @@ class BaseController extends ResourceController return $user['id'] ?? null; } + // ======================================================================== + // Pagination & Sorting + // ======================================================================== + /** - * Success response + * Extract pagination params from the query string. + * + * Returns [page, perPage]. + * Default: page=1, perPage=50. Max perPage = 200. */ - protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200) + protected function getPaginationParams(): array { - return $this->response - ->setStatusCode($statusCode) - ->setHeader('Access-Control-Allow-Origin', '*') - ->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - ->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key') - ->setJSON([ - 'success' => true, - 'message' => $message, - 'data' => $data, - ]); + $page = max(1, (int) $this->request->getGet('page')); + $perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 50))); + return [$page, $perPage]; } /** - * Error response + * Extract allowed sort params from the query string. + * + * ?sort=title,-created_at → ASC on title, DESC on created_at + * Only fields listed in $allowed will be accepted. */ - protected function errorResponse(string $message, int $statusCode = 400, $errors = null) + protected function getSortParams(array $allowed = []): array { - $response = [ - 'success' => false, + $raw = $this->request->getGet('sort'); + if (empty($raw)) { + return []; + } + + $parts = explode(',', $raw); + $sorts = []; + + foreach ($parts as $part) { + $part = trim($part); + if (empty($part)) continue; + + $dir = 'ASC'; + if ($part[0] === '-') { + $dir = 'DESC'; + $part = substr($part, 1); + } + + if (in_array($part, $allowed, true)) { + $sorts[$part] = $dir; + } + } + + return $sorts; + } + + /** + * Extract allowed filter params from the query string. + * + * ?status=open&favorite=1 + * Only fields listed in $allowed will be accepted. + */ + protected function getFilterParams(array $allowed = []): array + { + $filters = []; + + foreach ($allowed as $field) { + $value = $this->request->getGet($field); + if ($value !== null && $value !== '') { + $filters[$field] = $value; + } + } + + return $filters; + } + + /** + * Apply sorting to a model query builder. + */ + protected function applySort($query, array $sorts): void + { + foreach ($sorts as $field => $dir) { + $query->orderBy($field, $dir); + } + } + + /** + * Apply filters to a model query builder (simple WHERE). + */ + protected function applyFilters($query, array $filters): void + { + foreach ($filters as $field => $value) { + $query->where($field, $value); + } + } + + /** + * Build a paginated response with meta information. + */ + protected function paginatedResponse($query, string $message = 'Success', int $statusCode = 200) + { + [$page, $perPage] = $this->getPaginationParams(); + + $total = $query->countAllResults(false); + $data = $query->get($perPage, ($page - 1) * $perPage)->getResultArray(); + $lastPage = (int) ceil($total / max($perPage, 1)); + + return $this->successResponse($data, $message, $statusCode, [ + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'last_page' => $lastPage, + 'has_more' => $page < $lastPage, + ], + ]); + } + + // ======================================================================== + // Success / Error Responses + // ======================================================================== + + /** + * Success response + */ + protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200, array $extraMeta = []) + { + $body = [ + 'success' => true, 'message' => $message, + 'data' => $data, ]; - if ($errors !== null) { - $response['errors'] = $errors; + if (!empty($extraMeta)) { + foreach ($extraMeta as $key => $value) { + $body[$key] = $value; + } } return $this->response @@ -59,17 +162,83 @@ class BaseController extends ResourceController ->setHeader('Access-Control-Allow-Origin', '*') ->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') ->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key') - ->setJSON($response); + ->setJSON($body); } /** - * Validate request data + * Error response with structured error info + */ + protected function errorResponse(string $message, int $statusCode = 400, $errors = null) + { + $body = [ + 'success' => false, + 'message' => $message, + ]; + + if ($errors !== null) { + $body['errors'] = $errors; + } + + return $this->response + ->setStatusCode($statusCode) + ->setHeader('Access-Control-Allow-Origin', '*') + ->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key') + ->setJSON($body); + } + + /** + * Validation error shorthand (422) + */ + protected function validationErrorResponse($errors): void + { + $this->errorResponse('Validation failed', 422, $errors); + } + + /** + * Not found shorthand (404) + */ + protected function notFoundResponse(string $resource = 'Resource'): void + { + $this->errorResponse("{$resource} not found", 404); + } + + // ======================================================================== + // Validation + // ======================================================================== + + /** + * Validate request data using the rules defined in a model. + * + * Returns true on success, sends a 422 JSON response and returns false on failure. + */ + protected function validateWithModel(\CodeIgniter\Model $model): bool + { + $validation = \Config\Services::validation(); + $rules = $model->getValidationRules(); + $errors = $model->getValidationMessages(); + + if (empty($rules)) { + return true; + } + + $validation->setRules($rules, $errors); + + if (!$validation->withRequest($this->request)->run()) { + $this->errorResponse('Validation failed', 422, $validation->getErrors()); + return false; + } + + return true; + } + + /** + * Legacy simple validation for controllers that define rules inline. */ protected function validateRequest(array $rules): bool { $validation = \Config\Services::validation(); - // Handle both old format (string) and new format (array with rules/errors) foreach ($rules as $field => $rule) { if (is_array($rule) && isset($rule['rules'])) { $validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []); @@ -85,4 +254,106 @@ class BaseController extends ResourceController return true; } + + // ======================================================================== + // Activity Logging + // ======================================================================== + + /** + * Log an activity to the activity_logs table. + * Safe — catches and logs errors silently so the main request is never broken. + */ + protected function logActivity(string $action, string $entityType, ?string $entityId, ?array $details = null): void + { + try { + $userId = $this->getUserId(); + $logModel = new \App\Models\ActivityLogModel(); + + $logModel->logActivity([ + 'user_id' => $userId, + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'details' => $details ? json_encode($details) : null, + 'ip_address' => $this->request->getIPAddress(), + 'user_agent' => $this->request->getUserAgent()->getAgentString(), + ]); + } catch (\Exception $e) { + log_message('error', 'Failed to log activity: ' . $e->getMessage()); + } + } + + // ======================================================================== + // JWT helpers + // ======================================================================== + + /** + * JWT secret key — should be read from env in production. + */ + protected function getJwtSecret(): string + { + return $_ENV['JWT_SECRET'] ?? 'todo-app-jwt-secret-change-in-production'; + } + + /** + * Decode a JWT from the Authorization header. + * Returns the payload on success, null on failure. + */ + protected function decodeJwtFromRequest(): ?array + { + $header = $this->request->getHeaderLine('Authorization'); + if (empty($header)) return null; + + if (strpos($header, 'Bearer ') !== 0) return null; + + $token = substr($header, 7); + return $this->decodeJwt($token); + } + + /** + * Decode and verify a JWT token. + */ + protected function decodeJwt(string $token): ?array + { + try { + $key = $this->getJwtSecret(); + $jwt = \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($key, 'HS256')); + return (array) $jwt; + } catch (\Exception $e) { + log_message('error', 'JWT decode failed: ' . $e->getMessage()); + return null; + } + } + + /** + * Encode a payload into a JWT token. + */ + protected function encodeJwt(array $payload): string + { + $key = $this->getJwtSecret(); + $issuedAt = time(); + $payload['iat'] = $issuedAt; + $payload['exp'] = $issuedAt + 3600; // 1 hour default + + return \Firebase\JWT\JWT::encode($payload, $key, 'HS256'); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /** + * Generate UUID v4 + */ + protected function generateUuid(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + } } diff --git a/app/Controllers/Api/V1/AuthController.php b/app/Controllers/Api/V1/AuthController.php index ccb06c8..ed3052b 100644 --- a/app/Controllers/Api/V1/AuthController.php +++ b/app/Controllers/Api/V1/AuthController.php @@ -221,18 +221,163 @@ class AuthController extends BaseController ], 'API key created successfully'); } + // ======================================================================== + // JWT Authentication + // ======================================================================== + + /** + * Register a new user and return JWT + API key + * POST /api/v1/auth/jwt/register + */ + public function jwtRegister() + { + // Reuse the existing register validation logic + $json = $this->request->getJSON(true); + + $rules = [ + 'email' => [ + 'rules' => 'required|valid_email|is_unique[users.email]', + 'errors' => [ + 'required' => 'Email is required', + 'valid_email' => 'Please provide a valid email address', + 'is_unique' => 'This email is already registered', + ], + ], + 'password' => [ + 'rules' => 'required|min_length[8]', + 'errors' => [ + 'required' => 'Password is required', + 'min_length' => 'Password must be at least 8 characters long', + ], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'Name is required', + 'max_length' => 'Name must not exceed 255 characters', + ], + ], + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + try { + $userId = $this->generateUuid(); + + $userData = [ + 'id' => $userId, + 'email' => $json['email'], + 'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT), + 'name' => $json['name'], + 'avatar_url' => $json['avatar_url'] ?? null, + 'settings' => isset($json['settings']) ? json_encode($json['settings']) : json_encode(['theme' => 'light']), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $this->userModel->insert($userData); + + // Create API key for the new user + $apiKey = $this->apiAuthKeyModel->createKey( + $userId, + 'Default API Key', + ['read', 'write'], + null + ); + + // Generate JWT + $jwt = $this->encodeJwt([ + 'sub' => $userId, + 'email' => $json['email'], + 'name' => $json['name'], + ]); + + unset($userData['password_hash']); + + return $this->successResponse([ + 'user' => $userData, + 'token' => $jwt, + 'api_key' => $apiKey['key'], + ], 'User registered successfully', 201); + } catch (\Exception $e) { + return $this->errorResponse('Registration failed: ' . $e->getMessage(), 500); + } + } + + /** + * Login with email/password and return JWT + * POST /api/v1/auth/jwt/login + */ + public function jwtLogin() + { + $json = $this->request->getJSON(true); + + $rules = [ + 'email' => 'required|valid_email', + 'password' => 'required', + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + try { + $user = $this->userModel->where('email', $json['email'])->first(); + + if (!$user || !password_verify($json['password'], $user['password_hash'])) { + return $this->errorResponse('Invalid email or password', 401); + } + + // Generate JWT + $jwt = $this->encodeJwt([ + 'sub' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + ]); + + return $this->successResponse([ + 'user' => [ + 'id' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + ], + 'token' => $jwt, + ], 'Login successful'); + } catch (\Exception $e) { + return $this->errorResponse('Login failed: ' . $e->getMessage(), 500); + } + } + + /** + * Refresh JWT token + * POST /api/v1/auth/jwt/refresh + */ + public function jwtRefresh() + { + $payload = $this->decodeJwtFromRequest(); + + if (!$payload || empty($payload['sub'])) { + return $this->errorResponse('Invalid or expired token', 401); + } + + $user = $this->userModel->find($payload['sub']); + if (!$user) { + return $this->errorResponse('User not found', 401); + } + + $jwt = $this->encodeJwt([ + 'sub' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + ]); + + return $this->successResponse(['token' => $jwt], 'Token refreshed successfully'); + } + /** * Generate UUID */ - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } + } diff --git a/app/Controllers/Api/V1/CategoryController.php b/app/Controllers/Api/V1/CategoryController.php index 9da6885..a296fef 100644 --- a/app/Controllers/Api/V1/CategoryController.php +++ b/app/Controllers/Api/V1/CategoryController.php @@ -14,37 +14,44 @@ class CategoryController extends BaseController $this->categoryModel = new CategoryModel(); } + const SORTABLE = ['name', 'created_at']; + const FILTERABLE = ['favorite']; + /** - * Get all categories for the authenticated user * GET /api/v1/categories */ public function index() { - $userId = $this->getUserId(); - $categories = $this->categoryModel->where('user_id', $userId)->findAll(); + $userId = $this->getUserId(); + $filters = $this->getFilterParams(self::FILTERABLE); + $sorts = $this->getSortParams(self::SORTABLE); - return $this->successResponse($categories, 'Categories retrieved successfully'); + $builder = $this->categoryModel->where('user_id', $userId); + $this->applyFilters($builder, $filters); + + if (empty($sorts)) { + $builder->orderBy('name', 'ASC'); + } else { + $this->applySort($builder, $sorts); + } + + return $this->paginatedResponse($builder, 'Categories retrieved successfully'); } /** - * Create a new category * POST /api/v1/categories */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'name' => 'required|max_length[255]', - 'color' => 'required|max_length[7]', - ]; - - if (!$this->validateRequest($rules)) { + if (!$this->validateWithModel($this->categoryModel)) { return; } - // Check for duplicate name per user + $json = $this->request->getJSON(true); + + // Custom duplicate check (per user) $existing = $this->categoryModel ->where('user_id', $userId) ->where('name', $json['name']) @@ -55,26 +62,29 @@ class CategoryController extends BaseController } $data = [ - 'id' => $this->generateUuid(), - 'user_id' => $userId, - 'name' => $json['name'], - 'color' => $json['color'], - 'favorite' => $json['favorite'] ?? false, + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'name' => $json['name'], + 'color' => $json['color'], + 'favorite' => !empty($json['favorite']), ]; $this->categoryModel->insert($data); $category = $this->categoryModel->find($data['id']); + $this->logActivity('category_created', 'category', $data['id'], [ + 'name' => $data['name'], + ]); + return $this->successResponse($category, 'Category created successfully', 201); } /** - * Get a specific category * GET /api/v1/categories/{id} */ public function show($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); if (!$category) { @@ -85,12 +95,11 @@ class CategoryController extends BaseController } /** - * Update a category * PUT /api/v1/categories/{id} */ public function update($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); if (!$category) { @@ -99,7 +108,7 @@ class CategoryController extends BaseController $json = $this->request->getJSON(true); - // Check for duplicate name on rename (excluding current category) + // Duplicate check on rename if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) { $existing = $this->categoryModel ->where('user_id', $userId) @@ -113,25 +122,33 @@ class CategoryController extends BaseController } $allowedFields = ['name', 'color', 'favorite']; - $updateData = array_intersect_key($json, array_flip($allowedFields)); + $updateData = array_intersect_key($json, array_flip($allowedFields)); if (empty($updateData)) { return $this->errorResponse('No valid fields to update'); } + // Convert boolean + if (array_key_exists('favorite', $updateData)) { + $updateData['favorite'] = !empty($updateData['favorite']); + } + $this->categoryModel->update($id, $updateData); $category = $this->categoryModel->find($id); + $this->logActivity('category_updated', 'category', $id, [ + 'name' => $category['name'] ?? 'Unknown', + ]); + return $this->successResponse($category, 'Category updated successfully'); } /** - * Delete a category * DELETE /api/v1/categories/{id} */ public function delete($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); if (!$category) { @@ -140,18 +157,10 @@ class CategoryController extends BaseController $this->categoryModel->delete($id); + $this->logActivity('category_deleted', 'category', $id, [ + 'name' => $category['name'] ?? 'Unknown', + ]); + return $this->successResponse(null, 'Category deleted successfully'); } - - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } } diff --git a/app/Controllers/Api/V1/ProjectController.php b/app/Controllers/Api/V1/ProjectController.php index 6bf2e0a..2b86038 100644 --- a/app/Controllers/Api/V1/ProjectController.php +++ b/app/Controllers/Api/V1/ProjectController.php @@ -14,57 +14,67 @@ class ProjectController extends BaseController $this->projectModel = new ProjectModel(); } + const SORTABLE = ['name', 'created_at']; + const FILTERABLE = []; + /** - * Get all projects for the authenticated user * GET /api/v1/projects */ public function index() { - $userId = $this->getUserId(); - $projects = $this->projectModel->where('user_id', $userId)->findAll(); + $userId = $this->getUserId(); + $filters = $this->getFilterParams(self::FILTERABLE); + $sorts = $this->getSortParams(self::SORTABLE); - return $this->successResponse($projects, 'Projects retrieved successfully'); + $builder = $this->projectModel->where('user_id', $userId); + $this->applyFilters($builder, $filters); + + if (empty($sorts)) { + $builder->orderBy('created_at', 'DESC'); + } else { + $this->applySort($builder, $sorts); + } + + return $this->paginatedResponse($builder, 'Projects retrieved successfully'); } /** - * Create a new project * POST /api/v1/projects */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'name' => 'required|max_length[255]', - 'color' => 'required|max_length[7]', - ]; - - if (!$this->validateRequest($rules)) { + if (!$this->validateWithModel($this->projectModel)) { return; } + $json = $this->request->getJSON(true); + $data = [ - 'id' => $this->generateUuid(), - 'user_id' => $userId, - 'name' => $json['name'], + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'name' => $json['name'], 'description' => $json['description'] ?? null, - 'color' => $json['color'], + 'color' => $json['color'] ?? '#8B5CF6', ]; $this->projectModel->insert($data); $project = $this->projectModel->find($data['id']); + $this->logActivity('project_created', 'project', $data['id'], [ + 'name' => $data['name'], + ]); + return $this->successResponse($project, 'Project created successfully', 201); } /** - * Get a specific project * GET /api/v1/projects/{id} */ public function show($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); if (!$project) { @@ -75,12 +85,11 @@ class ProjectController extends BaseController } /** - * Update a project * PUT /api/v1/projects/{id} */ public function update($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); if (!$project) { @@ -88,8 +97,9 @@ class ProjectController extends BaseController } $json = $this->request->getJSON(true); + $allowedFields = ['name', 'description', 'color']; - $updateData = array_intersect_key($json, array_flip($allowedFields)); + $updateData = array_intersect_key($json, array_flip($allowedFields)); if (empty($updateData)) { return $this->errorResponse('No valid fields to update'); @@ -98,16 +108,19 @@ class ProjectController extends BaseController $this->projectModel->update($id, $updateData); $project = $this->projectModel->find($id); + $this->logActivity('project_updated', 'project', $id, [ + 'name' => $project['name'] ?? 'Unknown', + ]); + return $this->successResponse($project, 'Project updated successfully'); } /** - * Delete a project * DELETE /api/v1/projects/{id} */ public function delete($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); if (!$project) { @@ -116,18 +129,10 @@ class ProjectController extends BaseController $this->projectModel->delete($id); + $this->logActivity('project_deleted', 'project', $id, [ + 'name' => $project['name'] ?? 'Unknown', + ]); + return $this->successResponse(null, 'Project deleted successfully'); } - - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } } diff --git a/app/Controllers/Api/V1/RecurringTaskController.php b/app/Controllers/Api/V1/RecurringTaskController.php index d7fa30b..566937c 100644 --- a/app/Controllers/Api/V1/RecurringTaskController.php +++ b/app/Controllers/Api/V1/RecurringTaskController.php @@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1; use App\Controllers\Api\BaseController; use App\Models\RecurringTaskModel; use App\Models\RecurringTaskCategoryModel; +use App\Models\CategoryModel; class RecurringTaskController extends BaseController { @@ -13,111 +14,147 @@ class RecurringTaskController extends BaseController public function __construct() { - $this->recurringTaskModel = new RecurringTaskModel(); + $this->recurringTaskModel = new RecurringTaskModel(); $this->recurringTaskCategoryModel = new RecurringTaskCategoryModel(); } + const SORTABLE = ['title', 'schedule', 'created_at']; + const FILTERABLE = ['schedule', 'favorite']; + /** - * Get all recurring tasks for the authenticated user * GET /api/v1/recurring-tasks */ public function index() { - $userId = $this->getUserId(); - $tasks = $this->recurringTaskModel->getByUserWithCategories($userId); + $userId = $this->getUserId(); + $filters = $this->getFilterParams(self::FILTERABLE); + $sorts = $this->getSortParams(self::SORTABLE); - return $this->successResponse($tasks, 'Recurring tasks retrieved successfully'); + $builder = $this->recurringTaskModel + ->select('recurring_tasks.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names') + ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') + ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') + ->where('recurring_tasks.user_id', $userId) + ->groupBy('recurring_tasks.id'); + + $this->applyFilters($builder, $filters); + + if (empty($sorts)) { + $builder->orderBy('recurring_tasks.created_at', 'DESC'); + } else { + $this->applySort($builder, $sorts); + } + + return $this->paginatedResponse($builder, 'Recurring tasks retrieved successfully'); } /** - * Create a new recurring task * POST /api/v1/recurring-tasks */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'title' => 'required|max_length[255]', - 'schedule' => 'required|in_list[daily,weekly,monthly,custom]', - ]; - - if (!$this->validateRequest($rules)) { + if (!$this->validateWithModel($this->recurringTaskModel)) { return; } + $json = $this->request->getJSON(true); + $data = [ - 'id' => $this->generateUuid(), - 'user_id' => $userId, - 'title' => $json['title'], + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'title' => $json['title'], 'description' => $json['description'] ?? null, - 'schedule' => $json['schedule'], - 'custom_days' => $json['custom_days'] ? json_encode($json['custom_days']) : json_encode([]), - 'favorite' => $json['favorite'] ?? false, + 'schedule' => $json['schedule'] ?? 'weekly', + 'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]', + 'favorite' => !empty($json['favorite']), ]; $this->recurringTaskModel->insert($data); + + // Link category if provided + if (!empty($json['category_id'])) { + $this->linkCategory($data['id'], $json['category_id']); + } + + $this->logActivity('recurring_task_created', 'recurring_task', $data['id'], [ + 'title' => $data['title'], + ]); + $task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']); - return $this->successResponse($task, 'Recurring task created successfully', 201); + return $this->successResponse($task[0] ?? null, 'Recurring task created successfully', 201); } /** - * Get a specific recurring task * GET /api/v1/recurring-tasks/{id} */ public function show($id = null) { $userId = $this->getUserId(); - $task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + $tasks = $this->recurringTaskModel->getByUserWithCategories($userId, $id); - if (!$task) { + if (empty($tasks)) { return $this->errorResponse('Recurring task not found', 404); } - return $this->successResponse($task, 'Recurring task retrieved successfully'); + return $this->successResponse($tasks[0], 'Recurring task retrieved successfully'); } /** - * Update a recurring task * PUT /api/v1/recurring-tasks/{id} */ public function update($id = null) { $userId = $this->getUserId(); - $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); + $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); } $json = $this->request->getJSON(true); - $allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite']; - $updateData = array_intersect_key($json, array_flip($allowedFields)); - if (isset($updateData['custom_days'])) { + // Handle category update + if (array_key_exists('category_id', $json)) { + $this->recurringTaskCategoryModel->where('recurring_task_id', $id)->delete(); + if (!empty($json['category_id'])) { + $this->linkCategory($id, $json['category_id']); + } + } + + $allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite']; + $updateData = array_intersect_key($json, array_flip($allowedFields)); + + // Convert custom_days array to JSON string + if (isset($updateData['custom_days']) && is_array($updateData['custom_days'])) { $updateData['custom_days'] = json_encode($updateData['custom_days']); } - - if (empty($updateData)) { - return $this->errorResponse('No valid fields to update'); + if (array_key_exists('favorite', $updateData)) { + $updateData['favorite'] = !empty($updateData['favorite']); } - $this->recurringTaskModel->update($id, $updateData); - $task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + if (!empty($updateData)) { + $this->recurringTaskModel->update($id, $updateData); + } - return $this->successResponse($task, 'Recurring task updated successfully'); + $this->logActivity('recurring_task_updated', 'recurring_task', $id, [ + 'title' => $task['title'] ?? 'Unknown', + ]); + + $updated = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + + return $this->successResponse($updated[0] ?? null, 'Recurring task updated successfully'); } /** - * Delete a recurring task * DELETE /api/v1/recurring-tasks/{id} */ public function delete($id = null) { $userId = $this->getUserId(); - $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); + $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); @@ -125,30 +162,31 @@ class RecurringTaskController extends BaseController $this->recurringTaskModel->delete($id); + $this->logActivity('recurring_task_deleted', 'recurring_task', $id, [ + 'title' => $task['title'] ?? 'Unknown', + ]); + return $this->successResponse(null, 'Recurring task deleted successfully'); } /** - * Add a category to a recurring task * POST /api/v1/recurring-tasks/{id}/categories */ public function addCategory($taskId = null) { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); + $json = $this->request->getJSON(true); $rules = ['category_id' => 'required']; if (!$this->validateRequest($rules)) { return; } - // Verify task belongs to user $task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); } - // Check if link already exists $existing = $this->recurringTaskCategoryModel ->where('recurring_task_id', $taskId) ->where('category_id', $json['category_id']) @@ -160,21 +198,19 @@ class RecurringTaskController extends BaseController $this->recurringTaskCategoryModel->insert([ 'recurring_task_id' => $taskId, - 'category_id' => $json['category_id'], + 'category_id' => $json['category_id'], ]); return $this->successResponse(null, 'Category added to recurring task successfully', 201); } /** - * Remove a category from a recurring task * DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId} */ public function removeCategory($taskId = null, $categoryId = null) { $userId = $this->getUserId(); - // Verify task belongs to user $task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); @@ -188,15 +224,27 @@ class RecurringTaskController extends BaseController return $this->successResponse(null, 'Category removed from recurring task successfully'); } - private function generateUuid(): string + /** + * Link a category (internal helper) + */ + private function linkCategory(string $taskId, string $categoryId): void { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); + $userId = $this->getUserId(); + $categoryModel = new CategoryModel(); + $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first(); + + if (!$category) return; + + $existing = $this->recurringTaskCategoryModel + ->where('recurring_task_id', $taskId) + ->where('category_id', $categoryId) + ->first(); + + if (!$existing) { + $this->recurringTaskCategoryModel->insert([ + 'recurring_task_id' => $taskId, + 'category_id' => $categoryId, + ]); + } } } diff --git a/app/Controllers/Api/V1/TodoController.php b/app/Controllers/Api/V1/TodoController.php index 5df7ed8..7fcf478 100644 --- a/app/Controllers/Api/V1/TodoController.php +++ b/app/Controllers/Api/V1/TodoController.php @@ -5,6 +5,7 @@ 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 { @@ -13,90 +14,102 @@ class TodoController extends BaseController public function __construct() { - $this->todoModel = new TodoModel(); + $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 all todos for the authenticated user * 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(); - $todos = $this->todoModel->getByUserWithCategories($userId); - return $this->successResponse($todos, 'Todos retrieved successfully'); + $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'); } /** - * Create a new todo * POST /api/v1/todos */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'title' => 'required|max_length[255]', - 'status' => 'permit_empty|in_list[open,in_progress,completed,archived]', - ]; - - if (!$this->validateRequest($rules)) { + 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' => $json['sync_enabled'] ?? true, - 'reminder_enabled' => $json['reminder_enabled'] ?? false, - 'recurring_enabled' => $json['recurring_enabled'] ?? false, - 'project_id' => $json['project_id'] ?? null, + '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']); } - - // Manually log the activity - try { - $activityLogModel = new \App\Models\ActivityLogModel(); - $activityLogModel->logActivity([ - 'user_id' => $userId, - 'action' => 'todo_created', - 'entity_type' => 'todo', - 'entity_id' => $data['id'], - 'details' => json_encode(['action' => 'created', 'title' => $data['title']]), - 'ip_address' => $this->request->getIPAddress(), - 'user_agent' => $this->request->getUserAgent()->getAgentString(), - ]); - } catch (\Exception $e) { - log_message('error', 'Failed to log activity: ' . $e->getMessage()); - } - + + $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 a specific todo * GET /api/v1/todos/{id} */ public function show($id = null) { $userId = $this->getUserId(); - $todos = $this->todoModel->getByUserWithCategories($userId, $id); + $todos = $this->todoModel->getByUserWithCategories($userId, $id); if (empty($todos)) { return $this->errorResponse('Todo not found', 404); @@ -106,116 +119,97 @@ class TodoController extends BaseController } /** - * Update a todo * PUT /api/v1/todos/{id} */ public function update($id = null) { $userId = $this->getUserId(); - $todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first(); + $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']; - // Remove all existing category links $this->todoCategoryModel->where('todo_id', $id)->delete(); - // Link the new category 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']; + $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); } - - // Manually log the activity - try { - $activityLogModel = new \App\Models\ActivityLogModel(); - $activityLogModel->logActivity([ - 'user_id' => $userId, - 'action' => 'todo_updated', - 'entity_type' => 'todo', - 'entity_id' => $id, - 'details' => json_encode(['action' => 'updated', 'title' => $todo['title'] ?? 'Unknown']), - 'ip_address' => $this->request->getIPAddress(), - 'user_agent' => $this->request->getUserAgent()->getAgentString(), - ]); - } catch (\Exception $e) { - log_message('error', 'Failed to log activity: ' . $e->getMessage()); - } - + + $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 a todo * DELETE /api/v1/todos/{id} */ public function delete($id = null) { $userId = $this->getUserId(); - $todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first(); + $todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first(); if (!$todo) { return $this->errorResponse('Todo not found', 404); } $this->todoModel->delete($id); - - // Manually log the activity - try { - $activityLogModel = new \App\Models\ActivityLogModel(); - $activityLogModel->logActivity([ - 'user_id' => $userId, - 'action' => 'todo_deleted', - 'entity_type' => 'todo', - 'entity_id' => $id, - 'details' => json_encode(['action' => 'deleted', 'title' => $todo['title'] ?? 'Unknown']), - 'ip_address' => $this->request->getIPAddress(), - 'user_agent' => $this->request->getUserAgent()->getAgentString(), - ]); - } catch (\Exception $e) { - log_message('error', 'Failed to log activity: ' . $e->getMessage()); - } + + $this->logActivity('todo_deleted', 'todo', $id, [ + 'title' => $todo['title'] ?? 'Unknown', + ]); return $this->successResponse(null, 'Todo deleted successfully'); } + // ── Category linking ─────────────────────────────────────────────────── + /** - * Add a category to a todo * POST /api/v1/todos/{id}/categories */ public function addCategory($todoId = null) { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); + $json = $this->request->getJSON(true); $rules = ['category_id' => 'required']; if (!$this->validateRequest($rules)) { return; } - // Verify todo belongs to user $todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first(); if (!$todo) { return $this->errorResponse('Todo not found', 404); } - // Check if link already exists $existing = $this->todoCategoryModel ->where('todo_id', $todoId) ->where('category_id', $json['category_id']) @@ -226,7 +220,7 @@ class TodoController extends BaseController } $this->todoCategoryModel->insert([ - 'todo_id' => $todoId, + 'todo_id' => $todoId, 'category_id' => $json['category_id'], ]); @@ -234,14 +228,12 @@ class TodoController extends BaseController } /** - * Remove a category from a todo * DELETE /api/v1/todos/{id}/categories/{categoryId} */ public function removeCategory($todoId = null, $categoryId = null) { $userId = $this->getUserId(); - // Verify todo belongs to user $todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first(); if (!$todo) { return $this->errorResponse('Todo not found', 404); @@ -256,42 +248,29 @@ class TodoController extends BaseController } /** - * Link a category to a todo + * Link a category to a todo (internal helper) */ private function linkCategory(string $todoId, string $categoryId): void { $userId = $this->getUserId(); - - // Verify category belongs to user - $categoryModel = new \App\Models\CategoryModel(); - $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first(); + + $categoryModel = new CategoryModel(); + $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first(); + if (!$category) { return; } - - // Check if link already exists + $existing = $this->todoCategoryModel ->where('todo_id', $todoId) ->where('category_id', $categoryId) ->first(); - + if (!$existing) { $this->todoCategoryModel->insert([ - 'todo_id' => $todoId, + 'todo_id' => $todoId, 'category_id' => $categoryId, ]); } } - - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } } diff --git a/app/Controllers/Api/V1/UserThemeController.php b/app/Controllers/Api/V1/UserThemeController.php index 110c9e4..9975bed 100644 --- a/app/Controllers/Api/V1/UserThemeController.php +++ b/app/Controllers/Api/V1/UserThemeController.php @@ -106,15 +106,5 @@ class UserThemeController extends BaseController return $this->successResponse(null, 'User theme deleted successfully'); } - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } + } diff --git a/app/Models/CategoryModel.php b/app/Models/CategoryModel.php index 84a31be..6556cfc 100644 --- a/app/Models/CategoryModel.php +++ b/app/Models/CategoryModel.php @@ -6,26 +6,38 @@ use CodeIgniter\Model; class CategoryModel extends Model { - protected $table = 'categories'; - protected $primaryKey = 'id'; + protected $table = 'categories'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'name', - 'color', - 'favorite', - 'created_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'name', 'color', 'favorite', 'created_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = ''; + protected $createdField = 'created_at'; + protected $updatedField = ''; protected $validationRules = [ - 'user_id' => 'required', - 'name' => 'required|max_length[255]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The category name is required.', + 'max_length' => 'The category name must not exceed 255 characters.', + ], + ], + 'color' => [ + 'rules' => 'required|max_length[7]|regex_match[/^#[0-9a-fA-F]{6}$/]', + 'errors' => [ + 'required' => 'A color value is required.', + 'max_length' => 'Color must be a hex code (e.g. #3B82F6).', + 'regex_match' => 'Color must be a valid hex code (e.g. #3B82F6).', + ], + ], ]; } diff --git a/app/Models/ProjectModel.php b/app/Models/ProjectModel.php index ea69a9d..64534ff 100644 --- a/app/Models/ProjectModel.php +++ b/app/Models/ProjectModel.php @@ -6,26 +6,30 @@ use CodeIgniter\Model; class ProjectModel extends Model { - protected $table = 'projects'; - protected $primaryKey = 'id'; + protected $table = 'projects'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'name', - 'description', - 'color', - 'created_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = ''; + protected $createdField = 'created_at'; + protected $updatedField = ''; protected $validationRules = [ - 'user_id' => 'required', - 'name' => 'required|max_length[255]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The project name is required.', + 'max_length' => 'The project name must not exceed 255 characters.', + ], + ], ]; } diff --git a/app/Models/RecurringTaskModel.php b/app/Models/RecurringTaskModel.php index c209c3d..cc7b16d 100644 --- a/app/Models/RecurringTaskModel.php +++ b/app/Models/RecurringTaskModel.php @@ -6,40 +6,53 @@ use CodeIgniter\Model; class RecurringTaskModel extends Model { - protected $table = 'recurring_tasks'; - protected $primaryKey = 'id'; + protected $table = 'recurring_tasks'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'title', - 'description', - 'schedule', - 'custom_days', - 'favorite', - 'created_at', - 'updated_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'title', 'description', 'schedule', + 'custom_days', 'favorite', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; protected $validationRules = [ - 'user_id' => 'required', - 'title' => 'required|max_length[255]', - 'schedule' => 'required|in_list[daily,weekly,monthly,custom]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'title' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The recurring task title is required.', + 'max_length' => 'The title must not exceed 255 characters.', + ], + ], + 'schedule' => [ + 'rules' => 'permit_empty|in_list[daily,weekly,monthly,custom]', + 'errors' => [ + 'in_list' => 'Schedule must be one of: daily, weekly, monthly, custom.', + ], + ], ]; - // Get recurring tasks with categories - public function getWithCategories($taskId = null) + // ── Queries ──────────────────────────────────────────────────────────── + + public function getByUserWithCategories($userId, $taskId = null) { - $builder = $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names') - ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') - ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') - ->groupBy('recurring_tasks.id'); + $builder = $this->select(' + recurring_tasks.*, + GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, + GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names + ') + ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') + ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') + ->where('recurring_tasks.user_id', $userId) + ->groupBy('recurring_tasks.id'); if ($taskId) { $builder->where('recurring_tasks.id', $taskId); @@ -47,16 +60,4 @@ class RecurringTaskModel extends Model return $builder->get()->getResultArray(); } - - // Get recurring tasks by user with categories - public function getByUserWithCategories($userId) - { - return $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names') - ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') - ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') - ->where('recurring_tasks.user_id', $userId) - ->groupBy('recurring_tasks.id') - ->get() - ->getResultArray(); - } } diff --git a/app/Models/TodoModel.php b/app/Models/TodoModel.php index c04012e..8c3acf1 100644 --- a/app/Models/TodoModel.php +++ b/app/Models/TodoModel.php @@ -6,53 +6,51 @@ use CodeIgniter\Model; class TodoModel extends Model { - protected $table = 'todos'; - protected $primaryKey = 'id'; + protected $table = 'todos'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'title', - 'description', - 'status', - 'due_date', - 'due_time', - 'sync_enabled', - 'reminder_enabled', - 'recurring_enabled', - 'project_id', - 'created_at', - 'updated_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'title', 'description', 'status', + 'due_date', 'due_time', 'sync_enabled', 'reminder_enabled', + 'recurring_enabled', 'project_id', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; protected $validationRules = [ - 'user_id' => 'required', - 'title' => 'required|max_length[255]', - 'status' => 'permit_empty|in_list[open,in_progress,completed,archived]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'title' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The todo title is required.', + 'max_length' => 'The title must not exceed 255 characters.', + ], + ], + 'status' => [ + 'rules' => 'permit_empty|in_list[open,in_progress,completed,archived]', + 'errors' => [ + 'in_list' => 'Status must be one of: open, in_progress, completed, archived.', + ], + ], + 'due_date' => [ + 'rules' => 'permit_empty|valid_date[Y-m-d]', + 'errors' => ['valid_date' => 'Due date must be in YYYY-MM-DD format.'], + ], + 'due_time' => [ + 'rules' => 'permit_empty|valid_date[H:i:s]', + 'errors' => ['valid_date' => 'Due time must be in HH:MM format.'], + ], ]; - // Get todos with categories - public function getWithCategories($todoId = null) - { - $builder = $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names') - ->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left') - ->join('categories', 'todo_categories.category_id = categories.id', 'left') - ->groupBy('todos.id'); + // ── Queries ──────────────────────────────────────────────────────────── - if ($todoId) { - $builder->where('todos.id', $todoId); - } - - return $builder->get()->getResultArray(); - } - - // Get todos by user with categories (optionally filtered by todo id) public function getByUserWithCategories($userId, $todoId = null) { $builder = $this->select(' diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index 177f32e..4dd059f 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -6,36 +6,35 @@ use CodeIgniter\Model; class UserModel extends Model { - protected $table = 'users'; - protected $primaryKey = 'id'; + protected $table = 'users'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'email', - 'password_hash', - 'name', - 'avatar_url', - 'settings', - 'created_at', - 'updated_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'email', 'password_hash', 'name', 'avatar_url', + 'settings', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; protected $validationRules = [ - 'email' => 'required|valid_email|is_unique[users.email]', - 'password_hash' => 'required', - ]; - - protected $validationMessages = [ 'email' => [ - 'required' => 'Email is required', - 'valid_email' => 'Please enter a valid email address', - 'is_unique' => 'This email is already registered', + 'rules' => 'required|valid_email|is_unique[users.email]', + 'errors' => [ + 'required' => 'Email is required.', + 'valid_email' => 'Please provide a valid email address.', + 'is_unique' => 'This email is already registered.', + ], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'Name is required.', + 'max_length' => 'Name must not exceed 255 characters.', + ], ], ]; } diff --git a/composer.json b/composer.json index d47149e..f47c294 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ }, "require": { "php": "^8.2", - "codeigniter4/framework": "^4.7" + "codeigniter4/framework": "^4.7", + "firebase/php-jwt": "^7.0" }, "require-dev": { "fakerphp/faker": "^1.9", diff --git a/composer.lock b/composer.lock index f50bf37..b026545 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f5cce40800fa5dae1504b9364f585e6a", + "content-hash": "86520263c0a2df285d17beea23def54d", "packages": [ { "name": "codeigniter4/framework", @@ -83,6 +83,70 @@ }, "time": "2026-03-24T18:26:09+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, { "name": "laminas/laminas-escaper", "version": "2.18.0",