mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
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
This commit is contained in:
@@ -33,6 +33,11 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => '
|
|||||||
// Marketplace - Public access
|
// Marketplace - Public access
|
||||||
$routes->get('marketplace/themes', 'MarketplaceController::index');
|
$routes->get('marketplace/themes', 'MarketplaceController::index');
|
||||||
$routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1');
|
$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)
|
// Protected endpoints (API key authentication required)
|
||||||
|
|||||||
@@ -23,35 +23,160 @@ class BaseController extends ResourceController
|
|||||||
return $user['id'] ?? null;
|
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
|
* Success response
|
||||||
*/
|
*/
|
||||||
protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200)
|
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
|
return $this->response
|
||||||
->setStatusCode($statusCode)
|
->setStatusCode($statusCode)
|
||||||
->setHeader('Access-Control-Allow-Origin', '*')
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||||
->setJSON([
|
->setJSON($body);
|
||||||
'success' => true,
|
|
||||||
'message' => $message,
|
|
||||||
'data' => $data,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error response
|
* Error response with structured error info
|
||||||
*/
|
*/
|
||||||
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
|
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
|
||||||
{
|
{
|
||||||
$response = [
|
$body = [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($errors !== null) {
|
if ($errors !== null) {
|
||||||
$response['errors'] = $errors;
|
$body['errors'] = $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->response
|
return $this->response
|
||||||
@@ -59,17 +184,61 @@ class BaseController extends ResourceController
|
|||||||
->setHeader('Access-Control-Allow-Origin', '*')
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||||
->setJSON($response);
|
->setJSON($body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate request data
|
* 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
|
protected function validateRequest(array $rules): bool
|
||||||
{
|
{
|
||||||
$validation = \Config\Services::validation();
|
$validation = \Config\Services::validation();
|
||||||
|
|
||||||
// Handle both old format (string) and new format (array with rules/errors)
|
|
||||||
foreach ($rules as $field => $rule) {
|
foreach ($rules as $field => $rule) {
|
||||||
if (is_array($rule) && isset($rule['rules'])) {
|
if (is_array($rule) && isset($rule['rules'])) {
|
||||||
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
|
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
|
||||||
@@ -85,4 +254,106 @@ class BaseController extends ResourceController
|
|||||||
|
|
||||||
return true;
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,18 +221,163 @@ class AuthController extends BaseController
|
|||||||
], 'API key created successfully');
|
], '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
|
* 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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,37 +14,44 @@ class CategoryController extends BaseController
|
|||||||
$this->categoryModel = new CategoryModel();
|
$this->categoryModel = new CategoryModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['name', 'created_at'];
|
||||||
|
const FILTERABLE = ['favorite'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all categories for the authenticated user
|
|
||||||
* GET /api/v1/categories
|
* GET /api/v1/categories
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$categories = $this->categoryModel->where('user_id', $userId)->findAll();
|
$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
|
* POST /api/v1/categories
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->categoryModel)) {
|
||||||
'name' => 'required|max_length[255]',
|
|
||||||
'color' => 'required|max_length[7]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate name per user
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// Custom duplicate check (per user)
|
||||||
$existing = $this->categoryModel
|
$existing = $this->categoryModel
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->where('name', $json['name'])
|
->where('name', $json['name'])
|
||||||
@@ -59,17 +66,20 @@ class CategoryController extends BaseController
|
|||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'name' => $json['name'],
|
'name' => $json['name'],
|
||||||
'color' => $json['color'],
|
'color' => $json['color'],
|
||||||
'favorite' => $json['favorite'] ?? false,
|
'favorite' => !empty($json['favorite']),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->categoryModel->insert($data);
|
$this->categoryModel->insert($data);
|
||||||
$category = $this->categoryModel->find($data['id']);
|
$category = $this->categoryModel->find($data['id']);
|
||||||
|
|
||||||
|
$this->logActivity('category_created', 'category', $data['id'], [
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($category, 'Category created successfully', 201);
|
return $this->successResponse($category, 'Category created successfully', 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific category
|
|
||||||
* GET /api/v1/categories/{id}
|
* GET /api/v1/categories/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
@@ -85,7 +95,6 @@ class CategoryController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a category
|
|
||||||
* PUT /api/v1/categories/{id}
|
* PUT /api/v1/categories/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -99,7 +108,7 @@ class CategoryController extends BaseController
|
|||||||
|
|
||||||
$json = $this->request->getJSON(true);
|
$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'])) {
|
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
|
||||||
$existing = $this->categoryModel
|
$existing = $this->categoryModel
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
@@ -119,14 +128,22 @@ class CategoryController extends BaseController
|
|||||||
return $this->errorResponse('No valid fields to update');
|
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);
|
$this->categoryModel->update($id, $updateData);
|
||||||
$category = $this->categoryModel->find($id);
|
$category = $this->categoryModel->find($id);
|
||||||
|
|
||||||
|
$this->logActivity('category_updated', 'category', $id, [
|
||||||
|
'name' => $category['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($category, 'Category updated successfully');
|
return $this->successResponse($category, 'Category updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a category
|
|
||||||
* DELETE /api/v1/categories/{id}
|
* DELETE /api/v1/categories/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -140,18 +157,10 @@ class CategoryController extends BaseController
|
|||||||
|
|
||||||
$this->categoryModel->delete($id);
|
$this->categoryModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('category_deleted', 'category', $id, [
|
||||||
|
'name' => $category['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse(null, 'Category deleted successfully');
|
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,52 +14,62 @@ class ProjectController extends BaseController
|
|||||||
$this->projectModel = new ProjectModel();
|
$this->projectModel = new ProjectModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['name', 'created_at'];
|
||||||
|
const FILTERABLE = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all projects for the authenticated user
|
|
||||||
* GET /api/v1/projects
|
* GET /api/v1/projects
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$projects = $this->projectModel->where('user_id', $userId)->findAll();
|
$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
|
* POST /api/v1/projects
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->projectModel)) {
|
||||||
'name' => 'required|max_length[255]',
|
|
||||||
'color' => 'required|max_length[7]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $this->generateUuid(),
|
'id' => $this->generateUuid(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'name' => $json['name'],
|
'name' => $json['name'],
|
||||||
'description' => $json['description'] ?? null,
|
'description' => $json['description'] ?? null,
|
||||||
'color' => $json['color'],
|
'color' => $json['color'] ?? '#8B5CF6',
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->projectModel->insert($data);
|
$this->projectModel->insert($data);
|
||||||
$project = $this->projectModel->find($data['id']);
|
$project = $this->projectModel->find($data['id']);
|
||||||
|
|
||||||
|
$this->logActivity('project_created', 'project', $data['id'], [
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($project, 'Project created successfully', 201);
|
return $this->successResponse($project, 'Project created successfully', 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific project
|
|
||||||
* GET /api/v1/projects/{id}
|
* GET /api/v1/projects/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
@@ -75,7 +85,6 @@ class ProjectController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a project
|
|
||||||
* PUT /api/v1/projects/{id}
|
* PUT /api/v1/projects/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -88,6 +97,7 @@ class ProjectController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$json = $this->request->getJSON(true);
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$allowedFields = ['name', 'description', 'color'];
|
$allowedFields = ['name', 'description', 'color'];
|
||||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
@@ -98,11 +108,14 @@ class ProjectController extends BaseController
|
|||||||
$this->projectModel->update($id, $updateData);
|
$this->projectModel->update($id, $updateData);
|
||||||
$project = $this->projectModel->find($id);
|
$project = $this->projectModel->find($id);
|
||||||
|
|
||||||
|
$this->logActivity('project_updated', 'project', $id, [
|
||||||
|
'name' => $project['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($project, 'Project updated successfully');
|
return $this->successResponse($project, 'Project updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a project
|
|
||||||
* DELETE /api/v1/projects/{id}
|
* DELETE /api/v1/projects/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -116,18 +129,10 @@ class ProjectController extends BaseController
|
|||||||
|
|
||||||
$this->projectModel->delete($id);
|
$this->projectModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('project_deleted', 'project', $id, [
|
||||||
|
'name' => $project['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse(null, 'Project deleted successfully');
|
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1;
|
|||||||
use App\Controllers\Api\BaseController;
|
use App\Controllers\Api\BaseController;
|
||||||
use App\Models\RecurringTaskModel;
|
use App\Models\RecurringTaskModel;
|
||||||
use App\Models\RecurringTaskCategoryModel;
|
use App\Models\RecurringTaskCategoryModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
|
||||||
class RecurringTaskController extends BaseController
|
class RecurringTaskController extends BaseController
|
||||||
{
|
{
|
||||||
@@ -17,70 +18,91 @@ class RecurringTaskController extends BaseController
|
|||||||
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
|
$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
|
* GET /api/v1/recurring-tasks
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId);
|
$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
|
* POST /api/v1/recurring-tasks
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->recurringTaskModel)) {
|
||||||
'title' => 'required|max_length[255]',
|
|
||||||
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $this->generateUuid(),
|
'id' => $this->generateUuid(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'title' => $json['title'],
|
'title' => $json['title'],
|
||||||
'description' => $json['description'] ?? null,
|
'description' => $json['description'] ?? null,
|
||||||
'schedule' => $json['schedule'],
|
'schedule' => $json['schedule'] ?? 'weekly',
|
||||||
'custom_days' => $json['custom_days'] ? json_encode($json['custom_days']) : json_encode([]),
|
'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]',
|
||||||
'favorite' => $json['favorite'] ?? false,
|
'favorite' => !empty($json['favorite']),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->recurringTaskModel->insert($data);
|
$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']);
|
$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}
|
* GET /api/v1/recurring-tasks/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$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->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}
|
* PUT /api/v1/recurring-tasks/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -93,25 +115,40 @@ class RecurringTaskController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$json = $this->request->getJSON(true);
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// 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'];
|
$allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite'];
|
||||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
if (isset($updateData['custom_days'])) {
|
// 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']);
|
$updateData['custom_days'] = json_encode($updateData['custom_days']);
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('favorite', $updateData)) {
|
||||||
if (empty($updateData)) {
|
$updateData['favorite'] = !empty($updateData['favorite']);
|
||||||
return $this->errorResponse('No valid fields to update');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($updateData)) {
|
||||||
$this->recurringTaskModel->update($id, $updateData);
|
$this->recurringTaskModel->update($id, $updateData);
|
||||||
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
}
|
||||||
|
|
||||||
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}
|
* DELETE /api/v1/recurring-tasks/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -125,11 +162,14 @@ class RecurringTaskController extends BaseController
|
|||||||
|
|
||||||
$this->recurringTaskModel->delete($id);
|
$this->recurringTaskModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('recurring_task_deleted', 'recurring_task', $id, [
|
||||||
|
'title' => $task['title'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse(null, 'Recurring task deleted successfully');
|
return $this->successResponse(null, 'Recurring task deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a category to a recurring task
|
|
||||||
* POST /api/v1/recurring-tasks/{id}/categories
|
* POST /api/v1/recurring-tasks/{id}/categories
|
||||||
*/
|
*/
|
||||||
public function addCategory($taskId = null)
|
public function addCategory($taskId = null)
|
||||||
@@ -142,13 +182,11 @@ class RecurringTaskController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify task belongs to user
|
|
||||||
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||||
if (!$task) {
|
if (!$task) {
|
||||||
return $this->errorResponse('Recurring task not found', 404);
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if link already exists
|
|
||||||
$existing = $this->recurringTaskCategoryModel
|
$existing = $this->recurringTaskCategoryModel
|
||||||
->where('recurring_task_id', $taskId)
|
->where('recurring_task_id', $taskId)
|
||||||
->where('category_id', $json['category_id'])
|
->where('category_id', $json['category_id'])
|
||||||
@@ -167,14 +205,12 @@ class RecurringTaskController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a category from a recurring task
|
|
||||||
* DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
|
* DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
|
||||||
*/
|
*/
|
||||||
public function removeCategory($taskId = null, $categoryId = null)
|
public function removeCategory($taskId = null, $categoryId = null)
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
// Verify task belongs to user
|
|
||||||
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||||
if (!$task) {
|
if (!$task) {
|
||||||
return $this->errorResponse('Recurring task not found', 404);
|
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');
|
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(
|
$userId = $this->getUserId();
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
$categoryModel = new CategoryModel();
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
if (!$category) return;
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
$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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1;
|
|||||||
use App\Controllers\Api\BaseController;
|
use App\Controllers\Api\BaseController;
|
||||||
use App\Models\TodoModel;
|
use App\Models\TodoModel;
|
||||||
use App\Models\TodoCategoryModel;
|
use App\Models\TodoCategoryModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
|
||||||
class TodoController extends BaseController
|
class TodoController extends BaseController
|
||||||
{
|
{
|
||||||
@@ -17,36 +18,61 @@ class TodoController extends BaseController
|
|||||||
$this->todoCategoryModel = new TodoCategoryModel();
|
$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
|
* 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()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$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
|
* POST /api/v1/todos
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->todoModel)) {
|
||||||
'title' => 'required|max_length[255]',
|
|
||||||
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $this->generateUuid(),
|
'id' => $this->generateUuid(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
@@ -55,9 +81,9 @@ class TodoController extends BaseController
|
|||||||
'status' => $json['status'] ?? 'open',
|
'status' => $json['status'] ?? 'open',
|
||||||
'due_date' => $json['due_date'] ?? null,
|
'due_date' => $json['due_date'] ?? null,
|
||||||
'due_time' => $json['due_time'] ?? null,
|
'due_time' => $json['due_time'] ?? null,
|
||||||
'sync_enabled' => $json['sync_enabled'] ?? true,
|
'sync_enabled' => !empty($json['sync_enabled']),
|
||||||
'reminder_enabled' => $json['reminder_enabled'] ?? false,
|
'reminder_enabled' => !empty($json['reminder_enabled']),
|
||||||
'recurring_enabled' => $json['recurring_enabled'] ?? false,
|
'recurring_enabled' => !empty($json['recurring_enabled']),
|
||||||
'project_id' => $json['project_id'] ?? null,
|
'project_id' => $json['project_id'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -68,21 +94,9 @@ class TodoController extends BaseController
|
|||||||
$this->linkCategory($data['id'], $json['category_id']);
|
$this->linkCategory($data['id'], $json['category_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually log the activity
|
$this->logActivity('todo_created', 'todo', $data['id'], [
|
||||||
try {
|
'title' => $data['title'],
|
||||||
$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());
|
|
||||||
}
|
|
||||||
|
|
||||||
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
|
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
|
||||||
|
|
||||||
@@ -90,7 +104,6 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific todo
|
|
||||||
* GET /api/v1/todos/{id}
|
* GET /api/v1/todos/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
@@ -106,7 +119,6 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a todo
|
|
||||||
* PUT /api/v1/todos/{id}
|
* PUT /api/v1/todos/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -124,37 +136,33 @@ class TodoController extends BaseController
|
|||||||
$hasCategoryUpdate = array_key_exists('category_id', $json);
|
$hasCategoryUpdate = array_key_exists('category_id', $json);
|
||||||
if ($hasCategoryUpdate) {
|
if ($hasCategoryUpdate) {
|
||||||
$categoryId = $json['category_id'];
|
$categoryId = $json['category_id'];
|
||||||
// Remove all existing category links
|
|
||||||
$this->todoCategoryModel->where('todo_id', $id)->delete();
|
$this->todoCategoryModel->where('todo_id', $id)->delete();
|
||||||
// Link the new category
|
|
||||||
if (!empty($categoryId)) {
|
if (!empty($categoryId)) {
|
||||||
$this->linkCategory($id, $categoryId);
|
$this->linkCategory($id, $categoryId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update todo fields
|
// 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));
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
if (!empty($updateData)) {
|
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->todoModel->update($id, $updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually log the activity
|
$this->logActivity('todo_updated', 'todo', $id, [
|
||||||
try {
|
'title' => $todo['title'] ?? 'Unknown',
|
||||||
$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());
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
|
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
|
||||||
|
|
||||||
@@ -162,7 +170,6 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a todo
|
|
||||||
* DELETE /api/v1/todos/{id}
|
* DELETE /api/v1/todos/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -176,27 +183,16 @@ class TodoController extends BaseController
|
|||||||
|
|
||||||
$this->todoModel->delete($id);
|
$this->todoModel->delete($id);
|
||||||
|
|
||||||
// Manually log the activity
|
$this->logActivity('todo_deleted', 'todo', $id, [
|
||||||
try {
|
'title' => $todo['title'] ?? 'Unknown',
|
||||||
$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());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->successResponse(null, 'Todo deleted successfully');
|
return $this->successResponse(null, 'Todo deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Category linking ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a category to a todo
|
|
||||||
* POST /api/v1/todos/{id}/categories
|
* POST /api/v1/todos/{id}/categories
|
||||||
*/
|
*/
|
||||||
public function addCategory($todoId = null)
|
public function addCategory($todoId = null)
|
||||||
@@ -209,13 +205,11 @@ class TodoController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify todo belongs to user
|
|
||||||
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||||
if (!$todo) {
|
if (!$todo) {
|
||||||
return $this->errorResponse('Todo not found', 404);
|
return $this->errorResponse('Todo not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if link already exists
|
|
||||||
$existing = $this->todoCategoryModel
|
$existing = $this->todoCategoryModel
|
||||||
->where('todo_id', $todoId)
|
->where('todo_id', $todoId)
|
||||||
->where('category_id', $json['category_id'])
|
->where('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}
|
* DELETE /api/v1/todos/{id}/categories/{categoryId}
|
||||||
*/
|
*/
|
||||||
public function removeCategory($todoId = null, $categoryId = null)
|
public function removeCategory($todoId = null, $categoryId = null)
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
// Verify todo belongs to user
|
|
||||||
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||||
if (!$todo) {
|
if (!$todo) {
|
||||||
return $this->errorResponse('Todo not found', 404);
|
return $this->errorResponse('Todo not found', 404);
|
||||||
@@ -256,20 +248,19 @@ 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
|
private function linkCategory(string $todoId, string $categoryId): void
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
// Verify category belongs to user
|
$categoryModel = new CategoryModel();
|
||||||
$categoryModel = new \App\Models\CategoryModel();
|
|
||||||
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
if (!$category) {
|
if (!$category) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if link already exists
|
|
||||||
$existing = $this->todoCategoryModel
|
$existing = $this->todoCategoryModel
|
||||||
->where('todo_id', $todoId)
|
->where('todo_id', $todoId)
|
||||||
->where('category_id', $categoryId)
|
->where('category_id', $categoryId)
|
||||||
@@ -282,16 +273,4 @@ class TodoController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,15 +106,5 @@ class UserThemeController extends BaseController
|
|||||||
return $this->successResponse(null, 'User theme deleted successfully');
|
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ class CategoryModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'name', 'color', 'favorite', 'created_at',
|
||||||
'user_id',
|
|
||||||
'name',
|
|
||||||
'color',
|
|
||||||
'favorite',
|
|
||||||
'created_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -25,7 +20,24 @@ class CategoryModel extends Model
|
|||||||
protected $updatedField = '';
|
protected $updatedField = '';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'name' => 'required|max_length[255]',
|
'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).',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ class ProjectModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at',
|
||||||
'user_id',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'color',
|
|
||||||
'created_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -25,7 +20,16 @@ class ProjectModel extends Model
|
|||||||
protected $updatedField = '';
|
protected $updatedField = '';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'name' => 'required|max_length[255]',
|
'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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,8 @@ class RecurringTaskModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'title', 'description', 'schedule',
|
||||||
'user_id',
|
'custom_days', 'favorite', 'created_at', 'updated_at',
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'schedule',
|
|
||||||
'custom_days',
|
|
||||||
'favorite',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -28,17 +21,37 @@ class RecurringTaskModel extends Model
|
|||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'title' => 'required|max_length[255]',
|
'rules' => 'required',
|
||||||
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
|
'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
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
public function getWithCategories($taskId = null)
|
|
||||||
|
public function getByUserWithCategories($userId, $taskId = null)
|
||||||
{
|
{
|
||||||
$builder = $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
|
$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('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||||
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||||
|
->where('recurring_tasks.user_id', $userId)
|
||||||
->groupBy('recurring_tasks.id');
|
->groupBy('recurring_tasks.id');
|
||||||
|
|
||||||
if ($taskId) {
|
if ($taskId) {
|
||||||
@@ -47,16 +60,4 @@ class RecurringTaskModel extends Model
|
|||||||
|
|
||||||
return $builder->get()->getResultArray();
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,9 @@ class TodoModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'title', 'description', 'status',
|
||||||
'user_id',
|
'due_date', 'due_time', 'sync_enabled', 'reminder_enabled',
|
||||||
'title',
|
'recurring_enabled', 'project_id', 'created_at', 'updated_at',
|
||||||
'description',
|
|
||||||
'status',
|
|
||||||
'due_date',
|
|
||||||
'due_time',
|
|
||||||
'sync_enabled',
|
|
||||||
'reminder_enabled',
|
|
||||||
'recurring_enabled',
|
|
||||||
'project_id',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -32,27 +22,35 @@ class TodoModel extends Model
|
|||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'title' => 'required|max_length[255]',
|
'rules' => 'required',
|
||||||
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
'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
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
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');
|
|
||||||
|
|
||||||
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)
|
public function getByUserWithCategories($userId, $todoId = null)
|
||||||
{
|
{
|
||||||
$builder = $this->select('
|
$builder = $this->select('
|
||||||
|
|||||||
@@ -12,14 +12,8 @@ class UserModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'email', 'password_hash', 'name', 'avatar_url',
|
||||||
'email',
|
'settings', 'created_at', 'updated_at',
|
||||||
'password_hash',
|
|
||||||
'name',
|
|
||||||
'avatar_url',
|
|
||||||
'settings',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -27,15 +21,20 @@ class UserModel extends Model
|
|||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'email' => 'required|valid_email|is_unique[users.email]',
|
|
||||||
'password_hash' => 'required',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $validationMessages = [
|
|
||||||
'email' => [
|
'email' => [
|
||||||
'required' => 'Email is required',
|
'rules' => 'required|valid_email|is_unique[users.email]',
|
||||||
'valid_email' => 'Please enter a valid email address',
|
'errors' => [
|
||||||
'is_unique' => 'This email is already registered',
|
'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.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"codeigniter4/framework": "^4.7"
|
"codeigniter4/framework": "^4.7",
|
||||||
|
"firebase/php-jwt": "^7.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.9",
|
"fakerphp/faker": "^1.9",
|
||||||
|
|||||||
66
composer.lock
generated
66
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f5cce40800fa5dae1504b9364f585e6a",
|
"content-hash": "86520263c0a2df285d17beea23def54d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "codeigniter4/framework",
|
"name": "codeigniter4/framework",
|
||||||
@@ -83,6 +83,70 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-24T18:26:09+00:00"
|
"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",
|
"name": "laminas/laminas-escaper",
|
||||||
"version": "2.18.0",
|
"version": "2.18.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user