mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
- 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
360 lines
11 KiB
PHP
360 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\Api;
|
|
|
|
use CodeIgniter\RESTful\ResourceController;
|
|
|
|
class BaseController extends ResourceController
|
|
{
|
|
/**
|
|
* Get the authenticated user from the request
|
|
*/
|
|
protected function getUser(): ?array
|
|
{
|
|
return $this->request->user ?? null;
|
|
}
|
|
|
|
/**
|
|
* Get the authenticated user ID
|
|
*/
|
|
protected function getUserId(): ?string
|
|
{
|
|
$user = $this->getUser();
|
|
return $user['id'] ?? null;
|
|
}
|
|
|
|
// ========================================================================
|
|
// Pagination & Sorting
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Extract pagination params from the query string.
|
|
*
|
|
* Returns [page, perPage].
|
|
* Default: page=1, perPage=50. Max perPage = 200.
|
|
*/
|
|
protected function getPaginationParams(): array
|
|
{
|
|
$page = max(1, (int) $this->request->getGet('page'));
|
|
$perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 50)));
|
|
return [$page, $perPage];
|
|
}
|
|
|
|
/**
|
|
* 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 getSortParams(array $allowed = []): array
|
|
{
|
|
$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 (!empty($extraMeta)) {
|
|
foreach ($extraMeta as $key => $value) {
|
|
$body[$key] = $value;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
foreach ($rules as $field => $rule) {
|
|
if (is_array($rule) && isset($rule['rules'])) {
|
|
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
|
|
} else {
|
|
$validation->setRule($field, $field, $rule);
|
|
}
|
|
}
|
|
|
|
if (!$validation->withRequest($this->request)->run()) {
|
|
$this->errorResponse('Validation failed', 422, $validation->getErrors());
|
|
return false;
|
|
}
|
|
|
|
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)
|
|
);
|
|
}
|
|
}
|