diff --git a/.gitignore b/.gitignore index 4dee025..4a8a2f4 100644 --- a/.gitignore +++ b/.gitignore @@ -127,4 +127,7 @@ _modules/* .env env .claude/ -.claude/* \ No newline at end of file +.claude/* + +# Generated docs +/public/api-docs.html \ No newline at end of file diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md deleted file mode 100644 index 8c1eb44..0000000 --- a/API_DOCUMENTATION.md +++ /dev/null @@ -1,825 +0,0 @@ -# Todo App API Documentation - -## Version: 1.0 - -Base URL: `http://localhost:8080/api/v1` - -## Overview - -This API provides access to the Todo App functionality with versioned endpoints. The API uses API key authentication for protected endpoints, while some endpoints (like the marketplace) are publicly accessible. - -## Authentication - -### API Key Authentication - -Most endpoints require an API key for authentication. The API key should be included in the `X-API-Key` header. - -**Header:** -``` -X-API-Key: todo_your_api_key_here -``` - -### Register a New User - -**Endpoint:** `POST /api/v1/auth/register` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "your_password", - "name": "John Doe", - "avatar_url": "https://example.com/avatar.jpg", - "settings": { - "theme": "dark", - "language": "en" - } -} -``` - -**Response:** -```json -{ - "success": true, - "message": "User registered successfully", - "data": { - "user": { - "id": "user-uuid", - "email": "user@example.com", - "name": "John Doe", - "avatar_url": "https://example.com/avatar.jpg", - "settings": {"theme": "dark"}, - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00" - }, - "api_key": "todo_abc123...", - "key_prefix": "todo_abc1" - } -} -``` - -**Important:** Store the API key securely. You won't be able to retrieve it again. - -### Login - -**Endpoint:** `POST /api/v1/auth/login` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "your_password" -} -``` - -**Response (New API Key Created):** -```json -{ - "success": true, - "message": "Login successful", - "data": { - "user": { - "id": "user-uuid", - "email": "user@example.com", - "name": "John Doe" - }, - "api_key": "todo_abc123...", - "key_prefix": "todo_abc1" - } -} -``` - -**Response (Using Existing API Key):** -```json -{ - "success": true, - "message": "Login successful", - "data": { - "user": { - "id": "user-uuid", - "email": "user@example.com", - "name": "John Doe" - }, - "api_key_prefix": "todo_abc1", - "message": "Using existing API key" - } -} -``` - -**Note:** If you already have an active API key, the login will return the key prefix only (not the full key for security). You should store your API key securely after the first login. - -### Creating an API Key (Legacy) - -To create an additional API key, you can use this endpoint: - -**Endpoint:** `POST /api/v1/auth/api-key` - -**Request Body:** -```json -{ - "email": "user@example.com", - "password": "your_password", - "name": "My App Key", - "scopes": ["read", "write"], - "expires_at": "2026-12-31 23:59:59" -} -``` - -**Response:** -```json -{ - "success": true, - "message": "API key created successfully", - "data": { - "key": "todo_abc123...", - "prefix": "todo_abc1", - "name": "My App Key", - "scopes": ["read", "write"], - "expires_at": "2026-12-31 23:59:59" - } -} -``` - -### Scopes - -API keys can have the following scopes: -- `read` - Read-only access to data -- `write` - Full access to create, update, and delete data - -If no scopes are specified, the key will have full access. - -## Public Endpoints - -These endpoints do not require authentication. - -### Marketplace Themes - -#### Get All Themes -**Endpoint:** `GET /api/v1/marketplace/themes` - -**Response:** -```json -{ - "success": true, - "message": "Marketplace themes retrieved successfully", - "data": [ - { - "id": "theme-id-1", - "name": "Dark Theme", - "description": "A dark theme for the app", - "preview_url": "https://example.com/preview.png", - "price": 0, - "is_free": true, - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Get Theme by ID -**Endpoint:** `GET /api/v1/marketplace/themes/{id}` - -**Response:** -```json -{ - "success": true, - "message": "Theme retrieved successfully", - "data": { - "id": "theme-id-1", - "name": "Dark Theme", - "description": "A dark theme for the app", - "preview_url": "https://example.com/preview.png", - "price": 0, - "is_free": true, - "created_at": "2025-01-01 00:00:00" - } -} -``` - -## Protected Endpoints - -These endpoints require an API key in the `X-API-Key` header. - -### User Management - -#### Get User Profile -**Endpoint:** `GET /api/v1/user/profile` - -**Response:** -```json -{ - "success": true, - "message": "Profile retrieved successfully", - "data": { - "id": "user-id", - "email": "user@example.com", - "name": "John Doe", - "avatar_url": null, - "settings": {"theme": "dark"}, - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00" - } -} -``` - -#### Update User Profile -**Endpoint:** `PUT /api/v1/user/profile` - -**Request Body:** -```json -{ - "name": "Jane Doe", - "avatar_url": "https://example.com/avatar.jpg", - "settings": {"theme": "light", "language": "en"} -} -``` - -#### List API Keys -**Endpoint:** `GET /api/v1/user/api-keys` - -**Response:** -```json -{ - "success": true, - "message": "API keys retrieved successfully", - "data": [ - { - "id": "key-id", - "key_prefix": "todo_abc1", - "name": "My App Key", - "scopes": ["read", "write"], - "is_active": true, - "last_used_at": "2025-01-01 12:00:00", - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create API Key -**Endpoint:** `POST /api/v1/user/api-keys` - -**Request Body:** -```json -{ - "name": "New App Key", - "scopes": ["read"], - "expires_at": "2026-12-31 23:59:59" -} -``` - -#### Revoke API Key -**Endpoint:** `DELETE /api/v1/user/api-keys/{id}` - -### Categories - -#### Get All Categories -**Endpoint:** `GET /api/v1/categories` - -**Response:** -```json -{ - "success": true, - "message": "Categories retrieved successfully", - "data": [ - { - "id": "cat-id-1", - "user_id": "user-id", - "name": "Work", - "color": "#3B82F6", - "favorite": true, - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create Category -**Endpoint:** `POST /api/v1/categories` - -**Request Body:** -```json -{ - "name": "Personal", - "color": "#10B981", - "favorite": false -} -``` - -#### Get Category -**Endpoint:** `GET /api/v1/categories/{id}` - -#### Update Category -**Endpoint:** `PUT /api/v1/categories/{id}` - -**Request Body:** -```json -{ - "name": "Updated Name", - "color": "#FF5733", - "favorite": true -} -``` - -#### Delete Category -**Endpoint:** `DELETE /api/v1/categories/{id}` - -### Projects - -#### Get All Projects -**Endpoint:** `GET /api/v1/projects` - -**Response:** -```json -{ - "success": true, - "message": "Projects retrieved successfully", - "data": [ - { - "id": "proj-id-1", - "user_id": "user-id", - "name": "Web Redesign", - "description": "Redesign the company website", - "color": "#8B5CF6", - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create Project -**Endpoint:** `POST /api/v1/projects` - -**Request Body:** -```json -{ - "name": "New Project", - "description": "Project description", - "color": "#EC4899" -} -``` - -#### Get Project -**Endpoint:** `GET /api/v1/projects/{id}` - -#### Update Project -**Endpoint:** `PUT /api/v1/projects/{id}` - -**Request Body:** -```json -{ - "name": "Updated Project", - "description": "Updated description", - "color": "#14B8A6" -} -``` - -#### Delete Project -**Endpoint:** `DELETE /api/v1/projects/{id}` - -### Todos - -#### Get All Todos -**Endpoint:** `GET /api/v1/todos` - -**Response:** -```json -{ - "success": true, - "message": "Todos retrieved successfully", - "data": [ - { - "id": "todo-id-1", - "user_id": "user-id", - "title": "Complete task", - "description": "Task description", - "status": "open", - "due_date": "2025-01-15", - "due_time": "10:30:00", - "sync_enabled": true, - "reminder_enabled": false, - "recurring_enabled": false, - "project_id": "proj-id-1", - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00", - "categories": [ - { - "id": "cat-id-1", - "name": "Work", - "color": "#3B82F6" - } - ] - } - ] -} -``` - -#### Create Todo -**Endpoint:** `POST /api/v1/todos` - -**Request Body:** -```json -{ - "title": "New Task", - "description": "Task description", - "status": "open", - "due_date": "2025-01-15", - "due_time": "10:30:00", - "sync_enabled": true, - "reminder_enabled": false, - "recurring_enabled": false, - "project_id": "proj-id-1" -} -``` - -**Status options:** `open`, `in_progress`, `completed`, `archived` - -#### Get Todo -**Endpoint:** `GET /api/v1/todos/{id}` - -#### Update Todo -**Endpoint:** `PUT /api/v1/todos/{id}` - -**Request Body:** -```json -{ - "title": "Updated Task", - "status": "in_progress", - "due_date": "2025-01-20" -} -``` - -#### Delete Todo -**Endpoint:** `DELETE /api/v1/todos/{id}` - -#### Add Category to Todo -**Endpoint:** `POST /api/v1/todos/{id}/categories` - -**Request Body:** -```json -{ - "category_id": "cat-id-1" -} -``` - -#### Remove Category from Todo -**Endpoint:** `DELETE /api/v1/todos/{id}/categories/{categoryId}` - -### Recurring Tasks - -#### Get All Recurring Tasks -**Endpoint:** `GET /api/v1/recurring-tasks` - -**Response:** -```json -{ - "success": true, - "message": "Recurring tasks retrieved successfully", - "data": [ - { - "id": "rt-id-1", - "user_id": "user-id", - "title": "Weekly Review", - "description": "Plan next week's tasks", - "schedule": "weekly", - "custom_days": [], - "favorite": true, - "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 00:00:00", - "categories": [ - { - "id": "cat-id-1", - "name": "Work", - "color": "#3B82F6" - } - ] - } - ] -} -``` - -#### Create Recurring Task -**Endpoint:** `POST /api/v1/recurring-tasks` - -**Request Body:** -```json -{ - "title": "Daily Standup", - "description": "Team meeting every morning", - "schedule": "daily", - "custom_days": [], - "favorite": true -} -``` - -**Schedule options:** `daily`, `weekly`, `monthly`, `custom` - -For `custom` schedule, provide days in `custom_days` array: `["mon", "wed", "fri"]` - -#### Get Recurring Task -**Endpoint:** `GET /api/v1/recurring-tasks/{id}` - -#### Update Recurring Task -**Endpoint:** `PUT /api/v1/recurring-tasks/{id}` - -**Request Body:** -```json -{ - "title": "Updated Task", - "schedule": "weekly", - "custom_days": ["mon"] -} -``` - -#### Delete Recurring Task -**Endpoint:** `DELETE /api/v1/recurring-tasks/{id}` - -#### Add Category to Recurring Task -**Endpoint:** `POST /api/v1/recurring-tasks/{id}/categories` - -**Request Body:** -```json -{ - "category_id": "cat-id-1" -} -``` - -#### Remove Category from Recurring Task -**Endpoint:** `DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}` - -### Activity Logs - -#### Get Activity Logs -**Endpoint:** `GET /api/v1/activity-logs?limit=50` - -**Response:** -```json -{ - "success": true, - "message": "Activity logs retrieved successfully", - "data": [ - { - "id": "log-id-1", - "user_id": "user-id", - "action": "todo_created", - "entity_type": "todo", - "entity_id": "todo-id-1", - "details": { - "title": "New Task" - }, - "ip_address": "127.0.0.1", - "user_agent": "Mozilla/5.0...", - "created_at": "2025-01-01 12:00:00" - } - ] -} -``` - -#### Get Activity Log -**Endpoint:** `GET /api/v1/activity-logs/{id}` - -### User Themes - -#### Get User Themes -**Endpoint:** `GET /api/v1/user/themes` - -**Response:** -```json -{ - "success": true, - "message": "User themes retrieved successfully", - "data": [ - { - "id": "ut-id-1", - "user_id": "user-id", - "theme_id": "theme-id-1", - "is_active": true, - "custom_settings": {"primary_color": "#3B82F6"}, - "created_at": "2025-01-01 00:00:00" - } - ] -} -``` - -#### Create User Theme -**Endpoint:** `POST /api/v1/user/themes` - -**Request Body:** -```json -{ - "theme_id": "theme-id-1", - "is_active": true, - "custom_settings": { - "primary_color": "#3B82F6", - "font_size": "medium" - } -} -``` - -#### Update User Theme -**Endpoint:** `PUT /api/v1/user/themes/{id}` - -**Request Body:** -```json -{ - "is_active": false, - "custom_settings": { - "primary_color": "#EC4899" - } -} -``` - -#### Delete User Theme -**Endpoint:** `DELETE /api/v1/user/themes/{id}` - -## Error Responses - -All error responses follow this format: - -```json -{ - "success": false, - "message": "Error message", - "errors": { - "field": "Validation error message" - } -} -``` - -### Common HTTP Status Codes - -- `200 OK` - Request successful -- `201 Created` - Resource created successfully -- `400 Bad Request` - Invalid request data -- `401 Unauthorized` - Missing or invalid API key -- `403 Forbidden` - Insufficient permissions -- `404 Not Found` - Resource not found -- `409 Conflict` - Resource already exists -- `422 Unprocessable Entity` - Validation failed -- `500 Internal Server Error` - Server error - -## Rate Limiting - -Currently, there is no rate limiting implemented. Consider adding rate limiting for production use. - -## CORS - -If you need to enable CORS for frontend applications, configure it in `app/Config/Filters.php`. - -## Example Usage - -### cURL Examples - -**Register a New User:** -```bash -curl -X POST http://localhost:8080/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "email": "newuser@example.com", - "password": "securepassword", - "name": "New User" - }' -``` - -**Login:** -```bash -curl -X POST http://localhost:8080/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "password": "password123" - }' -``` - -**Create API Key (additional key):** -```bash -curl -X POST http://localhost:8080/api/v1/auth/api-key \ - -H "Content-Type: application/json" \ - -d '{ - "email": "demo@example.com", - "password": "password123", - "name": "My App Key" - }' -``` - -**Get Todos (with API key):** -```bash -curl -X GET http://localhost:8080/api/v1/todos \ - -H "X-API-Key: todo_your_api_key_here" -``` - -**Create Todo:** -```bash -curl -X POST http://localhost:8080/api/v1/todos \ - -H "X-API-Key: todo_your_api_key_here" \ - -H "Content-Type: application/json" \ - -d '{ - "title": "New Task", - "status": "open" - }' -``` - -### JavaScript/Fetch Examples - -**Register a New User:** -```javascript -fetch('http://localhost:8080/api/v1/auth/register', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: 'newuser@example.com', - password: 'securepassword', - name: 'New User' - }) -}) -.then(response => response.json()) -.then(data => { - console.log('API Key:', data.data.api_key); - console.log('User:', data.data.user); -}); -``` - -**Login:** -```javascript -fetch('http://localhost:8080/api/v1/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: 'user@example.com', - password: 'password123' - }) -}) -.then(response => response.json()) -.then(data => { - if (data.data.api_key) { - console.log('New API Key:', data.data.api_key); - } else { - console.log('Using existing key with prefix:', data.data.api_key_prefix); - } -}); -``` - -**Create API Key (additional key):** -```javascript -fetch('http://localhost:8080/api/v1/auth/api-key', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: 'demo@example.com', - password: 'password123', - name: 'My App Key' - }) -}) -.then(response => response.json()) -.then(data => console.log(data)); -``` - -**Get Todos:** -```javascript -fetch('http://localhost:8080/api/v1/todos', { - headers: { - 'X-API-Key': 'todo_your_api_key_here' - } -}) -.then(response => response.json()) -.then(data => console.log(data)); -``` - -**Create Todo:** -```javascript -fetch('http://localhost:8080/api/v1/todos', { - method: 'POST', - headers: { - 'X-API-Key': 'todo_your_api_key_here', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - title: 'New Task', - status: 'open' - }) -}) -.then(response => response.json()) -.then(data => console.log(data)); -``` - -## Testing - -To test the API, you can use tools like: -- Postman -- Insomnia -- cURL -- HTTPie - -## Versioning - -The API is versioned using the URL path. The current version is `v1`. Future versions will be numbered incrementally (v2, v3, etc.). - -## Support - -For issues or questions, please refer to the project documentation or contact the development team. diff --git a/README.md b/README.md index 1763919..573fb81 100644 --- a/README.md +++ b/README.md @@ -1 +1,552 @@ -# Todo-App-Backend \ No newline at end of file +# Todo App Backend + +A RESTful API backend for a todo application built with **CodeIgniter 4**. +Supports user authentication (API key + JWT), CRUD for todos/categories/projects, +recurring tasks, activity logging, and a theme marketplace. + +--- + +## Table of Contents + +- [Quick Start](#quick-start) +- [API Documentation](#api-documentation) +- [Authentication](#authentication) +- [API Overview](#api-overview) +- [Testing](#testing) +- [Project Structure](#project-structure) +- [Database](#database) +- [Development](#development) +- [Contributing](#contributing) + +--- + +## Quick Start + +### Requirements + +- PHP ^8.2 +- MySQL 8+ (or MariaDB 10.5+) +- Composer +- `ext-intl`, `ext-mbstring` + +### Setup + +```bash +# 1. Clone and enter the project +cd Todo-App-Backend + +# 2. Install dependencies +composer install + +# 3. Configure your environment +cp env.example .env +# Edit .env — set database credentials and app.baseURL + +# 4. Run database migrations +php spark migrate + +# 5. (Optional) Seed sample data +php spark db:seed SampleDataSeeder + +# 6. Start the development server +php spark serve + +# The API is now available at http://localhost:8080/api/v1 +``` + +### Quick Test + +```bash +# Register a new user +curl -s -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"demo@example.com","password":"password123","name":"Demo User"}' + +# Save the returned api_key, then: +curl -s http://localhost:8080/api/v1/todos \ + -H "X-API-Key: todo_your_key_here" +``` + +--- + +## API Documentation + +The API is fully documented using the **OpenAPI 3.0** specification. + +| Resource | Location | +|----------|----------| +| OpenAPI spec (canonical) | [`openapi/openapi.yaml`](openapi/openapi.yaml) | +| Generated HTML docs | `public/api-docs.html` (generated, see below) | +| Swagger/Postman import | Use `openapi/openapi.yaml` directly | + +### Generating API Docs + +From the project root: + +```bash +# Generate HTML documentation page +php spark generate:api-docs + +# Validate spec only (no file written) +php spark generate:api-docs --watch + +# Open http://localhost:8080/api-docs.html after generating +``` + +The generated HTML uses [Redoc](https://redocly.com/redoc) for rendering and is +fully self-contained (the spec is embedded as a base64 data URI). + +### Importing into Tools + +- **Postman**: File → Import → choose `openapi/openapi.yaml` +- **Insomnia**: Import → From File → choose `openapi/openapi.yaml` +- **Swagger Editor**: Paste the contents of `openapi/openapi.yaml` +- **cURL/HTTPie**: Examples are in the OpenAPI spec under each endpoint + +--- + +## Authentication + +The API supports two authentication methods: + +### 1. API Key Authentication (Primary) + +``` +X-API-Key: todo_abc123def456... +``` + +Used by most protected endpoints. Keys are obtained on registration or can be +created via `POST /user/api-keys`. Keys can be scoped (`read`, `write`) and +optionally expire. + +### 2. JWT Bearer Authentication + +``` +Authorization: Bearer eyJ0eXAiOiJKV1Qi... +``` + +Available via the `/auth/jwt/*` endpoints. Tokens are valid for 1 hour and can +be refreshed via `/auth/jwt/refresh`. + +### Authentication Flow + +1. **Register** → receive API key + key prefix +2. **Login** → receive the same or existing API key +3. Include `X-API-Key` header on all protected requests +4. Optionally use JWT endpoints for short-lived bearer tokens + +--- + +## API Overview + +All endpoints live under the `/api/v1` prefix. + +### Public Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/auth/register` | Register a new user | +| POST | `/auth/login` | Login and get API key | +| POST | `/auth/api-key` | Create additional API key (legacy) | +| POST | `/auth/jwt/register` | Register and receive JWT + API key | +| POST | `/auth/jwt/login` | Login and receive JWT | +| POST | `/auth/jwt/refresh` | Refresh an existing JWT | +| GET | `/marketplace/themes` | List published themes | +| GET | `/marketplace/themes/{id}` | Get a single theme | + +### Protected Endpoints (API key required) + +#### User + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/user/profile` | Get your profile | +| PUT | `/user/profile` | Update your profile | +| GET | `/user/api-keys` | List your API keys | +| POST | `/user/api-keys` | Create a new API key | +| DELETE | `/user/api-keys/{id}` | Revoke an API key | + +#### Categories + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/categories` | List categories (paginated, sortable) | +| POST | `/categories` | Create a category | +| GET | `/categories/{id}` | Get a category | +| PUT | `/categories/{id}` | Update a category | +| DELETE | `/categories/{id}` | Delete a category | + +#### Projects + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/projects` | List projects (paginated, sortable) | +| POST | `/projects` | Create a project | +| GET | `/projects/{id}` | Get a project | +| PUT | `/projects/{id}` | Update a project | +| DELETE | `/projects/{id}` | Delete a project | + +#### Todos + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/todos` | List todos (paginated, sortable, filterable) | +| POST | `/todos` | Create a todo | +| GET | `/todos/{id}` | Get a todo | +| PUT | `/todos/{id}` | Update a todo | +| DELETE | `/todos/{id}` | Delete a todo | +| POST | `/todos/{id}/categories` | Link a category | +| DELETE | `/todos/{id}/categories/{catId}` | Unlink a category | + +#### Recurring Tasks + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/recurring-tasks` | List recurring tasks (paginated, sortable, filterable) | +| POST | `/recurring-tasks` | Create a recurring task | +| GET | `/recurring-tasks/{id}` | Get a recurring task | +| PUT | `/recurring-tasks/{id}` | Update a recurring task | +| DELETE | `/recurring-tasks/{id}` | Delete a recurring task | +| POST | `/recurring-tasks/{id}/categories` | Link a category | +| DELETE | `/recurring-tasks/{id}/categories/{catId}` | Unlink a category | + +#### Activity Logs + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/activity-logs` | List activity logs | +| GET | `/activity-logs/{id}` | Get a single log entry | + +#### User Themes + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/user/themes` | List installed themes | +| POST | `/user/themes` | Install a theme | +| PUT | `/user/themes/{id}` | Update theme settings | +| DELETE | `/user/themes/{id}` | Uninstall a theme | + +### Common Query Parameters + +| Parameter | Type | Description | Example | +|-----------|------|-------------|---------| +| `page` | int | Page number (default: 1) | `?page=2` | +| `per_page` | int | Items per page (default: 50, max: 200) | `?per_page=10` | +| `sort` | string | Sort fields, `-` for descending, comma-separated | `?sort=-created_at,title` | +| `status` | string | Filter by status (todos) | `?status=open` | +| `favorite` | bool | Filter favorites (categories) | `?favorite=1` | +| `limit` | int | Max items (activity logs, default: 50) | `?limit=100` | + +### Sorting + +Sortable fields vary per resource. Prefix a field with `-` for descending: + +``` +GET /api/v1/todos?sort=-created_at,title +GET /api/v1/categories?sort=name +GET /api/v1/projects?sort=-created_at +GET /api/v1/recurring-tasks?sort=title,-created_at +``` + +### Response Format + +All responses follow a consistent envelope: + +**Success:** +```json +{ + "success": true, + "message": "Todos retrieved successfully", + "data": [ ... ], + "pagination": { + "page": 1, + "per_page": 50, + "total": 123, + "last_page": 3, + "has_more": true + } +} +``` + +**Error:** +```json +{ + "success": false, + "message": "Validation failed", + "errors": { + "title": "The todo title is required." + } +} +``` + +### HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 400 | Bad request | +| 401 | Unauthorized (missing/invalid API key) | +| 403 | Forbidden (insufficient scope) | +| 404 | Not found | +| 409 | Conflict (duplicate) | +| 422 | Validation failed | +| 500 | Server error | + +### Pagination + +Paginated responses include a `pagination` object: + +```json +{ + "pagination": { + "page": 1, + "per_page": 50, + "total": 123, + "last_page": 3, + "has_more": true + } +} +``` + +### Todo Status Values + +- `open` +- `in_progress` +- `completed` +- `archived` + +### Recurring Task Schedule Values + +- `daily` +- `weekly` +- `monthly` +- `custom` (requires `custom_days` array, e.g. `["mon","wed","fri"]`) + +--- + +## Testing + +### Running Tests + +```bash +# Run all tests via composer +composer run test + +# Or use phpunit directly +./vendor/bin/phpunit + +# Run only API tests +./vendor/bin/phpunit tests/api + +# With code coverage +./vendor/bin/phpunit --coverage-text +``` + +### Database Setup + +Integration tests use your configured MySQL database. Make sure migrations are applied first: + +```bash +php spark migrate +``` + +For a dedicated test database, uncomment the test DB config in `phpunit.xml.dist`: + +```xml + + + + + +``` + +Then create it and migrate: + +```bash +mysql -e "CREATE DATABASE IF NOT EXISTS todo_app_test;" +php spark migrate +``` + +### Test Suite + +| Directory | Description | +|-----------|-------------| +| `tests/api/ApiTest.php` | Full API integration tests (auth, CRUD, filtering, error handling) | +| `tests/unit/` | Unit tests for individual components | +| `tests/database/` | Database migration and seed tests | +| `tests/session/` | Session-related tests | + +The API test suite covers: + +- Registration and login +- Authentication errors (missing key, invalid credentials) +- Full CRUD for all resources (categories, projects, todos, recurring tasks) +- Category-todo / category-recurring-task linking +- Status and sort filtering +- Activity logging verification +- Ownership isolation (cross-user access denied) +- Validation error responses +- Pagination structure + +--- + +## Project Structure + +``` +├── app/ +│ ├── Commands/ # Spark CLI commands +│ │ ├── TestModels.php # Model testing command +│ │ └── GenerateApiDocs.php # OpenAPI → HTML docs generator +│ ├── Config/ # Application configuration +│ ├── Controllers/ +│ │ ├── Api/ +│ │ │ ├── BaseController.php # Shared API helpers (pagination, JWT, responses) +│ │ │ └── V1/ +│ │ │ ├── AuthController.php +│ │ │ ├── CategoryController.php +│ │ │ ├── ProjectController.php +│ │ │ ├── TodoController.php +│ │ │ ├── RecurringTaskController.php +│ │ │ ├── UserController.php +│ │ │ ├── ActivityLogController.php +│ │ │ ├── MarketplaceController.php +│ │ │ └── UserThemeController.php +│ │ ├── BaseController.php +│ │ ├── Home.php +│ │ └── ThemeStore.php +│ ├── Database/ +│ │ ├── Migrations/ # 16 migration files (users → api_auth_keys) +│ │ └── Seeds/ # Sample data, themes, AI providers +│ ├── Filters/ +│ │ └── ApiAuthFilter.php # API key authentication filter +│ ├── Models/ # Database models (TodoModel, CategoryModel, etc.) +│ └── Views/ # Error pages, welcome message, theme store +├── openapi/ +│ └── openapi.yaml # Canonical OpenAPI 3.0 specification +├── public/ +│ ├── api-docs.html # Generated API documentation (gitignored?) +│ ├── index.php # Front controller +│ └── themes/ # Uploaded theme CSS files +├── tests/ +│ ├── api/ApiTest.php # Full API integration tests +│ ├── unit/ # Unit tests +│ ├── database/ # Database tests +│ └── _support/ # Test helpers, models, seeds +├── writable/ # Logs, cache, uploads +├── composer.json +├── env.example +└── README.md +``` + +--- + +## Database + +### Schema Overview + +The database consists of 12 tables: + +| Table | Description | +|-------|-------------| +| `users` | User accounts (email, password hash, settings) | +| `api_auth_keys` | API keys (hashed, scoped, expirable) | +| `categories` | User-defined categories (with hex color) | +| `projects` | User-defined projects | +| `todos` | Tasks with status, due dates, project links | +| `todo_categories` | Many-to-many: todos ↔ categories | +| `recurring_tasks` | Recurring task templates (daily/weekly/etc.) | +| `recurring_task_categories` | Many-to-many: recurring_tasks ↔ categories | +| `activity_logs` | Audit trail (CRUD events, login, etc.) | +| `marketplace_themes` | Published theme definitions | +| `user_themes` | Per-user theme installations | +| `ai_chats / ai_messages / ai_providers / user_ai_settings / user_api_keys` | AI assistant features | + +### Migrations + +```bash +# Run all pending migrations +php spark migrate + +# Roll back all migrations +php spark migrate:rollback + +# Seed sample data +php spark db:seed SampleDataSeeder +``` + +--- + +## Development + +### Adding a New Endpoint + +1. Add the route in `app/Config/Routes.php` +2. Create the controller method (extends `App\Controllers\Api\BaseController`) +3. Create the model (extends `CodeIgniter\Model`) +4. Write migration if needed +5. Update `openapi/openapi.yaml` with the new endpoint +6. Run `php spark generate:api-docs` to regenerate HTML docs +7. Write tests in `tests/api/ApiTest.php` + +### Updating Documentation + +The **single source of truth** is `openapi/openapi.yaml`. After any API change: + +1. Update the YAML spec +2. Run `php spark generate:api-docs` +3. Commit both files + +### Available Spark Commands + +```bash +php spark list # List all available commands +php spark generate:api-docs # Generate HTML docs from OpenAPI spec +php spark generate:api-docs --watch # Validate spec only +php spark migrate # Run database migrations +php spark db:seed # Seed the database +composer run test # Run all tests +php spark test:models # Test models +``` + +### Common Workflows + +**New todo with category:** +```bash +KEY="todo_your_key_here" + +# Create category +CAT=$(curl -s -X POST http://localhost:8080/api/v1/categories \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"Work","color":"#3B82F6"}') +CAT_ID=$(echo $CAT | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + +# Create todo with that category +curl -s -X POST http://localhost:8080/api/v1/todos \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d "{\"title\":\"Finish report\",\"status\":\"open\",\"category_id\":\"$CAT_ID\"}" +``` + +**Filter and sort todos:** +```bash +curl -s "http://localhost:8080/api/v1/todos?status=open&sort=-due_date,title&per_page=5" \ + -H "X-API-Key: $KEY" +``` + +--- + +## Contributing + +1. Keep the OpenAPI spec (`openapi/openapi.yaml`) in sync with code changes +2. Run `php spark generate:api-docs --watch` to validate your YAML changes +3. Write tests for new endpoints +4. Run the full test suite before pushing +5. Follow CodeIgniter 4 conventions + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/app/Commands/GenerateApiDocs.php b/app/Commands/GenerateApiDocs.php new file mode 100644 index 0000000..ce7e629 --- /dev/null +++ b/app/Commands/GenerateApiDocs.php @@ -0,0 +1,208 @@ + '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 = << + + + + + {$apiTitle} + + + + + +
+
+

{$apiTitle}

+
Todo App Backend — OpenAPI 3.0
+
+
Generated: GENERATED_DATE
+
+
Loading API documentation...
+
+ + + + + +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; + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index c01ff7b..d36090f 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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) diff --git a/app/Controllers/Api/BaseController.php b/app/Controllers/Api/BaseController.php index ebe85da..3b35b62 100644 --- a/app/Controllers/Api/BaseController.php +++ b/app/Controllers/Api/BaseController.php @@ -23,35 +23,138 @@ class BaseController extends ResourceController return $user['id'] ?? null; } + // ======================================================================== + // Pagination & Sorting + // ======================================================================== + /** - * Success response + * Extract pagination params from the query string. + * + * Returns [page, perPage]. + * Default: page=1, perPage=50. Max perPage = 200. */ - protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200) + protected function getPaginationParams(): array { - return $this->response - ->setStatusCode($statusCode) - ->setHeader('Access-Control-Allow-Origin', '*') - ->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - ->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key') - ->setJSON([ - 'success' => true, - 'message' => $message, - 'data' => $data, - ]); + $page = max(1, (int) $this->request->getGet('page')); + $perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 50))); + return [$page, $perPage]; } /** - * Error response + * Extract allowed sort params from the query string. + * + * ?sort=title,-created_at → ASC on title, DESC on created_at + * Only fields listed in $allowed will be accepted. */ - protected function errorResponse(string $message, int $statusCode = 400, $errors = null) + protected function getSortParams(array $allowed = []): array { - $response = [ - 'success' => false, + $raw = $this->request->getGet('sort'); + if (empty($raw)) { + return []; + } + + $parts = explode(',', $raw); + $sorts = []; + + foreach ($parts as $part) { + $part = trim($part); + if (empty($part)) continue; + + $dir = 'ASC'; + if ($part[0] === '-') { + $dir = 'DESC'; + $part = substr($part, 1); + } + + if (in_array($part, $allowed, true)) { + $sorts[$part] = $dir; + } + } + + return $sorts; + } + + /** + * Extract allowed filter params from the query string. + * + * ?status=open&favorite=1 + * Only fields listed in $allowed will be accepted. + */ + protected function getFilterParams(array $allowed = []): array + { + $filters = []; + + foreach ($allowed as $field) { + $value = $this->request->getGet($field); + if ($value !== null && $value !== '') { + $filters[$field] = $value; + } + } + + return $filters; + } + + /** + * Apply sorting to a model query builder. + */ + protected function applySort($query, array $sorts): void + { + foreach ($sorts as $field => $dir) { + $query->orderBy($field, $dir); + } + } + + /** + * Apply filters to a model query builder (simple WHERE). + */ + protected function applyFilters($query, array $filters): void + { + foreach ($filters as $field => $value) { + $query->where($field, $value); + } + } + + /** + * Build a paginated response with meta information. + */ + protected function paginatedResponse($query, string $message = 'Success', int $statusCode = 200) + { + [$page, $perPage] = $this->getPaginationParams(); + + $total = $query->countAllResults(false); + $data = $query->get($perPage, ($page - 1) * $perPage)->getResultArray(); + $lastPage = (int) ceil($total / max($perPage, 1)); + + return $this->successResponse($data, $message, $statusCode, [ + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'last_page' => $lastPage, + 'has_more' => $page < $lastPage, + ], + ]); + } + + // ======================================================================== + // Success / Error Responses + // ======================================================================== + + /** + * Success response + */ + protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200, array $extraMeta = []) + { + $body = [ + 'success' => true, 'message' => $message, + 'data' => $data, ]; - if ($errors !== null) { - $response['errors'] = $errors; + if (!empty($extraMeta)) { + foreach ($extraMeta as $key => $value) { + $body[$key] = $value; + } } return $this->response @@ -59,17 +162,83 @@ class BaseController extends ResourceController ->setHeader('Access-Control-Allow-Origin', '*') ->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') ->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key') - ->setJSON($response); + ->setJSON($body); } /** - * Validate request data + * Error response with structured error info + */ + protected function errorResponse(string $message, int $statusCode = 400, $errors = null) + { + $body = [ + 'success' => false, + 'message' => $message, + ]; + + if ($errors !== null) { + $body['errors'] = $errors; + } + + return $this->response + ->setStatusCode($statusCode) + ->setHeader('Access-Control-Allow-Origin', '*') + ->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key') + ->setJSON($body); + } + + /** + * Validation error shorthand (422) + */ + protected function validationErrorResponse($errors): void + { + $this->errorResponse('Validation failed', 422, $errors); + } + + /** + * Not found shorthand (404) + */ + protected function notFoundResponse(string $resource = 'Resource'): void + { + $this->errorResponse("{$resource} not found", 404); + } + + // ======================================================================== + // Validation + // ======================================================================== + + /** + * Validate request data using the rules defined in a model. + * + * Returns true on success, sends a 422 JSON response and returns false on failure. + */ + protected function validateWithModel(\CodeIgniter\Model $model): bool + { + $validation = \Config\Services::validation(); + $rules = $model->getValidationRules(); + $errors = $model->getValidationMessages(); + + if (empty($rules)) { + return true; + } + + $validation->setRules($rules, $errors); + + if (!$validation->withRequest($this->request)->run()) { + $this->errorResponse('Validation failed', 422, $validation->getErrors()); + return false; + } + + return true; + } + + /** + * Legacy simple validation for controllers that define rules inline. */ protected function validateRequest(array $rules): bool { $validation = \Config\Services::validation(); - // Handle both old format (string) and new format (array with rules/errors) foreach ($rules as $field => $rule) { if (is_array($rule) && isset($rule['rules'])) { $validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []); @@ -85,4 +254,106 @@ class BaseController extends ResourceController return true; } + + // ======================================================================== + // Activity Logging + // ======================================================================== + + /** + * Log an activity to the activity_logs table. + * Safe — catches and logs errors silently so the main request is never broken. + */ + protected function logActivity(string $action, string $entityType, ?string $entityId, ?array $details = null): void + { + try { + $userId = $this->getUserId(); + $logModel = new \App\Models\ActivityLogModel(); + + $logModel->logActivity([ + 'user_id' => $userId, + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'details' => $details ? json_encode($details) : null, + 'ip_address' => $this->request->getIPAddress(), + 'user_agent' => $this->request->getUserAgent()->getAgentString(), + ]); + } catch (\Exception $e) { + log_message('error', 'Failed to log activity: ' . $e->getMessage()); + } + } + + // ======================================================================== + // JWT helpers + // ======================================================================== + + /** + * JWT secret key — should be read from env in production. + */ + protected function getJwtSecret(): string + { + return $_ENV['JWT_SECRET'] ?? 'todo-app-jwt-secret-change-in-production'; + } + + /** + * Decode a JWT from the Authorization header. + * Returns the payload on success, null on failure. + */ + protected function decodeJwtFromRequest(): ?array + { + $header = $this->request->getHeaderLine('Authorization'); + if (empty($header)) return null; + + if (strpos($header, 'Bearer ') !== 0) return null; + + $token = substr($header, 7); + return $this->decodeJwt($token); + } + + /** + * Decode and verify a JWT token. + */ + protected function decodeJwt(string $token): ?array + { + try { + $key = $this->getJwtSecret(); + $jwt = \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($key, 'HS256')); + return (array) $jwt; + } catch (\Exception $e) { + log_message('error', 'JWT decode failed: ' . $e->getMessage()); + return null; + } + } + + /** + * Encode a payload into a JWT token. + */ + protected function encodeJwt(array $payload): string + { + $key = $this->getJwtSecret(); + $issuedAt = time(); + $payload['iat'] = $issuedAt; + $payload['exp'] = $issuedAt + 3600; // 1 hour default + + return \Firebase\JWT\JWT::encode($payload, $key, 'HS256'); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /** + * Generate UUID v4 + */ + protected function generateUuid(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + } } diff --git a/app/Controllers/Api/V1/AuthController.php b/app/Controllers/Api/V1/AuthController.php index ccb06c8..ed3052b 100644 --- a/app/Controllers/Api/V1/AuthController.php +++ b/app/Controllers/Api/V1/AuthController.php @@ -221,18 +221,163 @@ class AuthController extends BaseController ], 'API key created successfully'); } + // ======================================================================== + // JWT Authentication + // ======================================================================== + + /** + * Register a new user and return JWT + API key + * POST /api/v1/auth/jwt/register + */ + public function jwtRegister() + { + // Reuse the existing register validation logic + $json = $this->request->getJSON(true); + + $rules = [ + 'email' => [ + 'rules' => 'required|valid_email|is_unique[users.email]', + 'errors' => [ + 'required' => 'Email is required', + 'valid_email' => 'Please provide a valid email address', + 'is_unique' => 'This email is already registered', + ], + ], + 'password' => [ + 'rules' => 'required|min_length[8]', + 'errors' => [ + 'required' => 'Password is required', + 'min_length' => 'Password must be at least 8 characters long', + ], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'Name is required', + 'max_length' => 'Name must not exceed 255 characters', + ], + ], + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + try { + $userId = $this->generateUuid(); + + $userData = [ + 'id' => $userId, + 'email' => $json['email'], + 'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT), + 'name' => $json['name'], + 'avatar_url' => $json['avatar_url'] ?? null, + 'settings' => isset($json['settings']) ? json_encode($json['settings']) : json_encode(['theme' => 'light']), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $this->userModel->insert($userData); + + // Create API key for the new user + $apiKey = $this->apiAuthKeyModel->createKey( + $userId, + 'Default API Key', + ['read', 'write'], + null + ); + + // Generate JWT + $jwt = $this->encodeJwt([ + 'sub' => $userId, + 'email' => $json['email'], + 'name' => $json['name'], + ]); + + unset($userData['password_hash']); + + return $this->successResponse([ + 'user' => $userData, + 'token' => $jwt, + 'api_key' => $apiKey['key'], + ], 'User registered successfully', 201); + } catch (\Exception $e) { + return $this->errorResponse('Registration failed: ' . $e->getMessage(), 500); + } + } + + /** + * Login with email/password and return JWT + * POST /api/v1/auth/jwt/login + */ + public function jwtLogin() + { + $json = $this->request->getJSON(true); + + $rules = [ + 'email' => 'required|valid_email', + 'password' => 'required', + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + try { + $user = $this->userModel->where('email', $json['email'])->first(); + + if (!$user || !password_verify($json['password'], $user['password_hash'])) { + return $this->errorResponse('Invalid email or password', 401); + } + + // Generate JWT + $jwt = $this->encodeJwt([ + 'sub' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + ]); + + return $this->successResponse([ + 'user' => [ + 'id' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + ], + 'token' => $jwt, + ], 'Login successful'); + } catch (\Exception $e) { + return $this->errorResponse('Login failed: ' . $e->getMessage(), 500); + } + } + + /** + * Refresh JWT token + * POST /api/v1/auth/jwt/refresh + */ + public function jwtRefresh() + { + $payload = $this->decodeJwtFromRequest(); + + if (!$payload || empty($payload['sub'])) { + return $this->errorResponse('Invalid or expired token', 401); + } + + $user = $this->userModel->find($payload['sub']); + if (!$user) { + return $this->errorResponse('User not found', 401); + } + + $jwt = $this->encodeJwt([ + 'sub' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + ]); + + return $this->successResponse(['token' => $jwt], 'Token refreshed successfully'); + } + /** * Generate UUID */ - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } + } diff --git a/app/Controllers/Api/V1/CategoryController.php b/app/Controllers/Api/V1/CategoryController.php index 9da6885..a296fef 100644 --- a/app/Controllers/Api/V1/CategoryController.php +++ b/app/Controllers/Api/V1/CategoryController.php @@ -14,37 +14,44 @@ class CategoryController extends BaseController $this->categoryModel = new CategoryModel(); } + const SORTABLE = ['name', 'created_at']; + const FILTERABLE = ['favorite']; + /** - * Get all categories for the authenticated user * GET /api/v1/categories */ public function index() { - $userId = $this->getUserId(); - $categories = $this->categoryModel->where('user_id', $userId)->findAll(); + $userId = $this->getUserId(); + $filters = $this->getFilterParams(self::FILTERABLE); + $sorts = $this->getSortParams(self::SORTABLE); - return $this->successResponse($categories, 'Categories retrieved successfully'); + $builder = $this->categoryModel->where('user_id', $userId); + $this->applyFilters($builder, $filters); + + if (empty($sorts)) { + $builder->orderBy('name', 'ASC'); + } else { + $this->applySort($builder, $sorts); + } + + return $this->paginatedResponse($builder, 'Categories retrieved successfully'); } /** - * Create a new category * POST /api/v1/categories */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'name' => 'required|max_length[255]', - 'color' => 'required|max_length[7]', - ]; - - if (!$this->validateRequest($rules)) { + if (!$this->validateWithModel($this->categoryModel)) { return; } - // Check for duplicate name per user + $json = $this->request->getJSON(true); + + // Custom duplicate check (per user) $existing = $this->categoryModel ->where('user_id', $userId) ->where('name', $json['name']) @@ -55,26 +62,29 @@ class CategoryController extends BaseController } $data = [ - 'id' => $this->generateUuid(), - 'user_id' => $userId, - 'name' => $json['name'], - 'color' => $json['color'], - 'favorite' => $json['favorite'] ?? false, + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'name' => $json['name'], + 'color' => $json['color'], + 'favorite' => !empty($json['favorite']), ]; $this->categoryModel->insert($data); $category = $this->categoryModel->find($data['id']); + $this->logActivity('category_created', 'category', $data['id'], [ + 'name' => $data['name'], + ]); + return $this->successResponse($category, 'Category created successfully', 201); } /** - * Get a specific category * GET /api/v1/categories/{id} */ public function show($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); if (!$category) { @@ -85,12 +95,11 @@ class CategoryController extends BaseController } /** - * Update a category * PUT /api/v1/categories/{id} */ public function update($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); if (!$category) { @@ -99,7 +108,7 @@ class CategoryController extends BaseController $json = $this->request->getJSON(true); - // Check for duplicate name on rename (excluding current category) + // Duplicate check on rename if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) { $existing = $this->categoryModel ->where('user_id', $userId) @@ -113,25 +122,33 @@ class CategoryController extends BaseController } $allowedFields = ['name', 'color', 'favorite']; - $updateData = array_intersect_key($json, array_flip($allowedFields)); + $updateData = array_intersect_key($json, array_flip($allowedFields)); if (empty($updateData)) { return $this->errorResponse('No valid fields to update'); } + // Convert boolean + if (array_key_exists('favorite', $updateData)) { + $updateData['favorite'] = !empty($updateData['favorite']); + } + $this->categoryModel->update($id, $updateData); $category = $this->categoryModel->find($id); + $this->logActivity('category_updated', 'category', $id, [ + 'name' => $category['name'] ?? 'Unknown', + ]); + return $this->successResponse($category, 'Category updated successfully'); } /** - * Delete a category * DELETE /api/v1/categories/{id} */ public function delete($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); if (!$category) { @@ -140,18 +157,10 @@ class CategoryController extends BaseController $this->categoryModel->delete($id); + $this->logActivity('category_deleted', 'category', $id, [ + 'name' => $category['name'] ?? 'Unknown', + ]); + return $this->successResponse(null, 'Category deleted successfully'); } - - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } } diff --git a/app/Controllers/Api/V1/ProjectController.php b/app/Controllers/Api/V1/ProjectController.php index 6bf2e0a..2b86038 100644 --- a/app/Controllers/Api/V1/ProjectController.php +++ b/app/Controllers/Api/V1/ProjectController.php @@ -14,57 +14,67 @@ class ProjectController extends BaseController $this->projectModel = new ProjectModel(); } + const SORTABLE = ['name', 'created_at']; + const FILTERABLE = []; + /** - * Get all projects for the authenticated user * GET /api/v1/projects */ public function index() { - $userId = $this->getUserId(); - $projects = $this->projectModel->where('user_id', $userId)->findAll(); + $userId = $this->getUserId(); + $filters = $this->getFilterParams(self::FILTERABLE); + $sorts = $this->getSortParams(self::SORTABLE); - return $this->successResponse($projects, 'Projects retrieved successfully'); + $builder = $this->projectModel->where('user_id', $userId); + $this->applyFilters($builder, $filters); + + if (empty($sorts)) { + $builder->orderBy('created_at', 'DESC'); + } else { + $this->applySort($builder, $sorts); + } + + return $this->paginatedResponse($builder, 'Projects retrieved successfully'); } /** - * Create a new project * POST /api/v1/projects */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'name' => 'required|max_length[255]', - 'color' => 'required|max_length[7]', - ]; - - if (!$this->validateRequest($rules)) { + if (!$this->validateWithModel($this->projectModel)) { return; } + $json = $this->request->getJSON(true); + $data = [ - 'id' => $this->generateUuid(), - 'user_id' => $userId, - 'name' => $json['name'], + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'name' => $json['name'], 'description' => $json['description'] ?? null, - 'color' => $json['color'], + 'color' => $json['color'] ?? '#8B5CF6', ]; $this->projectModel->insert($data); $project = $this->projectModel->find($data['id']); + $this->logActivity('project_created', 'project', $data['id'], [ + 'name' => $data['name'], + ]); + return $this->successResponse($project, 'Project created successfully', 201); } /** - * Get a specific project * GET /api/v1/projects/{id} */ public function show($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); if (!$project) { @@ -75,12 +85,11 @@ class ProjectController extends BaseController } /** - * Update a project * PUT /api/v1/projects/{id} */ public function update($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); if (!$project) { @@ -88,8 +97,9 @@ class ProjectController extends BaseController } $json = $this->request->getJSON(true); + $allowedFields = ['name', 'description', 'color']; - $updateData = array_intersect_key($json, array_flip($allowedFields)); + $updateData = array_intersect_key($json, array_flip($allowedFields)); if (empty($updateData)) { return $this->errorResponse('No valid fields to update'); @@ -98,16 +108,19 @@ class ProjectController extends BaseController $this->projectModel->update($id, $updateData); $project = $this->projectModel->find($id); + $this->logActivity('project_updated', 'project', $id, [ + 'name' => $project['name'] ?? 'Unknown', + ]); + return $this->successResponse($project, 'Project updated successfully'); } /** - * Delete a project * DELETE /api/v1/projects/{id} */ public function delete($id = null) { - $userId = $this->getUserId(); + $userId = $this->getUserId(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); if (!$project) { @@ -116,18 +129,10 @@ class ProjectController extends BaseController $this->projectModel->delete($id); + $this->logActivity('project_deleted', 'project', $id, [ + 'name' => $project['name'] ?? 'Unknown', + ]); + return $this->successResponse(null, 'Project deleted successfully'); } - - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } } diff --git a/app/Controllers/Api/V1/RecurringTaskController.php b/app/Controllers/Api/V1/RecurringTaskController.php index d7fa30b..566937c 100644 --- a/app/Controllers/Api/V1/RecurringTaskController.php +++ b/app/Controllers/Api/V1/RecurringTaskController.php @@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1; use App\Controllers\Api\BaseController; use App\Models\RecurringTaskModel; use App\Models\RecurringTaskCategoryModel; +use App\Models\CategoryModel; class RecurringTaskController extends BaseController { @@ -13,111 +14,147 @@ class RecurringTaskController extends BaseController public function __construct() { - $this->recurringTaskModel = new RecurringTaskModel(); + $this->recurringTaskModel = new RecurringTaskModel(); $this->recurringTaskCategoryModel = new RecurringTaskCategoryModel(); } + const SORTABLE = ['title', 'schedule', 'created_at']; + const FILTERABLE = ['schedule', 'favorite']; + /** - * Get all recurring tasks for the authenticated user * GET /api/v1/recurring-tasks */ public function index() { - $userId = $this->getUserId(); - $tasks = $this->recurringTaskModel->getByUserWithCategories($userId); + $userId = $this->getUserId(); + $filters = $this->getFilterParams(self::FILTERABLE); + $sorts = $this->getSortParams(self::SORTABLE); - return $this->successResponse($tasks, 'Recurring tasks retrieved successfully'); + $builder = $this->recurringTaskModel + ->select('recurring_tasks.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names') + ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') + ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') + ->where('recurring_tasks.user_id', $userId) + ->groupBy('recurring_tasks.id'); + + $this->applyFilters($builder, $filters); + + if (empty($sorts)) { + $builder->orderBy('recurring_tasks.created_at', 'DESC'); + } else { + $this->applySort($builder, $sorts); + } + + return $this->paginatedResponse($builder, 'Recurring tasks retrieved successfully'); } /** - * Create a new recurring task * POST /api/v1/recurring-tasks */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'title' => 'required|max_length[255]', - 'schedule' => 'required|in_list[daily,weekly,monthly,custom]', - ]; - - if (!$this->validateRequest($rules)) { + if (!$this->validateWithModel($this->recurringTaskModel)) { return; } + $json = $this->request->getJSON(true); + $data = [ - 'id' => $this->generateUuid(), - 'user_id' => $userId, - 'title' => $json['title'], + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'title' => $json['title'], 'description' => $json['description'] ?? null, - 'schedule' => $json['schedule'], - 'custom_days' => $json['custom_days'] ? json_encode($json['custom_days']) : json_encode([]), - 'favorite' => $json['favorite'] ?? false, + 'schedule' => $json['schedule'] ?? 'weekly', + 'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]', + 'favorite' => !empty($json['favorite']), ]; $this->recurringTaskModel->insert($data); + + // Link category if provided + if (!empty($json['category_id'])) { + $this->linkCategory($data['id'], $json['category_id']); + } + + $this->logActivity('recurring_task_created', 'recurring_task', $data['id'], [ + 'title' => $data['title'], + ]); + $task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']); - return $this->successResponse($task, 'Recurring task created successfully', 201); + return $this->successResponse($task[0] ?? null, 'Recurring task created successfully', 201); } /** - * Get a specific recurring task * GET /api/v1/recurring-tasks/{id} */ public function show($id = null) { $userId = $this->getUserId(); - $task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + $tasks = $this->recurringTaskModel->getByUserWithCategories($userId, $id); - if (!$task) { + if (empty($tasks)) { return $this->errorResponse('Recurring task not found', 404); } - return $this->successResponse($task, 'Recurring task retrieved successfully'); + return $this->successResponse($tasks[0], 'Recurring task retrieved successfully'); } /** - * Update a recurring task * PUT /api/v1/recurring-tasks/{id} */ public function update($id = null) { $userId = $this->getUserId(); - $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); + $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); } $json = $this->request->getJSON(true); - $allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite']; - $updateData = array_intersect_key($json, array_flip($allowedFields)); - if (isset($updateData['custom_days'])) { + // Handle category update + if (array_key_exists('category_id', $json)) { + $this->recurringTaskCategoryModel->where('recurring_task_id', $id)->delete(); + if (!empty($json['category_id'])) { + $this->linkCategory($id, $json['category_id']); + } + } + + $allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite']; + $updateData = array_intersect_key($json, array_flip($allowedFields)); + + // Convert custom_days array to JSON string + if (isset($updateData['custom_days']) && is_array($updateData['custom_days'])) { $updateData['custom_days'] = json_encode($updateData['custom_days']); } - - if (empty($updateData)) { - return $this->errorResponse('No valid fields to update'); + if (array_key_exists('favorite', $updateData)) { + $updateData['favorite'] = !empty($updateData['favorite']); } - $this->recurringTaskModel->update($id, $updateData); - $task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + if (!empty($updateData)) { + $this->recurringTaskModel->update($id, $updateData); + } - return $this->successResponse($task, 'Recurring task updated successfully'); + $this->logActivity('recurring_task_updated', 'recurring_task', $id, [ + 'title' => $task['title'] ?? 'Unknown', + ]); + + $updated = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + + return $this->successResponse($updated[0] ?? null, 'Recurring task updated successfully'); } /** - * Delete a recurring task * DELETE /api/v1/recurring-tasks/{id} */ public function delete($id = null) { $userId = $this->getUserId(); - $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); + $task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); @@ -125,30 +162,31 @@ class RecurringTaskController extends BaseController $this->recurringTaskModel->delete($id); + $this->logActivity('recurring_task_deleted', 'recurring_task', $id, [ + 'title' => $task['title'] ?? 'Unknown', + ]); + return $this->successResponse(null, 'Recurring task deleted successfully'); } /** - * Add a category to a recurring task * POST /api/v1/recurring-tasks/{id}/categories */ public function addCategory($taskId = null) { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); + $json = $this->request->getJSON(true); $rules = ['category_id' => 'required']; if (!$this->validateRequest($rules)) { return; } - // Verify task belongs to user $task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); } - // Check if link already exists $existing = $this->recurringTaskCategoryModel ->where('recurring_task_id', $taskId) ->where('category_id', $json['category_id']) @@ -160,21 +198,19 @@ class RecurringTaskController extends BaseController $this->recurringTaskCategoryModel->insert([ 'recurring_task_id' => $taskId, - 'category_id' => $json['category_id'], + 'category_id' => $json['category_id'], ]); return $this->successResponse(null, 'Category added to recurring task successfully', 201); } /** - * Remove a category from a recurring task * DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId} */ public function removeCategory($taskId = null, $categoryId = null) { $userId = $this->getUserId(); - // Verify task belongs to user $task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first(); if (!$task) { return $this->errorResponse('Recurring task not found', 404); @@ -188,15 +224,27 @@ class RecurringTaskController extends BaseController return $this->successResponse(null, 'Category removed from recurring task successfully'); } - private function generateUuid(): string + /** + * Link a category (internal helper) + */ + private function linkCategory(string $taskId, string $categoryId): void { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); + $userId = $this->getUserId(); + $categoryModel = new CategoryModel(); + $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first(); + + if (!$category) return; + + $existing = $this->recurringTaskCategoryModel + ->where('recurring_task_id', $taskId) + ->where('category_id', $categoryId) + ->first(); + + if (!$existing) { + $this->recurringTaskCategoryModel->insert([ + 'recurring_task_id' => $taskId, + 'category_id' => $categoryId, + ]); + } } } diff --git a/app/Controllers/Api/V1/TodoController.php b/app/Controllers/Api/V1/TodoController.php index 5df7ed8..7fcf478 100644 --- a/app/Controllers/Api/V1/TodoController.php +++ b/app/Controllers/Api/V1/TodoController.php @@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1; use App\Controllers\Api\BaseController; use App\Models\TodoModel; use App\Models\TodoCategoryModel; +use App\Models\CategoryModel; class TodoController extends BaseController { @@ -13,90 +14,102 @@ class TodoController extends BaseController public function __construct() { - $this->todoModel = new TodoModel(); + $this->todoModel = new TodoModel(); $this->todoCategoryModel = new TodoCategoryModel(); } + // ── Allowed sort & filter fields ─────────────────────────────────────── + const SORTABLE = ['title', 'status', 'due_date', 'due_time', 'created_at', 'updated_at']; + const FILTERABLE = ['status', 'project_id', 'sync_enabled', 'reminder_enabled', 'recurring_enabled']; + /** - * Get all todos for the authenticated user * GET /api/v1/todos + * Paginated, sortable, filterable list of todos for the authenticated user. + * + * Query params: + * page (int) – page number, default 1 + * per_page (int) – items per page, default 50, max 200 + * sort (string) – e.g. "title" or "-created_at,title" + * status (string) – filter by status + * project_id (string) – filter by project */ public function index() { $userId = $this->getUserId(); - $todos = $this->todoModel->getByUserWithCategories($userId); - return $this->successResponse($todos, 'Todos retrieved successfully'); + $filters = $this->getFilterParams(self::FILTERABLE); + $sorts = $this->getSortParams(self::SORTABLE); + + $builder = $this->todoModel + ->select('todos.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names') + ->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left') + ->join('categories', 'todo_categories.category_id = categories.id', 'left') + ->where('todos.user_id', $userId) + ->groupBy('todos.id'); + + // Apply filters + $this->applyFilters($builder, $filters); + + // Apply sorting (default: newest first) + if (empty($sorts)) { + $builder->orderBy('todos.created_at', 'DESC'); + } else { + $this->applySort($builder, $sorts); + } + + return $this->paginatedResponse($builder, 'Todos retrieved successfully'); } /** - * Create a new todo * POST /api/v1/todos */ public function create() { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); - $rules = [ - 'title' => 'required|max_length[255]', - 'status' => 'permit_empty|in_list[open,in_progress,completed,archived]', - ]; - - if (!$this->validateRequest($rules)) { + if (!$this->validateWithModel($this->todoModel)) { return; } + $json = $this->request->getJSON(true); + $data = [ - 'id' => $this->generateUuid(), - 'user_id' => $userId, - 'title' => $json['title'], - 'description' => $json['description'] ?? null, - 'status' => $json['status'] ?? 'open', - 'due_date' => $json['due_date'] ?? null, - 'due_time' => $json['due_time'] ?? null, - 'sync_enabled' => $json['sync_enabled'] ?? true, - 'reminder_enabled' => $json['reminder_enabled'] ?? false, - 'recurring_enabled' => $json['recurring_enabled'] ?? false, - 'project_id' => $json['project_id'] ?? null, + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'title' => $json['title'], + 'description' => $json['description'] ?? null, + 'status' => $json['status'] ?? 'open', + 'due_date' => $json['due_date'] ?? null, + 'due_time' => $json['due_time'] ?? null, + 'sync_enabled' => !empty($json['sync_enabled']), + 'reminder_enabled' => !empty($json['reminder_enabled']), + 'recurring_enabled' => !empty($json['recurring_enabled']), + 'project_id' => $json['project_id'] ?? null, ]; $this->todoModel->insert($data); - + // Link category if provided if (!empty($json['category_id'])) { $this->linkCategory($data['id'], $json['category_id']); } - - // Manually log the activity - try { - $activityLogModel = new \App\Models\ActivityLogModel(); - $activityLogModel->logActivity([ - 'user_id' => $userId, - 'action' => 'todo_created', - 'entity_type' => 'todo', - 'entity_id' => $data['id'], - 'details' => json_encode(['action' => 'created', 'title' => $data['title']]), - 'ip_address' => $this->request->getIPAddress(), - 'user_agent' => $this->request->getUserAgent()->getAgentString(), - ]); - } catch (\Exception $e) { - log_message('error', 'Failed to log activity: ' . $e->getMessage()); - } - + + $this->logActivity('todo_created', 'todo', $data['id'], [ + 'title' => $data['title'], + ]); + $todos = $this->todoModel->getByUserWithCategories($userId, $data['id']); return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201); } /** - * Get a specific todo * GET /api/v1/todos/{id} */ public function show($id = null) { $userId = $this->getUserId(); - $todos = $this->todoModel->getByUserWithCategories($userId, $id); + $todos = $this->todoModel->getByUserWithCategories($userId, $id); if (empty($todos)) { return $this->errorResponse('Todo not found', 404); @@ -106,116 +119,97 @@ class TodoController extends BaseController } /** - * Update a todo * PUT /api/v1/todos/{id} */ public function update($id = null) { $userId = $this->getUserId(); - $todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first(); + $todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first(); if (!$todo) { return $this->errorResponse('Todo not found', 404); } $json = $this->request->getJSON(true); - + // Handle category update separately (not a column in todos table) $hasCategoryUpdate = array_key_exists('category_id', $json); if ($hasCategoryUpdate) { $categoryId = $json['category_id']; - // Remove all existing category links $this->todoCategoryModel->where('todo_id', $id)->delete(); - // Link the new category if (!empty($categoryId)) { $this->linkCategory($id, $categoryId); } } - + // Update todo fields - $allowedFields = ['title', 'description', 'status', 'due_date', 'due_time', 'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id']; + $allowedFields = [ + 'title', 'description', 'status', 'due_date', 'due_time', + 'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id', + ]; $updateData = array_intersect_key($json, array_flip($allowedFields)); if (!empty($updateData)) { + // Convert boolean-ish values + foreach (['sync_enabled', 'reminder_enabled', 'recurring_enabled'] as $boolField) { + if (array_key_exists($boolField, $updateData)) { + $updateData[$boolField] = !empty($updateData[$boolField]); + } + } + $this->todoModel->update($id, $updateData); } - - // Manually log the activity - try { - $activityLogModel = new \App\Models\ActivityLogModel(); - $activityLogModel->logActivity([ - 'user_id' => $userId, - 'action' => 'todo_updated', - 'entity_type' => 'todo', - 'entity_id' => $id, - 'details' => json_encode(['action' => 'updated', 'title' => $todo['title'] ?? 'Unknown']), - 'ip_address' => $this->request->getIPAddress(), - 'user_agent' => $this->request->getUserAgent()->getAgentString(), - ]); - } catch (\Exception $e) { - log_message('error', 'Failed to log activity: ' . $e->getMessage()); - } - + + $this->logActivity('todo_updated', 'todo', $id, [ + 'title' => $todo['title'] ?? 'Unknown', + ]); + $updated = $this->todoModel->getByUserWithCategories($userId, $id); return $this->successResponse($updated[0] ?? null, 'Todo updated successfully'); } /** - * Delete a todo * DELETE /api/v1/todos/{id} */ public function delete($id = null) { $userId = $this->getUserId(); - $todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first(); + $todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first(); if (!$todo) { return $this->errorResponse('Todo not found', 404); } $this->todoModel->delete($id); - - // Manually log the activity - try { - $activityLogModel = new \App\Models\ActivityLogModel(); - $activityLogModel->logActivity([ - 'user_id' => $userId, - 'action' => 'todo_deleted', - 'entity_type' => 'todo', - 'entity_id' => $id, - 'details' => json_encode(['action' => 'deleted', 'title' => $todo['title'] ?? 'Unknown']), - 'ip_address' => $this->request->getIPAddress(), - 'user_agent' => $this->request->getUserAgent()->getAgentString(), - ]); - } catch (\Exception $e) { - log_message('error', 'Failed to log activity: ' . $e->getMessage()); - } + + $this->logActivity('todo_deleted', 'todo', $id, [ + 'title' => $todo['title'] ?? 'Unknown', + ]); return $this->successResponse(null, 'Todo deleted successfully'); } + // ── Category linking ─────────────────────────────────────────────────── + /** - * Add a category to a todo * POST /api/v1/todos/{id}/categories */ public function addCategory($todoId = null) { $userId = $this->getUserId(); - $json = $this->request->getJSON(true); + $json = $this->request->getJSON(true); $rules = ['category_id' => 'required']; if (!$this->validateRequest($rules)) { return; } - // Verify todo belongs to user $todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first(); if (!$todo) { return $this->errorResponse('Todo not found', 404); } - // Check if link already exists $existing = $this->todoCategoryModel ->where('todo_id', $todoId) ->where('category_id', $json['category_id']) @@ -226,7 +220,7 @@ class TodoController extends BaseController } $this->todoCategoryModel->insert([ - 'todo_id' => $todoId, + 'todo_id' => $todoId, 'category_id' => $json['category_id'], ]); @@ -234,14 +228,12 @@ class TodoController extends BaseController } /** - * Remove a category from a todo * DELETE /api/v1/todos/{id}/categories/{categoryId} */ public function removeCategory($todoId = null, $categoryId = null) { $userId = $this->getUserId(); - // Verify todo belongs to user $todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first(); if (!$todo) { return $this->errorResponse('Todo not found', 404); @@ -256,42 +248,29 @@ class TodoController extends BaseController } /** - * Link a category to a todo + * Link a category to a todo (internal helper) */ private function linkCategory(string $todoId, string $categoryId): void { $userId = $this->getUserId(); - - // Verify category belongs to user - $categoryModel = new \App\Models\CategoryModel(); - $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first(); + + $categoryModel = new CategoryModel(); + $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first(); + if (!$category) { return; } - - // Check if link already exists + $existing = $this->todoCategoryModel ->where('todo_id', $todoId) ->where('category_id', $categoryId) ->first(); - + if (!$existing) { $this->todoCategoryModel->insert([ - 'todo_id' => $todoId, + 'todo_id' => $todoId, 'category_id' => $categoryId, ]); } } - - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } } diff --git a/app/Controllers/Api/V1/UserThemeController.php b/app/Controllers/Api/V1/UserThemeController.php index 110c9e4..9975bed 100644 --- a/app/Controllers/Api/V1/UserThemeController.php +++ b/app/Controllers/Api/V1/UserThemeController.php @@ -106,15 +106,5 @@ class UserThemeController extends BaseController return $this->successResponse(null, 'User theme deleted successfully'); } - private function generateUuid(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); - } + } diff --git a/app/Models/CategoryModel.php b/app/Models/CategoryModel.php index 84a31be..6556cfc 100644 --- a/app/Models/CategoryModel.php +++ b/app/Models/CategoryModel.php @@ -6,26 +6,38 @@ use CodeIgniter\Model; class CategoryModel extends Model { - protected $table = 'categories'; - protected $primaryKey = 'id'; + protected $table = 'categories'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'name', - 'color', - 'favorite', - 'created_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'name', 'color', 'favorite', 'created_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = ''; + protected $createdField = 'created_at'; + protected $updatedField = ''; protected $validationRules = [ - 'user_id' => 'required', - 'name' => 'required|max_length[255]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The category name is required.', + 'max_length' => 'The category name must not exceed 255 characters.', + ], + ], + 'color' => [ + 'rules' => 'required|max_length[7]|regex_match[/^#[0-9a-fA-F]{6}$/]', + 'errors' => [ + 'required' => 'A color value is required.', + 'max_length' => 'Color must be a hex code (e.g. #3B82F6).', + 'regex_match' => 'Color must be a valid hex code (e.g. #3B82F6).', + ], + ], ]; } diff --git a/app/Models/ProjectModel.php b/app/Models/ProjectModel.php index ea69a9d..64534ff 100644 --- a/app/Models/ProjectModel.php +++ b/app/Models/ProjectModel.php @@ -6,26 +6,30 @@ use CodeIgniter\Model; class ProjectModel extends Model { - protected $table = 'projects'; - protected $primaryKey = 'id'; + protected $table = 'projects'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'name', - 'description', - 'color', - 'created_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = ''; + protected $createdField = 'created_at'; + protected $updatedField = ''; protected $validationRules = [ - 'user_id' => 'required', - 'name' => 'required|max_length[255]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The project name is required.', + 'max_length' => 'The project name must not exceed 255 characters.', + ], + ], ]; } diff --git a/app/Models/RecurringTaskModel.php b/app/Models/RecurringTaskModel.php index c209c3d..cc7b16d 100644 --- a/app/Models/RecurringTaskModel.php +++ b/app/Models/RecurringTaskModel.php @@ -6,40 +6,53 @@ use CodeIgniter\Model; class RecurringTaskModel extends Model { - protected $table = 'recurring_tasks'; - protected $primaryKey = 'id'; + protected $table = 'recurring_tasks'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'title', - 'description', - 'schedule', - 'custom_days', - 'favorite', - 'created_at', - 'updated_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'title', 'description', 'schedule', + 'custom_days', 'favorite', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; protected $validationRules = [ - 'user_id' => 'required', - 'title' => 'required|max_length[255]', - 'schedule' => 'required|in_list[daily,weekly,monthly,custom]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'title' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The recurring task title is required.', + 'max_length' => 'The title must not exceed 255 characters.', + ], + ], + 'schedule' => [ + 'rules' => 'permit_empty|in_list[daily,weekly,monthly,custom]', + 'errors' => [ + 'in_list' => 'Schedule must be one of: daily, weekly, monthly, custom.', + ], + ], ]; - // Get recurring tasks with categories - public function getWithCategories($taskId = null) + // ── Queries ──────────────────────────────────────────────────────────── + + public function getByUserWithCategories($userId, $taskId = null) { - $builder = $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names') - ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') - ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') - ->groupBy('recurring_tasks.id'); + $builder = $this->select(' + recurring_tasks.*, + GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, + GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names + ') + ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') + ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') + ->where('recurring_tasks.user_id', $userId) + ->groupBy('recurring_tasks.id'); if ($taskId) { $builder->where('recurring_tasks.id', $taskId); @@ -47,16 +60,4 @@ class RecurringTaskModel extends Model return $builder->get()->getResultArray(); } - - // Get recurring tasks by user with categories - public function getByUserWithCategories($userId) - { - return $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names') - ->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') - ->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') - ->where('recurring_tasks.user_id', $userId) - ->groupBy('recurring_tasks.id') - ->get() - ->getResultArray(); - } } diff --git a/app/Models/TodoModel.php b/app/Models/TodoModel.php index c04012e..8c3acf1 100644 --- a/app/Models/TodoModel.php +++ b/app/Models/TodoModel.php @@ -6,53 +6,51 @@ use CodeIgniter\Model; class TodoModel extends Model { - protected $table = 'todos'; - protected $primaryKey = 'id'; + protected $table = 'todos'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'user_id', - 'title', - 'description', - 'status', - 'due_date', - 'due_time', - 'sync_enabled', - 'reminder_enabled', - 'recurring_enabled', - 'project_id', - 'created_at', - 'updated_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'user_id', 'title', 'description', 'status', + 'due_date', 'due_time', 'sync_enabled', 'reminder_enabled', + 'recurring_enabled', 'project_id', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; protected $validationRules = [ - 'user_id' => 'required', - 'title' => 'required|max_length[255]', - 'status' => 'permit_empty|in_list[open,in_progress,completed,archived]', + 'user_id' => [ + 'rules' => 'required', + 'errors' => ['required' => 'User ID is required.'], + ], + 'title' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'The todo title is required.', + 'max_length' => 'The title must not exceed 255 characters.', + ], + ], + 'status' => [ + 'rules' => 'permit_empty|in_list[open,in_progress,completed,archived]', + 'errors' => [ + 'in_list' => 'Status must be one of: open, in_progress, completed, archived.', + ], + ], + 'due_date' => [ + 'rules' => 'permit_empty|valid_date[Y-m-d]', + 'errors' => ['valid_date' => 'Due date must be in YYYY-MM-DD format.'], + ], + 'due_time' => [ + 'rules' => 'permit_empty|valid_date[H:i:s]', + 'errors' => ['valid_date' => 'Due time must be in HH:MM format.'], + ], ]; - // Get todos with categories - public function getWithCategories($todoId = null) - { - $builder = $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names') - ->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left') - ->join('categories', 'todo_categories.category_id = categories.id', 'left') - ->groupBy('todos.id'); + // ── Queries ──────────────────────────────────────────────────────────── - if ($todoId) { - $builder->where('todos.id', $todoId); - } - - return $builder->get()->getResultArray(); - } - - // Get todos by user with categories (optionally filtered by todo id) public function getByUserWithCategories($userId, $todoId = null) { $builder = $this->select(' diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index 177f32e..4dd059f 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -6,36 +6,35 @@ use CodeIgniter\Model; class UserModel extends Model { - protected $table = 'users'; - protected $primaryKey = 'id'; + protected $table = 'users'; + protected $primaryKey = 'id'; protected $useAutoIncrement = false; - protected $returnType = 'array'; - protected $useSoftDeletes = false; - protected $allowedFields = [ - 'id', - 'email', - 'password_hash', - 'name', - 'avatar_url', - 'settings', - 'created_at', - 'updated_at', + protected $returnType = 'array'; + protected $useSoftDeletes = false; + protected $allowedFields = [ + 'id', 'email', 'password_hash', 'name', 'avatar_url', + 'settings', 'created_at', 'updated_at', ]; protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; protected $validationRules = [ - 'email' => 'required|valid_email|is_unique[users.email]', - 'password_hash' => 'required', - ]; - - protected $validationMessages = [ 'email' => [ - 'required' => 'Email is required', - 'valid_email' => 'Please enter a valid email address', - 'is_unique' => 'This email is already registered', + 'rules' => 'required|valid_email|is_unique[users.email]', + 'errors' => [ + 'required' => 'Email is required.', + 'valid_email' => 'Please provide a valid email address.', + 'is_unique' => 'This email is already registered.', + ], + ], + 'name' => [ + 'rules' => 'required|max_length[255]', + 'errors' => [ + 'required' => 'Name is required.', + 'max_length' => 'Name must not exceed 255 characters.', + ], ], ]; } diff --git a/composer.json b/composer.json index d47149e..f47c294 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ }, "require": { "php": "^8.2", - "codeigniter4/framework": "^4.7" + "codeigniter4/framework": "^4.7", + "firebase/php-jwt": "^7.0" }, "require-dev": { "fakerphp/faker": "^1.9", diff --git a/composer.lock b/composer.lock index f50bf37..b026545 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f5cce40800fa5dae1504b9364f585e6a", + "content-hash": "86520263c0a2df285d17beea23def54d", "packages": [ { "name": "codeigniter4/framework", @@ -83,6 +83,70 @@ }, "time": "2026-03-24T18:26:09+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, { "name": "laminas/laminas-escaper", "version": "2.18.0", diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..b4bfbdb --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,2186 @@ +openapi: "3.0.3" +info: + title: Todo App API + description: | + RESTful API for the Todo App backend. Provides user management, todo + tracking with categories, projects, recurring tasks, activity logging, + a theme marketplace, and JWT/ApiKey authentication. + + Base URL: `http://localhost:8080/api/v1` + version: "1.0.0" + contact: + name: Todo App Team + license: + name: MIT + +servers: + - url: http://localhost:8080/api/v1 + description: Local development server + +paths: + # ────────────────────────────────────────────────────────────────────────── + # AUTHENTICATION + # ────────────────────────────────────────────────────────────────────────── + + /auth/register: + post: + tags: [Authentication] + summary: Register a new user + description: > + Creates a new user account and returns the user data along with a + newly generated API key. Store the API key securely — it will not + be shown again. + operationId: registerUser + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password, name] + properties: + email: + type: string + format: email + description: Must be unique in the system + password: + type: string + format: password + minLength: 8 + description: Plain-text password (hashed with bcrypt on storage) + name: + type: string + maxLength: 255 + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + description: JSON-serialized user preferences + default: { theme: light } + properties: + theme: + type: string + example: dark + language: + type: string + example: en + example: + email: user@example.com + password: securepass123 + name: John Doe + avatar_url: https://example.com/avatar.jpg + settings: + theme: dark + language: en + responses: + "201": + description: User registered successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: User registered successfully + data: + type: object + properties: + user: + $ref: "#/components/schemas/User" + api_key: + type: string + description: Full API key (shown once only) + key_prefix: + type: string + example: todo_abc1 + "422": + $ref: "#/components/responses/ValidationError" + "409": + description: Email already registered + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + + /auth/login: + post: + tags: [Authentication] + summary: Login with email and password + description: > + Authenticates a user and returns an API key. If the user already has + an active API key, only the key prefix is returned (full key was + shown on first creation). + operationId: loginUser + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + example: + email: user@example.com + password: securepass123 + responses: + "200": + description: Login successful + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: Login successful + data: + type: object + properties: + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + name: + type: string + api_key: + type: string + description: Full API key (if new key was created) + api_key_prefix: + type: string + description: Key prefix when using an existing key + "401": + description: Invalid email or password + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + "422": + $ref: "#/components/responses/ValidationError" + + /auth/api-key: + post: + tags: [Authentication] + summary: Create an additional API key (legacy) + description: > + Creates a new API key for an app or integration. Requires email and + password for re-authentication. + operationId: createApiKeyAuth + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + minLength: 6 + name: + type: string + description: User-friendly label for the key + scopes: + type: array + items: + type: string + enum: [read, write] + description: > + Permission scopes. If omitted, grants full access. + example: [read, write] + expires_at: + type: string + format: date-time + nullable: true + description: ISO-8601 expiration date + example: + email: user@example.com + password: securepass123 + name: My App Key + scopes: [read, write] + responses: + "200": + description: API key created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: API key created successfully + data: + $ref: "#/components/schemas/ApiKeyCreated" + "401": + description: Invalid credentials + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + + /auth/jwt/register: + post: + tags: [Authentication] + summary: Register and receive JWT + API key + description: > + Registers a new user and returns both a JWT token (for bearer auth) + and an API key. + operationId: jwtRegister + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/requestBodies/UserRegistration" + responses: + "201": + description: Registration successful with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: User registered successfully + data: + type: object + properties: + user: + $ref: "#/components/schemas/User" + token: + type: string + description: JWT bearer token (valid 1 hour) + api_key: + type: string + description: Full API key value + + /auth/jwt/login: + post: + tags: [Authentication] + summary: Login and receive JWT + description: > + Authenticates with email/password and returns a JWT bearer token. + operationId: jwtLogin + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + responses: + "200": + description: Login successful with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: object + properties: + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + name: + type: string + token: + type: string + description: JWT bearer token (valid 1 hour) + + /auth/jwt/refresh: + post: + tags: [Authentication] + summary: Refresh JWT token + description: > + Obtains a new JWT token using a valid (not yet expired) bearer token. + operationId: jwtRefresh + security: + - bearerAuth: [] + responses: + "200": + description: Token refreshed + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: object + properties: + token: + type: string + "401": + description: Invalid or expired token + + # ────────────────────────────────────────────────────────────────────────── + # MARKETPLACE (public) + # ────────────────────────────────────────────────────────────────────────── + + /marketplace/themes: + get: + tags: [Marketplace] + summary: List all published marketplace themes + description: Public endpoint — no authentication required. + operationId: listMarketplaceThemes + responses: + "200": + description: List of published themes + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/MarketplaceTheme" + + /marketplace/themes/{id}: + get: + tags: [Marketplace] + summary: Get a single marketplace theme + operationId: getMarketplaceTheme + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Theme details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/MarketplaceTheme" + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # USER PROFILE + # ────────────────────────────────────────────────────────────────────────── + + /user/profile: + get: + tags: [User] + summary: Get authenticated user's profile + description: Returns profile data for the user identified by the API key. + operationId: getUserProfile + security: + - apiKeyAuth: [] + responses: + "200": + description: User profile + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/User" + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: [User] + summary: Update the authenticated user's profile + operationId: updateUserProfile + security: + - apiKeyAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 255 + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + description: User preferences (JSON) + responses: + "200": + description: Profile updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/User" + + /user/api-keys: + get: + tags: [User] + summary: List all API keys for the authenticated user + description: > + Returns API key metadata (prefix, name, scopes, active status). + The full key hash is never exposed. + operationId: listApiKeys + security: + - apiKeyAuth: [] + responses: + "200": + description: List of API keys + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/ApiKeyMetadata" + post: + tags: [User] + summary: Create a new API key + operationId: createApiKey + security: + - apiKeyAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: User-friendly label + default: API Key + scopes: + type: array + items: + type: string + enum: [read, write] + default: [read, write] + expires_at: + type: string + format: date-time + nullable: true + responses: + "200": + description: API key created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/ApiKeyCreated" + + /user/api-keys/{id}: + delete: + tags: [User] + summary: Revoke an API key + description: Permanently deactivates an API key. + operationId: revokeApiKey + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Key revoked + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: null + example: null + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # CATEGORIES + # ────────────────────────────────────────────────────────────────────────── + + /categories: + get: + tags: [Categories] + summary: List all categories for the authenticated user + operationId: listCategories + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + - name: favorite + in: query + schema: + type: boolean + description: Filter by favorite status + responses: + "200": + description: Paginated list of categories + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Category" + post: + tags: [Categories] + summary: Create a new category + operationId: createCategory + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, color] + properties: + name: + type: string + maxLength: 255 + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + description: Hex colour code for UI + example: "#3B82F6" + favorite: + type: boolean + default: false + responses: + "201": + description: Category created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Category" + "409": + description: A category with this name already exists for this user + "422": + $ref: "#/components/responses/ValidationError" + + /categories/{id}: + get: + tags: [Categories] + summary: Get a single category + operationId: getCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Category details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Category" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Categories] + summary: Update a category + operationId: updateCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 255 + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + favorite: + type: boolean + responses: + "200": + description: Category updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Category" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Categories] + summary: Delete a category + operationId: deleteCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Category deleted + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # PROJECTS + # ────────────────────────────────────────────────────────────────────────── + + /projects: + get: + tags: [Projects] + summary: List all projects for the authenticated user + operationId: listProjects + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + responses: + "200": + description: Paginated list of projects + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Project" + post: + tags: [Projects] + summary: Create a new project + operationId: createProject + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + maxLength: 255 + description: + type: string + nullable: true + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + default: "#8B5CF6" + example: "#8B5CF6" + responses: + "201": + description: Project created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Project" + "422": + $ref: "#/components/responses/ValidationError" + + /projects/{id}: + get: + tags: [Projects] + summary: Get a single project + operationId: getProject + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Project details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Project" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Projects] + summary: Update a project + operationId: updateProject + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 255 + description: + type: string + nullable: true + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + responses: + "200": + description: Project updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Project" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Projects] + summary: Delete a project + operationId: deleteProject + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Project deleted + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # TODOS + # ────────────────────────────────────────────────────────────────────────── + + /todos: + get: + tags: [Todos] + summary: List todos with pagination, sorting, and filtering + operationId: listTodos + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + - name: status + in: query + schema: + type: string + enum: [open, in_progress, completed, archived] + description: Filter by status + - name: project_id + in: query + schema: + type: string + format: uuid + - name: sync_enabled + in: query + schema: + type: boolean + - name: reminder_enabled + in: query + schema: + type: boolean + - name: recurring_enabled + in: query + schema: + type: boolean + responses: + "200": + description: Paginated list of todos (with linked categories) + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Todo" + post: + tags: [Todos] + summary: Create a new todo + operationId: createTodo + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [title] + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + status: + type: string + enum: [open, in_progress, completed, archived] + default: open + due_date: + type: string + format: date + nullable: true + example: "2025-01-15" + due_time: + type: string + format: partial-time + nullable: true + example: "10:30:00" + sync_enabled: + type: boolean + default: true + reminder_enabled: + type: boolean + default: false + recurring_enabled: + type: boolean + default: false + project_id: + type: string + format: uuid + nullable: true + category_id: + type: string + format: uuid + nullable: true + description: > + Optional — link an existing category on creation + responses: + "201": + description: Todo created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Todo" + "422": + $ref: "#/components/responses/ValidationError" + + /todos/{id}: + get: + tags: [Todos] + summary: Get a single todo + operationId: getTodo + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Todo details (with linked categories) + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Todo" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Todos] + summary: Update a todo + operationId: updateTodo + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + status: + type: string + enum: [open, in_progress, completed, archived] + due_date: + type: string + format: date + nullable: true + due_time: + type: string + format: partial-time + nullable: true + sync_enabled: + type: boolean + reminder_enabled: + type: boolean + recurring_enabled: + type: boolean + project_id: + type: string + format: uuid + nullable: true + category_id: + type: string + format: uuid + nullable: true + description: > + Replaces all category links with the given category + responses: + "200": + description: Todo updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/Todo" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Todos] + summary: Delete a todo + operationId: deleteTodo + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Todo deleted + "404": + $ref: "#/components/responses/NotFound" + + /todos/{id}/categories: + post: + tags: [Todos] + summary: Link a category to a todo + operationId: addTodoCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Todo UUID + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [category_id] + properties: + category_id: + type: string + format: uuid + responses: + "201": + description: Category linked to todo + "409": + description: Category already linked + "404": + $ref: "#/components/responses/NotFound" + + /todos/{id}/categories/{categoryId}: + delete: + tags: [Todos] + summary: Remove a category link from a todo + operationId: removeTodoCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Todo UUID + - name: categoryId + in: path + required: true + schema: + type: string + format: uuid + description: Category UUID + responses: + "200": + description: Category removed from todo + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # RECURRING TASKS + # ────────────────────────────────────────────────────────────────────────── + + /recurring-tasks: + get: + tags: [Recurring Tasks] + summary: List all recurring tasks + operationId: listRecurringTasks + security: + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/sort" + - name: schedule + in: query + schema: + type: string + enum: [daily, weekly, monthly, custom] + - name: favorite + in: query + schema: + type: boolean + responses: + "200": + description: Paginated list of recurring tasks + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SuccessBody" + - $ref: "#/components/schemas/PaginatedResponse" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/RecurringTask" + post: + tags: [Recurring Tasks] + summary: Create a recurring task + operationId: createRecurringTask + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [title] + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + schedule: + type: string + enum: [daily, weekly, monthly, custom] + default: weekly + custom_days: + type: array + items: + type: string + enum: [mon, tue, wed, thu, fri, sat, sun] + description: Active only when schedule=custom + example: [mon, wed, fri] + favorite: + type: boolean + default: false + category_id: + type: string + format: uuid + nullable: true + responses: + "201": + description: Recurring task created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/RecurringTask" + "422": + $ref: "#/components/responses/ValidationError" + + /recurring-tasks/{id}: + get: + tags: [Recurring Tasks] + summary: Get a single recurring task + operationId: getRecurringTask + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Recurring task details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/RecurringTask" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Recurring Tasks] + summary: Update a recurring task + operationId: updateRecurringTask + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + maxLength: 255 + description: + type: string + nullable: true + schedule: + type: string + enum: [daily, weekly, monthly, custom] + custom_days: + type: array + items: + type: string + enum: [mon, tue, wed, thu, fri, sat, sun] + favorite: + type: boolean + category_id: + type: string + format: uuid + nullable: true + description: Replaces all category links + responses: + "200": + description: Recurring task updated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/RecurringTask" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Recurring Tasks] + summary: Delete a recurring task + operationId: deleteRecurringTask + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Recurring task deleted + "404": + $ref: "#/components/responses/NotFound" + + /recurring-tasks/{id}/categories: + post: + tags: [Recurring Tasks] + summary: Link a category to a recurring task + operationId: addRecurringTaskCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [category_id] + properties: + category_id: + type: string + format: uuid + responses: + "201": + description: Category linked + "409": + description: Already linked + "404": + $ref: "#/components/responses/NotFound" + + /recurring-tasks/{id}/categories/{categoryId}: + delete: + tags: [Recurring Tasks] + summary: Remove a category link from a recurring task + operationId: removeRecurringTaskCategory + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: categoryId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Category removed + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # ACTIVITY LOGS + # ────────────────────────────────────────────────────────────────────────── + + /activity-logs: + get: + tags: [Activity Logs] + summary: List recent activity logs for the authenticated user + operationId: listActivityLogs + security: + - apiKeyAuth: [] + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + description: Maximum number of log entries to return + responses: + "200": + description: List of activity log entries + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/ActivityLog" + + /activity-logs/{id}: + get: + tags: [Activity Logs] + summary: Get a single activity log entry + operationId: getActivityLog + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Activity log entry + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/ActivityLog" + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # USER THEMES + # ────────────────────────────────────────────────────────────────────────── + + /user/themes: + get: + tags: [User Themes] + summary: List themes installed by the authenticated user + operationId: listUserThemes + security: + - apiKeyAuth: [] + responses: + "200": + description: List of user themes + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + type: array + items: + $ref: "#/components/schemas/UserTheme" + post: + tags: [User Themes] + summary: Install a theme for the user + operationId: createUserTheme + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [theme_id] + properties: + theme_id: + type: string + format: uuid + is_active: + type: boolean + default: false + custom_settings: + type: object + description: Theme variable overrides + properties: + primary_color: + type: string + example: "#3B82F6" + font_size: + type: string + example: medium + responses: + "201": + description: Theme installed + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + data: + $ref: "#/components/schemas/UserTheme" + + /user/themes/{id}: + put: + tags: [User Themes] + summary: Update a user's theme settings + operationId: updateUserTheme + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + is_active: + type: boolean + custom_settings: + type: object + responses: + "200": + description: Theme updated + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [User Themes] + summary: Uninstall a user theme + operationId: deleteUserTheme + security: + - apiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Theme uninstalled + "404": + $ref: "#/components/responses/NotFound" + + # ────────────────────────────────────────────────────────────────────────── + # CORS PREFLIGHT + # ────────────────────────────────────────────────────────────────────────── + /{any}: + options: + tags: [CORS] + summary: CORS preflight handler + description: Handles OPTIONS preflight requests for all API routes. + parameters: + - name: any + in: path + required: true + schema: + type: string + responses: + "200": + description: CORS headers set, no body + +# ============================================================================ +# COMPONENTS +# ============================================================================ + +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: > + API key obtained from `/auth/register`, `/auth/login`, or + `/user/api-keys`. Include it as `X-API-Key: todo_xxxxx`. + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: > + JWT token obtained from `/auth/jwt/login` or `/auth/jwt/register`. + Include it as `Authorization: Bearer `. + + parameters: + page: + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + description: Page number for pagination + per_page: + name: per_page + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + description: Items per page + sort: + name: sort + in: query + schema: + type: string + description: > + Sort fields with optional `-` prefix for descending order. + Comma-separated: `sort=title,-created_at` + + requestBodies: + UserRegistration: + description: User registration payload + required: true + content: + application/json: + schema: + type: object + required: [email, password, name] + properties: + email: + type: string + format: email + password: + type: string + format: password + minLength: 8 + name: + type: string + maxLength: 255 + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + properties: + theme: + type: string + example: dark + language: + type: string + example: en + + responses: + ValidationError: + description: Validation failed + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + enum: [false] + message: + type: string + example: Validation failed + errors: + type: object + additionalProperties: + type: string + example: + email: The Email field must contain a unique value. + Unauthorized: + description: Missing or invalid API key + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Unauthorized + message: + type: string + example: Invalid or expired API key + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorBody" + + schemas: + # ── Base envelope ────────────────────────────────────────────────────── + SuccessBody: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + ErrorBody: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + errors: + type: object + additionalProperties: + type: string + + PaginatedResponse: + type: object + properties: + pagination: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + last_page: + type: integer + has_more: + type: boolean + + # ── User ──────────────────────────────────────────────────────────────── + User: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + format: email + name: + type: string + avatar_url: + type: string + format: uri + nullable: true + settings: + type: object + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + example: + id: "550e8400-e29b-41d4-a716-446655440000" + email: user@example.com + name: John Doe + avatar_url: null + settings: + theme: dark + language: en + created_at: "2025-01-01 00:00:00" + updated_at: "2025-01-01 00:00:00" + + # ── API Keys ─────────────────────────────────────────────────────────── + ApiKeyCreated: + type: object + properties: + id: + type: string + format: uuid + key: + type: string + description: Full API key value (only shown once) + example: "todo_abc123def456..." + prefix: + type: string + example: "todo_abc1" + name: + type: string + example: "My App Key" + scopes: + type: array + items: + type: string + enum: [read, write] + expires_at: + type: string + format: date-time + nullable: true + + ApiKeyMetadata: + type: object + properties: + id: + type: string + format: uuid + key_prefix: + type: string + example: "todo_abc1" + name: + type: string + scopes: + type: array + items: + type: string + is_active: + type: boolean + last_used_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + + # ── Category ──────────────────────────────────────────────────────────── + Category: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + name: + type: string + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + example: "#3B82F6" + favorite: + type: boolean + created_at: + type: string + format: date-time + + # ── Project ──────────────────────────────────────────────────────────── + Project: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + name: + type: string + description: + type: string + nullable: true + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + example: "#8B5CF6" + created_at: + type: string + format: date-time + + # ── Todo ──────────────────────────────────────────────────────────────── + Todo: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + title: + type: string + description: + type: string + nullable: true + status: + type: string + enum: [open, in_progress, completed, archived] + due_date: + type: string + format: date + nullable: true + due_time: + type: string + format: partial-time + nullable: true + sync_enabled: + type: boolean + reminder_enabled: + type: boolean + recurring_enabled: + type: boolean + project_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + categories: + type: array + items: + $ref: "#/components/schemas/LinkedCategory" + example: + id: "660e8400-e29b-41d4-a716-446655440001" + user_id: "550e8400-e29b-41d4-a716-446655440000" + title: "Complete task" + description: "Task description" + status: open + due_date: "2025-01-15" + due_time: "10:30:00" + sync_enabled: true + reminder_enabled: false + recurring_enabled: false + project_id: "770e8400-e29b-41d4-a716-446655440002" + created_at: "2025-01-01 00:00:00" + updated_at: "2025-01-01 00:00:00" + categories: + - id: "880e8400-e29b-41d4-a716-446655440003" + name: "Work" + color: "#3B82F6" + + LinkedCategory: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + + # ── Recurring Task ────────────────────────────────────────────────────── + RecurringTask: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + title: + type: string + description: + type: string + nullable: true + schedule: + type: string + enum: [daily, weekly, monthly, custom] + custom_days: + type: array + items: + type: string + favorite: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + categories: + type: array + items: + $ref: "#/components/schemas/LinkedCategory" + + # ── Activity Log ─────────────────────────────────────────────────────── + ActivityLog: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + nullable: true + action: + type: string + example: "todo_created" + entity_type: + type: string + example: "todo" + entity_id: + type: string + format: uuid + nullable: true + details: + type: object + nullable: true + example: + title: "New Task" + ip_address: + type: string + example: "127.0.0.1" + user_agent: + type: string + created_at: + type: string + format: date-time + + # ── Marketplace Theme ────────────────────────────────────────────────── + MarketplaceTheme: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + display_name: + type: string + description: + type: string + nullable: true + author: + type: string + nullable: true + version: + type: string + thumbnail_url: + type: string + format: uri + nullable: true + price: + type: number + format: decimal + example: 0 + is_free: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + # ── User Theme ───────────────────────────────────────────────────────── + UserTheme: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + theme_id: + type: string + format: uuid + is_active: + type: boolean + custom_settings: + type: object + nullable: true + created_at: + type: string + format: date-time diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b408a99..afed558 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ ./tests + ./tests/database + ./tests/session @@ -51,13 +53,18 @@ - + + + + + + + + diff --git a/tests/api/ModelTest.php b/tests/api/ModelTest.php new file mode 100644 index 0000000..e3c3494 --- /dev/null +++ b/tests/api/ModelTest.php @@ -0,0 +1,251 @@ +first(); + + if ($user) { + self::$userId = $user['id']; + } + } + + protected function setUp(): void + { + parent::setUp(); + + if (empty(self::$userId)) { + $this->markTestSkipped('No users in database. Run migrations and register a user first.'); + } + } + + // ======================================================================== + // TODO MODEL + // ======================================================================== + + public function testTodoModelInsertAndFind(): void + { + $model = new TodoModel(); + + $data = [ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'title' => 'Test todo', + 'status' => 'open', + 'due_date' => '2025-12-31', + ]; + + $this->assertNotFalse($model->insert($data), 'Todo insert should succeed'); + + self::$todoId = $data['id']; + + $found = $model->find($data['id']); + $this->assertNotNull($found, 'Todo should be findable'); + $this->assertSame('Test todo', $found['title']); + } + + public function testTodoModelValidation(): void + { + $model = new TodoModel(); + + $result = $model->insert([ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + ]); + + $this->assertFalse($result, 'Insert without title should fail'); + $this->assertNotEmpty($model->errors()); + } + + public function testTodoModelUpdate(): void + { + $model = new TodoModel(); + + $updated = $model->update(self::$todoId, [ + 'status' => 'completed', + ]); + + $this->assertNotFalse($updated); + + $found = $model->find(self::$todoId); + $this->assertNotNull($found); + $this->assertSame('completed', $found['status']); + } + + public function testTodoModelFindByUser(): void + { + $model = new TodoModel(); + + $results = $model->where('user_id', self::$userId)->findAll(); + $this->assertGreaterThanOrEqual(1, count($results)); + } + + public function testTodoModelDelete(): void + { + $model = new TodoModel(); + $model->delete(self::$todoId); + + $found = $model->find(self::$todoId); + $this->assertNull($found, 'Todo should be deleted'); + } + + // ======================================================================== + // CATEGORY MODEL + // ======================================================================== + + public function testCategoryModelInsertAndFind(): void + { + $model = new CategoryModel(); + + $data = [ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'Work', + 'color' => '#3B82F6', + ]; + + $this->assertNotFalse($model->insert($data)); + + self::$categoryId = $data['id']; + + $found = $model->find($data['id']); + $this->assertNotNull($found); + $this->assertSame('Work', $found['name']); + $this->assertSame('#3B82F6', $found['color']); + } + + public function testCategoryValidationMissingColor(): void + { + $model = new CategoryModel(); + + $result = $model->insert([ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'No Color', + ]); + + $this->assertFalse($result); + } + + public function testCategoryValidationInvalidColor(): void + { + $model = new CategoryModel(); + + $result = $model->insert([ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'Bad Color', + 'color' => 'not-a-hex', + ]); + + $this->assertFalse($result); + } + + public function testCategoryModelUpdate(): void + { + $model = new CategoryModel(); + $model->update(self::$categoryId, ['name' => 'Updated Work']); + + $found = $model->find(self::$categoryId); + $this->assertSame('Updated Work', $found['name']); + } + + public function testCategoryModelDelete(): void + { + $model = new CategoryModel(); + $model->delete(self::$categoryId); + + $found = $model->find(self::$categoryId); + $this->assertNull($found); + } + + // ======================================================================== + // PROJECT MODEL + // ======================================================================== + + public function testProjectModelInsertAndFind(): void + { + $model = new ProjectModel(); + + $data = [ + 'id' => $this->uuid(), + 'user_id' => self::$userId, + 'name' => 'Test Project', + 'color' => '#8B5CF6', + ]; + + $this->assertNotFalse($model->insert($data)); + + self::$projectId = $data['id']; + + $found = $model->find($data['id']); + $this->assertNotNull($found); + $this->assertSame('Test Project', $found['name']); + } + + public function testProjectModelUpdate(): void + { + $model = new ProjectModel(); + $model->update(self::$projectId, [ + 'description' => 'A test project description', + ]); + + $found = $model->find(self::$projectId); + $this->assertSame('A test project description', $found['description']); + } + + public function testProjectModelDelete(): void + { + $model = new ProjectModel(); + $model->delete(self::$projectId); + + $found = $model->find(self::$projectId); + $this->assertNull($found); + } + + // ======================================================================== + // HELPERS + // ======================================================================== + + private function uuid(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b15fc7d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,19 @@ +