Merge branch 'main' into feature/marketplace

This commit is contained in:
Cametendo
2026-05-27 14:59:59 +02:00
23 changed files with 4179 additions and 1248 deletions

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
/**
* GenerateApiDocs
*
* Generates a standalone HTML documentation page from the OpenAPI spec.
* Uses Redoc (CDN) for rendering.
*
* Usage: php spark generate:api-docs
* php spark generate:api-docs --watch (validate only, no write)
* php spark generate:api-docs --serve (print live server URL)
*/
class GenerateApiDocs extends BaseCommand
{
protected $group = 'Documentation';
protected $name = 'generate:api-docs';
protected $description = 'Generate API documentation HTML from openapi/openapi.yaml';
protected $usage = 'generate:api-docs';
protected $arguments = [];
protected $options = [
'--watch' => 'Validate YAML only, do not write HTML',
'--serve' => 'Print the URL at which the docs are served',
];
public function run(array $params)
{
$projectRoot = ROOTPATH;
$openapiFile = $projectRoot . 'openapi/openapi.yaml';
$outputFile = $projectRoot . 'public/api-docs.html';
// ── Validate YAML exists ──────────────────────────────────────────
if (!file_exists($openapiFile)) {
CLI::error('[ERROR] openapi/openapi.yaml not found at: ' . $openapiFile);
CLI::write('Create it first, then run this command again.', 'yellow');
return EXIT_ERROR;
}
$yamlContent = file_get_contents($openapiFile);
if (empty($yamlContent)) {
CLI::error('[ERROR] openapi/openapi.yaml is empty.');
return EXIT_ERROR;
}
// Basic structural validation (line count, presence of openapi/info/paths)
$lines = explode("\n", $yamlContent);
$hasOpenapi = preg_match('/^openapi:/m', $yamlContent);
$hasInfo = preg_match('/^info:/m', $yamlContent);
$hasPaths = preg_match('/^paths:/m', $yamlContent);
CLI::write(sprintf(' Spec file: %s', $openapiFile), 'green');
CLI::write(sprintf(' Size: %d bytes', strlen($yamlContent)), 'green');
CLI::write(sprintf(' Lines: %d', count($lines)), 'green');
$errors = [];
if (!$hasOpenapi) $errors[] = 'Missing "openapi:" version declaration';
if (!$hasInfo) $errors[] = 'Missing "info:" section';
if (!$hasPaths) $errors[] = 'Missing "paths:" section';
$totalPaths = 0;
if (preg_match_all('/^\s{2}\/[a-z]/m', $yamlContent, $matches)) {
$totalPaths = count($matches[0]);
}
CLI::write(sprintf(' Endpoints: %d', $totalPaths), 'green');
if (!empty($errors)) {
CLI::error('[VALIDATION] ' . count($errors) . ' issue(s) found:');
foreach ($errors as $err) {
CLI::write(' - ' . $err, 'red');
}
return EXIT_ERROR;
}
CLI::write('[VALIDATION] OpenAPI spec looks valid.', 'green');
// ── --watch mode: stop here ────────────────────────────────────────
if (isset($params['watch']) || array_key_exists('watch', $params)) {
CLI::write('Watch mode — no files written.', 'yellow');
return EXIT_SUCCESS;
}
// ── Generate HTML ────────────────────────────────────────────────
$apiTitle = 'Todo App API Documentation';
// Escape YAML for embedding as a JS template literal.
// Safe: escape backtick, backslash, and template substitution.
$escapedYaml = str_replace(
['\\', '`', '${'],
['\\\\', '\\`', '\\${'],
$yamlContent
);
$html = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{$apiTitle}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.css" rel="stylesheet" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: #f8f9fa; }
.topbar {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
}
.topbar h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
.topbar .subtitle { font-size: 13px; color: #94a3b8; margin-top: 2px; }
.topbar .badge {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.15);
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
color: #e2e8f0;
}
#redoc-container { min-height: calc(100vh - 64px); }
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
color: #64748b;
font-size: 14px;
}
.loading::after {
content: '';
width: 20px;
height: 20px;
margin-left: 10px;
border: 2px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="topbar">
<div>
<h1>{$apiTitle}</h1>
<div class="subtitle">Todo App Backend — OpenAPI 3.0</div>
</div>
<div class="badge">Generated: GENERATED_DATE</div>
</div>
<div class="loading" id="loading">Loading API documentation...</div>
<div id="redoc-container"></div>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
<script>
var yamlText = `YAML_CONTENT`;
var spec = jsyaml.load(yamlText);
Redoc.init(
spec,
{
scrollYOffset: 64,
hideDownloadButton: false,
expandResponses: "200,201",
hideSingleRequestSampleTab: false,
sortPropsAlphabetically: false,
requiredPropsFirst: true,
showObjectSchemaExamples: true,
theme: {
colors: { primary: { main: '#3b82f6' } },
sidebar: { backgroundColor: '#ffffff', width: '280px' },
rightPanel: { backgroundColor: '#1e293b' }
},
nativeScrollbars: true
},
document.getElementById('redoc-container')
);
document.getElementById('loading').style.display = 'none';
</script>
</body>
</html>
HTML;
$html = str_replace(
['YAML_CONTENT', 'GENERATED_DATE'],
[$escapedYaml, date('Y-m-d H:i:s')],
$html
);
file_put_contents($outputFile, $html);
CLI::write(sprintf('[DONE] Docs generated: %s', $outputFile), 'green');
CLI::write(sprintf(' Size: %d bytes', filesize($outputFile)), 'green');
if (isset($params['serve']) || array_key_exists('serve', $params)) {
$baseUrl = CLI::getOption('base-url') ?? 'http://localhost:8080';
CLI::write(sprintf(' Open in browser: %s/api-docs.html', $baseUrl), 'cyan');
}
return EXIT_SUCCESS;
}
}

View File

@@ -36,6 +36,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)

View File

@@ -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)
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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,
]);
}
}
}

View File

@@ -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)
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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).',
],
],
];
}

View File

@@ -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.',
],
],
];
}

View File

@@ -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();
}
}

View File

@@ -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('

View File

@@ -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.',
],
],
];
}