mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Compare commits
7 Commits
bf05b5d295
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b30c66307 | ||
|
|
d038ebc2e3 | ||
|
|
e19828868f | ||
|
|
3615d029ea | ||
|
|
f01e04fbad | ||
|
|
3ab93381f5 | ||
|
|
02f77a15a7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -128,3 +128,6 @@ _modules/*
|
|||||||
env
|
env
|
||||||
.claude/
|
.claude/
|
||||||
.claude/*
|
.claude/*
|
||||||
|
|
||||||
|
# Generated docs
|
||||||
|
/public/api-docs.html
|
||||||
@@ -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
553
README.md
@@ -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).
|
||||||
|
|||||||
208
app/Commands/GenerateApiDocs.php
Normal file
208
app/Commands/GenerateApiDocs.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ use CodeIgniter\Router\RouteCollection;
|
|||||||
/**
|
/**
|
||||||
* @var RouteCollection $routes
|
* @var RouteCollection $routes
|
||||||
*/
|
*/
|
||||||
$routes->get('/', 'Home::index');
|
$routes->get('/', static function () {
|
||||||
|
return redirect()->to('/themes');
|
||||||
|
});
|
||||||
$routes->get('/themes', 'ThemeStore::index');
|
$routes->get('/themes', 'ThemeStore::index');
|
||||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||||
@@ -36,6 +38,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)
|
||||||
@@ -91,18 +98,20 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => [
|
|||||||
});
|
});
|
||||||
$routes->get('/themes', 'ThemeStore::index');
|
$routes->get('/themes', 'ThemeStore::index');
|
||||||
$routes->options('/themes', static function () {
|
$routes->options('/themes', static function () {
|
||||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||||
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||||
header('Access-Control-Allow-Credentials: true');
|
header('Vary: Origin');
|
||||||
return response()->setStatusCode(204);
|
return response()->setStatusCode(204);
|
||||||
});
|
});
|
||||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||||
$routes->options('/themes/upload', static function () {
|
$routes->options('/themes/upload', static function () {
|
||||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||||
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||||
header('Access-Control-Allow-Credentials: true');
|
header('Vary: Origin');
|
||||||
return response()->setStatusCode(204);
|
return response()->setStatusCode(204);
|
||||||
});
|
});
|
||||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||||
|
|||||||
@@ -23,35 +23,160 @@ class BaseController extends ResourceController
|
|||||||
return $user['id'] ?? null;
|
return $user['id'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Pagination & Sorting
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pagination params from the query string.
|
||||||
|
*
|
||||||
|
* Returns [page, perPage].
|
||||||
|
* Default: page=1, perPage=50. Max perPage = 200.
|
||||||
|
*/
|
||||||
|
protected function getPaginationParams(): array
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $this->request->getGet('page'));
|
||||||
|
$perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 50)));
|
||||||
|
return [$page, $perPage];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract allowed sort params from the query string.
|
||||||
|
*
|
||||||
|
* ?sort=title,-created_at → ASC on title, DESC on created_at
|
||||||
|
* Only fields listed in $allowed will be accepted.
|
||||||
|
*/
|
||||||
|
protected function getSortParams(array $allowed = []): array
|
||||||
|
{
|
||||||
|
$raw = $this->request->getGet('sort');
|
||||||
|
if (empty($raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode(',', $raw);
|
||||||
|
$sorts = [];
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if (empty($part)) continue;
|
||||||
|
|
||||||
|
$dir = 'ASC';
|
||||||
|
if ($part[0] === '-') {
|
||||||
|
$dir = 'DESC';
|
||||||
|
$part = substr($part, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($part, $allowed, true)) {
|
||||||
|
$sorts[$part] = $dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sorts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract allowed filter params from the query string.
|
||||||
|
*
|
||||||
|
* ?status=open&favorite=1
|
||||||
|
* Only fields listed in $allowed will be accepted.
|
||||||
|
*/
|
||||||
|
protected function getFilterParams(array $allowed = []): array
|
||||||
|
{
|
||||||
|
$filters = [];
|
||||||
|
|
||||||
|
foreach ($allowed as $field) {
|
||||||
|
$value = $this->request->getGet($field);
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$filters[$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply sorting to a model query builder.
|
||||||
|
*/
|
||||||
|
protected function applySort($query, array $sorts): void
|
||||||
|
{
|
||||||
|
foreach ($sorts as $field => $dir) {
|
||||||
|
$query->orderBy($field, $dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply filters to a model query builder (simple WHERE).
|
||||||
|
*/
|
||||||
|
protected function applyFilters($query, array $filters): void
|
||||||
|
{
|
||||||
|
foreach ($filters as $field => $value) {
|
||||||
|
$query->where($field, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a paginated response with meta information.
|
||||||
|
*/
|
||||||
|
protected function paginatedResponse($query, string $message = 'Success', int $statusCode = 200)
|
||||||
|
{
|
||||||
|
[$page, $perPage] = $this->getPaginationParams();
|
||||||
|
|
||||||
|
$total = $query->countAllResults(false);
|
||||||
|
$data = $query->get($perPage, ($page - 1) * $perPage)->getResultArray();
|
||||||
|
$lastPage = (int) ceil($total / max($perPage, 1));
|
||||||
|
|
||||||
|
return $this->successResponse($data, $message, $statusCode, [
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'last_page' => $lastPage,
|
||||||
|
'has_more' => $page < $lastPage,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Success / Error Responses
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Success response
|
* Success response
|
||||||
*/
|
*/
|
||||||
protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200)
|
protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200, array $extraMeta = [])
|
||||||
{
|
{
|
||||||
|
$body = [
|
||||||
|
'success' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($extraMeta)) {
|
||||||
|
foreach ($extraMeta as $key => $value) {
|
||||||
|
$body[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $this->response
|
return $this->response
|
||||||
->setStatusCode($statusCode)
|
->setStatusCode($statusCode)
|
||||||
->setHeader('Access-Control-Allow-Origin', '*')
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||||
->setJSON([
|
->setJSON($body);
|
||||||
'success' => true,
|
|
||||||
'message' => $message,
|
|
||||||
'data' => $data,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error response
|
* Error response with structured error info
|
||||||
*/
|
*/
|
||||||
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
|
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
|
||||||
{
|
{
|
||||||
$response = [
|
$body = [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($errors !== null) {
|
if ($errors !== null) {
|
||||||
$response['errors'] = $errors;
|
$body['errors'] = $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->response
|
return $this->response
|
||||||
@@ -59,17 +184,61 @@ class BaseController extends ResourceController
|
|||||||
->setHeader('Access-Control-Allow-Origin', '*')
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||||
->setJSON($response);
|
->setJSON($body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate request data
|
* Validation error shorthand (422)
|
||||||
|
*/
|
||||||
|
protected function validationErrorResponse($errors): void
|
||||||
|
{
|
||||||
|
$this->errorResponse('Validation failed', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not found shorthand (404)
|
||||||
|
*/
|
||||||
|
protected function notFoundResponse(string $resource = 'Resource'): void
|
||||||
|
{
|
||||||
|
$this->errorResponse("{$resource} not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Validation
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate request data using the rules defined in a model.
|
||||||
|
*
|
||||||
|
* Returns true on success, sends a 422 JSON response and returns false on failure.
|
||||||
|
*/
|
||||||
|
protected function validateWithModel(\CodeIgniter\Model $model): bool
|
||||||
|
{
|
||||||
|
$validation = \Config\Services::validation();
|
||||||
|
$rules = $model->getValidationRules();
|
||||||
|
$errors = $model->getValidationMessages();
|
||||||
|
|
||||||
|
if (empty($rules)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validation->setRules($rules, $errors);
|
||||||
|
|
||||||
|
if (!$validation->withRequest($this->request)->run()) {
|
||||||
|
$this->errorResponse('Validation failed', 422, $validation->getErrors());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy simple validation for controllers that define rules inline.
|
||||||
*/
|
*/
|
||||||
protected function validateRequest(array $rules): bool
|
protected function validateRequest(array $rules): bool
|
||||||
{
|
{
|
||||||
$validation = \Config\Services::validation();
|
$validation = \Config\Services::validation();
|
||||||
|
|
||||||
// Handle both old format (string) and new format (array with rules/errors)
|
|
||||||
foreach ($rules as $field => $rule) {
|
foreach ($rules as $field => $rule) {
|
||||||
if (is_array($rule) && isset($rule['rules'])) {
|
if (is_array($rule) && isset($rule['rules'])) {
|
||||||
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
|
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
|
||||||
@@ -85,4 +254,106 @@ class BaseController extends ResourceController
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Activity Logging
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an activity to the activity_logs table.
|
||||||
|
* Safe — catches and logs errors silently so the main request is never broken.
|
||||||
|
*/
|
||||||
|
protected function logActivity(string $action, string $entityType, ?string $entityId, ?array $details = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$logModel = new \App\Models\ActivityLogModel();
|
||||||
|
|
||||||
|
$logModel->logActivity([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'action' => $action,
|
||||||
|
'entity_type' => $entityType,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'details' => $details ? json_encode($details) : null,
|
||||||
|
'ip_address' => $this->request->getIPAddress(),
|
||||||
|
'user_agent' => $this->request->getUserAgent()->getAgentString(),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// JWT helpers
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT secret key — should be read from env in production.
|
||||||
|
*/
|
||||||
|
protected function getJwtSecret(): string
|
||||||
|
{
|
||||||
|
return $_ENV['JWT_SECRET'] ?? 'todo-app-jwt-secret-change-in-production';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a JWT from the Authorization header.
|
||||||
|
* Returns the payload on success, null on failure.
|
||||||
|
*/
|
||||||
|
protected function decodeJwtFromRequest(): ?array
|
||||||
|
{
|
||||||
|
$header = $this->request->getHeaderLine('Authorization');
|
||||||
|
if (empty($header)) return null;
|
||||||
|
|
||||||
|
if (strpos($header, 'Bearer ') !== 0) return null;
|
||||||
|
|
||||||
|
$token = substr($header, 7);
|
||||||
|
return $this->decodeJwt($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode and verify a JWT token.
|
||||||
|
*/
|
||||||
|
protected function decodeJwt(string $token): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$key = $this->getJwtSecret();
|
||||||
|
$jwt = \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($key, 'HS256'));
|
||||||
|
return (array) $jwt;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('error', 'JWT decode failed: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a payload into a JWT token.
|
||||||
|
*/
|
||||||
|
protected function encodeJwt(array $payload): string
|
||||||
|
{
|
||||||
|
$key = $this->getJwtSecret();
|
||||||
|
$issuedAt = time();
|
||||||
|
$payload['iat'] = $issuedAt;
|
||||||
|
$payload['exp'] = $issuedAt + 3600; // 1 hour default
|
||||||
|
|
||||||
|
return \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Helpers
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate UUID v4
|
||||||
|
*/
|
||||||
|
protected function generateUuid(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,18 +221,163 @@ class AuthController extends BaseController
|
|||||||
], 'API key created successfully');
|
], 'API key created successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// JWT Authentication
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user and return JWT + API key
|
||||||
|
* POST /api/v1/auth/jwt/register
|
||||||
|
*/
|
||||||
|
public function jwtRegister()
|
||||||
|
{
|
||||||
|
// Reuse the existing register validation logic
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'email' => [
|
||||||
|
'rules' => 'required|valid_email|is_unique[users.email]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Email is required',
|
||||||
|
'valid_email' => 'Please provide a valid email address',
|
||||||
|
'is_unique' => 'This email is already registered',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'password' => [
|
||||||
|
'rules' => 'required|min_length[8]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Password is required',
|
||||||
|
'min_length' => 'Password must be at least 8 characters long',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Name is required',
|
||||||
|
'max_length' => 'Name must not exceed 255 characters',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userId = $this->generateUuid();
|
||||||
|
|
||||||
|
$userData = [
|
||||||
|
'id' => $userId,
|
||||||
|
'email' => $json['email'],
|
||||||
|
'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT),
|
||||||
|
'name' => $json['name'],
|
||||||
|
'avatar_url' => $json['avatar_url'] ?? null,
|
||||||
|
'settings' => isset($json['settings']) ? json_encode($json['settings']) : json_encode(['theme' => 'light']),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->userModel->insert($userData);
|
||||||
|
|
||||||
|
// Create API key for the new user
|
||||||
|
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||||
|
$userId,
|
||||||
|
'Default API Key',
|
||||||
|
['read', 'write'],
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
$jwt = $this->encodeJwt([
|
||||||
|
'sub' => $userId,
|
||||||
|
'email' => $json['email'],
|
||||||
|
'name' => $json['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
unset($userData['password_hash']);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'user' => $userData,
|
||||||
|
'token' => $jwt,
|
||||||
|
'api_key' => $apiKey['key'],
|
||||||
|
], 'User registered successfully', 201);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse('Registration failed: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with email/password and return JWT
|
||||||
|
* POST /api/v1/auth/jwt/login
|
||||||
|
*/
|
||||||
|
public function jwtLogin()
|
||||||
|
{
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'email' => 'required|valid_email',
|
||||||
|
'password' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $this->userModel->where('email', $json['email'])->first();
|
||||||
|
|
||||||
|
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
|
||||||
|
return $this->errorResponse('Invalid email or password', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
$jwt = $this->encodeJwt([
|
||||||
|
'sub' => $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'name' => $user['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'name' => $user['name'],
|
||||||
|
],
|
||||||
|
'token' => $jwt,
|
||||||
|
], 'Login successful');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse('Login failed: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh JWT token
|
||||||
|
* POST /api/v1/auth/jwt/refresh
|
||||||
|
*/
|
||||||
|
public function jwtRefresh()
|
||||||
|
{
|
||||||
|
$payload = $this->decodeJwtFromRequest();
|
||||||
|
|
||||||
|
if (!$payload || empty($payload['sub'])) {
|
||||||
|
return $this->errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userModel->find($payload['sub']);
|
||||||
|
if (!$user) {
|
||||||
|
return $this->errorResponse('User not found', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jwt = $this->encodeJwt([
|
||||||
|
'sub' => $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'name' => $user['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse(['token' => $jwt], 'Token refreshed successfully');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate UUID
|
* Generate UUID
|
||||||
*/
|
*/
|
||||||
private function generateUuid(): string
|
|
||||||
{
|
|
||||||
return sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,37 +14,44 @@ class CategoryController extends BaseController
|
|||||||
$this->categoryModel = new CategoryModel();
|
$this->categoryModel = new CategoryModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['name', 'created_at'];
|
||||||
|
const FILTERABLE = ['favorite'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all categories for the authenticated user
|
|
||||||
* GET /api/v1/categories
|
* GET /api/v1/categories
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$categories = $this->categoryModel->where('user_id', $userId)->findAll();
|
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||||
|
$sorts = $this->getSortParams(self::SORTABLE);
|
||||||
|
|
||||||
return $this->successResponse($categories, 'Categories retrieved successfully');
|
$builder = $this->categoryModel->where('user_id', $userId);
|
||||||
|
$this->applyFilters($builder, $filters);
|
||||||
|
|
||||||
|
if (empty($sorts)) {
|
||||||
|
$builder->orderBy('name', 'ASC');
|
||||||
|
} else {
|
||||||
|
$this->applySort($builder, $sorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->paginatedResponse($builder, 'Categories retrieved successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new category
|
|
||||||
* POST /api/v1/categories
|
* POST /api/v1/categories
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->categoryModel)) {
|
||||||
'name' => 'required|max_length[255]',
|
|
||||||
'color' => 'required|max_length[7]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate name per user
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// Custom duplicate check (per user)
|
||||||
$existing = $this->categoryModel
|
$existing = $this->categoryModel
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->where('name', $json['name'])
|
->where('name', $json['name'])
|
||||||
@@ -59,17 +66,20 @@ class CategoryController extends BaseController
|
|||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'name' => $json['name'],
|
'name' => $json['name'],
|
||||||
'color' => $json['color'],
|
'color' => $json['color'],
|
||||||
'favorite' => $json['favorite'] ?? false,
|
'favorite' => !empty($json['favorite']),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->categoryModel->insert($data);
|
$this->categoryModel->insert($data);
|
||||||
$category = $this->categoryModel->find($data['id']);
|
$category = $this->categoryModel->find($data['id']);
|
||||||
|
|
||||||
|
$this->logActivity('category_created', 'category', $data['id'], [
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($category, 'Category created successfully', 201);
|
return $this->successResponse($category, 'Category created successfully', 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific category
|
|
||||||
* GET /api/v1/categories/{id}
|
* GET /api/v1/categories/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
@@ -85,7 +95,6 @@ class CategoryController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a category
|
|
||||||
* PUT /api/v1/categories/{id}
|
* PUT /api/v1/categories/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -99,7 +108,7 @@ class CategoryController extends BaseController
|
|||||||
|
|
||||||
$json = $this->request->getJSON(true);
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
// Check for duplicate name on rename (excluding current category)
|
// Duplicate check on rename
|
||||||
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
|
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
|
||||||
$existing = $this->categoryModel
|
$existing = $this->categoryModel
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
@@ -119,14 +128,22 @@ class CategoryController extends BaseController
|
|||||||
return $this->errorResponse('No valid fields to update');
|
return $this->errorResponse('No valid fields to update');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert boolean
|
||||||
|
if (array_key_exists('favorite', $updateData)) {
|
||||||
|
$updateData['favorite'] = !empty($updateData['favorite']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->categoryModel->update($id, $updateData);
|
$this->categoryModel->update($id, $updateData);
|
||||||
$category = $this->categoryModel->find($id);
|
$category = $this->categoryModel->find($id);
|
||||||
|
|
||||||
|
$this->logActivity('category_updated', 'category', $id, [
|
||||||
|
'name' => $category['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($category, 'Category updated successfully');
|
return $this->successResponse($category, 'Category updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a category
|
|
||||||
* DELETE /api/v1/categories/{id}
|
* DELETE /api/v1/categories/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -140,18 +157,10 @@ class CategoryController extends BaseController
|
|||||||
|
|
||||||
$this->categoryModel->delete($id);
|
$this->categoryModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('category_deleted', 'category', $id, [
|
||||||
|
'name' => $category['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse(null, 'Category deleted successfully');
|
return $this->successResponse(null, 'Category deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateUuid(): string
|
|
||||||
{
|
|
||||||
return sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,52 +14,62 @@ class ProjectController extends BaseController
|
|||||||
$this->projectModel = new ProjectModel();
|
$this->projectModel = new ProjectModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['name', 'created_at'];
|
||||||
|
const FILTERABLE = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all projects for the authenticated user
|
|
||||||
* GET /api/v1/projects
|
* GET /api/v1/projects
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$projects = $this->projectModel->where('user_id', $userId)->findAll();
|
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||||
|
$sorts = $this->getSortParams(self::SORTABLE);
|
||||||
|
|
||||||
return $this->successResponse($projects, 'Projects retrieved successfully');
|
$builder = $this->projectModel->where('user_id', $userId);
|
||||||
|
$this->applyFilters($builder, $filters);
|
||||||
|
|
||||||
|
if (empty($sorts)) {
|
||||||
|
$builder->orderBy('created_at', 'DESC');
|
||||||
|
} else {
|
||||||
|
$this->applySort($builder, $sorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->paginatedResponse($builder, 'Projects retrieved successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new project
|
|
||||||
* POST /api/v1/projects
|
* POST /api/v1/projects
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->projectModel)) {
|
||||||
'name' => 'required|max_length[255]',
|
|
||||||
'color' => 'required|max_length[7]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $this->generateUuid(),
|
'id' => $this->generateUuid(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'name' => $json['name'],
|
'name' => $json['name'],
|
||||||
'description' => $json['description'] ?? null,
|
'description' => $json['description'] ?? null,
|
||||||
'color' => $json['color'],
|
'color' => $json['color'] ?? '#8B5CF6',
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->projectModel->insert($data);
|
$this->projectModel->insert($data);
|
||||||
$project = $this->projectModel->find($data['id']);
|
$project = $this->projectModel->find($data['id']);
|
||||||
|
|
||||||
|
$this->logActivity('project_created', 'project', $data['id'], [
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($project, 'Project created successfully', 201);
|
return $this->successResponse($project, 'Project created successfully', 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific project
|
|
||||||
* GET /api/v1/projects/{id}
|
* GET /api/v1/projects/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
@@ -75,7 +85,6 @@ class ProjectController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a project
|
|
||||||
* PUT /api/v1/projects/{id}
|
* PUT /api/v1/projects/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -88,6 +97,7 @@ class ProjectController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$json = $this->request->getJSON(true);
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$allowedFields = ['name', 'description', 'color'];
|
$allowedFields = ['name', 'description', 'color'];
|
||||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
@@ -98,11 +108,14 @@ class ProjectController extends BaseController
|
|||||||
$this->projectModel->update($id, $updateData);
|
$this->projectModel->update($id, $updateData);
|
||||||
$project = $this->projectModel->find($id);
|
$project = $this->projectModel->find($id);
|
||||||
|
|
||||||
|
$this->logActivity('project_updated', 'project', $id, [
|
||||||
|
'name' => $project['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse($project, 'Project updated successfully');
|
return $this->successResponse($project, 'Project updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a project
|
|
||||||
* DELETE /api/v1/projects/{id}
|
* DELETE /api/v1/projects/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -116,18 +129,10 @@ class ProjectController extends BaseController
|
|||||||
|
|
||||||
$this->projectModel->delete($id);
|
$this->projectModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('project_deleted', 'project', $id, [
|
||||||
|
'name' => $project['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse(null, 'Project deleted successfully');
|
return $this->successResponse(null, 'Project deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateUuid(): string
|
|
||||||
{
|
|
||||||
return sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1;
|
|||||||
use App\Controllers\Api\BaseController;
|
use App\Controllers\Api\BaseController;
|
||||||
use App\Models\RecurringTaskModel;
|
use App\Models\RecurringTaskModel;
|
||||||
use App\Models\RecurringTaskCategoryModel;
|
use App\Models\RecurringTaskCategoryModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
|
||||||
class RecurringTaskController extends BaseController
|
class RecurringTaskController extends BaseController
|
||||||
{
|
{
|
||||||
@@ -17,70 +18,91 @@ class RecurringTaskController extends BaseController
|
|||||||
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
|
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['title', 'schedule', 'created_at'];
|
||||||
|
const FILTERABLE = ['schedule', 'favorite'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all recurring tasks for the authenticated user
|
|
||||||
* GET /api/v1/recurring-tasks
|
* GET /api/v1/recurring-tasks
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId);
|
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||||
|
$sorts = $this->getSortParams(self::SORTABLE);
|
||||||
|
|
||||||
return $this->successResponse($tasks, 'Recurring tasks retrieved successfully');
|
$builder = $this->recurringTaskModel
|
||||||
|
->select('recurring_tasks.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
|
||||||
|
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||||
|
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||||
|
->where('recurring_tasks.user_id', $userId)
|
||||||
|
->groupBy('recurring_tasks.id');
|
||||||
|
|
||||||
|
$this->applyFilters($builder, $filters);
|
||||||
|
|
||||||
|
if (empty($sorts)) {
|
||||||
|
$builder->orderBy('recurring_tasks.created_at', 'DESC');
|
||||||
|
} else {
|
||||||
|
$this->applySort($builder, $sorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->paginatedResponse($builder, 'Recurring tasks retrieved successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new recurring task
|
|
||||||
* POST /api/v1/recurring-tasks
|
* POST /api/v1/recurring-tasks
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->recurringTaskModel)) {
|
||||||
'title' => 'required|max_length[255]',
|
|
||||||
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $this->generateUuid(),
|
'id' => $this->generateUuid(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'title' => $json['title'],
|
'title' => $json['title'],
|
||||||
'description' => $json['description'] ?? null,
|
'description' => $json['description'] ?? null,
|
||||||
'schedule' => $json['schedule'],
|
'schedule' => $json['schedule'] ?? 'weekly',
|
||||||
'custom_days' => $json['custom_days'] ? json_encode($json['custom_days']) : json_encode([]),
|
'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]',
|
||||||
'favorite' => $json['favorite'] ?? false,
|
'favorite' => !empty($json['favorite']),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->recurringTaskModel->insert($data);
|
$this->recurringTaskModel->insert($data);
|
||||||
|
|
||||||
|
// Link category if provided
|
||||||
|
if (!empty($json['category_id'])) {
|
||||||
|
$this->linkCategory($data['id'], $json['category_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logActivity('recurring_task_created', 'recurring_task', $data['id'], [
|
||||||
|
'title' => $data['title'],
|
||||||
|
]);
|
||||||
|
|
||||||
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']);
|
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']);
|
||||||
|
|
||||||
return $this->successResponse($task, 'Recurring task created successfully', 201);
|
return $this->successResponse($task[0] ?? null, 'Recurring task created successfully', 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific recurring task
|
|
||||||
* GET /api/v1/recurring-tasks/{id}
|
* GET /api/v1/recurring-tasks/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
||||||
|
|
||||||
if (!$task) {
|
if (empty($tasks)) {
|
||||||
return $this->errorResponse('Recurring task not found', 404);
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->successResponse($task, 'Recurring task retrieved successfully');
|
return $this->successResponse($tasks[0], 'Recurring task retrieved successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a recurring task
|
|
||||||
* PUT /api/v1/recurring-tasks/{id}
|
* PUT /api/v1/recurring-tasks/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -93,25 +115,40 @@ class RecurringTaskController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$json = $this->request->getJSON(true);
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// Handle category update
|
||||||
|
if (array_key_exists('category_id', $json)) {
|
||||||
|
$this->recurringTaskCategoryModel->where('recurring_task_id', $id)->delete();
|
||||||
|
if (!empty($json['category_id'])) {
|
||||||
|
$this->linkCategory($id, $json['category_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite'];
|
$allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite'];
|
||||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
if (isset($updateData['custom_days'])) {
|
// Convert custom_days array to JSON string
|
||||||
|
if (isset($updateData['custom_days']) && is_array($updateData['custom_days'])) {
|
||||||
$updateData['custom_days'] = json_encode($updateData['custom_days']);
|
$updateData['custom_days'] = json_encode($updateData['custom_days']);
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('favorite', $updateData)) {
|
||||||
if (empty($updateData)) {
|
$updateData['favorite'] = !empty($updateData['favorite']);
|
||||||
return $this->errorResponse('No valid fields to update');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($updateData)) {
|
||||||
$this->recurringTaskModel->update($id, $updateData);
|
$this->recurringTaskModel->update($id, $updateData);
|
||||||
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
}
|
||||||
|
|
||||||
return $this->successResponse($task, 'Recurring task updated successfully');
|
$this->logActivity('recurring_task_updated', 'recurring_task', $id, [
|
||||||
|
'title' => $task['title'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
||||||
|
|
||||||
|
return $this->successResponse($updated[0] ?? null, 'Recurring task updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a recurring task
|
|
||||||
* DELETE /api/v1/recurring-tasks/{id}
|
* DELETE /api/v1/recurring-tasks/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -125,11 +162,14 @@ class RecurringTaskController extends BaseController
|
|||||||
|
|
||||||
$this->recurringTaskModel->delete($id);
|
$this->recurringTaskModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('recurring_task_deleted', 'recurring_task', $id, [
|
||||||
|
'title' => $task['title'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->successResponse(null, 'Recurring task deleted successfully');
|
return $this->successResponse(null, 'Recurring task deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a category to a recurring task
|
|
||||||
* POST /api/v1/recurring-tasks/{id}/categories
|
* POST /api/v1/recurring-tasks/{id}/categories
|
||||||
*/
|
*/
|
||||||
public function addCategory($taskId = null)
|
public function addCategory($taskId = null)
|
||||||
@@ -142,13 +182,11 @@ class RecurringTaskController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify task belongs to user
|
|
||||||
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||||
if (!$task) {
|
if (!$task) {
|
||||||
return $this->errorResponse('Recurring task not found', 404);
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if link already exists
|
|
||||||
$existing = $this->recurringTaskCategoryModel
|
$existing = $this->recurringTaskCategoryModel
|
||||||
->where('recurring_task_id', $taskId)
|
->where('recurring_task_id', $taskId)
|
||||||
->where('category_id', $json['category_id'])
|
->where('category_id', $json['category_id'])
|
||||||
@@ -167,14 +205,12 @@ class RecurringTaskController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a category from a recurring task
|
|
||||||
* DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
|
* DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
|
||||||
*/
|
*/
|
||||||
public function removeCategory($taskId = null, $categoryId = null)
|
public function removeCategory($taskId = null, $categoryId = null)
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
// Verify task belongs to user
|
|
||||||
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||||
if (!$task) {
|
if (!$task) {
|
||||||
return $this->errorResponse('Recurring task not found', 404);
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
@@ -188,15 +224,27 @@ class RecurringTaskController extends BaseController
|
|||||||
return $this->successResponse(null, 'Category removed from recurring task successfully');
|
return $this->successResponse(null, 'Category removed from recurring task successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateUuid(): string
|
/**
|
||||||
|
* Link a category (internal helper)
|
||||||
|
*/
|
||||||
|
private function linkCategory(string $taskId, string $categoryId): void
|
||||||
{
|
{
|
||||||
return sprintf(
|
$userId = $this->getUserId();
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
$categoryModel = new CategoryModel();
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
if (!$category) return;
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
$existing = $this->recurringTaskCategoryModel
|
||||||
);
|
->where('recurring_task_id', $taskId)
|
||||||
|
->where('category_id', $categoryId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
$this->recurringTaskCategoryModel->insert([
|
||||||
|
'recurring_task_id' => $taskId,
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controllers\Api\V1;
|
|||||||
use App\Controllers\Api\BaseController;
|
use App\Controllers\Api\BaseController;
|
||||||
use App\Models\TodoModel;
|
use App\Models\TodoModel;
|
||||||
use App\Models\TodoCategoryModel;
|
use App\Models\TodoCategoryModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
|
||||||
class TodoController extends BaseController
|
class TodoController extends BaseController
|
||||||
{
|
{
|
||||||
@@ -17,36 +18,61 @@ class TodoController extends BaseController
|
|||||||
$this->todoCategoryModel = new TodoCategoryModel();
|
$this->todoCategoryModel = new TodoCategoryModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Allowed sort & filter fields ───────────────────────────────────────
|
||||||
|
const SORTABLE = ['title', 'status', 'due_date', 'due_time', 'created_at', 'updated_at'];
|
||||||
|
const FILTERABLE = ['status', 'project_id', 'sync_enabled', 'reminder_enabled', 'recurring_enabled'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all todos for the authenticated user
|
|
||||||
* GET /api/v1/todos
|
* GET /api/v1/todos
|
||||||
|
* Paginated, sortable, filterable list of todos for the authenticated user.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* page (int) – page number, default 1
|
||||||
|
* per_page (int) – items per page, default 50, max 200
|
||||||
|
* sort (string) – e.g. "title" or "-created_at,title"
|
||||||
|
* status (string) – filter by status
|
||||||
|
* project_id (string) – filter by project
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$todos = $this->todoModel->getByUserWithCategories($userId);
|
|
||||||
|
|
||||||
return $this->successResponse($todos, 'Todos retrieved successfully');
|
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||||
|
$sorts = $this->getSortParams(self::SORTABLE);
|
||||||
|
|
||||||
|
$builder = $this->todoModel
|
||||||
|
->select('todos.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
|
||||||
|
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
|
||||||
|
->join('categories', 'todo_categories.category_id = categories.id', 'left')
|
||||||
|
->where('todos.user_id', $userId)
|
||||||
|
->groupBy('todos.id');
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
$this->applyFilters($builder, $filters);
|
||||||
|
|
||||||
|
// Apply sorting (default: newest first)
|
||||||
|
if (empty($sorts)) {
|
||||||
|
$builder->orderBy('todos.created_at', 'DESC');
|
||||||
|
} else {
|
||||||
|
$this->applySort($builder, $sorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->paginatedResponse($builder, 'Todos retrieved successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new todo
|
|
||||||
* POST /api/v1/todos
|
* POST /api/v1/todos
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
$json = $this->request->getJSON(true);
|
|
||||||
|
|
||||||
$rules = [
|
if (!$this->validateWithModel($this->todoModel)) {
|
||||||
'title' => 'required|max_length[255]',
|
|
||||||
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$this->validateRequest($rules)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $this->generateUuid(),
|
'id' => $this->generateUuid(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
@@ -55,9 +81,9 @@ class TodoController extends BaseController
|
|||||||
'status' => $json['status'] ?? 'open',
|
'status' => $json['status'] ?? 'open',
|
||||||
'due_date' => $json['due_date'] ?? null,
|
'due_date' => $json['due_date'] ?? null,
|
||||||
'due_time' => $json['due_time'] ?? null,
|
'due_time' => $json['due_time'] ?? null,
|
||||||
'sync_enabled' => $json['sync_enabled'] ?? true,
|
'sync_enabled' => !empty($json['sync_enabled']),
|
||||||
'reminder_enabled' => $json['reminder_enabled'] ?? false,
|
'reminder_enabled' => !empty($json['reminder_enabled']),
|
||||||
'recurring_enabled' => $json['recurring_enabled'] ?? false,
|
'recurring_enabled' => !empty($json['recurring_enabled']),
|
||||||
'project_id' => $json['project_id'] ?? null,
|
'project_id' => $json['project_id'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -68,21 +94,9 @@ class TodoController extends BaseController
|
|||||||
$this->linkCategory($data['id'], $json['category_id']);
|
$this->linkCategory($data['id'], $json['category_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually log the activity
|
$this->logActivity('todo_created', 'todo', $data['id'], [
|
||||||
try {
|
'title' => $data['title'],
|
||||||
$activityLogModel = new \App\Models\ActivityLogModel();
|
|
||||||
$activityLogModel->logActivity([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'action' => 'todo_created',
|
|
||||||
'entity_type' => 'todo',
|
|
||||||
'entity_id' => $data['id'],
|
|
||||||
'details' => json_encode(['action' => 'created', 'title' => $data['title']]),
|
|
||||||
'ip_address' => $this->request->getIPAddress(),
|
|
||||||
'user_agent' => $this->request->getUserAgent()->getAgentString(),
|
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
|
||||||
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
|
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
|
||||||
|
|
||||||
@@ -90,7 +104,6 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific todo
|
|
||||||
* GET /api/v1/todos/{id}
|
* GET /api/v1/todos/{id}
|
||||||
*/
|
*/
|
||||||
public function show($id = null)
|
public function show($id = null)
|
||||||
@@ -106,7 +119,6 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a todo
|
|
||||||
* PUT /api/v1/todos/{id}
|
* PUT /api/v1/todos/{id}
|
||||||
*/
|
*/
|
||||||
public function update($id = null)
|
public function update($id = null)
|
||||||
@@ -124,37 +136,33 @@ class TodoController extends BaseController
|
|||||||
$hasCategoryUpdate = array_key_exists('category_id', $json);
|
$hasCategoryUpdate = array_key_exists('category_id', $json);
|
||||||
if ($hasCategoryUpdate) {
|
if ($hasCategoryUpdate) {
|
||||||
$categoryId = $json['category_id'];
|
$categoryId = $json['category_id'];
|
||||||
// Remove all existing category links
|
|
||||||
$this->todoCategoryModel->where('todo_id', $id)->delete();
|
$this->todoCategoryModel->where('todo_id', $id)->delete();
|
||||||
// Link the new category
|
|
||||||
if (!empty($categoryId)) {
|
if (!empty($categoryId)) {
|
||||||
$this->linkCategory($id, $categoryId);
|
$this->linkCategory($id, $categoryId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update todo fields
|
// Update todo fields
|
||||||
$allowedFields = ['title', 'description', 'status', 'due_date', 'due_time', 'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id'];
|
$allowedFields = [
|
||||||
|
'title', 'description', 'status', 'due_date', 'due_time',
|
||||||
|
'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id',
|
||||||
|
];
|
||||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
if (!empty($updateData)) {
|
if (!empty($updateData)) {
|
||||||
|
// Convert boolean-ish values
|
||||||
|
foreach (['sync_enabled', 'reminder_enabled', 'recurring_enabled'] as $boolField) {
|
||||||
|
if (array_key_exists($boolField, $updateData)) {
|
||||||
|
$updateData[$boolField] = !empty($updateData[$boolField]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->todoModel->update($id, $updateData);
|
$this->todoModel->update($id, $updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually log the activity
|
$this->logActivity('todo_updated', 'todo', $id, [
|
||||||
try {
|
'title' => $todo['title'] ?? 'Unknown',
|
||||||
$activityLogModel = new \App\Models\ActivityLogModel();
|
|
||||||
$activityLogModel->logActivity([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'action' => 'todo_updated',
|
|
||||||
'entity_type' => 'todo',
|
|
||||||
'entity_id' => $id,
|
|
||||||
'details' => json_encode(['action' => 'updated', 'title' => $todo['title'] ?? 'Unknown']),
|
|
||||||
'ip_address' => $this->request->getIPAddress(),
|
|
||||||
'user_agent' => $this->request->getUserAgent()->getAgentString(),
|
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
|
||||||
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
|
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
|
||||||
|
|
||||||
@@ -162,7 +170,6 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a todo
|
|
||||||
* DELETE /api/v1/todos/{id}
|
* DELETE /api/v1/todos/{id}
|
||||||
*/
|
*/
|
||||||
public function delete($id = null)
|
public function delete($id = null)
|
||||||
@@ -176,27 +183,16 @@ class TodoController extends BaseController
|
|||||||
|
|
||||||
$this->todoModel->delete($id);
|
$this->todoModel->delete($id);
|
||||||
|
|
||||||
// Manually log the activity
|
$this->logActivity('todo_deleted', 'todo', $id, [
|
||||||
try {
|
'title' => $todo['title'] ?? 'Unknown',
|
||||||
$activityLogModel = new \App\Models\ActivityLogModel();
|
|
||||||
$activityLogModel->logActivity([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'action' => 'todo_deleted',
|
|
||||||
'entity_type' => 'todo',
|
|
||||||
'entity_id' => $id,
|
|
||||||
'details' => json_encode(['action' => 'deleted', 'title' => $todo['title'] ?? 'Unknown']),
|
|
||||||
'ip_address' => $this->request->getIPAddress(),
|
|
||||||
'user_agent' => $this->request->getUserAgent()->getAgentString(),
|
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
|
||||||
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->successResponse(null, 'Todo deleted successfully');
|
return $this->successResponse(null, 'Todo deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Category linking ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a category to a todo
|
|
||||||
* POST /api/v1/todos/{id}/categories
|
* POST /api/v1/todos/{id}/categories
|
||||||
*/
|
*/
|
||||||
public function addCategory($todoId = null)
|
public function addCategory($todoId = null)
|
||||||
@@ -209,13 +205,11 @@ class TodoController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify todo belongs to user
|
|
||||||
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||||
if (!$todo) {
|
if (!$todo) {
|
||||||
return $this->errorResponse('Todo not found', 404);
|
return $this->errorResponse('Todo not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if link already exists
|
|
||||||
$existing = $this->todoCategoryModel
|
$existing = $this->todoCategoryModel
|
||||||
->where('todo_id', $todoId)
|
->where('todo_id', $todoId)
|
||||||
->where('category_id', $json['category_id'])
|
->where('category_id', $json['category_id'])
|
||||||
@@ -234,14 +228,12 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a category from a todo
|
|
||||||
* DELETE /api/v1/todos/{id}/categories/{categoryId}
|
* DELETE /api/v1/todos/{id}/categories/{categoryId}
|
||||||
*/
|
*/
|
||||||
public function removeCategory($todoId = null, $categoryId = null)
|
public function removeCategory($todoId = null, $categoryId = null)
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
// Verify todo belongs to user
|
|
||||||
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||||
if (!$todo) {
|
if (!$todo) {
|
||||||
return $this->errorResponse('Todo not found', 404);
|
return $this->errorResponse('Todo not found', 404);
|
||||||
@@ -256,20 +248,19 @@ class TodoController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link a category to a todo
|
* Link a category to a todo (internal helper)
|
||||||
*/
|
*/
|
||||||
private function linkCategory(string $todoId, string $categoryId): void
|
private function linkCategory(string $todoId, string $categoryId): void
|
||||||
{
|
{
|
||||||
$userId = $this->getUserId();
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
// Verify category belongs to user
|
$categoryModel = new CategoryModel();
|
||||||
$categoryModel = new \App\Models\CategoryModel();
|
|
||||||
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
if (!$category) {
|
if (!$category) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if link already exists
|
|
||||||
$existing = $this->todoCategoryModel
|
$existing = $this->todoCategoryModel
|
||||||
->where('todo_id', $todoId)
|
->where('todo_id', $todoId)
|
||||||
->where('category_id', $categoryId)
|
->where('category_id', $categoryId)
|
||||||
@@ -282,16 +273,4 @@ class TodoController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateUuid(): string
|
|
||||||
{
|
|
||||||
return sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,15 +106,5 @@ class UserThemeController extends BaseController
|
|||||||
return $this->successResponse(null, 'User theme deleted successfully');
|
return $this->successResponse(null, 'User theme deleted successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateUuid(): string
|
|
||||||
{
|
|
||||||
return sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0xffff),
|
|
||||||
mt_rand(0, 0x0fff) | 0x4000,
|
|
||||||
mt_rand(0, 0x3fff) | 0x8000,
|
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ class ThemeStore extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch') || str_contains($this->request->getHeaderLine('Accept'), 'application/json')) {
|
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch') || str_contains($this->request->getHeaderLine('Accept'), 'application/json')) {
|
||||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||||
header('Access-Control-Allow-Credentials: true');
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Vary: Origin');
|
||||||
return $this->response->setJSON($themes);
|
return $this->response->setJSON($themes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,8 +39,9 @@ class ThemeStore extends BaseController
|
|||||||
|
|
||||||
public function upload(): Response
|
public function upload(): Response
|
||||||
{
|
{
|
||||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||||
header('Access-Control-Allow-Credentials: true');
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Vary: Origin');
|
||||||
|
|
||||||
$file = $this->request->getFile('theme_css');
|
$file = $this->request->getFile('theme_css');
|
||||||
$displayName = trim($this->request->getPost('display_name') ?? '');
|
$displayName = trim($this->request->getPost('display_name') ?? '');
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ class CategoryModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'name', 'color', 'favorite', 'created_at',
|
||||||
'user_id',
|
|
||||||
'name',
|
|
||||||
'color',
|
|
||||||
'favorite',
|
|
||||||
'created_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -25,7 +20,24 @@ class CategoryModel extends Model
|
|||||||
protected $updatedField = '';
|
protected $updatedField = '';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'name' => 'required|max_length[255]',
|
'rules' => 'required',
|
||||||
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The category name is required.',
|
||||||
|
'max_length' => 'The category name must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'color' => [
|
||||||
|
'rules' => 'required|max_length[7]|regex_match[/^#[0-9a-fA-F]{6}$/]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'A color value is required.',
|
||||||
|
'max_length' => 'Color must be a hex code (e.g. #3B82F6).',
|
||||||
|
'regex_match' => 'Color must be a valid hex code (e.g. #3B82F6).',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ class ProjectModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at',
|
||||||
'user_id',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'color',
|
|
||||||
'created_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -25,7 +20,16 @@ class ProjectModel extends Model
|
|||||||
protected $updatedField = '';
|
protected $updatedField = '';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'name' => 'required|max_length[255]',
|
'rules' => 'required',
|
||||||
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The project name is required.',
|
||||||
|
'max_length' => 'The project name must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,8 @@ class RecurringTaskModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'title', 'description', 'schedule',
|
||||||
'user_id',
|
'custom_days', 'favorite', 'created_at', 'updated_at',
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'schedule',
|
|
||||||
'custom_days',
|
|
||||||
'favorite',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -28,17 +21,37 @@ class RecurringTaskModel extends Model
|
|||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'title' => 'required|max_length[255]',
|
'rules' => 'required',
|
||||||
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The recurring task title is required.',
|
||||||
|
'max_length' => 'The title must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'schedule' => [
|
||||||
|
'rules' => 'permit_empty|in_list[daily,weekly,monthly,custom]',
|
||||||
|
'errors' => [
|
||||||
|
'in_list' => 'Schedule must be one of: daily, weekly, monthly, custom.',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get recurring tasks with categories
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
public function getWithCategories($taskId = null)
|
|
||||||
|
public function getByUserWithCategories($userId, $taskId = null)
|
||||||
{
|
{
|
||||||
$builder = $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
|
$builder = $this->select('
|
||||||
|
recurring_tasks.*,
|
||||||
|
GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
|
||||||
|
GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names
|
||||||
|
')
|
||||||
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||||
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||||
|
->where('recurring_tasks.user_id', $userId)
|
||||||
->groupBy('recurring_tasks.id');
|
->groupBy('recurring_tasks.id');
|
||||||
|
|
||||||
if ($taskId) {
|
if ($taskId) {
|
||||||
@@ -47,16 +60,4 @@ class RecurringTaskModel extends Model
|
|||||||
|
|
||||||
return $builder->get()->getResultArray();
|
return $builder->get()->getResultArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recurring tasks by user with categories
|
|
||||||
public function getByUserWithCategories($userId)
|
|
||||||
{
|
|
||||||
return $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
|
|
||||||
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
|
||||||
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
|
||||||
->where('recurring_tasks.user_id', $userId)
|
|
||||||
->groupBy('recurring_tasks.id')
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,9 @@ class TodoModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'user_id', 'title', 'description', 'status',
|
||||||
'user_id',
|
'due_date', 'due_time', 'sync_enabled', 'reminder_enabled',
|
||||||
'title',
|
'recurring_enabled', 'project_id', 'created_at', 'updated_at',
|
||||||
'description',
|
|
||||||
'status',
|
|
||||||
'due_date',
|
|
||||||
'due_time',
|
|
||||||
'sync_enabled',
|
|
||||||
'reminder_enabled',
|
|
||||||
'recurring_enabled',
|
|
||||||
'project_id',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -32,27 +22,35 @@ class TodoModel extends Model
|
|||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'user_id' => 'required',
|
'user_id' => [
|
||||||
'title' => 'required|max_length[255]',
|
'rules' => 'required',
|
||||||
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The todo title is required.',
|
||||||
|
'max_length' => 'The title must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'status' => [
|
||||||
|
'rules' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
||||||
|
'errors' => [
|
||||||
|
'in_list' => 'Status must be one of: open, in_progress, completed, archived.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'due_date' => [
|
||||||
|
'rules' => 'permit_empty|valid_date[Y-m-d]',
|
||||||
|
'errors' => ['valid_date' => 'Due date must be in YYYY-MM-DD format.'],
|
||||||
|
],
|
||||||
|
'due_time' => [
|
||||||
|
'rules' => 'permit_empty|valid_date[H:i:s]',
|
||||||
|
'errors' => ['valid_date' => 'Due time must be in HH:MM format.'],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get todos with categories
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
public function getWithCategories($todoId = null)
|
|
||||||
{
|
|
||||||
$builder = $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names')
|
|
||||||
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
|
|
||||||
->join('categories', 'todo_categories.category_id = categories.id', 'left')
|
|
||||||
->groupBy('todos.id');
|
|
||||||
|
|
||||||
if ($todoId) {
|
|
||||||
$builder->where('todos.id', $todoId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $builder->get()->getResultArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get todos by user with categories (optionally filtered by todo id)
|
|
||||||
public function getByUserWithCategories($userId, $todoId = null)
|
public function getByUserWithCategories($userId, $todoId = null)
|
||||||
{
|
{
|
||||||
$builder = $this->select('
|
$builder = $this->select('
|
||||||
|
|||||||
@@ -12,14 +12,8 @@ class UserModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'id',
|
'id', 'email', 'password_hash', 'name', 'avatar_url',
|
||||||
'email',
|
'settings', 'created_at', 'updated_at',
|
||||||
'password_hash',
|
|
||||||
'name',
|
|
||||||
'avatar_url',
|
|
||||||
'settings',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
@@ -27,15 +21,20 @@ class UserModel extends Model
|
|||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
protected $validationRules = [
|
protected $validationRules = [
|
||||||
'email' => 'required|valid_email|is_unique[users.email]',
|
|
||||||
'password_hash' => 'required',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $validationMessages = [
|
|
||||||
'email' => [
|
'email' => [
|
||||||
'required' => 'Email is required',
|
'rules' => 'required|valid_email|is_unique[users.email]',
|
||||||
'valid_email' => 'Please enter a valid email address',
|
'errors' => [
|
||||||
'is_unique' => 'This email is already registered',
|
'required' => 'Email is required.',
|
||||||
|
'valid_email' => 'Please provide a valid email address.',
|
||||||
|
'is_unique' => 'This email is already registered.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Name is required.',
|
||||||
|
'max_length' => 'Name must not exceed 255 characters.',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"codeigniter4/framework": "^4.7"
|
"codeigniter4/framework": "^4.7",
|
||||||
|
"firebase/php-jwt": "^7.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.9",
|
"fakerphp/faker": "^1.9",
|
||||||
|
|||||||
66
composer.lock
generated
66
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f5cce40800fa5dae1504b9364f585e6a",
|
"content-hash": "86520263c0a2df285d17beea23def54d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "codeigniter4/framework",
|
"name": "codeigniter4/framework",
|
||||||
@@ -83,6 +83,70 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-24T18:26:09+00:00"
|
"time": "2026-03-24T18:26:09+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "firebase/php-jwt",
|
||||||
|
"version": "v7.0.5",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/googleapis/php-jwt.git",
|
||||||
|
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
||||||
|
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"guzzlehttp/guzzle": "^7.4",
|
||||||
|
"phpfastcache/phpfastcache": "^9.2",
|
||||||
|
"phpspec/prophecy-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^9.5",
|
||||||
|
"psr/cache": "^2.0||^3.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||||
|
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Firebase\\JWT\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Neuman Vong",
|
||||||
|
"email": "neuman+pear@twilio.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anant Narayanan",
|
||||||
|
"email": "anant@php.net",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||||
|
"homepage": "https://github.com/firebase/php-jwt",
|
||||||
|
"keywords": [
|
||||||
|
"jwt",
|
||||||
|
"php"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||||
|
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
|
||||||
|
},
|
||||||
|
"time": "2026-04-01T20:38:03+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laminas/laminas-escaper",
|
"name": "laminas/laminas-escaper",
|
||||||
"version": "2.18.0",
|
"version": "2.18.0",
|
||||||
|
|||||||
2186
openapi/openapi.yaml
Normal file
2186
openapi/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.hostname" value="127.0.0.1"/>
|
||||||
|
<env name="database.tests.database" value="TodoApp"/>
|
||||||
|
<env name="database.tests.username" value="root"/>
|
||||||
<env name="database.tests.password" value=""/>
|
<env name="database.tests.password" value=""/>
|
||||||
<env name="database.tests.DBDriver" value="MySQLi"/>
|
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||||
<env name="database.tests.DBPrefix" value="tests_"/>
|
<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
251
tests/api/ModelTest.php
Normal 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
19
tests/bootstrap.php
Normal 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';
|
||||||
Reference in New Issue
Block a user