Merge branch 'main' into feature/marketplace

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

5
.gitignore vendored
View File

@@ -127,4 +127,7 @@ _modules/*
.env .env
env env
.claude/ .claude/
.claude/* .claude/*
# Generated docs
/public/api-docs.html

View File

@@ -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.

553
README.md
View File

@@ -1 +1,552 @@
# Todo-App-Backend # 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
<env name="database.tests.hostname" value="localhost"/>
<env name="database.tests.database" value="todo_app_test"/>
<env name="database.tests.username" value="root"/>
<env name="database.tests.password" value=""/>
<env name="database.tests.DBDriver" value="MySQLi"/>
```
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).

View File

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

View File

@@ -36,6 +36,11 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => '
// Marketplace - Public access // Marketplace - Public access
$routes->get('marketplace/themes', 'MarketplaceController::index'); $routes->get('marketplace/themes', 'MarketplaceController::index');
$routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1'); $routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1');
// JWT Authentication
$routes->post('auth/jwt/register', 'AuthController::jwtRegister');
$routes->post('auth/jwt/login', 'AuthController::jwtLogin');
$routes->post('auth/jwt/refresh', 'AuthController::jwtRefresh');
}); });
// Protected endpoints (API key authentication required) // Protected endpoints (API key authentication required)

View File

@@ -23,35 +23,138 @@ class BaseController extends ResourceController
return $user['id'] ?? null; 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 $page = max(1, (int) $this->request->getGet('page'));
->setStatusCode($statusCode) $perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 50)));
->setHeader('Access-Control-Allow-Origin', '*') return [$page, $perPage];
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
->setJSON([
'success' => true,
'message' => $message,
'data' => $data,
]);
} }
/** /**
* Error response * 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 = [ $raw = $this->request->getGet('sort');
'success' => false, 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, 'message' => $message,
'data' => $data,
]; ];
if ($errors !== null) { if (!empty($extraMeta)) {
$response['errors'] = $errors; foreach ($extraMeta as $key => $value) {
$body[$key] = $value;
}
} }
return $this->response return $this->response
@@ -59,17 +162,83 @@ class BaseController extends ResourceController
->setHeader('Access-Control-Allow-Origin', '*') ->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') ->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key') ->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
->setJSON($response); ->setJSON($body);
} }
/** /**
* Validate request data * 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 protected function validateRequest(array $rules): bool
{ {
$validation = \Config\Services::validation(); $validation = \Config\Services::validation();
// Handle both old format (string) and new format (array with rules/errors)
foreach ($rules as $field => $rule) { foreach ($rules as $field => $rule) {
if (is_array($rule) && isset($rule['rules'])) { if (is_array($rule) && isset($rule['rules'])) {
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []); $validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
@@ -85,4 +254,106 @@ class BaseController extends ResourceController
return true; return true;
} }
// ========================================================================
// Activity Logging
// ========================================================================
/**
* Log an activity to the activity_logs table.
* Safe — catches and logs errors silently so the main request is never broken.
*/
protected function logActivity(string $action, string $entityType, ?string $entityId, ?array $details = null): void
{
try {
$userId = $this->getUserId();
$logModel = new \App\Models\ActivityLogModel();
$logModel->logActivity([
'user_id' => $userId,
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'details' => $details ? json_encode($details) : null,
'ip_address' => $this->request->getIPAddress(),
'user_agent' => $this->request->getUserAgent()->getAgentString(),
]);
} catch (\Exception $e) {
log_message('error', 'Failed to log activity: ' . $e->getMessage());
}
}
// ========================================================================
// JWT helpers
// ========================================================================
/**
* JWT secret key — should be read from env in production.
*/
protected function getJwtSecret(): string
{
return $_ENV['JWT_SECRET'] ?? 'todo-app-jwt-secret-change-in-production';
}
/**
* Decode a JWT from the Authorization header.
* Returns the payload on success, null on failure.
*/
protected function decodeJwtFromRequest(): ?array
{
$header = $this->request->getHeaderLine('Authorization');
if (empty($header)) return null;
if (strpos($header, 'Bearer ') !== 0) return null;
$token = substr($header, 7);
return $this->decodeJwt($token);
}
/**
* Decode and verify a JWT token.
*/
protected function decodeJwt(string $token): ?array
{
try {
$key = $this->getJwtSecret();
$jwt = \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($key, 'HS256'));
return (array) $jwt;
} catch (\Exception $e) {
log_message('error', 'JWT decode failed: ' . $e->getMessage());
return null;
}
}
/**
* Encode a payload into a JWT token.
*/
protected function encodeJwt(array $payload): string
{
$key = $this->getJwtSecret();
$issuedAt = time();
$payload['iat'] = $issuedAt;
$payload['exp'] = $issuedAt + 3600; // 1 hour default
return \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
}
// ========================================================================
// Helpers
// ========================================================================
/**
* Generate UUID v4
*/
protected function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
} }

View File

@@ -221,18 +221,163 @@ class AuthController extends BaseController
], 'API key created successfully'); ], 'API key created successfully');
} }
// ========================================================================
// JWT Authentication
// ========================================================================
/**
* Register a new user and return JWT + API key
* POST /api/v1/auth/jwt/register
*/
public function jwtRegister()
{
// Reuse the existing register validation logic
$json = $this->request->getJSON(true);
$rules = [
'email' => [
'rules' => 'required|valid_email|is_unique[users.email]',
'errors' => [
'required' => 'Email is required',
'valid_email' => 'Please provide a valid email address',
'is_unique' => 'This email is already registered',
],
],
'password' => [
'rules' => 'required|min_length[8]',
'errors' => [
'required' => 'Password is required',
'min_length' => 'Password must be at least 8 characters long',
],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'Name is required',
'max_length' => 'Name must not exceed 255 characters',
],
],
];
if (!$this->validateRequest($rules)) {
return;
}
try {
$userId = $this->generateUuid();
$userData = [
'id' => $userId,
'email' => $json['email'],
'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT),
'name' => $json['name'],
'avatar_url' => $json['avatar_url'] ?? null,
'settings' => isset($json['settings']) ? json_encode($json['settings']) : json_encode(['theme' => 'light']),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
$this->userModel->insert($userData);
// Create API key for the new user
$apiKey = $this->apiAuthKeyModel->createKey(
$userId,
'Default API Key',
['read', 'write'],
null
);
// Generate JWT
$jwt = $this->encodeJwt([
'sub' => $userId,
'email' => $json['email'],
'name' => $json['name'],
]);
unset($userData['password_hash']);
return $this->successResponse([
'user' => $userData,
'token' => $jwt,
'api_key' => $apiKey['key'],
], 'User registered successfully', 201);
} catch (\Exception $e) {
return $this->errorResponse('Registration failed: ' . $e->getMessage(), 500);
}
}
/**
* Login with email/password and return JWT
* POST /api/v1/auth/jwt/login
*/
public function jwtLogin()
{
$json = $this->request->getJSON(true);
$rules = [
'email' => 'required|valid_email',
'password' => 'required',
];
if (!$this->validateRequest($rules)) {
return;
}
try {
$user = $this->userModel->where('email', $json['email'])->first();
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
return $this->errorResponse('Invalid email or password', 401);
}
// Generate JWT
$jwt = $this->encodeJwt([
'sub' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
]);
return $this->successResponse([
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
],
'token' => $jwt,
], 'Login successful');
} catch (\Exception $e) {
return $this->errorResponse('Login failed: ' . $e->getMessage(), 500);
}
}
/**
* Refresh JWT token
* POST /api/v1/auth/jwt/refresh
*/
public function jwtRefresh()
{
$payload = $this->decodeJwtFromRequest();
if (!$payload || empty($payload['sub'])) {
return $this->errorResponse('Invalid or expired token', 401);
}
$user = $this->userModel->find($payload['sub']);
if (!$user) {
return $this->errorResponse('User not found', 401);
}
$jwt = $this->encodeJwt([
'sub' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
]);
return $this->successResponse(['token' => $jwt], 'Token refreshed successfully');
}
/** /**
* Generate UUID * Generate UUID
*/ */
private function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
} }

View File

@@ -14,37 +14,44 @@ class CategoryController extends BaseController
$this->categoryModel = new CategoryModel(); $this->categoryModel = new CategoryModel();
} }
const SORTABLE = ['name', 'created_at'];
const FILTERABLE = ['favorite'];
/** /**
* Get all categories for the authenticated user
* GET /api/v1/categories * GET /api/v1/categories
*/ */
public function index() public function index()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$categories = $this->categoryModel->where('user_id', $userId)->findAll(); $filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
return $this->successResponse($categories, 'Categories retrieved successfully'); $builder = $this->categoryModel->where('user_id', $userId);
$this->applyFilters($builder, $filters);
if (empty($sorts)) {
$builder->orderBy('name', 'ASC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Categories retrieved successfully');
} }
/** /**
* Create a new category
* POST /api/v1/categories * POST /api/v1/categories
*/ */
public function create() public function create()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = [ if (!$this->validateWithModel($this->categoryModel)) {
'name' => 'required|max_length[255]',
'color' => 'required|max_length[7]',
];
if (!$this->validateRequest($rules)) {
return; return;
} }
// Check for duplicate name per user $json = $this->request->getJSON(true);
// Custom duplicate check (per user)
$existing = $this->categoryModel $existing = $this->categoryModel
->where('user_id', $userId) ->where('user_id', $userId)
->where('name', $json['name']) ->where('name', $json['name'])
@@ -55,26 +62,29 @@ class CategoryController extends BaseController
} }
$data = [ $data = [
'id' => $this->generateUuid(), 'id' => $this->generateUuid(),
'user_id' => $userId, 'user_id' => $userId,
'name' => $json['name'], 'name' => $json['name'],
'color' => $json['color'], 'color' => $json['color'],
'favorite' => $json['favorite'] ?? false, 'favorite' => !empty($json['favorite']),
]; ];
$this->categoryModel->insert($data); $this->categoryModel->insert($data);
$category = $this->categoryModel->find($data['id']); $category = $this->categoryModel->find($data['id']);
$this->logActivity('category_created', 'category', $data['id'], [
'name' => $data['name'],
]);
return $this->successResponse($category, 'Category created successfully', 201); return $this->successResponse($category, 'Category created successfully', 201);
} }
/** /**
* Get a specific category
* GET /api/v1/categories/{id} * GET /api/v1/categories/{id}
*/ */
public function show($id = null) public function show($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
if (!$category) { if (!$category) {
@@ -85,12 +95,11 @@ class CategoryController extends BaseController
} }
/** /**
* Update a category
* PUT /api/v1/categories/{id} * PUT /api/v1/categories/{id}
*/ */
public function update($id = null) public function update($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
if (!$category) { if (!$category) {
@@ -99,7 +108,7 @@ class CategoryController extends BaseController
$json = $this->request->getJSON(true); $json = $this->request->getJSON(true);
// Check for duplicate name on rename (excluding current category) // Duplicate check on rename
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) { if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
$existing = $this->categoryModel $existing = $this->categoryModel
->where('user_id', $userId) ->where('user_id', $userId)
@@ -113,25 +122,33 @@ class CategoryController extends BaseController
} }
$allowedFields = ['name', 'color', 'favorite']; $allowedFields = ['name', 'color', 'favorite'];
$updateData = array_intersect_key($json, array_flip($allowedFields)); $updateData = array_intersect_key($json, array_flip($allowedFields));
if (empty($updateData)) { if (empty($updateData)) {
return $this->errorResponse('No valid fields to update'); return $this->errorResponse('No valid fields to update');
} }
// Convert boolean
if (array_key_exists('favorite', $updateData)) {
$updateData['favorite'] = !empty($updateData['favorite']);
}
$this->categoryModel->update($id, $updateData); $this->categoryModel->update($id, $updateData);
$category = $this->categoryModel->find($id); $category = $this->categoryModel->find($id);
$this->logActivity('category_updated', 'category', $id, [
'name' => $category['name'] ?? 'Unknown',
]);
return $this->successResponse($category, 'Category updated successfully'); return $this->successResponse($category, 'Category updated successfully');
} }
/** /**
* Delete a category
* DELETE /api/v1/categories/{id} * DELETE /api/v1/categories/{id}
*/ */
public function delete($id = null) public function delete($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first(); $category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
if (!$category) { if (!$category) {
@@ -140,18 +157,10 @@ class CategoryController extends BaseController
$this->categoryModel->delete($id); $this->categoryModel->delete($id);
$this->logActivity('category_deleted', 'category', $id, [
'name' => $category['name'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Category deleted successfully'); return $this->successResponse(null, 'Category deleted successfully');
} }
private function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
} }

View File

@@ -14,57 +14,67 @@ class ProjectController extends BaseController
$this->projectModel = new ProjectModel(); $this->projectModel = new ProjectModel();
} }
const SORTABLE = ['name', 'created_at'];
const FILTERABLE = [];
/** /**
* Get all projects for the authenticated user
* GET /api/v1/projects * GET /api/v1/projects
*/ */
public function index() public function index()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$projects = $this->projectModel->where('user_id', $userId)->findAll(); $filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
return $this->successResponse($projects, 'Projects retrieved successfully'); $builder = $this->projectModel->where('user_id', $userId);
$this->applyFilters($builder, $filters);
if (empty($sorts)) {
$builder->orderBy('created_at', 'DESC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Projects retrieved successfully');
} }
/** /**
* Create a new project
* POST /api/v1/projects * POST /api/v1/projects
*/ */
public function create() public function create()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = [ if (!$this->validateWithModel($this->projectModel)) {
'name' => 'required|max_length[255]',
'color' => 'required|max_length[7]',
];
if (!$this->validateRequest($rules)) {
return; return;
} }
$json = $this->request->getJSON(true);
$data = [ $data = [
'id' => $this->generateUuid(), 'id' => $this->generateUuid(),
'user_id' => $userId, 'user_id' => $userId,
'name' => $json['name'], 'name' => $json['name'],
'description' => $json['description'] ?? null, 'description' => $json['description'] ?? null,
'color' => $json['color'], 'color' => $json['color'] ?? '#8B5CF6',
]; ];
$this->projectModel->insert($data); $this->projectModel->insert($data);
$project = $this->projectModel->find($data['id']); $project = $this->projectModel->find($data['id']);
$this->logActivity('project_created', 'project', $data['id'], [
'name' => $data['name'],
]);
return $this->successResponse($project, 'Project created successfully', 201); return $this->successResponse($project, 'Project created successfully', 201);
} }
/** /**
* Get a specific project
* GET /api/v1/projects/{id} * GET /api/v1/projects/{id}
*/ */
public function show($id = null) public function show($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
if (!$project) { if (!$project) {
@@ -75,12 +85,11 @@ class ProjectController extends BaseController
} }
/** /**
* Update a project
* PUT /api/v1/projects/{id} * PUT /api/v1/projects/{id}
*/ */
public function update($id = null) public function update($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
if (!$project) { if (!$project) {
@@ -88,8 +97,9 @@ class ProjectController extends BaseController
} }
$json = $this->request->getJSON(true); $json = $this->request->getJSON(true);
$allowedFields = ['name', 'description', 'color']; $allowedFields = ['name', 'description', 'color'];
$updateData = array_intersect_key($json, array_flip($allowedFields)); $updateData = array_intersect_key($json, array_flip($allowedFields));
if (empty($updateData)) { if (empty($updateData)) {
return $this->errorResponse('No valid fields to update'); return $this->errorResponse('No valid fields to update');
@@ -98,16 +108,19 @@ class ProjectController extends BaseController
$this->projectModel->update($id, $updateData); $this->projectModel->update($id, $updateData);
$project = $this->projectModel->find($id); $project = $this->projectModel->find($id);
$this->logActivity('project_updated', 'project', $id, [
'name' => $project['name'] ?? 'Unknown',
]);
return $this->successResponse($project, 'Project updated successfully'); return $this->successResponse($project, 'Project updated successfully');
} }
/** /**
* Delete a project
* DELETE /api/v1/projects/{id} * DELETE /api/v1/projects/{id}
*/ */
public function delete($id = null) public function delete($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first(); $project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
if (!$project) { if (!$project) {
@@ -116,18 +129,10 @@ class ProjectController extends BaseController
$this->projectModel->delete($id); $this->projectModel->delete($id);
$this->logActivity('project_deleted', 'project', $id, [
'name' => $project['name'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Project deleted successfully'); return $this->successResponse(null, 'Project deleted successfully');
} }
private function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
} }

View File

@@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController; use App\Controllers\Api\BaseController;
use App\Models\RecurringTaskModel; use App\Models\RecurringTaskModel;
use App\Models\RecurringTaskCategoryModel; use App\Models\RecurringTaskCategoryModel;
use App\Models\CategoryModel;
class RecurringTaskController extends BaseController class RecurringTaskController extends BaseController
{ {
@@ -13,111 +14,147 @@ class RecurringTaskController extends BaseController
public function __construct() public function __construct()
{ {
$this->recurringTaskModel = new RecurringTaskModel(); $this->recurringTaskModel = new RecurringTaskModel();
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel(); $this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
} }
const SORTABLE = ['title', 'schedule', 'created_at'];
const FILTERABLE = ['schedule', 'favorite'];
/** /**
* Get all recurring tasks for the authenticated user
* GET /api/v1/recurring-tasks * GET /api/v1/recurring-tasks
*/ */
public function index() public function index()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId); $filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
return $this->successResponse($tasks, 'Recurring tasks retrieved successfully'); $builder = $this->recurringTaskModel
->select('recurring_tasks.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
->where('recurring_tasks.user_id', $userId)
->groupBy('recurring_tasks.id');
$this->applyFilters($builder, $filters);
if (empty($sorts)) {
$builder->orderBy('recurring_tasks.created_at', 'DESC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Recurring tasks retrieved successfully');
} }
/** /**
* Create a new recurring task
* POST /api/v1/recurring-tasks * POST /api/v1/recurring-tasks
*/ */
public function create() public function create()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = [ if (!$this->validateWithModel($this->recurringTaskModel)) {
'title' => 'required|max_length[255]',
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
];
if (!$this->validateRequest($rules)) {
return; return;
} }
$json = $this->request->getJSON(true);
$data = [ $data = [
'id' => $this->generateUuid(), 'id' => $this->generateUuid(),
'user_id' => $userId, 'user_id' => $userId,
'title' => $json['title'], 'title' => $json['title'],
'description' => $json['description'] ?? null, 'description' => $json['description'] ?? null,
'schedule' => $json['schedule'], 'schedule' => $json['schedule'] ?? 'weekly',
'custom_days' => $json['custom_days'] ? json_encode($json['custom_days']) : json_encode([]), 'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]',
'favorite' => $json['favorite'] ?? false, 'favorite' => !empty($json['favorite']),
]; ];
$this->recurringTaskModel->insert($data); $this->recurringTaskModel->insert($data);
// Link category if provided
if (!empty($json['category_id'])) {
$this->linkCategory($data['id'], $json['category_id']);
}
$this->logActivity('recurring_task_created', 'recurring_task', $data['id'], [
'title' => $data['title'],
]);
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']); $task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']);
return $this->successResponse($task, 'Recurring task created successfully', 201); return $this->successResponse($task[0] ?? null, 'Recurring task created successfully', 201);
} }
/** /**
* Get a specific recurring task
* GET /api/v1/recurring-tasks/{id} * GET /api/v1/recurring-tasks/{id}
*/ */
public function show($id = null) public function show($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); $tasks = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
if (!$task) { if (empty($tasks)) {
return $this->errorResponse('Recurring task not found', 404); return $this->errorResponse('Recurring task not found', 404);
} }
return $this->successResponse($task, 'Recurring task retrieved successfully'); return $this->successResponse($tasks[0], 'Recurring task retrieved successfully');
} }
/** /**
* Update a recurring task
* PUT /api/v1/recurring-tasks/{id} * PUT /api/v1/recurring-tasks/{id}
*/ */
public function update($id = null) public function update($id = null)
{ {
$userId = $this->getUserId(); $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) { if (!$task) {
return $this->errorResponse('Recurring task not found', 404); return $this->errorResponse('Recurring task not found', 404);
} }
$json = $this->request->getJSON(true); $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']); $updateData['custom_days'] = json_encode($updateData['custom_days']);
} }
if (array_key_exists('favorite', $updateData)) {
if (empty($updateData)) { $updateData['favorite'] = !empty($updateData['favorite']);
return $this->errorResponse('No valid fields to update');
} }
$this->recurringTaskModel->update($id, $updateData); if (!empty($updateData)) {
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); $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} * DELETE /api/v1/recurring-tasks/{id}
*/ */
public function delete($id = null) public function delete($id = null)
{ {
$userId = $this->getUserId(); $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) { if (!$task) {
return $this->errorResponse('Recurring task not found', 404); return $this->errorResponse('Recurring task not found', 404);
@@ -125,30 +162,31 @@ class RecurringTaskController extends BaseController
$this->recurringTaskModel->delete($id); $this->recurringTaskModel->delete($id);
$this->logActivity('recurring_task_deleted', 'recurring_task', $id, [
'title' => $task['title'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Recurring task deleted successfully'); return $this->successResponse(null, 'Recurring task deleted successfully');
} }
/** /**
* Add a category to a recurring task
* POST /api/v1/recurring-tasks/{id}/categories * POST /api/v1/recurring-tasks/{id}/categories
*/ */
public function addCategory($taskId = null) public function addCategory($taskId = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$json = $this->request->getJSON(true); $json = $this->request->getJSON(true);
$rules = ['category_id' => 'required']; $rules = ['category_id' => 'required'];
if (!$this->validateRequest($rules)) { if (!$this->validateRequest($rules)) {
return; return;
} }
// Verify task belongs to user
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first(); $task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
if (!$task) { if (!$task) {
return $this->errorResponse('Recurring task not found', 404); return $this->errorResponse('Recurring task not found', 404);
} }
// Check if link already exists
$existing = $this->recurringTaskCategoryModel $existing = $this->recurringTaskCategoryModel
->where('recurring_task_id', $taskId) ->where('recurring_task_id', $taskId)
->where('category_id', $json['category_id']) ->where('category_id', $json['category_id'])
@@ -160,21 +198,19 @@ class RecurringTaskController extends BaseController
$this->recurringTaskCategoryModel->insert([ $this->recurringTaskCategoryModel->insert([
'recurring_task_id' => $taskId, '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); 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} * DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
*/ */
public function removeCategory($taskId = null, $categoryId = null) public function removeCategory($taskId = null, $categoryId = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
// Verify task belongs to user
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first(); $task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
if (!$task) { if (!$task) {
return $this->errorResponse('Recurring task not found', 404); return $this->errorResponse('Recurring task not found', 404);
@@ -188,15 +224,27 @@ class RecurringTaskController extends BaseController
return $this->successResponse(null, 'Category removed from recurring task successfully'); return $this->successResponse(null, 'Category removed from recurring task successfully');
} }
private function generateUuid(): string /**
* Link a category (internal helper)
*/
private function linkCategory(string $taskId, string $categoryId): void
{ {
return sprintf( $userId = $this->getUserId();
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x', $categoryModel = new CategoryModel();
mt_rand(0, 0xffff), mt_rand(0, 0xffff), $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000, if (!$category) return;
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) $existing = $this->recurringTaskCategoryModel
); ->where('recurring_task_id', $taskId)
->where('category_id', $categoryId)
->first();
if (!$existing) {
$this->recurringTaskCategoryModel->insert([
'recurring_task_id' => $taskId,
'category_id' => $categoryId,
]);
}
} }
} }

View File

@@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController; use App\Controllers\Api\BaseController;
use App\Models\TodoModel; use App\Models\TodoModel;
use App\Models\TodoCategoryModel; use App\Models\TodoCategoryModel;
use App\Models\CategoryModel;
class TodoController extends BaseController class TodoController extends BaseController
{ {
@@ -13,90 +14,102 @@ class TodoController extends BaseController
public function __construct() public function __construct()
{ {
$this->todoModel = new TodoModel(); $this->todoModel = new TodoModel();
$this->todoCategoryModel = new TodoCategoryModel(); $this->todoCategoryModel = new TodoCategoryModel();
} }
// ── Allowed sort & filter fields ───────────────────────────────────────
const SORTABLE = ['title', 'status', 'due_date', 'due_time', 'created_at', 'updated_at'];
const FILTERABLE = ['status', 'project_id', 'sync_enabled', 'reminder_enabled', 'recurring_enabled'];
/** /**
* Get all todos for the authenticated user
* GET /api/v1/todos * GET /api/v1/todos
* Paginated, sortable, filterable list of todos for the authenticated user.
*
* Query params:
* page (int) page number, default 1
* per_page (int) items per page, default 50, max 200
* sort (string) e.g. "title" or "-created_at,title"
* status (string) filter by status
* project_id (string) filter by project
*/ */
public function index() public function index()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$todos = $this->todoModel->getByUserWithCategories($userId);
return $this->successResponse($todos, 'Todos retrieved successfully'); $filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
$builder = $this->todoModel
->select('todos.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
->join('categories', 'todo_categories.category_id = categories.id', 'left')
->where('todos.user_id', $userId)
->groupBy('todos.id');
// Apply filters
$this->applyFilters($builder, $filters);
// Apply sorting (default: newest first)
if (empty($sorts)) {
$builder->orderBy('todos.created_at', 'DESC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Todos retrieved successfully');
} }
/** /**
* Create a new todo
* POST /api/v1/todos * POST /api/v1/todos
*/ */
public function create() public function create()
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = [ if (!$this->validateWithModel($this->todoModel)) {
'title' => 'required|max_length[255]',
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
];
if (!$this->validateRequest($rules)) {
return; return;
} }
$json = $this->request->getJSON(true);
$data = [ $data = [
'id' => $this->generateUuid(), 'id' => $this->generateUuid(),
'user_id' => $userId, 'user_id' => $userId,
'title' => $json['title'], 'title' => $json['title'],
'description' => $json['description'] ?? null, 'description' => $json['description'] ?? null,
'status' => $json['status'] ?? 'open', 'status' => $json['status'] ?? 'open',
'due_date' => $json['due_date'] ?? null, 'due_date' => $json['due_date'] ?? null,
'due_time' => $json['due_time'] ?? null, 'due_time' => $json['due_time'] ?? null,
'sync_enabled' => $json['sync_enabled'] ?? true, 'sync_enabled' => !empty($json['sync_enabled']),
'reminder_enabled' => $json['reminder_enabled'] ?? false, 'reminder_enabled' => !empty($json['reminder_enabled']),
'recurring_enabled' => $json['recurring_enabled'] ?? false, 'recurring_enabled' => !empty($json['recurring_enabled']),
'project_id' => $json['project_id'] ?? null, 'project_id' => $json['project_id'] ?? null,
]; ];
$this->todoModel->insert($data); $this->todoModel->insert($data);
// Link category if provided // Link category if provided
if (!empty($json['category_id'])) { if (!empty($json['category_id'])) {
$this->linkCategory($data['id'], $json['category_id']); $this->linkCategory($data['id'], $json['category_id']);
} }
// Manually log the activity $this->logActivity('todo_created', 'todo', $data['id'], [
try { 'title' => $data['title'],
$activityLogModel = new \App\Models\ActivityLogModel(); ]);
$activityLogModel->logActivity([
'user_id' => $userId,
'action' => 'todo_created',
'entity_type' => 'todo',
'entity_id' => $data['id'],
'details' => json_encode(['action' => 'created', 'title' => $data['title']]),
'ip_address' => $this->request->getIPAddress(),
'user_agent' => $this->request->getUserAgent()->getAgentString(),
]);
} catch (\Exception $e) {
log_message('error', 'Failed to log activity: ' . $e->getMessage());
}
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']); $todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201); return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201);
} }
/** /**
* Get a specific todo
* GET /api/v1/todos/{id} * GET /api/v1/todos/{id}
*/ */
public function show($id = null) public function show($id = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$todos = $this->todoModel->getByUserWithCategories($userId, $id); $todos = $this->todoModel->getByUserWithCategories($userId, $id);
if (empty($todos)) { if (empty($todos)) {
return $this->errorResponse('Todo not found', 404); return $this->errorResponse('Todo not found', 404);
@@ -106,116 +119,97 @@ class TodoController extends BaseController
} }
/** /**
* Update a todo
* PUT /api/v1/todos/{id} * PUT /api/v1/todos/{id}
*/ */
public function update($id = null) public function update($id = null)
{ {
$userId = $this->getUserId(); $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) { if (!$todo) {
return $this->errorResponse('Todo not found', 404); return $this->errorResponse('Todo not found', 404);
} }
$json = $this->request->getJSON(true); $json = $this->request->getJSON(true);
// Handle category update separately (not a column in todos table) // Handle category update separately (not a column in todos table)
$hasCategoryUpdate = array_key_exists('category_id', $json); $hasCategoryUpdate = array_key_exists('category_id', $json);
if ($hasCategoryUpdate) { if ($hasCategoryUpdate) {
$categoryId = $json['category_id']; $categoryId = $json['category_id'];
// Remove all existing category links
$this->todoCategoryModel->where('todo_id', $id)->delete(); $this->todoCategoryModel->where('todo_id', $id)->delete();
// Link the new category
if (!empty($categoryId)) { if (!empty($categoryId)) {
$this->linkCategory($id, $categoryId); $this->linkCategory($id, $categoryId);
} }
} }
// Update todo fields // Update todo fields
$allowedFields = ['title', 'description', 'status', 'due_date', 'due_time', 'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id']; $allowedFields = [
'title', 'description', 'status', 'due_date', 'due_time',
'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id',
];
$updateData = array_intersect_key($json, array_flip($allowedFields)); $updateData = array_intersect_key($json, array_flip($allowedFields));
if (!empty($updateData)) { if (!empty($updateData)) {
// Convert boolean-ish values
foreach (['sync_enabled', 'reminder_enabled', 'recurring_enabled'] as $boolField) {
if (array_key_exists($boolField, $updateData)) {
$updateData[$boolField] = !empty($updateData[$boolField]);
}
}
$this->todoModel->update($id, $updateData); $this->todoModel->update($id, $updateData);
} }
// Manually log the activity $this->logActivity('todo_updated', 'todo', $id, [
try { 'title' => $todo['title'] ?? 'Unknown',
$activityLogModel = new \App\Models\ActivityLogModel(); ]);
$activityLogModel->logActivity([
'user_id' => $userId,
'action' => 'todo_updated',
'entity_type' => 'todo',
'entity_id' => $id,
'details' => json_encode(['action' => 'updated', 'title' => $todo['title'] ?? 'Unknown']),
'ip_address' => $this->request->getIPAddress(),
'user_agent' => $this->request->getUserAgent()->getAgentString(),
]);
} catch (\Exception $e) {
log_message('error', 'Failed to log activity: ' . $e->getMessage());
}
$updated = $this->todoModel->getByUserWithCategories($userId, $id); $updated = $this->todoModel->getByUserWithCategories($userId, $id);
return $this->successResponse($updated[0] ?? null, 'Todo updated successfully'); return $this->successResponse($updated[0] ?? null, 'Todo updated successfully');
} }
/** /**
* Delete a todo
* DELETE /api/v1/todos/{id} * DELETE /api/v1/todos/{id}
*/ */
public function delete($id = null) public function delete($id = null)
{ {
$userId = $this->getUserId(); $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) { if (!$todo) {
return $this->errorResponse('Todo not found', 404); return $this->errorResponse('Todo not found', 404);
} }
$this->todoModel->delete($id); $this->todoModel->delete($id);
// Manually log the activity $this->logActivity('todo_deleted', 'todo', $id, [
try { 'title' => $todo['title'] ?? 'Unknown',
$activityLogModel = new \App\Models\ActivityLogModel(); ]);
$activityLogModel->logActivity([
'user_id' => $userId,
'action' => 'todo_deleted',
'entity_type' => 'todo',
'entity_id' => $id,
'details' => json_encode(['action' => 'deleted', 'title' => $todo['title'] ?? 'Unknown']),
'ip_address' => $this->request->getIPAddress(),
'user_agent' => $this->request->getUserAgent()->getAgentString(),
]);
} catch (\Exception $e) {
log_message('error', 'Failed to log activity: ' . $e->getMessage());
}
return $this->successResponse(null, 'Todo deleted successfully'); return $this->successResponse(null, 'Todo deleted successfully');
} }
// ── Category linking ───────────────────────────────────────────────────
/** /**
* Add a category to a todo
* POST /api/v1/todos/{id}/categories * POST /api/v1/todos/{id}/categories
*/ */
public function addCategory($todoId = null) public function addCategory($todoId = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
$json = $this->request->getJSON(true); $json = $this->request->getJSON(true);
$rules = ['category_id' => 'required']; $rules = ['category_id' => 'required'];
if (!$this->validateRequest($rules)) { if (!$this->validateRequest($rules)) {
return; return;
} }
// Verify todo belongs to user
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first(); $todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
if (!$todo) { if (!$todo) {
return $this->errorResponse('Todo not found', 404); return $this->errorResponse('Todo not found', 404);
} }
// Check if link already exists
$existing = $this->todoCategoryModel $existing = $this->todoCategoryModel
->where('todo_id', $todoId) ->where('todo_id', $todoId)
->where('category_id', $json['category_id']) ->where('category_id', $json['category_id'])
@@ -226,7 +220,7 @@ class TodoController extends BaseController
} }
$this->todoCategoryModel->insert([ $this->todoCategoryModel->insert([
'todo_id' => $todoId, 'todo_id' => $todoId,
'category_id' => $json['category_id'], 'category_id' => $json['category_id'],
]); ]);
@@ -234,14 +228,12 @@ class TodoController extends BaseController
} }
/** /**
* Remove a category from a todo
* DELETE /api/v1/todos/{id}/categories/{categoryId} * DELETE /api/v1/todos/{id}/categories/{categoryId}
*/ */
public function removeCategory($todoId = null, $categoryId = null) public function removeCategory($todoId = null, $categoryId = null)
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
// Verify todo belongs to user
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first(); $todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
if (!$todo) { if (!$todo) {
return $this->errorResponse('Todo not found', 404); return $this->errorResponse('Todo not found', 404);
@@ -256,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 private function linkCategory(string $todoId, string $categoryId): void
{ {
$userId = $this->getUserId(); $userId = $this->getUserId();
// Verify category belongs to user $categoryModel = new CategoryModel();
$categoryModel = new \App\Models\CategoryModel(); $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
if (!$category) { if (!$category) {
return; return;
} }
// Check if link already exists
$existing = $this->todoCategoryModel $existing = $this->todoCategoryModel
->where('todo_id', $todoId) ->where('todo_id', $todoId)
->where('category_id', $categoryId) ->where('category_id', $categoryId)
->first(); ->first();
if (!$existing) { if (!$existing) {
$this->todoCategoryModel->insert([ $this->todoCategoryModel->insert([
'todo_id' => $todoId, 'todo_id' => $todoId,
'category_id' => $categoryId, 'category_id' => $categoryId,
]); ]);
} }
} }
private function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
} }

View File

@@ -106,15 +106,5 @@ class UserThemeController extends BaseController
return $this->successResponse(null, 'User theme deleted successfully'); return $this->successResponse(null, 'User theme deleted successfully');
} }
private function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
} }

View File

@@ -6,26 +6,38 @@ use CodeIgniter\Model;
class CategoryModel extends Model class CategoryModel extends Model
{ {
protected $table = 'categories'; protected $table = 'categories';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = false; protected $useAutoIncrement = false;
protected $returnType = 'array'; protected $returnType = 'array';
protected $useSoftDeletes = false; protected $useSoftDeletes = false;
protected $allowedFields = [ protected $allowedFields = [
'id', 'id', 'user_id', 'name', 'color', 'favorite', 'created_at',
'user_id',
'name',
'color',
'favorite',
'created_at',
]; ];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'created_at'; protected $createdField = 'created_at';
protected $updatedField = ''; protected $updatedField = '';
protected $validationRules = [ protected $validationRules = [
'user_id' => 'required', 'user_id' => [
'name' => 'required|max_length[255]', 'rules' => 'required',
'errors' => ['required' => 'User ID is required.'],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The category name is required.',
'max_length' => 'The category name must not exceed 255 characters.',
],
],
'color' => [
'rules' => 'required|max_length[7]|regex_match[/^#[0-9a-fA-F]{6}$/]',
'errors' => [
'required' => 'A color value is required.',
'max_length' => 'Color must be a hex code (e.g. #3B82F6).',
'regex_match' => 'Color must be a valid hex code (e.g. #3B82F6).',
],
],
]; ];
} }

View File

@@ -6,26 +6,30 @@ use CodeIgniter\Model;
class ProjectModel extends Model class ProjectModel extends Model
{ {
protected $table = 'projects'; protected $table = 'projects';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = false; protected $useAutoIncrement = false;
protected $returnType = 'array'; protected $returnType = 'array';
protected $useSoftDeletes = false; protected $useSoftDeletes = false;
protected $allowedFields = [ protected $allowedFields = [
'id', 'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at',
'user_id',
'name',
'description',
'color',
'created_at',
]; ];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'created_at'; protected $createdField = 'created_at';
protected $updatedField = ''; protected $updatedField = '';
protected $validationRules = [ protected $validationRules = [
'user_id' => 'required', 'user_id' => [
'name' => 'required|max_length[255]', 'rules' => 'required',
'errors' => ['required' => 'User ID is required.'],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The project name is required.',
'max_length' => 'The project name must not exceed 255 characters.',
],
],
]; ];
} }

View File

@@ -6,40 +6,53 @@ use CodeIgniter\Model;
class RecurringTaskModel extends Model class RecurringTaskModel extends Model
{ {
protected $table = 'recurring_tasks'; protected $table = 'recurring_tasks';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = false; protected $useAutoIncrement = false;
protected $returnType = 'array'; protected $returnType = 'array';
protected $useSoftDeletes = false; protected $useSoftDeletes = false;
protected $allowedFields = [ protected $allowedFields = [
'id', 'id', 'user_id', 'title', 'description', 'schedule',
'user_id', 'custom_days', 'favorite', 'created_at', 'updated_at',
'title',
'description',
'schedule',
'custom_days',
'favorite',
'created_at',
'updated_at',
]; ];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'created_at'; protected $createdField = 'created_at';
protected $updatedField = 'updated_at'; protected $updatedField = 'updated_at';
protected $validationRules = [ protected $validationRules = [
'user_id' => 'required', 'user_id' => [
'title' => 'required|max_length[255]', 'rules' => 'required',
'schedule' => 'required|in_list[daily,weekly,monthly,custom]', 'errors' => ['required' => 'User ID is required.'],
],
'title' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The recurring task title is required.',
'max_length' => 'The title must not exceed 255 characters.',
],
],
'schedule' => [
'rules' => 'permit_empty|in_list[daily,weekly,monthly,custom]',
'errors' => [
'in_list' => 'Schedule must be one of: daily, weekly, monthly, custom.',
],
],
]; ];
// Get recurring tasks with categories // ── Queries ────────────────────────────────────────────────────────────
public function getWithCategories($taskId = null)
public function getByUserWithCategories($userId, $taskId = null)
{ {
$builder = $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names') $builder = $this->select('
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left') recurring_tasks.*,
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left') GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
->groupBy('recurring_tasks.id'); 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) { if ($taskId) {
$builder->where('recurring_tasks.id', $taskId); $builder->where('recurring_tasks.id', $taskId);
@@ -47,16 +60,4 @@ class RecurringTaskModel extends Model
return $builder->get()->getResultArray(); return $builder->get()->getResultArray();
} }
// Get recurring tasks by user with categories
public function getByUserWithCategories($userId)
{
return $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
->where('recurring_tasks.user_id', $userId)
->groupBy('recurring_tasks.id')
->get()
->getResultArray();
}
} }

View File

@@ -6,53 +6,51 @@ use CodeIgniter\Model;
class TodoModel extends Model class TodoModel extends Model
{ {
protected $table = 'todos'; protected $table = 'todos';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = false; protected $useAutoIncrement = false;
protected $returnType = 'array'; protected $returnType = 'array';
protected $useSoftDeletes = false; protected $useSoftDeletes = false;
protected $allowedFields = [ protected $allowedFields = [
'id', 'id', 'user_id', 'title', 'description', 'status',
'user_id', 'due_date', 'due_time', 'sync_enabled', 'reminder_enabled',
'title', 'recurring_enabled', 'project_id', 'created_at', 'updated_at',
'description',
'status',
'due_date',
'due_time',
'sync_enabled',
'reminder_enabled',
'recurring_enabled',
'project_id',
'created_at',
'updated_at',
]; ];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'created_at'; protected $createdField = 'created_at';
protected $updatedField = 'updated_at'; protected $updatedField = 'updated_at';
protected $validationRules = [ protected $validationRules = [
'user_id' => 'required', 'user_id' => [
'title' => 'required|max_length[255]', 'rules' => 'required',
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]', 'errors' => ['required' => 'User ID is required.'],
],
'title' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The todo title is required.',
'max_length' => 'The title must not exceed 255 characters.',
],
],
'status' => [
'rules' => 'permit_empty|in_list[open,in_progress,completed,archived]',
'errors' => [
'in_list' => 'Status must be one of: open, in_progress, completed, archived.',
],
],
'due_date' => [
'rules' => 'permit_empty|valid_date[Y-m-d]',
'errors' => ['valid_date' => 'Due date must be in YYYY-MM-DD format.'],
],
'due_time' => [
'rules' => 'permit_empty|valid_date[H:i:s]',
'errors' => ['valid_date' => 'Due time must be in HH:MM format.'],
],
]; ];
// Get todos with categories // ── Queries ────────────────────────────────────────────────────────────
public function getWithCategories($todoId = null)
{
$builder = $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names')
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
->join('categories', 'todo_categories.category_id = categories.id', 'left')
->groupBy('todos.id');
if ($todoId) {
$builder->where('todos.id', $todoId);
}
return $builder->get()->getResultArray();
}
// Get todos by user with categories (optionally filtered by todo id)
public function getByUserWithCategories($userId, $todoId = null) public function getByUserWithCategories($userId, $todoId = null)
{ {
$builder = $this->select(' $builder = $this->select('

View File

@@ -6,36 +6,35 @@ use CodeIgniter\Model;
class UserModel extends Model class UserModel extends Model
{ {
protected $table = 'users'; protected $table = 'users';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = false; protected $useAutoIncrement = false;
protected $returnType = 'array'; protected $returnType = 'array';
protected $useSoftDeletes = false; protected $useSoftDeletes = false;
protected $allowedFields = [ protected $allowedFields = [
'id', 'id', 'email', 'password_hash', 'name', 'avatar_url',
'email', 'settings', 'created_at', 'updated_at',
'password_hash',
'name',
'avatar_url',
'settings',
'created_at',
'updated_at',
]; ];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $createdField = 'created_at'; protected $createdField = 'created_at';
protected $updatedField = 'updated_at'; protected $updatedField = 'updated_at';
protected $validationRules = [ protected $validationRules = [
'email' => 'required|valid_email|is_unique[users.email]',
'password_hash' => 'required',
];
protected $validationMessages = [
'email' => [ 'email' => [
'required' => 'Email is required', 'rules' => 'required|valid_email|is_unique[users.email]',
'valid_email' => 'Please enter a valid email address', 'errors' => [
'is_unique' => 'This email is already registered', 'required' => 'Email is required.',
'valid_email' => 'Please provide a valid email address.',
'is_unique' => 'This email is already registered.',
],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'Name is required.',
'max_length' => 'Name must not exceed 255 characters.',
],
], ],
]; ];
} }

View File

@@ -11,7 +11,8 @@
}, },
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"codeigniter4/framework": "^4.7" "codeigniter4/framework": "^4.7",
"firebase/php-jwt": "^7.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.9", "fakerphp/faker": "^1.9",

66
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f5cce40800fa5dae1504b9364f585e6a", "content-hash": "86520263c0a2df285d17beea23def54d",
"packages": [ "packages": [
{ {
"name": "codeigniter4/framework", "name": "codeigniter4/framework",
@@ -83,6 +83,70 @@
}, },
"time": "2026-03-24T18:26:09+00:00" "time": "2026-03-24T18:26:09+00:00"
}, },
{
"name": "firebase/php-jwt",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
"time": "2026-04-01T20:38:03+00:00"
},
{ {
"name": "laminas/laminas-escaper", "name": "laminas/laminas-escaper",
"version": "2.18.0", "version": "2.18.0",

2186
openapi/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
<phpunit <phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php" bootstrap="tests/bootstrap.php"
backupGlobals="false" backupGlobals="false"
beStrictAboutOutputDuringTests="true" beStrictAboutOutputDuringTests="true"
colors="true" colors="true"
@@ -25,6 +25,8 @@
<testsuites> <testsuites>
<testsuite name="App"> <testsuite name="App">
<directory>./tests</directory> <directory>./tests</directory>
<exclude>./tests/database</exclude>
<exclude>./tests/session</exclude>
</testsuite> </testsuite>
</testsuites> </testsuites>
<logging> <logging>
@@ -51,13 +53,18 @@
<!-- Directory containing the front controller (index.php) --> <!-- Directory containing the front controller (index.php) -->
<const name="PUBLICPATH" value="./public/"/> <const name="PUBLICPATH" value="./public/"/>
<!-- Database configuration --> <!-- Database configuration -->
<!-- Uncomment to provide your own database for testing <!-- MySQLi test database matching the .env credentials.
<env name="database.tests.hostname" value="localhost"/> The tests group defaults to SQLite3 which is not available.
<env name="database.tests.database" value="tests"/> Change the database name to a separate test DB to avoid
<env name="database.tests.username" value="tests_user"/> overwriting live data. -->
<env name="database.tests.password" value=""/> <env name="database.tests.hostname" value="127.0.0.1"/>
<env name="database.tests.DBDriver" value="MySQLi"/> <env name="database.tests.database" value="TodoApp"/>
<env name="database.tests.DBPrefix" value="tests_"/> <env name="database.tests.username" value="root"/>
--> <env name="database.tests.password" value=""/>
<env name="database.tests.DBDriver" value="MySQLi"/>
<env name="database.tests.DBPrefix" value=""/>
<!-- Note: DBPrefix removed so queries match the migrated tables.
CI4 default tests DBPrefix = "db_" would query db_users,
db_todos etc. which don't exist on this database. -->
</php> </php>
</phpunit> </phpunit>

251
tests/api/ModelTest.php Normal file
View File

@@ -0,0 +1,251 @@
<?php
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\TodoModel;
use App\Models\CategoryModel;
use App\Models\ProjectModel;
use App\Models\UserModel;
/**
* Model Unit Tests
*
* Tests the Todo App Backend models directly.
* Requires a working MySQL database with migrations applied.
*
* @internal
*/
final class ModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = false;
protected $refresh = false;
private static string $userId = '';
private static string $todoId = '';
private static string $categoryId = '';
private static string $projectId = '';
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
// Pick the first real user from the database
$userModel = new UserModel();
$user = $userModel->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)
);
}
}

19
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
/**
* Test Bootstrap
*
* Sets up the testing environment before the framework boots.
* Disables the debug toolbar that would wrap JSON API responses in HTML.
* The framework's own Test/bootstrap.php handles ENVIRONMENT constant.
*/
// Disable debug toolbar before the framework defines CI_DEBUG
defined('CI_DEBUG') || define('CI_DEBUG', false);
// Ensure CI_ENVIRONMENT env var is set (framework checks this)
putenv('CI_ENVIRONMENT=testing');
$_SERVER['CI_ENVIRONMENT'] = 'testing';
// Load the framework's test bootstrap (defines ENVIRONMENT constant)
require __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';