mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Merge main into feature/marketplace
This commit is contained in:
@@ -34,7 +34,11 @@ class Cors extends BaseConfig
|
||||
* - ['http://localhost:8080']
|
||||
* - ['https://www.example.com']
|
||||
*/
|
||||
<<<<<<< HEAD
|
||||
'allowedOrigins' => ['http://localhost:5173', 'http://127.0.0.1:5173'],
|
||||
=======
|
||||
'allowedOrigins' => ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost'],
|
||||
>>>>>>> main
|
||||
|
||||
/**
|
||||
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
||||
@@ -68,7 +72,11 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||
*/
|
||||
<<<<<<< HEAD
|
||||
'allowedHeaders' => ['*'],
|
||||
=======
|
||||
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-API-Key'],
|
||||
>>>>>>> main
|
||||
|
||||
/**
|
||||
* Set headers to expose.
|
||||
@@ -93,7 +101,11 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||
*/
|
||||
<<<<<<< HEAD
|
||||
'allowedMethods' => ['*'],
|
||||
=======
|
||||
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
>>>>>>> main
|
||||
|
||||
/**
|
||||
* Set how many seconds the results of a preflight request can be cached.
|
||||
|
||||
@@ -34,6 +34,7 @@ class Filters extends BaseFilters
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
'apiauth' => \App\Filters\ApiAuthFilter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ class Filters extends BaseFilters
|
||||
*/
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'cors',
|
||||
// 'honeypot',
|
||||
// 'csrf',
|
||||
// 'invalidchars',
|
||||
|
||||
@@ -7,6 +7,92 @@ use CodeIgniter\Router\RouteCollection;
|
||||
*/
|
||||
$routes->get('/', 'Home::index');
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
|
||||
// ============================================================================
|
||||
// API Routes - Version 1.0
|
||||
// ============================================================================
|
||||
|
||||
// Catch-all CORS preflight handler for all API routes
|
||||
$routes->options('api/v1/(:any)', function () {
|
||||
$response = service('response');
|
||||
return $response->setStatusCode(200)
|
||||
->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');
|
||||
});
|
||||
|
||||
// Public endpoints (no authentication required)
|
||||
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => 'cors'], function ($routes) {
|
||||
// Authentication
|
||||
$routes->options('auth/register', 'AuthController::options');
|
||||
$routes->post('auth/register', 'AuthController::register');
|
||||
$routes->options('auth/login', 'AuthController::options');
|
||||
$routes->post('auth/login', 'AuthController::login');
|
||||
$routes->options('auth/api-key', 'AuthController::options');
|
||||
$routes->post('auth/api-key', 'AuthController::createApiKey');
|
||||
|
||||
// Marketplace - Public access
|
||||
$routes->get('marketplace/themes', 'MarketplaceController::index');
|
||||
$routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1');
|
||||
});
|
||||
|
||||
// Protected endpoints (API key authentication required)
|
||||
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => ['cors', 'apiauth']], function ($routes) {
|
||||
// User endpoints
|
||||
$routes->get('user/profile', 'UserController::profile');
|
||||
$routes->put('user/profile', 'UserController::updateProfile');
|
||||
$routes->get('user/api-keys', 'UserController::listApiKeys');
|
||||
$routes->post('user/api-keys', 'UserController::createApiKey');
|
||||
$routes->delete('user/api-keys/(:segment)', 'UserController::revokeApiKey/$1');
|
||||
|
||||
// Categories
|
||||
$routes->get('categories', 'CategoryController::index');
|
||||
$routes->post('categories', 'CategoryController::create');
|
||||
$routes->get('categories/(:segment)', 'CategoryController::show/$1');
|
||||
$routes->put('categories/(:segment)', 'CategoryController::update/$1');
|
||||
$routes->delete('categories/(:segment)', 'CategoryController::delete/$1');
|
||||
|
||||
// Projects
|
||||
$routes->get('projects', 'ProjectController::index');
|
||||
$routes->post('projects', 'ProjectController::create');
|
||||
$routes->get('projects/(:segment)', 'ProjectController::show/$1');
|
||||
$routes->put('projects/(:segment)', 'ProjectController::update/$1');
|
||||
$routes->delete('projects/(:segment)', 'ProjectController::delete/$1');
|
||||
|
||||
// Todos
|
||||
$routes->get('todos', 'TodoController::index');
|
||||
$routes->post('todos', 'TodoController::create');
|
||||
$routes->get('todos/(:segment)', 'TodoController::show/$1');
|
||||
$routes->put('todos/(:segment)', 'TodoController::update/$1');
|
||||
$routes->delete('todos/(:segment)', 'TodoController::delete/$1');
|
||||
$routes->post('todos/(:segment)/categories', 'TodoController::addCategory/$1');
|
||||
$routes->delete('todos/(:segment)/categories/(:segment)', 'TodoController::removeCategory/$1/$2');
|
||||
|
||||
// Recurring Tasks
|
||||
$routes->get('recurring-tasks', 'RecurringTaskController::index');
|
||||
$routes->post('recurring-tasks', 'RecurringTaskController::create');
|
||||
$routes->get('recurring-tasks/(:segment)', 'RecurringTaskController::show/$1');
|
||||
$routes->put('recurring-tasks/(:segment)', 'RecurringTaskController::update/$1');
|
||||
$routes->delete('recurring-tasks/(:segment)', 'RecurringTaskController::delete/$1');
|
||||
$routes->post('recurring-tasks/(:segment)/categories', 'RecurringTaskController::addCategory/$1');
|
||||
$routes->delete('recurring-tasks/(:segment)/categories/(:segment)', 'RecurringTaskController::removeCategory/$1/$2');
|
||||
|
||||
// Activity Logs
|
||||
$routes->get('activity-logs', 'ActivityLogController::index');
|
||||
$routes->get('activity-logs/(:segment)', 'ActivityLogController::show/$1');
|
||||
|
||||
// User Themes
|
||||
$routes->get('user/themes', 'UserThemeController::index');
|
||||
$routes->post('user/themes', 'UserThemeController::create');
|
||||
$routes->put('user/themes/(:segment)', 'UserThemeController::update/$1');
|
||||
$routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1');
|
||||
});
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
>>>>>>> main
|
||||
$routes->options('/themes', static function () {
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
@@ -23,8 +109,11 @@ $routes->options('/themes/upload', static function () {
|
||||
return response()->setStatusCode(204);
|
||||
});
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
<<<<<<< HEAD
|
||||
$routes->post('/themes/install/(:segment)', 'ThemeStore::install/$1');
|
||||
$routes->post('/themes/activate/(:segment)', 'ThemeStore::activate/$1');
|
||||
$routes->delete('/themes/uninstall/(:segment)', 'ThemeStore::uninstall/$1');
|
||||
$routes->get('/themes/my-themes', 'ThemeStore::myThemes');
|
||||
$routes->get('/themes/(:segment)', 'ThemeStore::serveCss/$1');
|
||||
=======
|
||||
>>>>>>> main
|
||||
|
||||
88
app/Controllers/Api/BaseController.php
Normal file
88
app/Controllers/Api/BaseController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class BaseController extends ResourceController
|
||||
{
|
||||
/**
|
||||
* Get the authenticated user from the request
|
||||
*/
|
||||
protected function getUser(): ?array
|
||||
{
|
||||
return $this->request->user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user ID
|
||||
*/
|
||||
protected function getUserId(): ?string
|
||||
{
|
||||
$user = $this->getUser();
|
||||
return $user['id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success response
|
||||
*/
|
||||
protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200)
|
||||
{
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response
|
||||
*/
|
||||
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
|
||||
{
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($errors !== null) {
|
||||
$response['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($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate request data
|
||||
*/
|
||||
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'] ?? []);
|
||||
} else {
|
||||
$validation->setRule($field, $field, $rule);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
$this->errorResponse('Validation failed', 422, $validation->getErrors());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
45
app/Controllers/Api/V1/ActivityLogController.php
Normal file
45
app/Controllers/Api/V1/ActivityLogController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\ActivityLogModel;
|
||||
|
||||
class ActivityLogController extends BaseController
|
||||
{
|
||||
protected $activityLogModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->activityLogModel = new ActivityLogModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activity logs for the authenticated user
|
||||
* GET /api/v1/activity-logs
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$limit = (int)($this->request->getVar('limit') ?? 50);
|
||||
$logs = $this->activityLogModel->getByUser($userId, $limit);
|
||||
|
||||
return $this->successResponse($logs, 'Activity logs retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific activity log
|
||||
* GET /api/v1/activity-logs/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$log = $this->activityLogModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$log) {
|
||||
return $this->errorResponse('Activity log not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($log, 'Activity log retrieved successfully');
|
||||
}
|
||||
}
|
||||
238
app/Controllers/Api/V1/AuthController.php
Normal file
238
app/Controllers/Api/V1/AuthController.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ApiAuthKeyModel;
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $apiAuthKeyModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->apiAuthKeyModel = new ApiAuthKeyModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight requests
|
||||
* OPTIONS /api/v1/auth/*
|
||||
*/
|
||||
public function options()
|
||||
{
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* POST /api/v1/auth/register
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$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 {
|
||||
// Generate UUID for user
|
||||
$userId = $this->generateUuid();
|
||||
|
||||
// Create user
|
||||
$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['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
|
||||
);
|
||||
|
||||
// Remove sensitive data from response
|
||||
unset($userData['password_hash']);
|
||||
|
||||
return $this->successResponse([
|
||||
'user' => $userData,
|
||||
'api_key' => $apiKey['key'],
|
||||
'key_prefix' => $apiKey['prefix'],
|
||||
], 'User registered successfully', 201);
|
||||
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
|
||||
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user and return API key
|
||||
* POST /api/v1/auth/login
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'email' => 'required|valid_email',
|
||||
'password' => 'required',
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Authenticate user
|
||||
$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);
|
||||
}
|
||||
|
||||
// Check if user has an existing active API key
|
||||
$existingKey = $this->apiAuthKeyModel
|
||||
->where('user_id', $user['id'])
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if ($existingKey) {
|
||||
// Return existing key
|
||||
return $this->successResponse([
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
],
|
||||
'api_key_prefix' => $existingKey['key_prefix'],
|
||||
'message' => 'Using existing API key',
|
||||
], 'Login successful');
|
||||
}
|
||||
|
||||
// Create new API key
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$user['id'],
|
||||
'Login API Key',
|
||||
['read', 'write'],
|
||||
null
|
||||
);
|
||||
|
||||
return $this->successResponse([
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
],
|
||||
'api_key' => $apiKey['key'],
|
||||
'key_prefix' => $apiKey['prefix'],
|
||||
], 'Login successful');
|
||||
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
|
||||
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API key using email and password (legacy endpoint)
|
||||
* POST /api/v1/auth/api-key
|
||||
*/
|
||||
public function createApiKey()
|
||||
{
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'email' => 'required|valid_email',
|
||||
'password' => 'required|min_length[6]',
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
$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);
|
||||
}
|
||||
|
||||
// Create API key
|
||||
$name = $json['name'] ?? 'API Key';
|
||||
$scopes = $json['scopes'] ?? ['read', 'write'];
|
||||
$expiresAt = $json['expires_at'] ?? null;
|
||||
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$user['id'],
|
||||
$name,
|
||||
$scopes,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
return $this->successResponse([
|
||||
'key' => $apiKey['key'],
|
||||
'prefix' => $apiKey['prefix'],
|
||||
'name' => $apiKey['name'],
|
||||
'scopes' => $apiKey['scopes'],
|
||||
'expires_at' => $apiKey['expires_at'],
|
||||
], 'API key created 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
157
app/Controllers/Api/V1/CategoryController.php
Normal file
157
app/Controllers/Api/V1/CategoryController.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\CategoryModel;
|
||||
|
||||
class CategoryController extends BaseController
|
||||
{
|
||||
protected $categoryModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categoryModel = new CategoryModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
return $this->successResponse($categories, '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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate name per user
|
||||
$existing = $this->categoryModel
|
||||
->where('user_id', $userId)
|
||||
->where('name', $json['name'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('A category with this name already exists.', 409);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'name' => $json['name'],
|
||||
'color' => $json['color'],
|
||||
'favorite' => $json['favorite'] ?? false,
|
||||
];
|
||||
|
||||
$this->categoryModel->insert($data);
|
||||
$category = $this->categoryModel->find($data['id']);
|
||||
|
||||
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();
|
||||
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) {
|
||||
return $this->errorResponse('Category not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($category, 'Category retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a category
|
||||
* PUT /api/v1/categories/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) {
|
||||
return $this->errorResponse('Category not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
// Check for duplicate name on rename (excluding current category)
|
||||
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
|
||||
$existing = $this->categoryModel
|
||||
->where('user_id', $userId)
|
||||
->where('name', $json['name'])
|
||||
->where('id !=', $id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('A category with this name already exists.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
$allowedFields = ['name', 'color', 'favorite'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->categoryModel->update($id, $updateData);
|
||||
$category = $this->categoryModel->find($id);
|
||||
|
||||
return $this->successResponse($category, 'Category updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a category
|
||||
* DELETE /api/v1/categories/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) {
|
||||
return $this->errorResponse('Category not found', 404);
|
||||
}
|
||||
|
||||
$this->categoryModel->delete($id);
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/Controllers/Api/V1/MarketplaceController.php
Normal file
42
app/Controllers/Api/V1/MarketplaceController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\MarketplaceThemeModel;
|
||||
|
||||
class MarketplaceController extends BaseController
|
||||
{
|
||||
protected $marketplaceThemeModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->marketplaceThemeModel = new MarketplaceThemeModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all marketplace themes
|
||||
* GET /api/v1/marketplace/themes
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$themes = $this->marketplaceThemeModel->getPublished();
|
||||
|
||||
return $this->successResponse($themes, 'Marketplace themes retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific marketplace theme
|
||||
* GET /api/v1/marketplace/themes/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$theme = $this->marketplaceThemeModel->find($id);
|
||||
|
||||
if (!$theme) {
|
||||
return $this->errorResponse('Theme not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($theme, 'Theme retrieved successfully');
|
||||
}
|
||||
}
|
||||
133
app/Controllers/Api/V1/ProjectController.php
Normal file
133
app/Controllers/Api/V1/ProjectController.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\ProjectModel;
|
||||
|
||||
class ProjectController extends BaseController
|
||||
{
|
||||
protected $projectModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->projectModel = new ProjectModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
return $this->successResponse($projects, '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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'name' => $json['name'],
|
||||
'description' => $json['description'] ?? null,
|
||||
'color' => $json['color'],
|
||||
];
|
||||
|
||||
$this->projectModel->insert($data);
|
||||
$project = $this->projectModel->find($data['id']);
|
||||
|
||||
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();
|
||||
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$project) {
|
||||
return $this->errorResponse('Project not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($project, 'Project retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a project
|
||||
* PUT /api/v1/projects/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$project) {
|
||||
return $this->errorResponse('Project not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
$allowedFields = ['name', 'description', 'color'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->projectModel->update($id, $updateData);
|
||||
$project = $this->projectModel->find($id);
|
||||
|
||||
return $this->successResponse($project, 'Project updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
* DELETE /api/v1/projects/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$project) {
|
||||
return $this->errorResponse('Project not found', 404);
|
||||
}
|
||||
|
||||
$this->projectModel->delete($id);
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
202
app/Controllers/Api/V1/RecurringTaskController.php
Normal file
202
app/Controllers/Api/V1/RecurringTaskController.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\RecurringTaskModel;
|
||||
use App\Models\RecurringTaskCategoryModel;
|
||||
|
||||
class RecurringTaskController extends BaseController
|
||||
{
|
||||
protected $recurringTaskModel;
|
||||
protected $recurringTaskCategoryModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->recurringTaskModel = new RecurringTaskModel();
|
||||
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recurring tasks for the authenticated user
|
||||
* GET /api/v1/recurring-tasks
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId);
|
||||
|
||||
return $this->successResponse($tasks, '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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'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,
|
||||
];
|
||||
|
||||
$this->recurringTaskModel->insert($data);
|
||||
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']);
|
||||
|
||||
return $this->successResponse($task, '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);
|
||||
|
||||
if (!$task) {
|
||||
return $this->errorResponse('Recurring task not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($task, '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();
|
||||
|
||||
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'])) {
|
||||
$updateData['custom_days'] = json_encode($updateData['custom_days']);
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->recurringTaskModel->update($id, $updateData);
|
||||
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
||||
|
||||
return $this->successResponse($task, '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();
|
||||
|
||||
if (!$task) {
|
||||
return $this->errorResponse('Recurring task not found', 404);
|
||||
}
|
||||
|
||||
$this->recurringTaskModel->delete($id);
|
||||
|
||||
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);
|
||||
|
||||
$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'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('Category already linked to this task', 409);
|
||||
}
|
||||
|
||||
$this->recurringTaskCategoryModel->insert([
|
||||
'recurring_task_id' => $taskId,
|
||||
'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);
|
||||
}
|
||||
|
||||
$this->recurringTaskCategoryModel
|
||||
->where('recurring_task_id', $taskId)
|
||||
->where('category_id', $categoryId)
|
||||
->delete();
|
||||
|
||||
return $this->successResponse(null, 'Category removed from recurring task 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
297
app/Controllers/Api/V1/TodoController.php
Normal file
297
app/Controllers/Api/V1/TodoController.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\TodoModel;
|
||||
use App\Models\TodoCategoryModel;
|
||||
|
||||
class TodoController extends BaseController
|
||||
{
|
||||
protected $todoModel;
|
||||
protected $todoCategoryModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->todoModel = new TodoModel();
|
||||
$this->todoCategoryModel = new TodoCategoryModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all todos for the authenticated user
|
||||
* GET /api/v1/todos
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$todos = $this->todoModel->getByUserWithCategories($userId);
|
||||
|
||||
return $this->successResponse($todos, '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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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,
|
||||
];
|
||||
|
||||
$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());
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
if (empty($todos)) {
|
||||
return $this->errorResponse('Todo not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($todos[0], 'Todo retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
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'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (!empty($updateData)) {
|
||||
$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());
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
return $this->successResponse(null, 'Todo deleted successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
$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'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('Category already linked to this todo', 409);
|
||||
}
|
||||
|
||||
$this->todoCategoryModel->insert([
|
||||
'todo_id' => $todoId,
|
||||
'category_id' => $json['category_id'],
|
||||
]);
|
||||
|
||||
return $this->successResponse(null, 'Category added to todo successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
$this->todoCategoryModel
|
||||
->where('todo_id', $todoId)
|
||||
->where('category_id', $categoryId)
|
||||
->delete();
|
||||
|
||||
return $this->successResponse(null, 'Category removed from todo successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a category to a todo
|
||||
*/
|
||||
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();
|
||||
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,
|
||||
'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)
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/Controllers/Api/V1/UserController.php
Normal file
126
app/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ApiAuthKeyModel;
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $apiAuthKeyModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->apiAuthKeyModel = new ApiAuthKeyModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile
|
||||
* GET /api/v1/user/profile
|
||||
*/
|
||||
public function profile()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
return $this->errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
// Remove sensitive data
|
||||
unset($user['password_hash']);
|
||||
|
||||
return $this->successResponse($user, 'Profile retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
* PUT /api/v1/user/profile
|
||||
*/
|
||||
public function updateProfile()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$allowedFields = ['name', 'avatar_url', 'settings'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->userModel->update($userId, $updateData);
|
||||
$user = $this->userModel->find($userId);
|
||||
unset($user['password_hash']);
|
||||
|
||||
return $this->successResponse($user, 'Profile updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's API keys
|
||||
* GET /api/v1/user/api-keys
|
||||
*/
|
||||
public function listApiKeys()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$apiKeys = $this->apiAuthKeyModel->getByUser($userId);
|
||||
|
||||
// Remove sensitive data
|
||||
foreach ($apiKeys as &$key) {
|
||||
unset($key['key_hash']);
|
||||
}
|
||||
|
||||
return $this->successResponse($apiKeys, 'API keys retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key
|
||||
* POST /api/v1/user/api-keys
|
||||
*/
|
||||
public function createApiKey()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$name = $json['name'] ?? 'API Key';
|
||||
$scopes = $json['scopes'] ?? ['read', 'write'];
|
||||
$expiresAt = $json['expires_at'] ?? null;
|
||||
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$userId,
|
||||
$name,
|
||||
$scopes,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
return $this->successResponse([
|
||||
'id' => $apiKey['id'],
|
||||
'key' => $apiKey['key'],
|
||||
'prefix' => $apiKey['prefix'],
|
||||
'name' => $apiKey['name'],
|
||||
'scopes' => $apiKey['scopes'],
|
||||
'expires_at' => $apiKey['expires_at'],
|
||||
], 'API key created successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key
|
||||
* DELETE /api/v1/user/api-keys/{id}
|
||||
*/
|
||||
public function revokeApiKey($id)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$apiKey = $this->apiAuthKeyModel->find($id);
|
||||
|
||||
if (!$apiKey || $apiKey['user_id'] !== $userId) {
|
||||
return $this->errorResponse('API key not found', 404);
|
||||
}
|
||||
|
||||
$this->apiAuthKeyModel->revokeKey($id);
|
||||
|
||||
return $this->successResponse(null, 'API key revoked successfully');
|
||||
}
|
||||
}
|
||||
120
app/Controllers/Api/V1/UserThemeController.php
Normal file
120
app/Controllers/Api/V1/UserThemeController.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\UserThemeModel;
|
||||
|
||||
class UserThemeController extends BaseController
|
||||
{
|
||||
protected $userThemeModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userThemeModel = new UserThemeModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all themes for the authenticated user
|
||||
* GET /api/v1/user/themes
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$themes = $this->userThemeModel->getByUser($userId);
|
||||
|
||||
return $this->successResponse($themes, 'User themes retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user theme
|
||||
* POST /api/v1/user/themes
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'theme_id' => 'required',
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'theme_id' => $json['theme_id'],
|
||||
'is_active' => $json['is_active'] ?? false,
|
||||
'custom_settings' => $json['custom_settings'] ? json_encode($json['custom_settings']) : null,
|
||||
];
|
||||
|
||||
$this->userThemeModel->insert($data);
|
||||
$theme = $this->userThemeModel->find($data['id']);
|
||||
|
||||
return $this->successResponse($theme, 'User theme created successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user theme
|
||||
* PUT /api/v1/user/themes/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$theme) {
|
||||
return $this->errorResponse('User theme not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
$allowedFields = ['is_active', 'custom_settings'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (isset($updateData['custom_settings'])) {
|
||||
$updateData['custom_settings'] = json_encode($updateData['custom_settings']);
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->userThemeModel->update($id, $updateData);
|
||||
$theme = $this->userThemeModel->find($id);
|
||||
|
||||
return $this->successResponse($theme, 'User theme updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user theme
|
||||
* DELETE /api/v1/user/themes/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$theme) {
|
||||
return $this->errorResponse('User theme not found', 404);
|
||||
}
|
||||
|
||||
$this->userThemeModel->delete($id);
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateApiAuthKeysTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'CHAR',
|
||||
'constraint' => 36,
|
||||
'null' => false,
|
||||
],
|
||||
'user_id' => [
|
||||
'type' => 'CHAR',
|
||||
'constraint' => 36,
|
||||
'null' => false,
|
||||
],
|
||||
'key_hash' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => false,
|
||||
'comment' => 'SHA-256 hash of the API key',
|
||||
],
|
||||
'key_prefix' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 20,
|
||||
'null' => false,
|
||||
'comment' => 'First 8 characters for identification',
|
||||
],
|
||||
'name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
'comment' => 'User-friendly name for the key',
|
||||
],
|
||||
'scopes' => [
|
||||
'type' => 'JSON',
|
||||
'null' => true,
|
||||
'comment' => 'Array of allowed scopes (e.g., ["read", "write"])',
|
||||
],
|
||||
'expires_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
'comment' => 'Optional expiration date',
|
||||
],
|
||||
'last_used_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'last_used_ip' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 45,
|
||||
'null' => true,
|
||||
'comment' => 'IPv4 or IPv6 address',
|
||||
],
|
||||
'is_active' => [
|
||||
'type' => 'BOOLEAN',
|
||||
'default' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('user_id');
|
||||
$this->forge->addKey('key_hash');
|
||||
$this->forge->addKey('is_active');
|
||||
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||
$this->forge->createTable('api_auth_keys');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropTable('api_auth_keys');
|
||||
}
|
||||
}
|
||||
@@ -309,5 +309,38 @@ class SampleDataSeeder extends Seeder
|
||||
if (!empty($recurringTaskCategories)) {
|
||||
$this->db->table('recurring_task_categories')->insertBatch($recurringTaskCategories);
|
||||
}
|
||||
|
||||
// Create an API key for the demo user
|
||||
$existingApiKey = $this->db->table('api_auth_keys')
|
||||
->where('user_id', $userId)
|
||||
->where('name', 'Demo API Key')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$existingApiKey) {
|
||||
$apiKey = 'todo_' . bin2hex(random_bytes(32));
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
$keyPrefix = substr($apiKey, 0, 8);
|
||||
|
||||
$this->db->table('api_auth_keys')->insert([
|
||||
'id' => $generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'key_hash' => $keyHash,
|
||||
'key_prefix' => $keyPrefix,
|
||||
'name' => 'Demo API Key',
|
||||
'scopes' => json_encode(['read', 'write']),
|
||||
'expires_at' => null,
|
||||
'is_active' => true,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
echo "\n========================================\n";
|
||||
echo "DEMO API KEY CREATED:\n";
|
||||
echo "========================================\n";
|
||||
echo "API Key: {$apiKey}\n";
|
||||
echo "Prefix: {$keyPrefix}\n";
|
||||
echo "Use this key in the X-API-Key header\n";
|
||||
echo "========================================\n\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
app/Filters/ApiAuthFilter.php
Normal file
100
app/Filters/ApiAuthFilter.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class ApiAuthFilter implements FilterInterface
|
||||
{
|
||||
/**
|
||||
* Do whatever processing this filter needs to do.
|
||||
* By default it should not return anything during
|
||||
* normal execution. However, when an abnormal state
|
||||
* is found, it should return an instance of
|
||||
* CodeIgniter\HTTP\Response. If it does, script
|
||||
* execution will end and that Response will be
|
||||
* sent back to the client, allowing for error pages,
|
||||
* redirects, etc.
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param array|null $arguments
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$apiKey = $request->getHeaderLine('X-API-Key');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return $this->unauthorized('API key is required');
|
||||
}
|
||||
|
||||
$apiAuthKeyModel = new \App\Models\ApiAuthKeyModel();
|
||||
$result = $apiAuthKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$result) {
|
||||
return $this->unauthorized('Invalid or expired API key');
|
||||
}
|
||||
|
||||
// Store the authenticated user in the request
|
||||
$request->user = $result['user'];
|
||||
$request->authKey = $result['auth_key'];
|
||||
|
||||
// Check scopes if required
|
||||
if (!empty($arguments)) {
|
||||
$requiredScopes = $arguments;
|
||||
$keyScopes = $result['auth_key']['scopes'] ? json_decode($result['auth_key']['scopes'], true) : [];
|
||||
|
||||
if (empty($keyScopes)) {
|
||||
// No scopes defined, allow all
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($requiredScopes as $scope) {
|
||||
if (!in_array($scope, $keyScopes)) {
|
||||
return $this->forbidden('Insufficient permissions. Required scope: ' . $scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't need to do anything here.
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param array|null $arguments
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Return unauthorized response
|
||||
*/
|
||||
private function unauthorized(string $message): ResponseInterface
|
||||
{
|
||||
$response = \Config\Services::response();
|
||||
return $response->setStatusCode(401)->setJSON([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return forbidden response
|
||||
*/
|
||||
private function forbidden(string $message): ResponseInterface
|
||||
{
|
||||
$response = \Config\Services::response();
|
||||
return $response->setStatusCode(403)->setJSON([
|
||||
'error' => 'Forbidden',
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class ActivityLogModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'action' => 'required|max_length[255]',
|
||||
@@ -34,9 +34,6 @@ class ActivityLogModel extends Model
|
||||
// Log an activity
|
||||
public function logActivity($data)
|
||||
{
|
||||
// Disable events to prevent any recursive logging
|
||||
$this->skipEvents();
|
||||
|
||||
if (!isset($data['id'])) {
|
||||
$data['id'] = $this->generateUuid();
|
||||
}
|
||||
@@ -44,12 +41,9 @@ class ActivityLogModel extends Model
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$result = $this->insert($data);
|
||||
|
||||
// Re-enable events
|
||||
$this->skipEvents(false);
|
||||
|
||||
return $result;
|
||||
// Use builder directly to avoid triggering events
|
||||
$builder = $this->db->table($this->table);
|
||||
return $builder->insert($data);
|
||||
}
|
||||
|
||||
// Get logs by user
|
||||
|
||||
@@ -22,7 +22,7 @@ class AiMessageModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'chat_id' => 'required',
|
||||
|
||||
@@ -22,7 +22,7 @@ class AiProviderModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'name' => 'required|max_length[100]|is_unique[ai_providers.name]',
|
||||
|
||||
164
app/Models/ApiAuthKeyModel.php
Normal file
164
app/Models/ApiAuthKeyModel.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ApiAuthKeyModel extends Model
|
||||
{
|
||||
protected $table = 'api_auth_keys';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'user_id',
|
||||
'key_hash',
|
||||
'key_prefix',
|
||||
'name',
|
||||
'scopes',
|
||||
'expires_at',
|
||||
'last_used_at',
|
||||
'last_used_ip',
|
||||
'is_active',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = false;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'key_hash' => 'required',
|
||||
'key_prefix' => 'required|max_length[20]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*/
|
||||
public function generateKey(): string
|
||||
{
|
||||
return 'todo_' . bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key for a user
|
||||
*/
|
||||
public function createKey(string $userId, ?string $name = null, ?array $scopes = null, ?string $expiresAt = null): array
|
||||
{
|
||||
$key = $this->generateKey();
|
||||
$keyHash = hash('sha256', $key);
|
||||
$keyPrefix = substr($key, 0, 8);
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'key_hash' => $keyHash,
|
||||
'key_prefix' => $keyPrefix,
|
||||
'name' => $name,
|
||||
'scopes' => $scopes ? json_encode($scopes) : null,
|
||||
'expires_at' => $expiresAt,
|
||||
'is_active' => true,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$this->insert($data);
|
||||
|
||||
return [
|
||||
'id' => $data['id'],
|
||||
'key' => $key,
|
||||
'prefix' => $keyPrefix,
|
||||
'name' => $name,
|
||||
'scopes' => $scopes,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key and return the associated user
|
||||
*/
|
||||
public function validateKey(string $key): ?array
|
||||
{
|
||||
$keyHash = hash('sha256', $key);
|
||||
|
||||
$authKey = $this->where('key_hash', $keyHash)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$authKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if key has expired
|
||||
if ($authKey['expires_at'] && strtotime($authKey['expires_at']) < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used information
|
||||
$this->update($authKey['id'], [
|
||||
'last_used_at' => date('Y-m-d H:i:s'),
|
||||
'last_used_ip' => $this->getClientIp(),
|
||||
]);
|
||||
|
||||
// Get the user
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($authKey['user_id']);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'auth_key' => $authKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys for a user
|
||||
*/
|
||||
public function getByUser(string $userId): array
|
||||
{
|
||||
return $this->where('user_id', $userId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key
|
||||
*/
|
||||
public function revokeKey(string $keyId): bool
|
||||
{
|
||||
return $this->update($keyId, ['is_active' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*/
|
||||
private function getClientIp(): ?string
|
||||
{
|
||||
try {
|
||||
$request = \Config\Services::request();
|
||||
return $request->getIPAddress();
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
|
||||
|
||||
class CategoryModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'categories';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
@@ -24,15 +22,10 @@ class CategoryModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'name' => 'required|max_length[255]',
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'category';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ trait LoggableTrait
|
||||
{
|
||||
try {
|
||||
$request = \Config\Services::request();
|
||||
return $request->getUserAgent()->toString();
|
||||
return $request->getUserAgent()->getAgentString();
|
||||
} catch (\Exception $e) {
|
||||
return 'CLI/Script';
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
|
||||
|
||||
class ProjectModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'projects';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
@@ -24,15 +22,10 @@ class ProjectModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'name' => 'required|max_length[255]',
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'project';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
|
||||
|
||||
class RecurringTaskModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'recurring_tasks';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
@@ -35,11 +33,6 @@ class RecurringTaskModel extends Model
|
||||
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'recurring_task';
|
||||
}
|
||||
|
||||
// Get recurring tasks with categories
|
||||
public function getWithCategories($taskId = null)
|
||||
{
|
||||
|
||||
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
|
||||
|
||||
class TodoModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'todos';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
@@ -39,11 +37,6 @@ class TodoModel extends Model
|
||||
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
// Get todos with categories
|
||||
public function getWithCategories($todoId = null)
|
||||
{
|
||||
@@ -59,15 +52,23 @@ class TodoModel extends Model
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
// Get todos by user with categories
|
||||
public function getByUserWithCategories($userId)
|
||||
// Get todos by user with categories (optionally filtered by todo id)
|
||||
public function getByUserWithCategories($userId, $todoId = null)
|
||||
{
|
||||
return $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')
|
||||
->where('todos.user_id', $userId)
|
||||
->groupBy('todos.id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$builder = $this->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');
|
||||
|
||||
if ($todoId) {
|
||||
$builder->where('todos.id', $todoId);
|
||||
}
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
|
||||
|
||||
class UserModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'users';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
@@ -40,9 +38,4 @@ class UserModel extends Model
|
||||
'is_unique' => 'This email is already registered',
|
||||
],
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user