mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Compare commits
15 Commits
feature/co
...
APIhardeni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f01e04fbad | ||
|
|
3ab93381f5 | ||
|
|
5454644a31 | ||
|
|
caf81ea4e2 | ||
|
|
f27498dc26 | ||
|
|
bb09f3d024 | ||
|
|
daa6ec8b1e | ||
|
|
02f77a15a7 | ||
|
|
e125ac34d7 | ||
|
|
fb9ff9d56b | ||
|
|
7c81586d3f | ||
|
|
092bb53324 | ||
|
|
6cbb6a2e3e | ||
|
|
deba81fadb | ||
|
|
b2dab73f17 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -124,3 +124,7 @@ _modules/*
|
|||||||
|
|
||||||
/results/
|
/results/
|
||||||
/phpunit*.xml
|
/phpunit*.xml
|
||||||
|
.env
|
||||||
|
env
|
||||||
|
.claude/
|
||||||
|
.claude/*
|
||||||
825
API_DOCUMENTATION.md
Normal file
825
API_DOCUMENTATION.md
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
# 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.
|
||||||
189
AUTO_LOGGING_GUIDE.md
Normal file
189
AUTO_LOGGING_GUIDE.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Automatic Activity Logging Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Todo App Backend now includes automatic activity logging for all CRUD operations (Create, Read, Update, Delete) on key entities. This is implemented using a PHP trait that can be easily added to any model.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### LoggableTrait
|
||||||
|
|
||||||
|
The `LoggableTrait` in `app/Models/LoggableTrait.php` provides automatic logging functionality by hooking into CodeIgniter 4's model lifecycle events:
|
||||||
|
|
||||||
|
- **afterInsert**: Logs when a new record is created
|
||||||
|
- **afterUpdate**: Logs when a record is updated
|
||||||
|
- **afterDelete**: Logs when a record is deleted
|
||||||
|
|
||||||
|
### Enabled Models
|
||||||
|
|
||||||
|
The following models now have automatic logging enabled:
|
||||||
|
|
||||||
|
1. **UserModel** - Logs user creation, updates, and deletion
|
||||||
|
2. **TodoModel** - Logs todo creation, updates, and deletion
|
||||||
|
3. **CategoryModel** - Logs category creation, updates, and deletion
|
||||||
|
4. **ProjectModel** - Logs project creation, updates, and deletion
|
||||||
|
5. **RecurringTaskModel** - Logs recurring task creation, updates, and deletion
|
||||||
|
|
||||||
|
### What Gets Logged
|
||||||
|
|
||||||
|
Each log entry includes:
|
||||||
|
|
||||||
|
- **user_id**: The ID of the user performing the action (from the record or session)
|
||||||
|
- **action**: Formatted action name (e.g., "todo_created", "user_updated", "category_deleted")
|
||||||
|
- **entity_type**: The type of entity (e.g., "todo", "user", "category")
|
||||||
|
- **entity_id**: The ID of the affected entity
|
||||||
|
- **details**: JSON object with relevant fields (title, name, email, etc.)
|
||||||
|
- **ip_address**: Client IP address
|
||||||
|
- **user_agent**: Browser user agent string
|
||||||
|
- **created_at**: Timestamp of the action
|
||||||
|
|
||||||
|
### Example Log Entries
|
||||||
|
|
||||||
|
**Creating a todo:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"action": "todo_created",
|
||||||
|
"entity_type": "todo",
|
||||||
|
"entity_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"details": {
|
||||||
|
"action": "created",
|
||||||
|
"title": "Complete project documentation"
|
||||||
|
},
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"created_at": "2026-04-29 13:42:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Updating a user:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"action": "user_updated",
|
||||||
|
"entity_type": "user",
|
||||||
|
"entity_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"details": {
|
||||||
|
"action": "updated",
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com"
|
||||||
|
},
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"created_at": "2026-04-29 13:45:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deleting a category:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"action": "category_deleted",
|
||||||
|
"entity_type": "category",
|
||||||
|
"entity_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
"details": {
|
||||||
|
"action": "deleted",
|
||||||
|
"name": "Work"
|
||||||
|
},
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"created_at": "2026-04-29 13:50:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding Logging to Other Models
|
||||||
|
|
||||||
|
To add automatic logging to a new model:
|
||||||
|
|
||||||
|
1. Add the `use LoggableTrait;` statement to your model class
|
||||||
|
2. Override the `getEntityType()` method to return the entity type name
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class MarketplaceThemeModel extends Model
|
||||||
|
{
|
||||||
|
use LoggableTrait;
|
||||||
|
|
||||||
|
protected $table = 'marketplace_themes';
|
||||||
|
// ... other model properties ...
|
||||||
|
|
||||||
|
protected function getEntityType(): string
|
||||||
|
{
|
||||||
|
return 'marketplace_theme';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customizing Log Details
|
||||||
|
|
||||||
|
You can customize what details are logged by overriding the `getLogDetails()` method in your model:
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected function getLogDetails($action, $data): array
|
||||||
|
{
|
||||||
|
$details = parent::getLogDetails($action, $data);
|
||||||
|
|
||||||
|
// Add custom fields
|
||||||
|
if (isset($data['price'])) {
|
||||||
|
$details['price'] = $data['price'];
|
||||||
|
}
|
||||||
|
if (isset($data['is_published'])) {
|
||||||
|
$details['is_published'] = $data['is_published'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $details;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Querying Activity Logs
|
||||||
|
|
||||||
|
Use the `ActivityLogModel` to query logs:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$activityLogModel = new ActivityLogModel();
|
||||||
|
|
||||||
|
// Get logs for a specific user
|
||||||
|
$logs = $activityLogModel->getByUser($userId);
|
||||||
|
|
||||||
|
// Get logs for a specific entity
|
||||||
|
$logs = $activityLogModel->getByEntity('todo', $todoId);
|
||||||
|
|
||||||
|
// Get logs by action type
|
||||||
|
$logs = $activityLogModel->getByAction('todo_created');
|
||||||
|
|
||||||
|
// Custom query
|
||||||
|
$logs = $activityLogModel
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('entity_type', 'todo')
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->limit(20)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Requirement
|
||||||
|
|
||||||
|
The logging system attempts to get the user ID from:
|
||||||
|
1. The data being inserted/updated (e.g., `user_id` field in the record)
|
||||||
|
2. The session (`session()->get('user_id')`)
|
||||||
|
|
||||||
|
Make sure your authentication system sets the user ID in the session for proper logging.
|
||||||
|
|
||||||
|
## Disabling Logging
|
||||||
|
|
||||||
|
To disable automatic logging for a specific operation, you can temporarily disable the model events:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$todoModel = new TodoModel();
|
||||||
|
$todoModel->disableEvents();
|
||||||
|
$todoModel->insert($data);
|
||||||
|
$todoModel->enableEvents();
|
||||||
|
```
|
||||||
|
|
||||||
|
Or remove the `use LoggableTrait;` from the model class entirely.
|
||||||
548
DATABASE_DOCUMENTATION.md
Normal file
548
DATABASE_DOCUMENTATION.md
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
# Todo App Backend - Database Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the database schema for the Todo App Backend, built with CodeIgniter 4 and MySQL. The database supports user accounts, todo management, recurring tasks, activity logging, theme marketplace, and AI-powered chat features.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
|
||||||
|
#### 1. users
|
||||||
|
Stores user account information and application settings.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| email | VARCHAR(255) | NO | User email (unique) |
|
||||||
|
| password_hash | VARCHAR(255) | NO | Bcrypt hashed password |
|
||||||
|
| name | VARCHAR(255) | YES | Display name |
|
||||||
|
| avatar_url | TEXT | YES | Profile image URL |
|
||||||
|
| settings | JSON | YES | App preferences (language, default view, etc.) |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
| updated_at | DATETIME | YES | Last update timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- UNIQUE KEY (email)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. categories
|
||||||
|
Per-user categories for organizing todos.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | NO | Foreign key to users |
|
||||||
|
| name | VARCHAR(255) | NO | Category name |
|
||||||
|
| color | VARCHAR(7) | YES | Hex color code for UI |
|
||||||
|
| favorite | BOOLEAN | NO | Mark as favorite |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- KEY (user_id)
|
||||||
|
- UNIQUE KEY (user_id, name)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. projects
|
||||||
|
Optional project grouping for todos.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | NO | Foreign key to users |
|
||||||
|
| name | VARCHAR(255) | NO | Project name |
|
||||||
|
| description | TEXT | YES | Project description |
|
||||||
|
| color | VARCHAR(7) | YES | Hex color code for UI |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- KEY (user_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. todos
|
||||||
|
Main todo items.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | NO | Foreign key to users |
|
||||||
|
| title | VARCHAR(255) | NO | Todo title |
|
||||||
|
| description | TEXT | YES | Detailed description |
|
||||||
|
| status | ENUM | NO | open, in_progress, completed, archived |
|
||||||
|
| due_date | DATE | YES | Due date |
|
||||||
|
| due_time | TIME | YES | Due time |
|
||||||
|
| sync_enabled | BOOLEAN | NO | Sync with external services |
|
||||||
|
| reminder_enabled | BOOLEAN | NO | Enable reminders |
|
||||||
|
| recurring_enabled | BOOLEAN | NO | Mark as recurring |
|
||||||
|
| project_id | CHAR(36) | YES | Foreign key to projects |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
| updated_at | DATETIME | YES | Last update timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- KEY (user_id)
|
||||||
|
- KEY (due_date)
|
||||||
|
- KEY (status)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
- project_id → projects(id) ON DELETE SET NULL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. todo_categories (Junction Table)
|
||||||
|
Many-to-many relationship between todos and categories.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| todo_id | CHAR(36) | NO | Foreign key to todos |
|
||||||
|
| category_id | CHAR(36) | NO | Foreign key to categories |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (todo_id, category_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- todo_id → todos(id) ON DELETE CASCADE
|
||||||
|
- category_id → categories(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. recurring_tasks
|
||||||
|
Templates for recurring todo items.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | NO | Foreign key to users |
|
||||||
|
| title | VARCHAR(255) | NO | Task title |
|
||||||
|
| description | TEXT | YES | Task description |
|
||||||
|
| schedule | ENUM | NO | daily, weekly, monthly, custom |
|
||||||
|
| custom_days | JSON | YES | Array of days (e.g., ["mon","wed","fri"]) |
|
||||||
|
| favorite | BOOLEAN | NO | Mark as favorite |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
| updated_at | DATETIME | YES | Last update timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- KEY (user_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. recurring_task_categories (Junction Table)
|
||||||
|
Many-to-many relationship between recurring tasks and categories.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| recurring_task_id | CHAR(36) | NO | Foreign key to recurring_tasks |
|
||||||
|
| category_id | CHAR(36) | NO | Foreign key to categories |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (recurring_task_id, category_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- recurring_task_id → recurring_tasks(id) ON DELETE CASCADE
|
||||||
|
- category_id → categories(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Activity Logging
|
||||||
|
|
||||||
|
#### 8. activity_logs
|
||||||
|
Audit trail for user actions and system events.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | YES | Foreign key to users (nullable for anonymous) |
|
||||||
|
| action | VARCHAR(255) | NO | Action type (e.g., todo_created, login) |
|
||||||
|
| entity_type | VARCHAR(100) | YES | Entity type (todo, category, project, etc.) |
|
||||||
|
| entity_id | CHAR(36) | YES | Entity ID |
|
||||||
|
| details | JSON | YES | Additional metadata (before/after values) |
|
||||||
|
| ip_address | VARCHAR(45) | YES | User IP address |
|
||||||
|
| user_agent | TEXT | YES | Browser user agent |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- KEY (user_id)
|
||||||
|
- KEY (created_at)
|
||||||
|
- KEY (action)
|
||||||
|
- KEY (entity_type, entity_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE SET NULL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Theme Marketplace
|
||||||
|
|
||||||
|
#### 9. marketplace_themes
|
||||||
|
Master list of available themes in the marketplace.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| name | VARCHAR(255) | NO | Theme identifier (unique) |
|
||||||
|
| display_name | VARCHAR(255) | NO | Human-readable name |
|
||||||
|
| description | TEXT | YES | Theme description |
|
||||||
|
| author | VARCHAR(255) | YES | Theme author |
|
||||||
|
| version | VARCHAR(50) | YES | Theme version |
|
||||||
|
| thumbnail_url | TEXT | YES | Preview image URL |
|
||||||
|
| download_url | TEXT | NO | Download URL |
|
||||||
|
| price | DECIMAL(10,2) | NO | Theme price (0 = free) |
|
||||||
|
| is_published | BOOLEAN | NO | Published status |
|
||||||
|
| metadata | JSON | YES | Tags, screenshots, etc. |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
| updated_at | DATETIME | YES | Last update timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- UNIQUE KEY (name)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 10. user_themes
|
||||||
|
Themes installed by users.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | NO | Foreign key to users |
|
||||||
|
| theme_id | CHAR(36) | NO | Foreign key to marketplace_themes |
|
||||||
|
| installed_at | DATETIME | YES | Installation timestamp |
|
||||||
|
| active | BOOLEAN | NO | Currently active theme |
|
||||||
|
| custom_settings | JSON | YES | User theme overrides |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- UNIQUE KEY (user_id, theme_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
- theme_id → marketplace_themes(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI Features
|
||||||
|
|
||||||
|
#### 11. ai_providers
|
||||||
|
Supported AI providers (OpenAI, Anthropic, Google, etc.).
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| name | VARCHAR(100) | NO | Provider identifier (unique) |
|
||||||
|
| display_name | VARCHAR(255) | NO | Human-readable name |
|
||||||
|
| base_url | TEXT | YES | API endpoint override |
|
||||||
|
| is_builtin | BOOLEAN | NO | System vs custom provider |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- UNIQUE KEY (name)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 12. user_api_keys
|
||||||
|
Encrypted API keys for each provider per user.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | NO | Foreign key to users |
|
||||||
|
| provider_id | CHAR(36) | NO | Foreign key to ai_providers |
|
||||||
|
| api_key_encrypted | TEXT | NO | Encrypted API key |
|
||||||
|
| label | VARCHAR(255) | YES | Key label (e.g., "Work Key") |
|
||||||
|
| is_active | BOOLEAN | NO | Active status |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
| last_used_at | DATETIME | YES | Last usage timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- UNIQUE KEY (user_id, provider_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
- provider_id → ai_providers(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 13. user_ai_settings
|
||||||
|
Per-user AI preferences.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| user_id | CHAR(36) | NO | Primary key, Foreign key to users |
|
||||||
|
| default_provider_id | CHAR(36) | YES | Foreign key to ai_providers |
|
||||||
|
| default_model | VARCHAR(100) | YES | Default model (e.g., gpt-4) |
|
||||||
|
| max_tokens | INT | NO | Maximum tokens (default: 2048) |
|
||||||
|
| temperature | FLOAT | NO | Temperature (default: 0.7) |
|
||||||
|
| updated_at | DATETIME | YES | Last update timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (user_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
- default_provider_id → ai_providers(id) ON DELETE SET NULL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 14. ai_chats
|
||||||
|
AI conversation threads.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| user_id | CHAR(36) | NO | Foreign key to users |
|
||||||
|
| title | VARCHAR(255) | YES | Chat title |
|
||||||
|
| provider_id | CHAR(36) | YES | Foreign key to ai_providers |
|
||||||
|
| model_used | VARCHAR(100) | YES | Model snapshot |
|
||||||
|
| system_prompt | TEXT | YES | Custom system prompt |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
| updated_at | DATETIME | YES | Last update timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- KEY (user_id)
|
||||||
|
- KEY (updated_at)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- user_id → users(id) ON DELETE CASCADE
|
||||||
|
- provider_id → ai_providers(id) ON DELETE SET NULL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 15. ai_messages
|
||||||
|
Individual messages in AI chats.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| id | CHAR(36) | NO | Primary key (UUID) |
|
||||||
|
| chat_id | CHAR(36) | NO | Foreign key to ai_chats |
|
||||||
|
| role | ENUM | NO | user, assistant, system |
|
||||||
|
| content | TEXT | NO | Message content |
|
||||||
|
| tokens_used | INT | YES | Token count for billing |
|
||||||
|
| created_at | DATETIME | YES | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- PRIMARY KEY (id)
|
||||||
|
- KEY (chat_id)
|
||||||
|
|
||||||
|
**Foreign Keys:**
|
||||||
|
- chat_id → ai_chats(id) ON DELETE CASCADE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Relationship Diagram (ERD)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ users │
|
||||||
|
├─────────────────┤
|
||||||
|
│ id (PK) │◄────────┐
|
||||||
|
│ email │ │
|
||||||
|
│ password_hash │ │
|
||||||
|
│ name │ │
|
||||||
|
│ settings │ │
|
||||||
|
│ created_at │ │
|
||||||
|
│ updated_at │ │
|
||||||
|
└─────────────────┘ │
|
||||||
|
│
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||||
|
│ categories │ │ projects │ │ activity_logs │
|
||||||
|
├───────────────┤ ├───────────────┤ ├───────────────┤
|
||||||
|
│ id (PK) │ │ id (PK) │ │ id (PK) │
|
||||||
|
│ user_id (FK) │ │ user_id (FK) │ │ user_id (FK) │
|
||||||
|
│ name │ │ name │ │ action │
|
||||||
|
│ color │ │ description │ │ entity_type │
|
||||||
|
│ favorite │ │ color │ │ entity_id │
|
||||||
|
│ created_at │ │ created_at │ │ details │
|
||||||
|
└───────────────┘ └───────────────┘ │ ip_address │
|
||||||
|
│ │ │ user_agent │
|
||||||
|
│ │ │ created_at │
|
||||||
|
│ │ └───────────────┘
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
┌───────────────┐ ┌───────────────┐
|
||||||
|
│ todos │ │recurring_tasks│
|
||||||
|
├───────────────┤ ├───────────────┤
|
||||||
|
│ id (PK) │ │ id (PK) │
|
||||||
|
│ user_id (FK) │ │ user_id (FK) │
|
||||||
|
│ title │ │ title │
|
||||||
|
│ description │ │ description │
|
||||||
|
│ status │ │ schedule │
|
||||||
|
│ due_date │ │ custom_days │
|
||||||
|
│ due_time │ │ favorite │
|
||||||
|
│ project_id(FK)│ │ created_at │
|
||||||
|
│ created_at │ │ updated_at │
|
||||||
|
│ updated_at │ └───────────────┘
|
||||||
|
└───────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ todo_categories │◄───────┘
|
||||||
|
├──────────────────┤
|
||||||
|
│ todo_id (PK,FK) │
|
||||||
|
│ category_id(PK,FK)│
|
||||||
|
└──────────────────┘
|
||||||
|
│
|
||||||
|
│
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│recurring_task_categories │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ recurring_task_id (PK,FK) │
|
||||||
|
│ category_id (PK,FK) │
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────┐
|
||||||
|
│marketplace_themes│
|
||||||
|
├─────────────────┤
|
||||||
|
│ id (PK) │◄────────┐
|
||||||
|
│ name │ │
|
||||||
|
│ display_name │ │
|
||||||
|
│ description │ │
|
||||||
|
│ download_url │ │
|
||||||
|
│ price │ │
|
||||||
|
│ is_published │ │
|
||||||
|
└─────────────────┘ │
|
||||||
|
│
|
||||||
|
│
|
||||||
|
┌─────────┴─────────┐
|
||||||
|
│ user_themes │
|
||||||
|
├──────────────────┤
|
||||||
|
│ id (PK) │
|
||||||
|
│ user_id (FK) │
|
||||||
|
│ theme_id (FK) │
|
||||||
|
│ active │
|
||||||
|
│ custom_settings │
|
||||||
|
└──────────────────┘
|
||||||
|
|
||||||
|
┌─────────────┐
|
||||||
|
│ai_providers │
|
||||||
|
├─────────────┤
|
||||||
|
│ id (PK) │◄────────┐
|
||||||
|
│ name │ │
|
||||||
|
│ display_name│ │
|
||||||
|
│ base_url │ │
|
||||||
|
│ is_builtin │ │
|
||||||
|
└─────────────┘ │
|
||||||
|
│
|
||||||
|
┌───────────────┼──────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||||
|
│ user_api_keys │ │user_ai_settin│ │ ai_chats │
|
||||||
|
├────────────────┤ ├──────────────┤ ├─────────────────┤
|
||||||
|
│ id (PK) │ │user_id (PK) │ │ id (PK) │
|
||||||
|
│ user_id (FK) │ │default_prv(FK)│ │ user_id (FK) │
|
||||||
|
│ provider_id(FK) │ │default_model │ │ provider_id(FK) │
|
||||||
|
│ api_key_enc │ │max_tokens │ │ title │
|
||||||
|
│ label │ │temperature │ │ model_used │
|
||||||
|
│ is_active │ │updated_at │ │ system_prompt │
|
||||||
|
│ last_used_at │ └──────────────┘ │ created_at │
|
||||||
|
└────────────────┘ │ updated_at │
|
||||||
|
└─────────────────┘
|
||||||
|
│
|
||||||
|
│
|
||||||
|
┌────────────────┐
|
||||||
|
│ ai_messages │
|
||||||
|
├────────────────┤
|
||||||
|
│ id (PK) │
|
||||||
|
│ chat_id (FK) │
|
||||||
|
│ role │
|
||||||
|
│ content │
|
||||||
|
│ tokens_used │
|
||||||
|
│ created_at │
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationships Summary
|
||||||
|
|
||||||
|
| Entity | Relations |
|
||||||
|
|--------|-----------|
|
||||||
|
| **users** | Has many: todos, categories, projects, recurring_tasks, activity_logs, user_themes, user_api_keys, ai_chats, user_ai_settings |
|
||||||
|
| **todos** | Belongs to: user, project (optional). Many-to-many with categories via todo_categories |
|
||||||
|
| **recurring_tasks** | Belongs to: user. Many-to-many with categories via recurring_task_categories |
|
||||||
|
| **categories** | Linked to: todos, recurring_tasks |
|
||||||
|
| **marketplace_themes** | Installed by users via user_themes |
|
||||||
|
| **ai_providers** | Referenced by: user_api_keys, ai_chats, user_ai_settings |
|
||||||
|
| **ai_chats** | Belongs to: user, provider (optional). Has many: ai_messages |
|
||||||
|
| **ai_messages** | Belongs to: chat |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **UUID Primary Keys**: Using CHAR(36) for UUIDs to support distributed systems and prevent ID enumeration attacks.
|
||||||
|
|
||||||
|
2. **Foreign Key Cascades**:
|
||||||
|
- CASCADE DELETE for user-owned entities to clean up data when users are deleted
|
||||||
|
- SET NULL for optional references (e.g., project_id in todos)
|
||||||
|
|
||||||
|
3. **JSON Fields**: Used for flexible data like settings, custom_days, and metadata.
|
||||||
|
|
||||||
|
4. **Junction Tables**: Proper normalization for many-to-many relationships (todo-categories, recurring_task-categories).
|
||||||
|
|
||||||
|
5. **Activity Logging**: Nullable user_id allows for anonymous/system events.
|
||||||
|
|
||||||
|
6. **Theme Marketplace**: Separation of global theme catalog and user installations.
|
||||||
|
|
||||||
|
7. **AI Multi-Provider**: Support for multiple AI providers with per-user encrypted API keys.
|
||||||
|
|
||||||
|
## Migration and Seeding
|
||||||
|
|
||||||
|
To set up the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all migrations
|
||||||
|
php spark migrate
|
||||||
|
|
||||||
|
# Run seeders
|
||||||
|
php spark db:seed AiProvidersSeeder
|
||||||
|
php spark db:seed MarketplaceThemesSeeder
|
||||||
|
php spark db:seed SampleDataSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model Files
|
||||||
|
|
||||||
|
All tables have corresponding CodeIgniter 4 models in `app/Models/`:
|
||||||
|
|
||||||
|
- UserModel
|
||||||
|
- CategoryModel
|
||||||
|
- ProjectModel
|
||||||
|
- TodoModel
|
||||||
|
- TodoCategoryModel
|
||||||
|
- RecurringTaskModel
|
||||||
|
- RecurringTaskCategoryModel
|
||||||
|
- ActivityLogModel
|
||||||
|
- MarketplaceThemeModel
|
||||||
|
- UserThemeModel
|
||||||
|
- AiProviderModel
|
||||||
|
- UserApiKeyModel
|
||||||
|
- UserAiSettingsModel
|
||||||
|
- AiChatModel
|
||||||
|
- AiMessageModel
|
||||||
|
|
||||||
|
Each model includes:
|
||||||
|
- Validation rules
|
||||||
|
- Timestamp handling
|
||||||
|
- Custom query methods for common operations
|
||||||
|
- Relationship helpers
|
||||||
93
app/Commands/TestModels.php
Normal file
93
app/Commands/TestModels.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
use App\Models\ProjectModel;
|
||||||
|
use App\Models\TodoModel;
|
||||||
|
use App\Models\RecurringTaskModel;
|
||||||
|
use App\Models\ActivityLogModel;
|
||||||
|
|
||||||
|
class TestModels extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Development';
|
||||||
|
protected $name = 'test:models';
|
||||||
|
protected $description = 'Test the database models and automatic logging';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
CLI::write('=== Testing Todo App Models ===', 'green');
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
// Get the seeded user
|
||||||
|
CLI::write('Test 1: Getting seeded user...', 'yellow');
|
||||||
|
$userModel = new UserModel();
|
||||||
|
$user = $userModel->where('email', 'demo@example.com')->first();
|
||||||
|
if (!$user) {
|
||||||
|
CLI::write('✗ No demo user found. Please run seeders first.', 'red');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$userId = $user['id'];
|
||||||
|
CLI::write("✓ Using user: {$user['name']} ({$userId})", 'green');
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
// Test 2: Query categories
|
||||||
|
CLI::write('Test 2: Querying categories...', 'yellow');
|
||||||
|
$categoryModel = new CategoryModel();
|
||||||
|
$categories = $categoryModel->where('user_id', $userId)->findAll();
|
||||||
|
CLI::write("✓ Found " . count($categories) . " categories for user", 'green');
|
||||||
|
foreach ($categories as $cat) {
|
||||||
|
CLI::write(" - {$cat['name']} ({$cat['color']})", 'light_gray');
|
||||||
|
}
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
// Test 3: Query projects
|
||||||
|
CLI::write('Test 3: Querying projects...', 'yellow');
|
||||||
|
$projectModel = new ProjectModel();
|
||||||
|
$projects = $projectModel->where('user_id', $userId)->findAll();
|
||||||
|
CLI::write("✓ Found " . count($projects) . " projects for user", 'green');
|
||||||
|
foreach ($projects as $proj) {
|
||||||
|
CLI::write(" - {$proj['name']}", 'light_gray');
|
||||||
|
}
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
// Test 4: Query todos
|
||||||
|
CLI::write('Test 4: Querying todos...', 'yellow');
|
||||||
|
$todoModel = new TodoModel();
|
||||||
|
$todos = $todoModel->getByUserWithCategories($userId);
|
||||||
|
CLI::write("✓ Found " . count($todos) . " todos for user", 'green');
|
||||||
|
foreach ($todos as $todo) {
|
||||||
|
CLI::write(" - {$todo['title']} ({$todo['status']})", 'light_gray');
|
||||||
|
}
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
// Test 5: Query recurring tasks
|
||||||
|
CLI::write('Test 5: Querying recurring tasks...', 'yellow');
|
||||||
|
$recurringTaskModel = new RecurringTaskModel();
|
||||||
|
$recurringTasks = $recurringTaskModel->getByUserWithCategories($userId);
|
||||||
|
CLI::write("✓ Found " . count($recurringTasks) . " recurring tasks for user", 'green');
|
||||||
|
foreach ($recurringTasks as $task) {
|
||||||
|
CLI::write(" - {$task['title']} ({$task['schedule']})", 'light_gray');
|
||||||
|
}
|
||||||
|
CLI::newLine();
|
||||||
|
|
||||||
|
CLI::write('=== All Tests Completed Successfully ===', 'green');
|
||||||
|
CLI::write("Test User ID: {$userId}", 'light_gray');
|
||||||
|
CLI::write('Models are working correctly. You can now use them in your controllers.', 'light_gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUuid()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ class App extends BaseConfig
|
|||||||
*
|
*
|
||||||
* E.g., http://example.com/
|
* E.g., http://example.com/
|
||||||
*/
|
*/
|
||||||
public string $baseURL = 'http://localhost:8080/';
|
public string $baseURL = 'http://localhost/Todo-App-Backend/public/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class Cors extends BaseConfig
|
|||||||
* - ['http://localhost:8080']
|
* - ['http://localhost:8080']
|
||||||
* - ['https://www.example.com']
|
* - ['https://www.example.com']
|
||||||
*/
|
*/
|
||||||
'allowedOrigins' => [],
|
'allowedOrigins' => ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost'],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
||||||
@@ -57,7 +57,7 @@ class Cors extends BaseConfig
|
|||||||
*
|
*
|
||||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
|
||||||
*/
|
*/
|
||||||
'supportsCredentials' => false,
|
'supportsCredentials' => true,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set headers to allow.
|
* Set headers to allow.
|
||||||
@@ -68,7 +68,7 @@ class Cors extends BaseConfig
|
|||||||
*
|
*
|
||||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||||
*/
|
*/
|
||||||
'allowedHeaders' => [],
|
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-API-Key'],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set headers to expose.
|
* Set headers to expose.
|
||||||
@@ -93,7 +93,7 @@ class Cors extends BaseConfig
|
|||||||
*
|
*
|
||||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||||
*/
|
*/
|
||||||
'allowedMethods' => [],
|
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set how many seconds the results of a preflight request can be cached.
|
* Set how many seconds the results of a preflight request can be cached.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class Filters extends BaseFilters
|
|||||||
'forcehttps' => ForceHTTPS::class,
|
'forcehttps' => ForceHTTPS::class,
|
||||||
'pagecache' => PageCache::class,
|
'pagecache' => PageCache::class,
|
||||||
'performance' => PerformanceMetrics::class,
|
'performance' => PerformanceMetrics::class,
|
||||||
|
'apiauth' => \App\Filters\ApiAuthFilter::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +73,7 @@ class Filters extends BaseFilters
|
|||||||
*/
|
*/
|
||||||
public array $globals = [
|
public array $globals = [
|
||||||
'before' => [
|
'before' => [
|
||||||
|
'cors',
|
||||||
// 'honeypot',
|
// 'honeypot',
|
||||||
// 'csrf',
|
// 'csrf',
|
||||||
// 'invalidchars',
|
// 'invalidchars',
|
||||||
|
|||||||
@@ -6,3 +6,108 @@ use CodeIgniter\Router\RouteCollection;
|
|||||||
* @var RouteCollection $routes
|
* @var RouteCollection $routes
|
||||||
*/
|
*/
|
||||||
$routes->get('/', 'Home::index');
|
$routes->get('/', 'Home::index');
|
||||||
|
$routes->get('/themes', 'ThemeStore::index');
|
||||||
|
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||||
|
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Routes - Version 1.0
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Catch-all CORS preflight handler for all API routes
|
||||||
|
$routes->options('api/v1/(:any)', function () {
|
||||||
|
$response = service('response');
|
||||||
|
return $response->setStatusCode(200)
|
||||||
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public endpoints (no authentication required)
|
||||||
|
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => 'cors'], function ($routes) {
|
||||||
|
// Authentication
|
||||||
|
$routes->options('auth/register', 'AuthController::options');
|
||||||
|
$routes->post('auth/register', 'AuthController::register');
|
||||||
|
$routes->options('auth/login', 'AuthController::options');
|
||||||
|
$routes->post('auth/login', 'AuthController::login');
|
||||||
|
$routes->options('auth/api-key', 'AuthController::options');
|
||||||
|
$routes->post('auth/api-key', 'AuthController::createApiKey');
|
||||||
|
|
||||||
|
// Marketplace - Public access
|
||||||
|
$routes->get('marketplace/themes', 'MarketplaceController::index');
|
||||||
|
$routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1');
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => ['cors', 'apiauth']], function ($routes) {
|
||||||
|
// User endpoints
|
||||||
|
$routes->get('user/profile', 'UserController::profile');
|
||||||
|
$routes->put('user/profile', 'UserController::updateProfile');
|
||||||
|
$routes->get('user/api-keys', 'UserController::listApiKeys');
|
||||||
|
$routes->post('user/api-keys', 'UserController::createApiKey');
|
||||||
|
$routes->delete('user/api-keys/(:segment)', 'UserController::revokeApiKey/$1');
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
$routes->get('categories', 'CategoryController::index');
|
||||||
|
$routes->post('categories', 'CategoryController::create');
|
||||||
|
$routes->get('categories/(:segment)', 'CategoryController::show/$1');
|
||||||
|
$routes->put('categories/(:segment)', 'CategoryController::update/$1');
|
||||||
|
$routes->delete('categories/(:segment)', 'CategoryController::delete/$1');
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
$routes->get('projects', 'ProjectController::index');
|
||||||
|
$routes->post('projects', 'ProjectController::create');
|
||||||
|
$routes->get('projects/(:segment)', 'ProjectController::show/$1');
|
||||||
|
$routes->put('projects/(:segment)', 'ProjectController::update/$1');
|
||||||
|
$routes->delete('projects/(:segment)', 'ProjectController::delete/$1');
|
||||||
|
|
||||||
|
// Todos
|
||||||
|
$routes->get('todos', 'TodoController::index');
|
||||||
|
$routes->post('todos', 'TodoController::create');
|
||||||
|
$routes->get('todos/(:segment)', 'TodoController::show/$1');
|
||||||
|
$routes->put('todos/(:segment)', 'TodoController::update/$1');
|
||||||
|
$routes->delete('todos/(:segment)', 'TodoController::delete/$1');
|
||||||
|
$routes->post('todos/(:segment)/categories', 'TodoController::addCategory/$1');
|
||||||
|
$routes->delete('todos/(:segment)/categories/(:segment)', 'TodoController::removeCategory/$1/$2');
|
||||||
|
|
||||||
|
// Recurring Tasks
|
||||||
|
$routes->get('recurring-tasks', 'RecurringTaskController::index');
|
||||||
|
$routes->post('recurring-tasks', 'RecurringTaskController::create');
|
||||||
|
$routes->get('recurring-tasks/(:segment)', 'RecurringTaskController::show/$1');
|
||||||
|
$routes->put('recurring-tasks/(:segment)', 'RecurringTaskController::update/$1');
|
||||||
|
$routes->delete('recurring-tasks/(:segment)', 'RecurringTaskController::delete/$1');
|
||||||
|
$routes->post('recurring-tasks/(:segment)/categories', 'RecurringTaskController::addCategory/$1');
|
||||||
|
$routes->delete('recurring-tasks/(:segment)/categories/(:segment)', 'RecurringTaskController::removeCategory/$1/$2');
|
||||||
|
|
||||||
|
// Activity Logs
|
||||||
|
$routes->get('activity-logs', 'ActivityLogController::index');
|
||||||
|
$routes->get('activity-logs/(:segment)', 'ActivityLogController::show/$1');
|
||||||
|
|
||||||
|
// User Themes
|
||||||
|
$routes->get('user/themes', 'UserThemeController::index');
|
||||||
|
$routes->post('user/themes', 'UserThemeController::create');
|
||||||
|
$routes->put('user/themes/(:segment)', 'UserThemeController::update/$1');
|
||||||
|
$routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1');
|
||||||
|
});
|
||||||
|
$routes->get('/themes', 'ThemeStore::index');
|
||||||
|
$routes->options('/themes', static function () {
|
||||||
|
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||||
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
return response()->setStatusCode(204);
|
||||||
|
});
|
||||||
|
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||||
|
$routes->options('/themes/upload', static function () {
|
||||||
|
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||||
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
return response()->setStatusCode(204);
|
||||||
|
});
|
||||||
|
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||||
|
|||||||
359
app/Controllers/Api/BaseController.php
Normal file
359
app/Controllers/Api/BaseController.php
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api;
|
||||||
|
|
||||||
|
use CodeIgniter\RESTful\ResourceController;
|
||||||
|
|
||||||
|
class BaseController extends ResourceController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the authenticated user from the request
|
||||||
|
*/
|
||||||
|
protected function getUser(): ?array
|
||||||
|
{
|
||||||
|
return $this->request->user ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authenticated user ID
|
||||||
|
*/
|
||||||
|
protected function getUserId(): ?string
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
return $user['id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
->setStatusCode($statusCode)
|
||||||
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||||
|
->setJSON($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error response with structured error info
|
||||||
|
*/
|
||||||
|
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
|
||||||
|
{
|
||||||
|
$body = [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($errors !== null) {
|
||||||
|
$body['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode($statusCode)
|
||||||
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||||
|
->setJSON($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation error shorthand (422)
|
||||||
|
*/
|
||||||
|
protected function validationErrorResponse($errors): void
|
||||||
|
{
|
||||||
|
$this->errorResponse('Validation failed', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not found shorthand (404)
|
||||||
|
*/
|
||||||
|
protected function notFoundResponse(string $resource = 'Resource'): void
|
||||||
|
{
|
||||||
|
$this->errorResponse("{$resource} not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Validation
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate request data using the rules defined in a model.
|
||||||
|
*
|
||||||
|
* Returns true on success, sends a 422 JSON response and returns false on failure.
|
||||||
|
*/
|
||||||
|
protected function validateWithModel(\CodeIgniter\Model $model): bool
|
||||||
|
{
|
||||||
|
$validation = \Config\Services::validation();
|
||||||
|
$rules = $model->getValidationRules();
|
||||||
|
$errors = $model->getValidationMessages();
|
||||||
|
|
||||||
|
if (empty($rules)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validation->setRules($rules, $errors);
|
||||||
|
|
||||||
|
if (!$validation->withRequest($this->request)->run()) {
|
||||||
|
$this->errorResponse('Validation failed', 422, $validation->getErrors());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy simple validation for controllers that define rules inline.
|
||||||
|
*/
|
||||||
|
protected function validateRequest(array $rules): bool
|
||||||
|
{
|
||||||
|
$validation = \Config\Services::validation();
|
||||||
|
|
||||||
|
foreach ($rules as $field => $rule) {
|
||||||
|
if (is_array($rule) && isset($rule['rules'])) {
|
||||||
|
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
|
||||||
|
} else {
|
||||||
|
$validation->setRule($field, $field, $rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$validation->withRequest($this->request)->run()) {
|
||||||
|
$this->errorResponse('Validation failed', 422, $validation->getErrors());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Controllers/Api/V1/ActivityLogController.php
Normal file
45
app/Controllers/Api/V1/ActivityLogController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\ActivityLogModel;
|
||||||
|
|
||||||
|
class ActivityLogController extends BaseController
|
||||||
|
{
|
||||||
|
protected $activityLogModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityLogModel = new ActivityLogModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activity logs for the authenticated user
|
||||||
|
* GET /api/v1/activity-logs
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$limit = (int)($this->request->getVar('limit') ?? 50);
|
||||||
|
$logs = $this->activityLogModel->getByUser($userId, $limit);
|
||||||
|
|
||||||
|
return $this->successResponse($logs, 'Activity logs retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific activity log
|
||||||
|
* GET /api/v1/activity-logs/{id}
|
||||||
|
*/
|
||||||
|
public function show($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$log = $this->activityLogModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$log) {
|
||||||
|
return $this->errorResponse('Activity log not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse($log, 'Activity log retrieved successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
383
app/Controllers/Api/V1/AuthController.php
Normal file
383
app/Controllers/Api/V1/AuthController.php
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Models\ApiAuthKeyModel;
|
||||||
|
|
||||||
|
class AuthController extends BaseController
|
||||||
|
{
|
||||||
|
protected $userModel;
|
||||||
|
protected $apiAuthKeyModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->userModel = new UserModel();
|
||||||
|
$this->apiAuthKeyModel = new ApiAuthKeyModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle CORS preflight requests
|
||||||
|
* OPTIONS /api/v1/auth/*
|
||||||
|
*/
|
||||||
|
public function options()
|
||||||
|
{
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(200)
|
||||||
|
->setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
|
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
* POST /api/v1/auth/register
|
||||||
|
*/
|
||||||
|
public function register()
|
||||||
|
{
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'email' => [
|
||||||
|
'rules' => 'required|valid_email|is_unique[users.email]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Email is required',
|
||||||
|
'valid_email' => 'Please provide a valid email address',
|
||||||
|
'is_unique' => 'This email is already registered'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'password' => [
|
||||||
|
'rules' => 'required|min_length[8]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Password is required',
|
||||||
|
'min_length' => 'Password must be at least 8 characters long'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Name is required',
|
||||||
|
'max_length' => 'Name must not exceed 255 characters'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate UUID for user
|
||||||
|
$userId = $this->generateUuid();
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
$userData = [
|
||||||
|
'id' => $userId,
|
||||||
|
'email' => $json['email'],
|
||||||
|
'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT),
|
||||||
|
'name' => $json['name'],
|
||||||
|
'avatar_url' => $json['avatar_url'] ?? null,
|
||||||
|
'settings' => isset($json['settings']) && $json['settings'] ? json_encode($json['settings']) : json_encode(['theme' => 'light']),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->userModel->insert($userData);
|
||||||
|
|
||||||
|
// Create API key for the new user
|
||||||
|
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||||
|
$userId,
|
||||||
|
'Default API Key',
|
||||||
|
['read', 'write'],
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove sensitive data from response
|
||||||
|
unset($userData['password_hash']);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'user' => $userData,
|
||||||
|
'api_key' => $apiKey['key'],
|
||||||
|
'key_prefix' => $apiKey['prefix'],
|
||||||
|
], 'User registered successfully', 201);
|
||||||
|
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
|
||||||
|
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login user and return API key
|
||||||
|
* POST /api/v1/auth/login
|
||||||
|
*/
|
||||||
|
public function login()
|
||||||
|
{
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'email' => 'required|valid_email',
|
||||||
|
'password' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
$user = $this->userModel->where('email', $json['email'])->first();
|
||||||
|
|
||||||
|
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
|
||||||
|
return $this->errorResponse('Invalid email or password', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has an existing active API key
|
||||||
|
$existingKey = $this->apiAuthKeyModel
|
||||||
|
->where('user_id', $user['id'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingKey) {
|
||||||
|
// Return existing key
|
||||||
|
return $this->successResponse([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'name' => $user['name'],
|
||||||
|
],
|
||||||
|
'api_key_prefix' => $existingKey['key_prefix'],
|
||||||
|
'message' => 'Using existing API key',
|
||||||
|
], 'Login successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new API key
|
||||||
|
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||||
|
$user['id'],
|
||||||
|
'Login API Key',
|
||||||
|
['read', 'write'],
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'name' => $user['name'],
|
||||||
|
],
|
||||||
|
'api_key' => $apiKey['key'],
|
||||||
|
'key_prefix' => $apiKey['prefix'],
|
||||||
|
], 'Login successful');
|
||||||
|
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
|
||||||
|
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an API key using email and password (legacy endpoint)
|
||||||
|
* POST /api/v1/auth/api-key
|
||||||
|
*/
|
||||||
|
public function createApiKey()
|
||||||
|
{
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'email' => 'required|valid_email',
|
||||||
|
'password' => 'required|min_length[6]',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
$user = $this->userModel->where('email', $json['email'])->first();
|
||||||
|
|
||||||
|
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
|
||||||
|
return $this->errorResponse('Invalid email or password', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create API key
|
||||||
|
$name = $json['name'] ?? 'API Key';
|
||||||
|
$scopes = $json['scopes'] ?? ['read', 'write'];
|
||||||
|
$expiresAt = $json['expires_at'] ?? null;
|
||||||
|
|
||||||
|
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||||
|
$user['id'],
|
||||||
|
$name,
|
||||||
|
$scopes,
|
||||||
|
$expiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'key' => $apiKey['key'],
|
||||||
|
'prefix' => $apiKey['prefix'],
|
||||||
|
'name' => $apiKey['name'],
|
||||||
|
'scopes' => $apiKey['scopes'],
|
||||||
|
'expires_at' => $apiKey['expires_at'],
|
||||||
|
], 'API key created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
}
|
||||||
166
app/Controllers/Api/V1/CategoryController.php
Normal file
166
app/Controllers/Api/V1/CategoryController.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
|
||||||
|
class CategoryController extends BaseController
|
||||||
|
{
|
||||||
|
protected $categoryModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->categoryModel = new CategoryModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['name', 'created_at'];
|
||||||
|
const FILTERABLE = ['favorite'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/categories
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||||
|
$sorts = $this->getSortParams(self::SORTABLE);
|
||||||
|
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/categories
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
if (!$this->validateWithModel($this->categoryModel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// Custom duplicate check (per user)
|
||||||
|
$existing = $this->categoryModel
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('name', $json['name'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->errorResponse('A category with this name already exists.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'name' => $json['name'],
|
||||||
|
'color' => $json['color'],
|
||||||
|
'favorite' => !empty($json['favorite']),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->categoryModel->insert($data);
|
||||||
|
$category = $this->categoryModel->find($data['id']);
|
||||||
|
|
||||||
|
$this->logActivity('category_created', 'category', $data['id'], [
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse($category, 'Category created successfully', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/categories/{id}
|
||||||
|
*/
|
||||||
|
public function show($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$category) {
|
||||||
|
return $this->errorResponse('Category not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse($category, 'Category retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/categories/{id}
|
||||||
|
*/
|
||||||
|
public function update($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$category) {
|
||||||
|
return $this->errorResponse('Category not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// Duplicate check on rename
|
||||||
|
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
|
||||||
|
$existing = $this->categoryModel
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('name', $json['name'])
|
||||||
|
->where('id !=', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->errorResponse('A category with this name already exists.', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedFields = ['name', 'color', 'favorite'];
|
||||||
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
|
if (empty($updateData)) {
|
||||||
|
return $this->errorResponse('No valid fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert boolean
|
||||||
|
if (array_key_exists('favorite', $updateData)) {
|
||||||
|
$updateData['favorite'] = !empty($updateData['favorite']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->categoryModel->update($id, $updateData);
|
||||||
|
$category = $this->categoryModel->find($id);
|
||||||
|
|
||||||
|
$this->logActivity('category_updated', 'category', $id, [
|
||||||
|
'name' => $category['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse($category, 'Category updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/categories/{id}
|
||||||
|
*/
|
||||||
|
public function delete($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$category) {
|
||||||
|
return $this->errorResponse('Category not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->categoryModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('category_deleted', 'category', $id, [
|
||||||
|
'name' => $category['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Category deleted successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Controllers/Api/V1/MarketplaceController.php
Normal file
42
app/Controllers/Api/V1/MarketplaceController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\MarketplaceThemeModel;
|
||||||
|
|
||||||
|
class MarketplaceController extends BaseController
|
||||||
|
{
|
||||||
|
protected $marketplaceThemeModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->marketplaceThemeModel = new MarketplaceThemeModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all marketplace themes
|
||||||
|
* GET /api/v1/marketplace/themes
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$themes = $this->marketplaceThemeModel->getPublished();
|
||||||
|
|
||||||
|
return $this->successResponse($themes, 'Marketplace themes retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific marketplace theme
|
||||||
|
* GET /api/v1/marketplace/themes/{id}
|
||||||
|
*/
|
||||||
|
public function show($id = null)
|
||||||
|
{
|
||||||
|
$theme = $this->marketplaceThemeModel->find($id);
|
||||||
|
|
||||||
|
if (!$theme) {
|
||||||
|
return $this->errorResponse('Theme not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse($theme, 'Theme retrieved successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/Controllers/Api/V1/ProjectController.php
Normal file
138
app/Controllers/Api/V1/ProjectController.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\ProjectModel;
|
||||||
|
|
||||||
|
class ProjectController extends BaseController
|
||||||
|
{
|
||||||
|
protected $projectModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->projectModel = new ProjectModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['name', 'created_at'];
|
||||||
|
const FILTERABLE = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/projects
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||||
|
$sorts = $this->getSortParams(self::SORTABLE);
|
||||||
|
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/projects
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
if (!$this->validateWithModel($this->projectModel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'name' => $json['name'],
|
||||||
|
'description' => $json['description'] ?? null,
|
||||||
|
'color' => $json['color'] ?? '#8B5CF6',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->projectModel->insert($data);
|
||||||
|
$project = $this->projectModel->find($data['id']);
|
||||||
|
|
||||||
|
$this->logActivity('project_created', 'project', $data['id'], [
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse($project, 'Project created successfully', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/projects/{id}
|
||||||
|
*/
|
||||||
|
public function show($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
return $this->errorResponse('Project not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse($project, 'Project retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/projects/{id}
|
||||||
|
*/
|
||||||
|
public function update($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
return $this->errorResponse('Project not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$allowedFields = ['name', 'description', 'color'];
|
||||||
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
|
if (empty($updateData)) {
|
||||||
|
return $this->errorResponse('No valid fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->projectModel->update($id, $updateData);
|
||||||
|
$project = $this->projectModel->find($id);
|
||||||
|
|
||||||
|
$this->logActivity('project_updated', 'project', $id, [
|
||||||
|
'name' => $project['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse($project, 'Project updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/projects/{id}
|
||||||
|
*/
|
||||||
|
public function delete($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
return $this->errorResponse('Project not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->projectModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('project_deleted', 'project', $id, [
|
||||||
|
'name' => $project['name'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Project deleted successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
250
app/Controllers/Api/V1/RecurringTaskController.php
Normal file
250
app/Controllers/Api/V1/RecurringTaskController.php
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\RecurringTaskModel;
|
||||||
|
use App\Models\RecurringTaskCategoryModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
|
||||||
|
class RecurringTaskController extends BaseController
|
||||||
|
{
|
||||||
|
protected $recurringTaskModel;
|
||||||
|
protected $recurringTaskCategoryModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->recurringTaskModel = new RecurringTaskModel();
|
||||||
|
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTABLE = ['title', 'schedule', 'created_at'];
|
||||||
|
const FILTERABLE = ['schedule', 'favorite'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/recurring-tasks
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||||
|
$sorts = $this->getSortParams(self::SORTABLE);
|
||||||
|
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/recurring-tasks
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
if (!$this->validateWithModel($this->recurringTaskModel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'title' => $json['title'],
|
||||||
|
'description' => $json['description'] ?? null,
|
||||||
|
'schedule' => $json['schedule'] ?? 'weekly',
|
||||||
|
'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]',
|
||||||
|
'favorite' => !empty($json['favorite']),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->recurringTaskModel->insert($data);
|
||||||
|
|
||||||
|
// Link category if provided
|
||||||
|
if (!empty($json['category_id'])) {
|
||||||
|
$this->linkCategory($data['id'], $json['category_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logActivity('recurring_task_created', 'recurring_task', $data['id'], [
|
||||||
|
'title' => $data['title'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']);
|
||||||
|
|
||||||
|
return $this->successResponse($task[0] ?? null, 'Recurring task created successfully', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/recurring-tasks/{id}
|
||||||
|
*/
|
||||||
|
public function show($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
||||||
|
|
||||||
|
if (empty($tasks)) {
|
||||||
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse($tasks[0], 'Recurring task retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/recurring-tasks/{id}
|
||||||
|
*/
|
||||||
|
public function update($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$task) {
|
||||||
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// Handle category update
|
||||||
|
if (array_key_exists('category_id', $json)) {
|
||||||
|
$this->recurringTaskCategoryModel->where('recurring_task_id', $id)->delete();
|
||||||
|
if (!empty($json['category_id'])) {
|
||||||
|
$this->linkCategory($id, $json['category_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite'];
|
||||||
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
|
// Convert custom_days array to JSON string
|
||||||
|
if (isset($updateData['custom_days']) && is_array($updateData['custom_days'])) {
|
||||||
|
$updateData['custom_days'] = json_encode($updateData['custom_days']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('favorite', $updateData)) {
|
||||||
|
$updateData['favorite'] = !empty($updateData['favorite']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($updateData)) {
|
||||||
|
$this->recurringTaskModel->update($id, $updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 /api/v1/recurring-tasks/{id}
|
||||||
|
*/
|
||||||
|
public function delete($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$task) {
|
||||||
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recurringTaskModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('recurring_task_deleted', 'recurring_task', $id, [
|
||||||
|
'title' => $task['title'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Recurring task deleted successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/recurring-tasks/{id}/categories
|
||||||
|
*/
|
||||||
|
public function addCategory($taskId = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = ['category_id' => 'required'];
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||||
|
if (!$task) {
|
||||||
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->recurringTaskCategoryModel
|
||||||
|
->where('recurring_task_id', $taskId)
|
||||||
|
->where('category_id', $json['category_id'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->errorResponse('Category already linked to this task', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recurringTaskCategoryModel->insert([
|
||||||
|
'recurring_task_id' => $taskId,
|
||||||
|
'category_id' => $json['category_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Category added to recurring task successfully', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
|
||||||
|
*/
|
||||||
|
public function removeCategory($taskId = null, $categoryId = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||||
|
if (!$task) {
|
||||||
|
return $this->errorResponse('Recurring task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recurringTaskCategoryModel
|
||||||
|
->where('recurring_task_id', $taskId)
|
||||||
|
->where('category_id', $categoryId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Category removed from recurring task successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a category (internal helper)
|
||||||
|
*/
|
||||||
|
private function linkCategory(string $taskId, string $categoryId): void
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$categoryModel = new CategoryModel();
|
||||||
|
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$category) return;
|
||||||
|
|
||||||
|
$existing = $this->recurringTaskCategoryModel
|
||||||
|
->where('recurring_task_id', $taskId)
|
||||||
|
->where('category_id', $categoryId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
$this->recurringTaskCategoryModel->insert([
|
||||||
|
'recurring_task_id' => $taskId,
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
276
app/Controllers/Api/V1/TodoController.php
Normal file
276
app/Controllers/Api/V1/TodoController.php
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\TodoModel;
|
||||||
|
use App\Models\TodoCategoryModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
|
||||||
|
class TodoController extends BaseController
|
||||||
|
{
|
||||||
|
protected $todoModel;
|
||||||
|
protected $todoCategoryModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->todoModel = new TodoModel();
|
||||||
|
$this->todoCategoryModel = new TodoCategoryModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Allowed sort & filter fields ───────────────────────────────────────
|
||||||
|
const SORTABLE = ['title', 'status', 'due_date', 'due_time', 'created_at', 'updated_at'];
|
||||||
|
const FILTERABLE = ['status', 'project_id', 'sync_enabled', 'reminder_enabled', 'recurring_enabled'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/todos
|
||||||
|
* Paginated, sortable, filterable list of todos for the authenticated user.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* page (int) – page number, default 1
|
||||||
|
* per_page (int) – items per page, default 50, max 200
|
||||||
|
* sort (string) – e.g. "title" or "-created_at,title"
|
||||||
|
* status (string) – filter by status
|
||||||
|
* project_id (string) – filter by project
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/todos
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
if (!$this->validateWithModel($this->todoModel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'title' => $json['title'],
|
||||||
|
'description' => $json['description'] ?? null,
|
||||||
|
'status' => $json['status'] ?? 'open',
|
||||||
|
'due_date' => $json['due_date'] ?? null,
|
||||||
|
'due_time' => $json['due_time'] ?? null,
|
||||||
|
'sync_enabled' => !empty($json['sync_enabled']),
|
||||||
|
'reminder_enabled' => !empty($json['reminder_enabled']),
|
||||||
|
'recurring_enabled' => !empty($json['recurring_enabled']),
|
||||||
|
'project_id' => $json['project_id'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->todoModel->insert($data);
|
||||||
|
|
||||||
|
// Link category if provided
|
||||||
|
if (!empty($json['category_id'])) {
|
||||||
|
$this->linkCategory($data['id'], $json['category_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logActivity('todo_created', 'todo', $data['id'], [
|
||||||
|
'title' => $data['title'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
|
||||||
|
|
||||||
|
return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/todos/{id}
|
||||||
|
*/
|
||||||
|
public function show($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$todos = $this->todoModel->getByUserWithCategories($userId, $id);
|
||||||
|
|
||||||
|
if (empty($todos)) {
|
||||||
|
return $this->errorResponse('Todo not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse($todos[0], 'Todo retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/todos/{id}
|
||||||
|
*/
|
||||||
|
public function update($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$todo) {
|
||||||
|
return $this->errorResponse('Todo not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
// Handle category update separately (not a column in todos table)
|
||||||
|
$hasCategoryUpdate = array_key_exists('category_id', $json);
|
||||||
|
if ($hasCategoryUpdate) {
|
||||||
|
$categoryId = $json['category_id'];
|
||||||
|
$this->todoCategoryModel->where('todo_id', $id)->delete();
|
||||||
|
if (!empty($categoryId)) {
|
||||||
|
$this->linkCategory($id, $categoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update todo fields
|
||||||
|
$allowedFields = [
|
||||||
|
'title', 'description', 'status', 'due_date', 'due_time',
|
||||||
|
'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id',
|
||||||
|
];
|
||||||
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
|
if (!empty($updateData)) {
|
||||||
|
// 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->logActivity('todo_updated', 'todo', $id, [
|
||||||
|
'title' => $todo['title'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
|
||||||
|
|
||||||
|
return $this->successResponse($updated[0] ?? null, 'Todo updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/todos/{id}
|
||||||
|
*/
|
||||||
|
public function delete($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$todo) {
|
||||||
|
return $this->errorResponse('Todo not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->todoModel->delete($id);
|
||||||
|
|
||||||
|
$this->logActivity('todo_deleted', 'todo', $id, [
|
||||||
|
'title' => $todo['title'] ?? 'Unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Todo deleted successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category linking ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/todos/{id}/categories
|
||||||
|
*/
|
||||||
|
public function addCategory($todoId = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = ['category_id' => 'required'];
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||||
|
if (!$todo) {
|
||||||
|
return $this->errorResponse('Todo not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->todoCategoryModel
|
||||||
|
->where('todo_id', $todoId)
|
||||||
|
->where('category_id', $json['category_id'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->errorResponse('Category already linked to this todo', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->todoCategoryModel->insert([
|
||||||
|
'todo_id' => $todoId,
|
||||||
|
'category_id' => $json['category_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Category added to todo successfully', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/todos/{id}/categories/{categoryId}
|
||||||
|
*/
|
||||||
|
public function removeCategory($todoId = null, $categoryId = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||||
|
if (!$todo) {
|
||||||
|
return $this->errorResponse('Todo not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->todoCategoryModel
|
||||||
|
->where('todo_id', $todoId)
|
||||||
|
->where('category_id', $categoryId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'Category removed from todo successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a category to a todo (internal helper)
|
||||||
|
*/
|
||||||
|
private function linkCategory(string $todoId, string $categoryId): void
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
$categoryModel = new CategoryModel();
|
||||||
|
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$category) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->todoCategoryModel
|
||||||
|
->where('todo_id', $todoId)
|
||||||
|
->where('category_id', $categoryId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
$this->todoCategoryModel->insert([
|
||||||
|
'todo_id' => $todoId,
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/Controllers/Api/V1/UserController.php
Normal file
126
app/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Models\ApiAuthKeyModel;
|
||||||
|
|
||||||
|
class UserController extends BaseController
|
||||||
|
{
|
||||||
|
protected $userModel;
|
||||||
|
protected $apiAuthKeyModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->userModel = new UserModel();
|
||||||
|
$this->apiAuthKeyModel = new ApiAuthKeyModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user profile
|
||||||
|
* GET /api/v1/user/profile
|
||||||
|
*/
|
||||||
|
public function profile()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return $this->errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sensitive data
|
||||||
|
unset($user['password_hash']);
|
||||||
|
|
||||||
|
return $this->successResponse($user, 'Profile retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user profile
|
||||||
|
* PUT /api/v1/user/profile
|
||||||
|
*/
|
||||||
|
public function updateProfile()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$allowedFields = ['name', 'avatar_url', 'settings'];
|
||||||
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
|
if (empty($updateData)) {
|
||||||
|
return $this->errorResponse('No valid fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userModel->update($userId, $updateData);
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
unset($user['password_hash']);
|
||||||
|
|
||||||
|
return $this->successResponse($user, 'Profile updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List user's API keys
|
||||||
|
* GET /api/v1/user/api-keys
|
||||||
|
*/
|
||||||
|
public function listApiKeys()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$apiKeys = $this->apiAuthKeyModel->getByUser($userId);
|
||||||
|
|
||||||
|
// Remove sensitive data
|
||||||
|
foreach ($apiKeys as &$key) {
|
||||||
|
unset($key['key_hash']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse($apiKeys, 'API keys retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* POST /api/v1/user/api-keys
|
||||||
|
*/
|
||||||
|
public function createApiKey()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$name = $json['name'] ?? 'API Key';
|
||||||
|
$scopes = $json['scopes'] ?? ['read', 'write'];
|
||||||
|
$expiresAt = $json['expires_at'] ?? null;
|
||||||
|
|
||||||
|
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||||
|
$userId,
|
||||||
|
$name,
|
||||||
|
$scopes,
|
||||||
|
$expiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'id' => $apiKey['id'],
|
||||||
|
'key' => $apiKey['key'],
|
||||||
|
'prefix' => $apiKey['prefix'],
|
||||||
|
'name' => $apiKey['name'],
|
||||||
|
'scopes' => $apiKey['scopes'],
|
||||||
|
'expires_at' => $apiKey['expires_at'],
|
||||||
|
], 'API key created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key
|
||||||
|
* DELETE /api/v1/user/api-keys/{id}
|
||||||
|
*/
|
||||||
|
public function revokeApiKey($id)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$apiKey = $this->apiAuthKeyModel->find($id);
|
||||||
|
|
||||||
|
if (!$apiKey || $apiKey['user_id'] !== $userId) {
|
||||||
|
return $this->errorResponse('API key not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiAuthKeyModel->revokeKey($id);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'API key revoked successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/Controllers/Api/V1/UserThemeController.php
Normal file
110
app/Controllers/Api/V1/UserThemeController.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Controllers\Api\BaseController;
|
||||||
|
use App\Models\UserThemeModel;
|
||||||
|
|
||||||
|
class UserThemeController extends BaseController
|
||||||
|
{
|
||||||
|
protected $userThemeModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->userThemeModel = new UserThemeModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all themes for the authenticated user
|
||||||
|
* GET /api/v1/user/themes
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$themes = $this->userThemeModel->getByUser($userId);
|
||||||
|
|
||||||
|
return $this->successResponse($themes, 'User themes retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user theme
|
||||||
|
* POST /api/v1/user/themes
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'theme_id' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validateRequest($rules)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'theme_id' => $json['theme_id'],
|
||||||
|
'is_active' => $json['is_active'] ?? false,
|
||||||
|
'custom_settings' => $json['custom_settings'] ? json_encode($json['custom_settings']) : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->userThemeModel->insert($data);
|
||||||
|
$theme = $this->userThemeModel->find($data['id']);
|
||||||
|
|
||||||
|
return $this->successResponse($theme, 'User theme created successfully', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a user theme
|
||||||
|
* PUT /api/v1/user/themes/{id}
|
||||||
|
*/
|
||||||
|
public function update($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$theme) {
|
||||||
|
return $this->errorResponse('User theme not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $this->request->getJSON(true);
|
||||||
|
$allowedFields = ['is_active', 'custom_settings'];
|
||||||
|
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||||
|
|
||||||
|
if (isset($updateData['custom_settings'])) {
|
||||||
|
$updateData['custom_settings'] = json_encode($updateData['custom_settings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updateData)) {
|
||||||
|
return $this->errorResponse('No valid fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userThemeModel->update($id, $updateData);
|
||||||
|
$theme = $this->userThemeModel->find($id);
|
||||||
|
|
||||||
|
return $this->successResponse($theme, 'User theme updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user theme
|
||||||
|
* DELETE /api/v1/user/themes/{id}
|
||||||
|
*/
|
||||||
|
public function delete($id = null)
|
||||||
|
{
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if (!$theme) {
|
||||||
|
return $this->errorResponse('User theme not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userThemeModel->delete($id);
|
||||||
|
|
||||||
|
return $this->successResponse(null, 'User theme deleted successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
266
app/Controllers/ThemeStore.php
Normal file
266
app/Controllers/ThemeStore.php
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Models\MarketplaceThemeModel;
|
||||||
|
use App\Models\UserThemeModel;
|
||||||
|
use CodeIgniter\HTTP\Response;
|
||||||
|
|
||||||
|
class ThemeStore extends BaseController
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$model = new MarketplaceThemeModel();
|
||||||
|
$themes = $model->where('is_published', 1)->findAll();
|
||||||
|
|
||||||
|
foreach ($themes as &$theme) {
|
||||||
|
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||||
|
$theme['colors'] = $meta['colors'] ?? [];
|
||||||
|
$theme['tags'] = $meta['tags'] ?? [];
|
||||||
|
$theme['vars'] = $meta['vars'] ?? [];
|
||||||
|
|
||||||
|
// Provide a preview array compatible with the frontend
|
||||||
|
$theme['preview'] = !empty($theme['colors']) ? array_values($theme['colors']) : ['#ffffff', '#f0f0f0', '#007acc'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch') || str_contains($this->request->getHeaderLine('Accept'), 'application/json')) {
|
||||||
|
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
return $this->response->setJSON($themes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('theme_store', [
|
||||||
|
'themes' => $themes,
|
||||||
|
'flash_success' => session()->getFlashdata('success'),
|
||||||
|
'flash_error' => session()->getFlashdata('error'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(): Response
|
||||||
|
{
|
||||||
|
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
|
||||||
|
$file = $this->request->getFile('theme_css');
|
||||||
|
$displayName = trim($this->request->getPost('display_name') ?? '');
|
||||||
|
$description = trim($this->request->getPost('description') ?? '');
|
||||||
|
|
||||||
|
if ($displayName === '') {
|
||||||
|
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||||
|
return $this->response->setStatusCode(400)->setJSON(['error' => 'Display name is required.']);
|
||||||
|
}
|
||||||
|
return redirect()->to('themes')->with('error', 'Display name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $file || ! $file->isValid() || $file->hasMoved()) {
|
||||||
|
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||||
|
return $this->response->setStatusCode(400)->setJSON(['error' => 'Please upload a valid CSS file.']);
|
||||||
|
}
|
||||||
|
return redirect()->to('themes')->with('error', 'Please upload a valid CSS file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtolower($file->getExtension()) !== 'css') {
|
||||||
|
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||||
|
return $this->response->setStatusCode(400)->setJSON(['error' => 'Only .css files are allowed.']);
|
||||||
|
}
|
||||||
|
return redirect()->to('themes')->with('error', 'Only .css files are allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $displayName));
|
||||||
|
$slug = trim($slug, '-');
|
||||||
|
$filename = $slug . '-' . substr(bin2hex(random_bytes(3)), 0, 6) . '.css';
|
||||||
|
|
||||||
|
$file->move(FCPATH . 'themes', $filename, true);
|
||||||
|
|
||||||
|
// Extract CSS variables and colors from the uploaded file
|
||||||
|
$cssContent = file_get_contents(FCPATH . 'themes/' . $filename);
|
||||||
|
preg_match_all('/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/', $cssContent, $matches);
|
||||||
|
|
||||||
|
$vars = [];
|
||||||
|
if (!empty($matches[1])) {
|
||||||
|
foreach ($matches[1] as $index => $key) {
|
||||||
|
$vars[$key] = trim($matches[2][$index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to generate 3-color preview based on standard variables
|
||||||
|
$colors = [];
|
||||||
|
if (isset($vars['--bg'])) $colors['bg'] = $vars['--bg'];
|
||||||
|
if (isset($vars['--surface'])) $colors['surface'] = $vars['--surface'];
|
||||||
|
if (isset($vars['--accent'])) $colors['accent'] = $vars['--accent'];
|
||||||
|
|
||||||
|
$model = new MarketplaceThemeModel();
|
||||||
|
$model->insert([
|
||||||
|
'id' => $this->uuid4(),
|
||||||
|
'name' => $slug,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'description' => $description ?: 'Custom community theme.',
|
||||||
|
'author' => 'Community',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'download_url' => '/themes/' . $filename,
|
||||||
|
'price' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'tags' => ['custom', 'community'],
|
||||||
|
'colors' => $colors,
|
||||||
|
'vars' => $vars
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '"' . esc($displayName) . '" uploaded successfully!'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return redirect()->to('themes')->with('success', '"' . esc($displayName) . '" uploaded successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(string $id): Response
|
||||||
|
{
|
||||||
|
$model = new MarketplaceThemeModel();
|
||||||
|
$theme = $model->find($id);
|
||||||
|
|
||||||
|
if (! $theme) {
|
||||||
|
return $this->response->setStatusCode(404)->setBody('<p style="font-family:sans-serif;padding:2rem">Theme not found.</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$distIndex = '/home/came/Nextcloud/arch-work/Projects/Todo-App/dist/index.html';
|
||||||
|
|
||||||
|
if (! file_exists($distIndex)) {
|
||||||
|
return $this->response->setBody(
|
||||||
|
'<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f0f17;color:#94a3b8">'
|
||||||
|
. '<div style="text-align:center"><p>Todo app dist not found.</p></div></body></html>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$todoHtml = file_get_contents($distIndex);
|
||||||
|
|
||||||
|
// Rewrite asset paths from /assets/ to the public symlink so Apache serves them
|
||||||
|
$assetBase = rtrim(base_url('todo-preview'), '/');
|
||||||
|
$todoHtml = str_replace('="/assets/', '="' . $assetBase . '/assets/', $todoHtml);
|
||||||
|
|
||||||
|
// Build CSS variable overrides from the stored vars map
|
||||||
|
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||||
|
$vars = $meta['vars'] ?? [];
|
||||||
|
|
||||||
|
$cssVars = ":root {\n";
|
||||||
|
foreach ($vars as $prop => $value) {
|
||||||
|
$cssVars .= " {$prop}: {$value};\n";
|
||||||
|
}
|
||||||
|
$cssVars .= "}\n";
|
||||||
|
|
||||||
|
// Also inject any raw CSS from the downloaded file (for custom/uploaded themes)
|
||||||
|
$cssPath = FCPATH . ltrim($theme['download_url'], '/');
|
||||||
|
$rawCss = file_exists($cssPath) ? file_get_contents($cssPath) : '';
|
||||||
|
|
||||||
|
$styleTag = "<style>\n/* Theme Store: {$theme['display_name']} */\n{$cssVars}\n{$rawCss}\n</style>";
|
||||||
|
|
||||||
|
$todoHtml = str_replace('</head>', $styleTag . "\n</head>", $todoHtml);
|
||||||
|
|
||||||
|
return $this->response
|
||||||
|
->setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
->setBody($todoHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function install(string $id): Response
|
||||||
|
{
|
||||||
|
$model = new MarketplaceThemeModel();
|
||||||
|
$theme = $model->find($id);
|
||||||
|
|
||||||
|
if (! $theme) {
|
||||||
|
return $this->response->setStatusCode(404)->setJSON(['error' => 'Theme not found in the marketplace.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using session user_id or a default placeholder since standard auth might be configured separately
|
||||||
|
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||||
|
|
||||||
|
$userThemeModel = new UserThemeModel();
|
||||||
|
|
||||||
|
if (! $userThemeModel->isInstalled($userId, $id)) {
|
||||||
|
$userThemeModel->installTheme($userId, $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => true,
|
||||||
|
'message' => '"' . esc($theme['display_name']) . '" has been installed to your account.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activate(string $id): Response
|
||||||
|
{
|
||||||
|
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||||
|
|
||||||
|
$userThemeModel = new UserThemeModel();
|
||||||
|
|
||||||
|
if (! $userThemeModel->isInstalled($userId, $id)) {
|
||||||
|
return $this->response->setStatusCode(400)->setJSON(['error' => 'Theme must be installed before it can be activated.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userThemeModel->setActiveTheme($userId, $id);
|
||||||
|
|
||||||
|
return $this->response->setJSON(['success' => true, 'message' => 'Theme activated successfully.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uninstall(string $id): Response
|
||||||
|
{
|
||||||
|
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||||
|
$userThemeModel = new UserThemeModel();
|
||||||
|
|
||||||
|
$userThemeModel->uninstallTheme($userId, $id);
|
||||||
|
|
||||||
|
return $this->response->setJSON(['success' => true, 'message' => 'Theme successfully uninstalled.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function myThemes(): Response
|
||||||
|
{
|
||||||
|
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||||
|
$userThemeModel = new UserThemeModel();
|
||||||
|
|
||||||
|
return $this->response->setJSON(['success' => true, 'data' => $userThemeModel->getUserThemes($userId)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serveCss(string $filename): Response
|
||||||
|
{
|
||||||
|
// Ensure it's just a file name (prevent directory traversal)
|
||||||
|
$filename = basename($filename);
|
||||||
|
$cssPath = FCPATH . 'themes/' . $filename;
|
||||||
|
|
||||||
|
// If the file actually exists on disk (e.g. newly uploaded themes)
|
||||||
|
if (file_exists($cssPath)) {
|
||||||
|
$css = file_get_contents($cssPath);
|
||||||
|
return $this->response->setContentType('text/css')->setBody($css);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate dynamically for seeded themes that don't have a physical file
|
||||||
|
$model = new MarketplaceThemeModel();
|
||||||
|
$name = preg_replace('/\.css$/i', '', $filename);
|
||||||
|
$theme = $model->where('name', $name)->first();
|
||||||
|
|
||||||
|
if (! $theme) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||||
|
$vars = $meta['vars'] ?? [];
|
||||||
|
|
||||||
|
$css = "/* Theme: {$theme['display_name']} */\n:root {\n";
|
||||||
|
foreach ($vars as $prop => $value) {
|
||||||
|
$css .= " {$prop}: {$value};\n";
|
||||||
|
}
|
||||||
|
$css .= "}\n";
|
||||||
|
|
||||||
|
return $this->response->setContentType('text/css')->setBody($css);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uuid4(): string
|
||||||
|
{
|
||||||
|
$data = random_bytes(16);
|
||||||
|
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||||
|
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateUsersTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'password_hash' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'avatar_url' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'settings' => [
|
||||||
|
'type' => 'JSON',
|
||||||
|
'null' => true,
|
||||||
|
'default' => '{}',
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey('email');
|
||||||
|
$this->forge->createTable('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('users');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateCategoriesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'color' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 7,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Hex color for UI',
|
||||||
|
],
|
||||||
|
'favorite' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id');
|
||||||
|
$this->forge->addUniqueKey(['user_id', 'name']);
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('categories');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateProjectsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'description' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'color' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 7,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Hex color for UI',
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id');
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateTodosTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'description' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'status' => [
|
||||||
|
'type' => 'ENUM',
|
||||||
|
'constraint' => ['open', 'in_progress', 'completed', 'archived'],
|
||||||
|
'default' => 'open',
|
||||||
|
],
|
||||||
|
'due_date' => [
|
||||||
|
'type' => 'DATE',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'due_time' => [
|
||||||
|
'type' => 'TIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'sync_enabled' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'reminder_enabled' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'recurring_enabled' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'project_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id');
|
||||||
|
$this->forge->addKey('due_date');
|
||||||
|
$this->forge->addKey('status');
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('project_id', 'projects', 'id', 'SET NULL', 'CASCADE');
|
||||||
|
$this->forge->createTable('todos');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('todos');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateTodoCategoriesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'todo_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'category_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addPrimaryKey(['todo_id', 'category_id']);
|
||||||
|
$this->forge->addForeignKey('todo_id', 'todos', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('category_id', 'categories', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('todo_categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('todo_categories');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateRecurringTasksTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'description' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'schedule' => [
|
||||||
|
'type' => 'ENUM',
|
||||||
|
'constraint' => ['daily', 'weekly', 'monthly', 'custom'],
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'custom_days' => [
|
||||||
|
'type' => 'JSON',
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Array of days e.g., ["mon","wed","fri"] when schedule=custom',
|
||||||
|
],
|
||||||
|
'favorite' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id');
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('recurring_tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('recurring_tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateRecurringTaskCategoriesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'recurring_task_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'category_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addPrimaryKey(['recurring_task_id', 'category_id']);
|
||||||
|
$this->forge->addForeignKey('recurring_task_id', 'recurring_tasks', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('category_id', 'categories', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('recurring_task_categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('recurring_task_categories');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateActivityLogsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Nullable for anonymous events',
|
||||||
|
],
|
||||||
|
'action' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'e.g., todo_created, login, theme_installed',
|
||||||
|
],
|
||||||
|
'entity_type' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 100,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'todo, category, project, etc.',
|
||||||
|
],
|
||||||
|
'entity_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'details' => [
|
||||||
|
'type' => 'JSON',
|
||||||
|
'null' => true,
|
||||||
|
'default' => '{}',
|
||||||
|
'comment' => 'before/after values, metadata',
|
||||||
|
],
|
||||||
|
'ip_address' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 45,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'user_agent' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id');
|
||||||
|
$this->forge->addKey('created_at');
|
||||||
|
$this->forge->addKey('action');
|
||||||
|
$this->forge->addKey(['entity_type', 'entity_id']);
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'SET NULL', 'CASCADE');
|
||||||
|
$this->forge->createTable('activity_logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('activity_logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateMarketplaceThemesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'display_name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'description' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'author' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'version' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 50,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'thumbnail_url' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'download_url' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'price' => [
|
||||||
|
'type' => 'DECIMAL',
|
||||||
|
'constraint' => '10,2',
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'is_published' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'type' => 'JSON',
|
||||||
|
'null' => true,
|
||||||
|
'default' => '{}',
|
||||||
|
'comment' => 'tags, screenshots, etc.',
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey('name');
|
||||||
|
$this->forge->createTable('marketplace_themes');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('marketplace_themes');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateUserThemesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'theme_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'installed_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'active' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => false,
|
||||||
|
'comment' => 'Whether this is the user\'s currently active theme',
|
||||||
|
],
|
||||||
|
'custom_settings' => [
|
||||||
|
'type' => 'JSON',
|
||||||
|
'null' => true,
|
||||||
|
'default' => '{}',
|
||||||
|
'comment' => 'User overrides for theme variables',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey(['user_id', 'theme_id']);
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('theme_id', 'marketplace_themes', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('user_themes');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('user_themes');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateAiProvidersTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 100,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'openai, anthropic, google, etc.',
|
||||||
|
],
|
||||||
|
'display_name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'base_url' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Override endpoint',
|
||||||
|
],
|
||||||
|
'is_builtin' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => true,
|
||||||
|
'comment' => 'False for user-added custom providers',
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey('name');
|
||||||
|
$this->forge->createTable('ai_providers');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('ai_providers');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateUserApiKeysTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'provider_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'api_key_encrypted' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'Store encrypted API key',
|
||||||
|
],
|
||||||
|
'label' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'e.g., Work OpenAI Key',
|
||||||
|
],
|
||||||
|
'is_active' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'last_used_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey(['user_id', 'provider_id']);
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('provider_id', 'ai_providers', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('user_api_keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('user_api_keys');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateUserAiSettingsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'default_provider_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'default_model' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 100,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'e.g., gpt-4, claude-3-opus',
|
||||||
|
],
|
||||||
|
'max_tokens' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'default' => 2048,
|
||||||
|
],
|
||||||
|
'temperature' => [
|
||||||
|
'type' => 'FLOAT',
|
||||||
|
'default' => 0.7,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addPrimaryKey('user_id');
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('default_provider_id', 'ai_providers', 'id', 'SET NULL', 'CASCADE');
|
||||||
|
$this->forge->createTable('user_ai_settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('user_ai_settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateAiChatsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Generated from first message or user-set',
|
||||||
|
],
|
||||||
|
'provider_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'model_used' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 100,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Snapshot of model at chat creation',
|
||||||
|
],
|
||||||
|
'system_prompt' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Optional custom system prompt for this chat',
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id');
|
||||||
|
$this->forge->addKey('updated_at');
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('provider_id', 'ai_providers', 'id', 'SET NULL', 'CASCADE');
|
||||||
|
$this->forge->createTable('ai_chats');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('ai_chats');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateAiMessagesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'chat_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'role' => [
|
||||||
|
'type' => 'ENUM',
|
||||||
|
'constraint' => ['user', 'assistant', 'system'],
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'content' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'tokens_used' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Optional token count for billing/analysis',
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('chat_id');
|
||||||
|
$this->forge->addForeignKey('chat_id', 'ai_chats', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('ai_messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('ai_messages');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateApiAuthKeysTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'CHAR',
|
||||||
|
'constraint' => 36,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'key_hash' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'SHA-256 hash of the API key',
|
||||||
|
],
|
||||||
|
'key_prefix' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 20,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'First 8 characters for identification',
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'User-friendly name for the key',
|
||||||
|
],
|
||||||
|
'scopes' => [
|
||||||
|
'type' => 'JSON',
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Array of allowed scopes (e.g., ["read", "write"])',
|
||||||
|
],
|
||||||
|
'expires_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'Optional expiration date',
|
||||||
|
],
|
||||||
|
'last_used_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'last_used_ip' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 45,
|
||||||
|
'null' => true,
|
||||||
|
'comment' => 'IPv4 or IPv6 address',
|
||||||
|
],
|
||||||
|
'is_active' => [
|
||||||
|
'type' => 'BOOLEAN',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id');
|
||||||
|
$this->forge->addKey('key_hash');
|
||||||
|
$this->forge->addKey('is_active');
|
||||||
|
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('api_auth_keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('api_auth_keys');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Database/Seeds/AiProvidersSeeder.php
Normal file
40
app/Database/Seeds/AiProvidersSeeder.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Seeds;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Seeder;
|
||||||
|
|
||||||
|
class AiProvidersSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run()
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440001',
|
||||||
|
'name' => 'openai',
|
||||||
|
'display_name' => 'OpenAI',
|
||||||
|
'base_url' => 'https://api.openai.com/v1',
|
||||||
|
'is_builtin' => true,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440002',
|
||||||
|
'name' => 'anthropic',
|
||||||
|
'display_name' => 'Anthropic',
|
||||||
|
'base_url' => 'https://api.anthropic.com',
|
||||||
|
'is_builtin' => true,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440003',
|
||||||
|
'name' => 'google',
|
||||||
|
'display_name' => 'Google AI',
|
||||||
|
'base_url' => 'https://generativelanguage.googleapis.com/v1',
|
||||||
|
'is_builtin' => true,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->db->table('ai_providers')->insertBatch($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
308
app/Database/Seeds/MarketplaceThemesSeeder.php
Normal file
308
app/Database/Seeds/MarketplaceThemesSeeder.php
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Seeds;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Seeder;
|
||||||
|
|
||||||
|
class MarketplaceThemesSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run()
|
||||||
|
{
|
||||||
|
$this->db->query('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
$this->db->table('marketplace_themes')->truncate();
|
||||||
|
$this->db->query('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440010',
|
||||||
|
'name' => 'ocean-breeze',
|
||||||
|
'display_name' => 'Ocean Breeze',
|
||||||
|
'description' => 'A refreshing light theme inspired by the open sea. Soft teals and ocean blues create a calm, productive workspace that\'s easy on the eyes during long work sessions.',
|
||||||
|
'author' => 'ThemeForge',
|
||||||
|
'version' => '1.2.0',
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'download_url' => '/themes/ocean-breeze.css',
|
||||||
|
'price' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'tags' => ['light', 'blue', 'calm', 'minimal'],
|
||||||
|
'colors' => [
|
||||||
|
'Primary' => '#0077B6',
|
||||||
|
'Secondary' => '#00B4D8',
|
||||||
|
'Background' => '#E0F4FF',
|
||||||
|
'Surface' => '#FFFFFF',
|
||||||
|
'Text' => '#1A2B3C',
|
||||||
|
'Accent' => '#48CAE4',
|
||||||
|
],
|
||||||
|
'vars' => [
|
||||||
|
'--bg' => '#E0F4FF',
|
||||||
|
'--surface' => '#FFFFFF',
|
||||||
|
'--surface-strong' => '#FFFFFF',
|
||||||
|
'--surface-muted' => '#F0F9FF',
|
||||||
|
'--border' => '#BAE0F2',
|
||||||
|
'--line' => '#90C8E0',
|
||||||
|
'--text' => '#1A2B3C',
|
||||||
|
'--text-muted' => '#4A6B7A',
|
||||||
|
'--text-strong' => '#0D1B26',
|
||||||
|
'--accent' => '#0077B6',
|
||||||
|
'--accent-text' => '#FFFFFF',
|
||||||
|
'--accent-soft' => '#CCE9F5',
|
||||||
|
'--sidebar-bg' => '#FFFFFF',
|
||||||
|
'--sidebar-border' => '#BAE0F2',
|
||||||
|
'--sidebar-text' => '#1A2B3C',
|
||||||
|
'--sidebar-text-muted' => '#4A6B7A',
|
||||||
|
'--input-bg' => '#FFFFFF',
|
||||||
|
'--input-border' => '#BAE0F2',
|
||||||
|
'--modal-bg' => '#FFFFFF',
|
||||||
|
'--chip' => '#C8E8F0',
|
||||||
|
'--success' => '#D4F0E4',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440011',
|
||||||
|
'name' => 'midnight-void',
|
||||||
|
'display_name' => 'Midnight Void',
|
||||||
|
'description' => 'Deep space dark theme for night owls and late-night coders. Rich dark purples and blues with vibrant neon accents give this theme a premium, modern feel.',
|
||||||
|
'author' => 'ThemeForge',
|
||||||
|
'version' => '2.0.1',
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'download_url' => '/themes/midnight-void.css',
|
||||||
|
'price' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'tags' => ['dark', 'purple', 'neon', 'night'],
|
||||||
|
'colors' => [
|
||||||
|
'Primary' => '#7C3AED',
|
||||||
|
'Secondary' => '#A78BFA',
|
||||||
|
'Background' => '#0D0D1A',
|
||||||
|
'Surface' => '#1A1A2E',
|
||||||
|
'Text' => '#E2E8F0',
|
||||||
|
'Accent' => '#F472B6',
|
||||||
|
],
|
||||||
|
'vars' => [
|
||||||
|
'--bg' => '#0D0D1A',
|
||||||
|
'--surface' => '#1A1A2E',
|
||||||
|
'--surface-strong' => '#222234',
|
||||||
|
'--surface-muted' => '#121220',
|
||||||
|
'--border' => '#2A2A44',
|
||||||
|
'--line' => '#333350',
|
||||||
|
'--text' => '#E2E8F0',
|
||||||
|
'--text-muted' => '#94A3B8',
|
||||||
|
'--text-strong' => '#F1F5F9',
|
||||||
|
'--accent' => '#7C3AED',
|
||||||
|
'--accent-text' => '#FFFFFF',
|
||||||
|
'--accent-soft' => '#2D1A5E',
|
||||||
|
'--sidebar-bg' => '#16162A',
|
||||||
|
'--sidebar-border' => '#2A2A44',
|
||||||
|
'--sidebar-text' => '#E2E8F0',
|
||||||
|
'--sidebar-text-muted' => '#94A3B8',
|
||||||
|
'--input-bg' => '#0D0D1A',
|
||||||
|
'--input-border' => '#2A2A44',
|
||||||
|
'--modal-bg' => '#1A1A2E',
|
||||||
|
'--chip' => '#2A2A44',
|
||||||
|
'--success' => '#0D2A1A',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440012',
|
||||||
|
'name' => 'forest-grove',
|
||||||
|
'display_name' => 'Forest Grove',
|
||||||
|
'description' => 'Earthy greens and warm neutrals bring the tranquility of a woodland retreat to your workspace. A grounding, nature-inspired theme designed for focused productivity.',
|
||||||
|
'author' => 'NaturePalette',
|
||||||
|
'version' => '1.0.5',
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'download_url' => '/themes/forest-grove.css',
|
||||||
|
'price' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'tags' => ['light', 'green', 'earthy', 'nature'],
|
||||||
|
'colors' => [
|
||||||
|
'Primary' => '#2D6A4F',
|
||||||
|
'Secondary' => '#52B788',
|
||||||
|
'Background' => '#F0F7EE',
|
||||||
|
'Surface' => '#FFFFFF',
|
||||||
|
'Text' => '#1B2E22',
|
||||||
|
'Accent' => '#B7E4C7',
|
||||||
|
],
|
||||||
|
'vars' => [
|
||||||
|
'--bg' => '#F0F7EE',
|
||||||
|
'--surface' => '#FFFFFF',
|
||||||
|
'--surface-strong' => '#FFFFFF',
|
||||||
|
'--surface-muted' => '#F5FAF4',
|
||||||
|
'--border' => '#C0DACB',
|
||||||
|
'--line' => '#A0C4B0',
|
||||||
|
'--text' => '#1B2E22',
|
||||||
|
'--text-muted' => '#527A62',
|
||||||
|
'--text-strong' => '#0D1F14',
|
||||||
|
'--accent' => '#2D6A4F',
|
||||||
|
'--accent-text' => '#FFFFFF',
|
||||||
|
'--accent-soft' => '#C0E8D4',
|
||||||
|
'--sidebar-bg' => '#FFFFFF',
|
||||||
|
'--sidebar-border' => '#C0DACB',
|
||||||
|
'--sidebar-text' => '#1B2E22',
|
||||||
|
'--sidebar-text-muted' => '#527A62',
|
||||||
|
'--input-bg' => '#FFFFFF',
|
||||||
|
'--input-border' => '#C0DACB',
|
||||||
|
'--modal-bg' => '#FFFFFF',
|
||||||
|
'--chip' => '#B8E0C8',
|
||||||
|
'--success' => '#CCF0DC',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440013',
|
||||||
|
'name' => 'sunset-ember',
|
||||||
|
'display_name' => 'Sunset Ember',
|
||||||
|
'description' => 'Warm oranges, deep reds, and golden highlights capture the magic of a perfect sunset. This vibrant theme adds energy and warmth to every interaction.',
|
||||||
|
'author' => 'ChromaCraft',
|
||||||
|
'version' => '1.1.2',
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'download_url' => '/themes/sunset-ember.css',
|
||||||
|
'price' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'tags' => ['warm', 'orange', 'vibrant', 'sunset'],
|
||||||
|
'colors' => [
|
||||||
|
'Primary' => '#D62828',
|
||||||
|
'Secondary' => '#F77F00',
|
||||||
|
'Background' => '#FFF5E4',
|
||||||
|
'Surface' => '#FFFFFF',
|
||||||
|
'Text' => '#2D1B00',
|
||||||
|
'Accent' => '#FCBF49',
|
||||||
|
],
|
||||||
|
'vars' => [
|
||||||
|
'--bg' => '#FFF5E4',
|
||||||
|
'--surface' => '#FFFFFF',
|
||||||
|
'--surface-strong' => '#FFFFFF',
|
||||||
|
'--surface-muted' => '#FFF8F0',
|
||||||
|
'--border' => '#F0D0A8',
|
||||||
|
'--line' => '#E0B880',
|
||||||
|
'--text' => '#2D1B00',
|
||||||
|
'--text-muted' => '#8A6040',
|
||||||
|
'--text-strong' => '#1A0A00',
|
||||||
|
'--accent' => '#D62828',
|
||||||
|
'--accent-text' => '#FFFFFF',
|
||||||
|
'--accent-soft' => '#FFE0CC',
|
||||||
|
'--sidebar-bg' => '#FFFFFF',
|
||||||
|
'--sidebar-border' => '#F0D0A8',
|
||||||
|
'--sidebar-text' => '#2D1B00',
|
||||||
|
'--sidebar-text-muted' => '#8A6040',
|
||||||
|
'--input-bg' => '#FFFFFF',
|
||||||
|
'--input-border' => '#F0D0A8',
|
||||||
|
'--modal-bg' => '#FFFFFF',
|
||||||
|
'--chip' => '#F8D8B0',
|
||||||
|
'--success' => '#DDFADC',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440014',
|
||||||
|
'name' => 'arctic-frost',
|
||||||
|
'display_name' => 'Arctic Frost',
|
||||||
|
'description' => 'Ultra-clean whites and icy blues inspired by frozen tundras. A minimalist theme that maximises clarity and focus with crisp contrast and breathable spacing.',
|
||||||
|
'author' => 'MinimalStudio',
|
||||||
|
'version' => '3.0.0',
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'download_url' => '/themes/arctic-frost.css',
|
||||||
|
'price' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'tags' => ['light', 'minimal', 'clean', 'ice'],
|
||||||
|
'colors' => [
|
||||||
|
'Primary' => '#2176AE',
|
||||||
|
'Secondary' => '#57C4E5',
|
||||||
|
'Background' => '#F8FBFF',
|
||||||
|
'Surface' => '#FFFFFF',
|
||||||
|
'Text' => '#1C2B3A',
|
||||||
|
'Accent' => '#A8DADC',
|
||||||
|
],
|
||||||
|
'vars' => [
|
||||||
|
'--bg' => '#F8FBFF',
|
||||||
|
'--surface' => '#FFFFFF',
|
||||||
|
'--surface-strong' => '#FFFFFF',
|
||||||
|
'--surface-muted' => '#F0F5FC',
|
||||||
|
'--border' => '#C0D4E8',
|
||||||
|
'--line' => '#A0BCDA',
|
||||||
|
'--text' => '#1C2B3A',
|
||||||
|
'--text-muted' => '#4E6478',
|
||||||
|
'--text-strong' => '#0D1B2A',
|
||||||
|
'--accent' => '#2176AE',
|
||||||
|
'--accent-text' => '#FFFFFF',
|
||||||
|
'--accent-soft' => '#CCE0F0',
|
||||||
|
'--sidebar-bg' => '#FFFFFF',
|
||||||
|
'--sidebar-border' => '#C0D4E8',
|
||||||
|
'--sidebar-text' => '#1C2B3A',
|
||||||
|
'--sidebar-text-muted' => '#4E6478',
|
||||||
|
'--input-bg' => '#FFFFFF',
|
||||||
|
'--input-border' => '#C0D4E8',
|
||||||
|
'--modal-bg' => '#FFFFFF',
|
||||||
|
'--chip' => '#B8D4E8',
|
||||||
|
'--success' => '#D4F0E4',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => '550e8400-e29b-41d4-a716-446655440015',
|
||||||
|
'name' => 'obsidian-rose',
|
||||||
|
'display_name' => 'Obsidian Rose',
|
||||||
|
'description' => 'A sophisticated dark theme blending deep charcoal blacks with rose gold accents. Elegant and bold, this theme is built for those who want style without sacrificing readability.',
|
||||||
|
'author' => 'ChromaCraft',
|
||||||
|
'version' => '1.3.0',
|
||||||
|
'thumbnail_url' => null,
|
||||||
|
'download_url' => '/themes/obsidian-rose.css',
|
||||||
|
'price' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'tags' => ['dark', 'elegant', 'rose', 'premium'],
|
||||||
|
'colors' => [
|
||||||
|
'Primary' => '#C9184A',
|
||||||
|
'Secondary' => '#FF4D6D',
|
||||||
|
'Background' => '#0A0A0F',
|
||||||
|
'Surface' => '#1C1C28',
|
||||||
|
'Text' => '#F1E3E4',
|
||||||
|
'Accent' => '#B5838D',
|
||||||
|
],
|
||||||
|
'vars' => [
|
||||||
|
'--bg' => '#0A0A0F',
|
||||||
|
'--surface' => '#1C1C28',
|
||||||
|
'--surface-strong' => '#242430',
|
||||||
|
'--surface-muted' => '#14141E',
|
||||||
|
'--border' => '#2A2A38',
|
||||||
|
'--line' => '#383848',
|
||||||
|
'--text' => '#F1E3E4',
|
||||||
|
'--text-muted' => '#B5939A',
|
||||||
|
'--text-strong' => '#FAF0F1',
|
||||||
|
'--accent' => '#C9184A',
|
||||||
|
'--accent-text' => '#FFFFFF',
|
||||||
|
'--accent-soft' => '#3D0A1A',
|
||||||
|
'--sidebar-bg' => '#161620',
|
||||||
|
'--sidebar-border' => '#2A2A38',
|
||||||
|
'--sidebar-text' => '#F1E3E4',
|
||||||
|
'--sidebar-text-muted' => '#B5939A',
|
||||||
|
'--input-bg' => '#0A0A0F',
|
||||||
|
'--input-border' => '#2A2A38',
|
||||||
|
'--modal-bg' => '#1C1C28',
|
||||||
|
'--chip' => '#2A2030',
|
||||||
|
'--success' => '#0A2016',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->db->table('marketplace_themes')->insertBatch($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
346
app/Database/Seeds/SampleDataSeeder.php
Normal file
346
app/Database/Seeds/SampleDataSeeder.php
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Seeds;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Seeder;
|
||||||
|
|
||||||
|
class SampleDataSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run()
|
||||||
|
{
|
||||||
|
// Generate a UUID helper function
|
||||||
|
$generateUuid = function() {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a sample user (or get existing one)
|
||||||
|
$existingUser = $this->db->table('users')->where('email', 'demo@example.com')->get()->getRowArray();
|
||||||
|
if ($existingUser) {
|
||||||
|
$userId = $existingUser['id'];
|
||||||
|
} else {
|
||||||
|
$userId = $generateUuid();
|
||||||
|
$this->db->table('users')->insert([
|
||||||
|
'id' => $userId,
|
||||||
|
'email' => 'demo@example.com',
|
||||||
|
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
|
||||||
|
'name' => 'Demo User',
|
||||||
|
'avatar_url' => null,
|
||||||
|
'settings' => json_encode(['language' => 'en', 'default_view' => 'list']),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample categories (check for existing)
|
||||||
|
$existingCategories = $this->db->table('categories')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
$existingCategoryNames = array_column($existingCategories, 'name');
|
||||||
|
|
||||||
|
$categories = [];
|
||||||
|
$categoryNames = ['Work', 'Home', 'Personal'];
|
||||||
|
$categoryColors = ['#3B82F6', '#10B981', '#F59E0B'];
|
||||||
|
$categoryFavorites = [true, false, false];
|
||||||
|
|
||||||
|
foreach ($categoryNames as $index => $name) {
|
||||||
|
if (!in_array($name, $existingCategoryNames)) {
|
||||||
|
$categories[] = [
|
||||||
|
'id' => $generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'name' => $name,
|
||||||
|
'color' => $categoryColors[$index],
|
||||||
|
'favorite' => $categoryFavorites[$index],
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($categories)) {
|
||||||
|
$this->db->table('categories')->insertBatch($categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all categories for the user (existing + newly created)
|
||||||
|
$allCategories = $this->db->table('categories')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
$categories = [];
|
||||||
|
foreach ($categoryNames as $name) {
|
||||||
|
foreach ($allCategories as $cat) {
|
||||||
|
if ($cat['name'] === $name) {
|
||||||
|
$categories[] = $cat;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample projects (check for existing)
|
||||||
|
$existingProjects = $this->db->table('projects')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
$existingProjectNames = array_column($existingProjects, 'name');
|
||||||
|
|
||||||
|
$projects = [];
|
||||||
|
$projectData = [
|
||||||
|
['name' => 'Web Redesign', 'description' => 'Redesign the company website', 'color' => '#8B5CF6'],
|
||||||
|
['name' => 'Home Renovation', 'description' => 'Renovate the kitchen and bathroom', 'color' => '#EC4899'],
|
||||||
|
['name' => 'Learning', 'description' => 'Learn new technologies and skills', 'color' => '#14B8A6'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($projectData as $proj) {
|
||||||
|
if (!in_array($proj['name'], $existingProjectNames)) {
|
||||||
|
$projects[] = [
|
||||||
|
'id' => $generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'name' => $proj['name'],
|
||||||
|
'description' => $proj['description'],
|
||||||
|
'color' => $proj['color'],
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($projects)) {
|
||||||
|
$this->db->table('projects')->insertBatch($projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all projects for the user
|
||||||
|
$allProjects = $this->db->table('projects')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
$projects = [];
|
||||||
|
foreach ($projectData as $proj) {
|
||||||
|
foreach ($allProjects as $p) {
|
||||||
|
if ($p['name'] === $proj['name']) {
|
||||||
|
$projects[] = $p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$webRedesignId = isset($projects[0]) ? $projects[0]['id'] : null;
|
||||||
|
$homeRenovationId = isset($projects[1]) ? $projects[1]['id'] : null;
|
||||||
|
$learningId = isset($projects[2]) ? $projects[2]['id'] : null;
|
||||||
|
|
||||||
|
// Create sample todos (check for existing)
|
||||||
|
$existingTodos = $this->db->table('todos')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
$existingTodoTitles = array_column($existingTodos, 'title');
|
||||||
|
|
||||||
|
$todos = [];
|
||||||
|
$todoData = [
|
||||||
|
[
|
||||||
|
'title' => 'Bestehende Aufgaben analysieren',
|
||||||
|
'description' => 'Aktuellen Aufbau der Todo-App sichten und Felder abstimmen.',
|
||||||
|
'status' => 'open',
|
||||||
|
'due_date' => date('Y-m-d', strtotime('+7 days')),
|
||||||
|
'due_time' => '10:30:00',
|
||||||
|
'sync_enabled' => true,
|
||||||
|
'reminder_enabled' => false,
|
||||||
|
'recurring_enabled' => false,
|
||||||
|
'project_id' => $webRedesignId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Wireframes erstellen',
|
||||||
|
'description' => 'Erste Skizzen für das neue Design machen.',
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'due_date' => date('Y-m-d', strtotime('+14 days')),
|
||||||
|
'sync_enabled' => true,
|
||||||
|
'reminder_enabled' => true,
|
||||||
|
'recurring_enabled' => false,
|
||||||
|
'project_id' => $webRedesignId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Küche planen',
|
||||||
|
'description' => 'Neue Küche auswählen und bestellen.',
|
||||||
|
'status' => 'open',
|
||||||
|
'due_date' => date('Y-m-d', strtotime('+30 days')),
|
||||||
|
'sync_enabled' => false,
|
||||||
|
'reminder_enabled' => true,
|
||||||
|
'recurring_enabled' => false,
|
||||||
|
'project_id' => $homeRenovationId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'CodeIgniter lernen',
|
||||||
|
'description' => 'Offizielle Dokumentation durchgehen.',
|
||||||
|
'status' => 'completed',
|
||||||
|
'due_date' => date('Y-m-d', strtotime('-5 days')),
|
||||||
|
'sync_enabled' => true,
|
||||||
|
'reminder_enabled' => false,
|
||||||
|
'recurring_enabled' => false,
|
||||||
|
'project_id' => $learningId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Einkaufen',
|
||||||
|
'description' => 'Milch, Brot, Eier, Gemüse',
|
||||||
|
'status' => 'open',
|
||||||
|
'due_date' => date('Y-m-d', strtotime('+1 day')),
|
||||||
|
'sync_enabled' => true,
|
||||||
|
'reminder_enabled' => true,
|
||||||
|
'recurring_enabled' => false,
|
||||||
|
'project_id' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($todoData as $todo) {
|
||||||
|
if (!in_array($todo['title'], $existingTodoTitles)) {
|
||||||
|
$todos[] = array_merge($todo, [
|
||||||
|
'id' => $generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($todos)) {
|
||||||
|
$this->db->table('todos')->insertBatch($todos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all todos for the user
|
||||||
|
$allTodos = $this->db->table('todos')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
|
||||||
|
// Link todos to categories
|
||||||
|
$workCategoryId = $categories[0]['id'];
|
||||||
|
$homeCategoryId = $categories[1]['id'];
|
||||||
|
$personalCategoryId = $categories[2]['id'];
|
||||||
|
|
||||||
|
$todoCategories = [];
|
||||||
|
$todoCategoryMap = [
|
||||||
|
'Bestehende Aufgaben analysieren' => $workCategoryId,
|
||||||
|
'Wireframes erstellen' => $workCategoryId,
|
||||||
|
'Küche planen' => $homeCategoryId,
|
||||||
|
'CodeIgniter lernen' => $workCategoryId,
|
||||||
|
'Einkaufen' => $personalCategoryId,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($allTodos as $todo) {
|
||||||
|
if (isset($todoCategoryMap[$todo['title']])) {
|
||||||
|
// Check if this link already exists
|
||||||
|
$existingLink = $this->db->table('todo_categories')
|
||||||
|
->where('todo_id', $todo['id'])
|
||||||
|
->where('category_id', $todoCategoryMap[$todo['title']])
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (!$existingLink) {
|
||||||
|
$todoCategories[] = [
|
||||||
|
'todo_id' => $todo['id'],
|
||||||
|
'category_id' => $todoCategoryMap[$todo['title']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($todoCategories)) {
|
||||||
|
$this->db->table('todo_categories')->insertBatch($todoCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample recurring tasks (check for existing)
|
||||||
|
$existingRecurringTasks = $this->db->table('recurring_tasks')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
$existingRecurringTaskTitles = array_column($existingRecurringTasks, 'title');
|
||||||
|
|
||||||
|
$recurringTasks = [];
|
||||||
|
$recurringTaskData = [
|
||||||
|
[
|
||||||
|
'title' => 'Weekly review',
|
||||||
|
'description' => 'Plan next week\'s tasks',
|
||||||
|
'schedule' => 'weekly',
|
||||||
|
'custom_days' => json_encode([]),
|
||||||
|
'favorite' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Clean the house',
|
||||||
|
'description' => 'Every Saturday',
|
||||||
|
'schedule' => 'custom',
|
||||||
|
'custom_days' => json_encode(['sat']),
|
||||||
|
'favorite' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Daily standup',
|
||||||
|
'description' => 'Team meeting every morning',
|
||||||
|
'schedule' => 'daily',
|
||||||
|
'custom_days' => json_encode([]),
|
||||||
|
'favorite' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($recurringTaskData as $task) {
|
||||||
|
if (!in_array($task['title'], $existingRecurringTaskTitles)) {
|
||||||
|
$recurringTasks[] = array_merge($task, [
|
||||||
|
'id' => $generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($recurringTasks)) {
|
||||||
|
$this->db->table('recurring_tasks')->insertBatch($recurringTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all recurring tasks for the user
|
||||||
|
$allRecurringTasks = $this->db->table('recurring_tasks')->where('user_id', $userId)->get()->getResultArray();
|
||||||
|
|
||||||
|
// Link recurring tasks to categories
|
||||||
|
$recurringTaskCategories = [];
|
||||||
|
$recurringTaskCategoryMap = [
|
||||||
|
'Weekly review' => $workCategoryId,
|
||||||
|
'Clean the house' => $homeCategoryId,
|
||||||
|
'Daily standup' => $workCategoryId,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($allRecurringTasks as $task) {
|
||||||
|
if (isset($recurringTaskCategoryMap[$task['title']])) {
|
||||||
|
// Check if this link already exists
|
||||||
|
$existingLink = $this->db->table('recurring_task_categories')
|
||||||
|
->where('recurring_task_id', $task['id'])
|
||||||
|
->where('category_id', $recurringTaskCategoryMap[$task['title']])
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (!$existingLink) {
|
||||||
|
$recurringTaskCategories[] = [
|
||||||
|
'recurring_task_id' => $task['id'],
|
||||||
|
'category_id' => $recurringTaskCategoryMap[$task['title']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($recurringTaskCategories)) {
|
||||||
|
$this->db->table('recurring_task_categories')->insertBatch($recurringTaskCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an API key for the demo user
|
||||||
|
$existingApiKey = $this->db->table('api_auth_keys')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('name', 'Demo API Key')
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (!$existingApiKey) {
|
||||||
|
$apiKey = 'todo_' . bin2hex(random_bytes(32));
|
||||||
|
$keyHash = hash('sha256', $apiKey);
|
||||||
|
$keyPrefix = substr($apiKey, 0, 8);
|
||||||
|
|
||||||
|
$this->db->table('api_auth_keys')->insert([
|
||||||
|
'id' => $generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'key_hash' => $keyHash,
|
||||||
|
'key_prefix' => $keyPrefix,
|
||||||
|
'name' => 'Demo API Key',
|
||||||
|
'scopes' => json_encode(['read', 'write']),
|
||||||
|
'expires_at' => null,
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "\n========================================\n";
|
||||||
|
echo "DEMO API KEY CREATED:\n";
|
||||||
|
echo "========================================\n";
|
||||||
|
echo "API Key: {$apiKey}\n";
|
||||||
|
echo "Prefix: {$keyPrefix}\n";
|
||||||
|
echo "Use this key in the X-API-Key header\n";
|
||||||
|
echo "========================================\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Filters/ApiAuthFilter.php
Normal file
100
app/Filters/ApiAuthFilter.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use CodeIgniter\Filters\FilterInterface;
|
||||||
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
|
|
||||||
|
class ApiAuthFilter implements FilterInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Do whatever processing this filter needs to do.
|
||||||
|
* By default it should not return anything during
|
||||||
|
* normal execution. However, when an abnormal state
|
||||||
|
* is found, it should return an instance of
|
||||||
|
* CodeIgniter\HTTP\Response. If it does, script
|
||||||
|
* execution will end and that Response will be
|
||||||
|
* sent back to the client, allowing for error pages,
|
||||||
|
* redirects, etc.
|
||||||
|
*
|
||||||
|
* @param RequestInterface $request
|
||||||
|
* @param array|null $arguments
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
|
{
|
||||||
|
$apiKey = $request->getHeaderLine('X-API-Key');
|
||||||
|
|
||||||
|
if (empty($apiKey)) {
|
||||||
|
return $this->unauthorized('API key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiAuthKeyModel = new \App\Models\ApiAuthKeyModel();
|
||||||
|
$result = $apiAuthKeyModel->validateKey($apiKey);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return $this->unauthorized('Invalid or expired API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the authenticated user in the request
|
||||||
|
$request->user = $result['user'];
|
||||||
|
$request->authKey = $result['auth_key'];
|
||||||
|
|
||||||
|
// Check scopes if required
|
||||||
|
if (!empty($arguments)) {
|
||||||
|
$requiredScopes = $arguments;
|
||||||
|
$keyScopes = $result['auth_key']['scopes'] ? json_decode($result['auth_key']['scopes'], true) : [];
|
||||||
|
|
||||||
|
if (empty($keyScopes)) {
|
||||||
|
// No scopes defined, allow all
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requiredScopes as $scope) {
|
||||||
|
if (!in_array($scope, $keyScopes)) {
|
||||||
|
return $this->forbidden('Insufficient permissions. Required scope: ' . $scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We don't need to do anything here.
|
||||||
|
*
|
||||||
|
* @param RequestInterface $request
|
||||||
|
* @param ResponseInterface $response
|
||||||
|
* @param array|null $arguments
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||||
|
{
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return unauthorized response
|
||||||
|
*/
|
||||||
|
private function unauthorized(string $message): ResponseInterface
|
||||||
|
{
|
||||||
|
$response = \Config\Services::response();
|
||||||
|
return $response->setStatusCode(401)->setJSON([
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
'message' => $message,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return forbidden response
|
||||||
|
*/
|
||||||
|
private function forbidden(string $message): ResponseInterface
|
||||||
|
{
|
||||||
|
$response = \Config\Services::response();
|
||||||
|
return $response->setStatusCode(403)->setJSON([
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
'message' => $message,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Models/ActivityLogModel.php
Normal file
91
app/Models/ActivityLogModel.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class ActivityLogModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'activity_logs';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'action',
|
||||||
|
'entity_type',
|
||||||
|
'entity_id',
|
||||||
|
'details',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = '';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'action' => 'required|max_length[255]',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Log an activity
|
||||||
|
public function logActivity($data)
|
||||||
|
{
|
||||||
|
if (!isset($data['id'])) {
|
||||||
|
$data['id'] = $this->generateUuid();
|
||||||
|
}
|
||||||
|
if (!isset($data['created_at'])) {
|
||||||
|
$data['created_at'] = date('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use builder directly to avoid triggering events
|
||||||
|
$builder = $this->db->table($this->table);
|
||||||
|
return $builder->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs by user
|
||||||
|
public function getByUser($userId, $limit = 50)
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs by entity
|
||||||
|
public function getByEntity($entityType, $entityId, $limit = 50)
|
||||||
|
{
|
||||||
|
return $this->where('entity_type', $entityType)
|
||||||
|
->where('entity_id', $entityId)
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs by action
|
||||||
|
public function getByAction($action, $limit = 50)
|
||||||
|
{
|
||||||
|
return $this->where('action', $action)
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUuid()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Models/AiChatModel.php
Normal file
105
app/Models/AiChatModel.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class AiChatModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'ai_chats';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'title',
|
||||||
|
'provider_id',
|
||||||
|
'model_used',
|
||||||
|
'system_prompt',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get chats by user
|
||||||
|
public function getByUser($userId, $limit = 50)
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get chat with message count
|
||||||
|
public function getWithMessageCount($chatId)
|
||||||
|
{
|
||||||
|
$chat = $this->find($chatId);
|
||||||
|
if ($chat) {
|
||||||
|
$messageModel = new AiMessageModel();
|
||||||
|
$chat['message_count'] = $messageModel->where('chat_id', $chatId)->countAllResults();
|
||||||
|
}
|
||||||
|
return $chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all chats by user with message counts
|
||||||
|
public function getByUserWithMessageCounts($userId)
|
||||||
|
{
|
||||||
|
$chats = $this->getByUser($userId);
|
||||||
|
$messageModel = new AiMessageModel();
|
||||||
|
|
||||||
|
foreach ($chats as &$chat) {
|
||||||
|
$chat['message_count'] = $messageModel->where('chat_id', $chat['id'])->countAllResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $chats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get chat with provider info
|
||||||
|
public function getWithProvider($chatId)
|
||||||
|
{
|
||||||
|
return $this->select('ai_chats.*, ai_providers.name as provider_name, ai_providers.display_name')
|
||||||
|
->join('ai_providers', 'ai_chats.provider_id = ai_providers.id', 'left')
|
||||||
|
->where('ai_chats.id', $chatId)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chat title
|
||||||
|
public function updateTitle($chatId, $title)
|
||||||
|
{
|
||||||
|
return $this->update($chatId, ['title' => $title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new chat
|
||||||
|
public function createChat($userId, $data = [])
|
||||||
|
{
|
||||||
|
$data['id'] = $this->generateUuid();
|
||||||
|
$data['user_id'] = $userId;
|
||||||
|
$data['created_at'] = date('Y-m-d H:i:s');
|
||||||
|
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUuid()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/Models/AiMessageModel.php
Normal file
93
app/Models/AiMessageModel.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class AiMessageModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'ai_messages';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'chat_id',
|
||||||
|
'role',
|
||||||
|
'content',
|
||||||
|
'tokens_used',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = '';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'chat_id' => 'required',
|
||||||
|
'role' => 'required|in_list[user,assistant,system]',
|
||||||
|
'content' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get messages by chat
|
||||||
|
public function getByChat($chatId)
|
||||||
|
{
|
||||||
|
return $this->where('chat_id', $chatId)
|
||||||
|
->orderBy('created_at', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message to chat
|
||||||
|
public function addMessage($chatId, $role, $content, $tokensUsed = null)
|
||||||
|
{
|
||||||
|
return $this->insert([
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'role' => $role,
|
||||||
|
'content' => $content,
|
||||||
|
'tokens_used' => $tokensUsed,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last message from chat
|
||||||
|
public function getLastMessage($chatId)
|
||||||
|
{
|
||||||
|
return $this->where('chat_id', $chatId)
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all messages from chat
|
||||||
|
public function deleteByChat($chatId)
|
||||||
|
{
|
||||||
|
return $this->where('chat_id', $chatId)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total tokens used by chat
|
||||||
|
public function getTotalTokens($chatId)
|
||||||
|
{
|
||||||
|
$result = $this->selectSum('tokens_used')
|
||||||
|
->where('chat_id', $chatId)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return $result ? (int) $result['tokens_used'] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUuid()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Models/AiProviderModel.php
Normal file
57
app/Models/AiProviderModel.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class AiProviderModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'ai_providers';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'base_url',
|
||||||
|
'is_builtin',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = '';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'name' => 'required|max_length[100]|is_unique[ai_providers.name]',
|
||||||
|
'display_name' => 'required|max_length[255]',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get builtin providers only
|
||||||
|
public function getBuiltinProviders()
|
||||||
|
{
|
||||||
|
return $this->where('is_builtin', true)
|
||||||
|
->orderBy('name', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get custom providers only
|
||||||
|
public function getCustomProviders()
|
||||||
|
{
|
||||||
|
return $this->where('is_builtin', false)
|
||||||
|
->orderBy('name', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider by name
|
||||||
|
public function getByName($name)
|
||||||
|
{
|
||||||
|
return $this->where('name', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
164
app/Models/ApiAuthKeyModel.php
Normal file
164
app/Models/ApiAuthKeyModel.php
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class ApiAuthKeyModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'api_auth_keys';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'key_hash',
|
||||||
|
'key_prefix',
|
||||||
|
'name',
|
||||||
|
'scopes',
|
||||||
|
'expires_at',
|
||||||
|
'last_used_at',
|
||||||
|
'last_used_ip',
|
||||||
|
'is_active',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = '';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => 'required',
|
||||||
|
'key_hash' => 'required',
|
||||||
|
'key_prefix' => 'required|max_length[20]',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new API key
|
||||||
|
*/
|
||||||
|
public function generateKey(): string
|
||||||
|
{
|
||||||
|
return 'todo_' . bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key for a user
|
||||||
|
*/
|
||||||
|
public function createKey(string $userId, ?string $name = null, ?array $scopes = null, ?string $expiresAt = null): array
|
||||||
|
{
|
||||||
|
$key = $this->generateKey();
|
||||||
|
$keyHash = hash('sha256', $key);
|
||||||
|
$keyPrefix = substr($key, 0, 8);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'key_hash' => $keyHash,
|
||||||
|
'key_prefix' => $keyPrefix,
|
||||||
|
'name' => $name,
|
||||||
|
'scopes' => $scopes ? json_encode($scopes) : null,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->insert($data);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $data['id'],
|
||||||
|
'key' => $key,
|
||||||
|
'prefix' => $keyPrefix,
|
||||||
|
'name' => $name,
|
||||||
|
'scopes' => $scopes,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key and return the associated user
|
||||||
|
*/
|
||||||
|
public function validateKey(string $key): ?array
|
||||||
|
{
|
||||||
|
$keyHash = hash('sha256', $key);
|
||||||
|
|
||||||
|
$authKey = $this->where('key_hash', $keyHash)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$authKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if key has expired
|
||||||
|
if ($authKey['expires_at'] && strtotime($authKey['expires_at']) < time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used information
|
||||||
|
$this->update($authKey['id'], [
|
||||||
|
'last_used_at' => date('Y-m-d H:i:s'),
|
||||||
|
'last_used_ip' => $this->getClientIp(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get the user
|
||||||
|
$userModel = new UserModel();
|
||||||
|
$user = $userModel->find($authKey['user_id']);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user' => $user,
|
||||||
|
'auth_key' => $authKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all API keys for a user
|
||||||
|
*/
|
||||||
|
public function getByUser(string $userId): array
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key
|
||||||
|
*/
|
||||||
|
public function revokeKey(string $keyId): bool
|
||||||
|
{
|
||||||
|
return $this->update($keyId, ['is_active' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address
|
||||||
|
*/
|
||||||
|
private function getClientIp(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request = \Config\Services::request();
|
||||||
|
return $request->getIPAddress();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate UUID
|
||||||
|
*/
|
||||||
|
private function generateUuid(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Models/CategoryModel.php
Normal file
43
app/Models/CategoryModel.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class CategoryModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'categories';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id', 'user_id', 'name', 'color', 'favorite', 'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = '';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => [
|
||||||
|
'rules' => 'required',
|
||||||
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The category name is required.',
|
||||||
|
'max_length' => 'The category name must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'color' => [
|
||||||
|
'rules' => 'required|max_length[7]|regex_match[/^#[0-9a-fA-F]{6}$/]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'A color value is required.',
|
||||||
|
'max_length' => 'Color must be a hex code (e.g. #3B82F6).',
|
||||||
|
'regex_match' => 'Color must be a valid hex code (e.g. #3B82F6).',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
142
app/Models/LoggableTrait.php
Normal file
142
app/Models/LoggableTrait.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
trait LoggableTrait
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Log activity after insert
|
||||||
|
*/
|
||||||
|
protected function afterInsert(array $data)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->logActivity('created', $data);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Silently fail to avoid breaking the main operation
|
||||||
|
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log activity after update
|
||||||
|
*/
|
||||||
|
protected function afterUpdate(array $data)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->logActivity('updated', $data);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log activity after delete
|
||||||
|
*/
|
||||||
|
protected function afterDelete(array $data)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->logActivity('deleted', $data);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log activity to activity_logs table
|
||||||
|
*/
|
||||||
|
protected function logActivity($action, $data)
|
||||||
|
{
|
||||||
|
$activityLogModel = new ActivityLogModel();
|
||||||
|
|
||||||
|
$entityType = $this->getEntityType();
|
||||||
|
$entityId = $data['id'] ?? $data[$this->primaryKey] ?? null;
|
||||||
|
$userId = $data['user_id'] ?? null;
|
||||||
|
|
||||||
|
// Try to get user from session if not in data
|
||||||
|
if ($userId === null && function_exists('session')) {
|
||||||
|
$userId = session()->get('user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$logData = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'action' => $this->getActionName($action, $entityType),
|
||||||
|
'entity_type' => $entityType,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'details' => json_encode($this->getLogDetails($action, $data)),
|
||||||
|
'ip_address' => $this->getClientIp(),
|
||||||
|
'user_agent' => $this->getUserAgent(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$activityLogModel->logActivity($logData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get entity type based on table name
|
||||||
|
*/
|
||||||
|
protected function getEntityType(): string
|
||||||
|
{
|
||||||
|
$table = $this->table;
|
||||||
|
// Remove plural 's' if present
|
||||||
|
return rtrim($table, 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted action name
|
||||||
|
*/
|
||||||
|
protected function getActionName($action, $entityType): string
|
||||||
|
{
|
||||||
|
return "{$entityType}_{$action}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get log details (can be overridden in models)
|
||||||
|
*/
|
||||||
|
protected function getLogDetails($action, $data): array
|
||||||
|
{
|
||||||
|
$details = [
|
||||||
|
'action' => $action,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add relevant fields based on entity type
|
||||||
|
if (isset($data['title'])) {
|
||||||
|
$details['title'] = $data['title'];
|
||||||
|
}
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$details['name'] = $data['name'];
|
||||||
|
}
|
||||||
|
if (isset($data['email'])) {
|
||||||
|
$details['email'] = $data['email'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $details;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address
|
||||||
|
*/
|
||||||
|
protected function getClientIp(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request = \Config\Services::request();
|
||||||
|
return $request->getIPAddress();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return 'CLI';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user agent
|
||||||
|
*/
|
||||||
|
protected function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request = \Config\Services::request();
|
||||||
|
return $request->getUserAgent()->getAgentString();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return 'CLI/Script';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Models/MarketplaceThemeModel.php
Normal file
68
app/Models/MarketplaceThemeModel.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class MarketplaceThemeModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'marketplace_themes';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'description',
|
||||||
|
'author',
|
||||||
|
'version',
|
||||||
|
'thumbnail_url',
|
||||||
|
'download_url',
|
||||||
|
'price',
|
||||||
|
'is_published',
|
||||||
|
'metadata',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'name' => 'required|max_length[255]|is_unique[marketplace_themes.name]',
|
||||||
|
'display_name' => 'required|max_length[255]',
|
||||||
|
'download_url' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get published themes only
|
||||||
|
public function getPublished()
|
||||||
|
{
|
||||||
|
return $this->where('is_published', true)
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get free themes
|
||||||
|
public function getFreeThemes()
|
||||||
|
{
|
||||||
|
return $this->where('price', 0)
|
||||||
|
->where('is_published', true)
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paid themes
|
||||||
|
public function getPaidThemes()
|
||||||
|
{
|
||||||
|
return $this->where('price >', 0)
|
||||||
|
->where('is_published', true)
|
||||||
|
->orderBy('price', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Models/ProjectModel.php
Normal file
35
app/Models/ProjectModel.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class ProjectModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'projects';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = '';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => [
|
||||||
|
'rules' => 'required',
|
||||||
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The project name is required.',
|
||||||
|
'max_length' => 'The project name must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
57
app/Models/RecurringTaskCategoryModel.php
Normal file
57
app/Models/RecurringTaskCategoryModel.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class RecurringTaskCategoryModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'recurring_task_categories';
|
||||||
|
protected $primaryKey = 'recurring_task_id'; // Composite primary key
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'recurring_task_id',
|
||||||
|
'category_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
// Add category to recurring task
|
||||||
|
public function addCategoryToTask($taskId, $categoryId)
|
||||||
|
{
|
||||||
|
return $this->insert([
|
||||||
|
'recurring_task_id' => $taskId,
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove category from recurring task
|
||||||
|
public function removeCategoryFromTask($taskId, $categoryId)
|
||||||
|
{
|
||||||
|
return $this->where('recurring_task_id', $taskId)
|
||||||
|
->where('category_id', $categoryId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get categories for a recurring task
|
||||||
|
public function getCategoriesForTask($taskId)
|
||||||
|
{
|
||||||
|
return $this->select('categories.*')
|
||||||
|
->join('categories', 'recurring_task_categories.category_id = categories.id')
|
||||||
|
->where('recurring_task_categories.recurring_task_id', $taskId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recurring tasks for a category
|
||||||
|
public function getTasksForCategory($categoryId)
|
||||||
|
{
|
||||||
|
return $this->select('recurring_tasks.*')
|
||||||
|
->join('recurring_tasks', 'recurring_task_categories.recurring_task_id = recurring_tasks.id')
|
||||||
|
->where('recurring_task_categories.category_id', $categoryId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Models/RecurringTaskModel.php
Normal file
63
app/Models/RecurringTaskModel.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class RecurringTaskModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'recurring_tasks';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id', 'user_id', 'title', 'description', 'schedule',
|
||||||
|
'custom_days', 'favorite', 'created_at', 'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => [
|
||||||
|
'rules' => 'required',
|
||||||
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The recurring task title is required.',
|
||||||
|
'max_length' => 'The title must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'schedule' => [
|
||||||
|
'rules' => 'permit_empty|in_list[daily,weekly,monthly,custom]',
|
||||||
|
'errors' => [
|
||||||
|
'in_list' => 'Schedule must be one of: daily, weekly, monthly, custom.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getByUserWithCategories($userId, $taskId = null)
|
||||||
|
{
|
||||||
|
$builder = $this->select('
|
||||||
|
recurring_tasks.*,
|
||||||
|
GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
|
||||||
|
GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names
|
||||||
|
')
|
||||||
|
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||||
|
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||||
|
->where('recurring_tasks.user_id', $userId)
|
||||||
|
->groupBy('recurring_tasks.id');
|
||||||
|
|
||||||
|
if ($taskId) {
|
||||||
|
$builder->where('recurring_tasks.id', $taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->get()->getResultArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Models/TodoCategoryModel.php
Normal file
57
app/Models/TodoCategoryModel.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class TodoCategoryModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'todo_categories';
|
||||||
|
protected $primaryKey = 'todo_id'; // Composite primary key
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'todo_id',
|
||||||
|
'category_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
// Add category to todo
|
||||||
|
public function addCategoryToTodo($todoId, $categoryId)
|
||||||
|
{
|
||||||
|
return $this->insert([
|
||||||
|
'todo_id' => $todoId,
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove category from todo
|
||||||
|
public function removeCategoryFromTodo($todoId, $categoryId)
|
||||||
|
{
|
||||||
|
return $this->where('todo_id', $todoId)
|
||||||
|
->where('category_id', $categoryId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get categories for a todo
|
||||||
|
public function getCategoriesForTodo($todoId)
|
||||||
|
{
|
||||||
|
return $this->select('categories.*')
|
||||||
|
->join('categories', 'todo_categories.category_id = categories.id')
|
||||||
|
->where('todo_categories.todo_id', $todoId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get todos for a category
|
||||||
|
public function getTodosForCategory($categoryId)
|
||||||
|
{
|
||||||
|
return $this->select('todos.*')
|
||||||
|
->join('todos', 'todo_categories.todo_id = todos.id')
|
||||||
|
->where('todo_categories.category_id', $categoryId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Models/TodoModel.php
Normal file
72
app/Models/TodoModel.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class TodoModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'todos';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id', 'user_id', 'title', 'description', 'status',
|
||||||
|
'due_date', 'due_time', 'sync_enabled', 'reminder_enabled',
|
||||||
|
'recurring_enabled', 'project_id', 'created_at', 'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => [
|
||||||
|
'rules' => 'required',
|
||||||
|
'errors' => ['required' => 'User ID is required.'],
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'The todo title is required.',
|
||||||
|
'max_length' => 'The title must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'status' => [
|
||||||
|
'rules' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
||||||
|
'errors' => [
|
||||||
|
'in_list' => 'Status must be one of: open, in_progress, completed, archived.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'due_date' => [
|
||||||
|
'rules' => 'permit_empty|valid_date[Y-m-d]',
|
||||||
|
'errors' => ['valid_date' => 'Due date must be in YYYY-MM-DD format.'],
|
||||||
|
],
|
||||||
|
'due_time' => [
|
||||||
|
'rules' => 'permit_empty|valid_date[H:i:s]',
|
||||||
|
'errors' => ['valid_date' => 'Due time must be in HH:MM format.'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getByUserWithCategories($userId, $todoId = null)
|
||||||
|
{
|
||||||
|
$builder = $this->select('
|
||||||
|
todos.*,
|
||||||
|
GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
|
||||||
|
GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names
|
||||||
|
')
|
||||||
|
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
|
||||||
|
->join('categories', 'todo_categories.category_id = categories.id', 'left')
|
||||||
|
->where('todos.user_id', $userId)
|
||||||
|
->groupBy('todos.id');
|
||||||
|
|
||||||
|
if ($todoId) {
|
||||||
|
$builder->where('todos.id', $todoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->get()->getResultArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Models/UserAiSettingsModel.php
Normal file
68
app/Models/UserAiSettingsModel.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class UserAiSettingsModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_ai_settings';
|
||||||
|
protected $primaryKey = 'user_id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'user_id',
|
||||||
|
'default_provider_id',
|
||||||
|
'default_model',
|
||||||
|
'max_tokens',
|
||||||
|
'temperature',
|
||||||
|
'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => 'required',
|
||||||
|
'max_tokens' => 'permit_empty|integer|greater_than[0]',
|
||||||
|
'temperature' => 'permit_empty|numeric|greater_than_equal_to[0]|less_than_equal_to[2]',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get or create settings for user
|
||||||
|
public function getSettings($userId)
|
||||||
|
{
|
||||||
|
$settings = $this->find($userId);
|
||||||
|
|
||||||
|
if (!$settings) {
|
||||||
|
// Create default settings
|
||||||
|
$this->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'default_provider_id' => null,
|
||||||
|
'default_model' => null,
|
||||||
|
'max_tokens' => 2048,
|
||||||
|
'temperature' => 0.7,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$settings = $this->find($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings for user
|
||||||
|
public function updateSettings($userId, $data)
|
||||||
|
{
|
||||||
|
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||||
|
return $this->update($userId, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get settings with provider info
|
||||||
|
public function getSettingsWithProvider($userId)
|
||||||
|
{
|
||||||
|
return $this->select('user_ai_settings.*, ai_providers.name as provider_name, ai_providers.display_name')
|
||||||
|
->join('ai_providers', 'user_ai_settings.default_provider_id = ai_providers.id', 'left')
|
||||||
|
->where('user_ai_settings.user_id', $userId)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Models/UserApiKeyModel.php
Normal file
108
app/Models/UserApiKeyModel.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class UserApiKeyModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_api_keys';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'provider_id',
|
||||||
|
'api_key_encrypted',
|
||||||
|
'label',
|
||||||
|
'is_active',
|
||||||
|
'created_at',
|
||||||
|
'last_used_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => 'required',
|
||||||
|
'provider_id' => 'required',
|
||||||
|
'api_key_encrypted' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Save or update API key for user and provider
|
||||||
|
public function saveApiKey($userId, $providerId, $encryptedKey, $label = null)
|
||||||
|
{
|
||||||
|
$existing = $this->where('user_id', $userId)
|
||||||
|
->where('provider_id', $providerId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $this->update($existing['id'], [
|
||||||
|
'api_key_encrypted' => $encryptedKey,
|
||||||
|
'label' => $label,
|
||||||
|
'is_active' => true,
|
||||||
|
'last_used_at' => null,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
return $this->insert([
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'provider_id' => $providerId,
|
||||||
|
'api_key_encrypted' => $encryptedKey,
|
||||||
|
'label' => $label,
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'last_used_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API key for user and provider
|
||||||
|
public function getApiKey($userId, $providerId)
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->where('provider_id', $providerId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all API keys for user
|
||||||
|
public function getUserApiKeys($userId)
|
||||||
|
{
|
||||||
|
return $this->select('user_api_keys.*, ai_providers.name as provider_name, ai_providers.display_name')
|
||||||
|
->join('ai_providers', 'user_api_keys.provider_id = ai_providers.id')
|
||||||
|
->where('user_api_keys.user_id', $userId)
|
||||||
|
->orderBy('user_api_keys.created_at', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate API key
|
||||||
|
public function deactivateApiKey($userId, $providerId)
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->where('provider_id', $providerId)
|
||||||
|
->update(['is_active' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
public function updateLastUsed($userId, $providerId)
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->where('provider_id', $providerId)
|
||||||
|
->update(['last_used_at' => date('Y-m-d H:i:s')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUuid()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Models/UserModel.php
Normal file
40
app/Models/UserModel.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class UserModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'users';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id', 'email', 'password_hash', 'name', 'avatar_url',
|
||||||
|
'settings', 'created_at', 'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'rules' => 'required|max_length[255]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Name is required.',
|
||||||
|
'max_length' => 'Name must not exceed 255 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
104
app/Models/UserThemeModel.php
Normal file
104
app/Models/UserThemeModel.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class UserThemeModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_themes';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'theme_id',
|
||||||
|
'installed_at',
|
||||||
|
'active',
|
||||||
|
'custom_settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'user_id' => 'required',
|
||||||
|
'theme_id' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install theme for user
|
||||||
|
public function installTheme($userId, $themeId)
|
||||||
|
{
|
||||||
|
return $this->insert([
|
||||||
|
'id' => $this->generateUuid(),
|
||||||
|
'user_id' => $userId,
|
||||||
|
'theme_id' => $themeId,
|
||||||
|
'installed_at' => date('Y-m-d H:i:s'),
|
||||||
|
'active' => false,
|
||||||
|
'custom_settings' => json_encode([]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall theme for user
|
||||||
|
public function uninstallTheme($userId, $themeId)
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->where('theme_id', $themeId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set active theme for user
|
||||||
|
public function setActiveTheme($userId, $themeId)
|
||||||
|
{
|
||||||
|
// Deactivate all themes for user
|
||||||
|
$this->where('user_id', $userId)->update(['active' => false]);
|
||||||
|
|
||||||
|
// Activate the specified theme
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->where('theme_id', $themeId)
|
||||||
|
->update(['active' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active theme for user
|
||||||
|
public function getActiveTheme($userId)
|
||||||
|
{
|
||||||
|
return $this->select('user_themes.*, marketplace_themes.*')
|
||||||
|
->join('marketplace_themes', 'user_themes.theme_id = marketplace_themes.id')
|
||||||
|
->where('user_themes.user_id', $userId)
|
||||||
|
->where('user_themes.active', true)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all installed themes for user
|
||||||
|
public function getUserThemes($userId)
|
||||||
|
{
|
||||||
|
return $this->select('user_themes.*, marketplace_themes.*')
|
||||||
|
->join('marketplace_themes', 'user_themes.theme_id = marketplace_themes.id')
|
||||||
|
->where('user_themes.user_id', $userId)
|
||||||
|
->orderBy('user_themes.installed_at', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if theme is installed for user
|
||||||
|
public function isInstalled($userId, $themeId)
|
||||||
|
{
|
||||||
|
return $this->where('user_id', $userId)
|
||||||
|
->where('theme_id', $themeId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUuid()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
861
app/Views/theme_store.php
Normal file
861
app/Views/theme_store.php
Normal file
@@ -0,0 +1,861 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Theme Store</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f0f17;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
padding: 60px 24px 50px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse at 50% 0%, rgba(124,58,237,0.15) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
background: linear-gradient(135deg, #a78bfa, #60a5fa, #f472b6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
header p {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
position: relative;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.header-badge {
|
||||||
|
background: rgba(124,58,237,0.2);
|
||||||
|
border: 1px solid rgba(124,58,237,0.4);
|
||||||
|
color: #a78bfa;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.btn-upload-header {
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.btn-upload-header:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* ── Flash messages ── */
|
||||||
|
.flash {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 24px auto 0;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.flash-success { background: rgba(52,211,153,0.12); border: 1px solid rgba(52,211,153,0.3); color: #34d399; }
|
||||||
|
.flash-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #f87171; }
|
||||||
|
|
||||||
|
/* ── Grid ── */
|
||||||
|
.grid-wrapper {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 80px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ── */
|
||||||
|
.card {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 20px 48px rgba(0,0,0,0.5);
|
||||||
|
border-color: rgba(167,139,250,0.3);
|
||||||
|
}
|
||||||
|
.card-preview {
|
||||||
|
height: 90px;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-preview .swatch { flex: 1; transition: flex 0.3s ease; }
|
||||||
|
.card:hover .card-preview .swatch { flex: 1.4; }
|
||||||
|
|
||||||
|
.card-body { padding: 22px; }
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card-name { font-size: 1.15rem; font-weight: 700; color: #f1f5f9; }
|
||||||
|
.card-version {
|
||||||
|
font-size: 0.75rem; color: #64748b;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 2px 8px; border-radius: 100px;
|
||||||
|
}
|
||||||
|
.card-author { font-size: 0.8rem; color: #94a3b8; margin-bottom: 10px; }
|
||||||
|
.card-desc {
|
||||||
|
font-size: 0.88rem; color: #94a3b8; line-height: 1.55;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
|
||||||
|
.tag {
|
||||||
|
background: rgba(167,139,250,0.1);
|
||||||
|
border: 1px solid rgba(167,139,250,0.25);
|
||||||
|
color: #a78bfa;
|
||||||
|
font-size: 0.72rem; padding: 2px 9px; border-radius: 100px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.card-actions { display: flex; gap: 10px; }
|
||||||
|
.btn {
|
||||||
|
flex: 1; padding: 10px 0; border-radius: 8px; border: none;
|
||||||
|
cursor: pointer; font-size: 0.85rem; font-weight: 600;
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
.btn-download {
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
color: #fff; text-decoration: none;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.btn-details {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.btn-details:hover { background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
/* ── Backdrop shared ── */
|
||||||
|
.modal-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.modal-backdrop.open { display: flex; }
|
||||||
|
|
||||||
|
/* ── Details Modal ── */
|
||||||
|
.details-modal {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 95vw;
|
||||||
|
max-width: 1100px;
|
||||||
|
height: 88vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@keyframes popIn {
|
||||||
|
from { opacity: 0; transform: scale(0.92) translateY(20px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* colour stripe at top */
|
||||||
|
.modal-preview-stripe {
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.modal-preview-stripe .swatch { flex: 1; }
|
||||||
|
|
||||||
|
/* tab bar */
|
||||||
|
.modal-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
.modal-tab {
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
background: none;
|
||||||
|
border-top: none; border-left: none; border-right: none;
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.modal-tab:hover { color: #94a3b8; }
|
||||||
|
.modal-tab.active { color: #a78bfa; border-bottom-color: #7c3aed; }
|
||||||
|
|
||||||
|
/* tab panels */
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* details panel */
|
||||||
|
.details-panel { padding: 28px; }
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 1.4rem; font-weight: 800; color: #f1f5f9; }
|
||||||
|
.modal-close {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 8px; width: 34px; height: 34px;
|
||||||
|
cursor: pointer; font-size: 1.1rem;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.modal-close:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
||||||
|
.modal-meta { font-size: 0.82rem; color: #64748b; margin-bottom: 16px; }
|
||||||
|
.modal-desc { font-size: 0.92rem; color: #94a3b8; line-height: 1.65; margin-bottom: 22px; }
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: #64748b; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.colors-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.color-chip { border-radius: 10px; overflow: hidden; border: 1px solid rgba(255,255,255,0.07); }
|
||||||
|
.color-chip-swatch { height: 48px; }
|
||||||
|
.color-chip-info { padding: 6px 8px; background: rgba(255,255,255,0.03); }
|
||||||
|
.color-chip-name { font-size: 0.7rem; color: #94a3b8; display: block; }
|
||||||
|
.color-chip-hex { font-size: 0.75rem; font-weight: 600; color: #e2e8f0; font-family: 'SF Mono', monospace; }
|
||||||
|
.modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 24px; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; }
|
||||||
|
.btn-download-lg {
|
||||||
|
flex: 1; padding: 13px; font-size: 0.95rem; border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
color: #fff; font-weight: 700; text-decoration: none;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
border: none; cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.btn-download-lg:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* preview panel */
|
||||||
|
.preview-panel {
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.preview-panel.active { display: flex; }
|
||||||
|
.preview-toolbar {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.preview-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.preview-url {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.preview-iframe-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.preview-iframe-wrap iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.preview-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #0f0f17;
|
||||||
|
color: #94a3b8;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border: 3px solid rgba(167,139,250,0.2);
|
||||||
|
border-top-color: #7c3aed;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Upload Modal ── */
|
||||||
|
.upload-modal {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.upload-header {
|
||||||
|
padding: 24px 28px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.upload-title { font-size: 1.25rem; font-weight: 700; color: #f1f5f9; }
|
||||||
|
.upload-body { padding: 0 28px 28px; }
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.field input[type="text"],
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input[type="text"]:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
border-color: rgba(124,58,237,0.5);
|
||||||
|
}
|
||||||
|
.field textarea { resize: vertical; min-height: 80px; }
|
||||||
|
.file-drop {
|
||||||
|
border: 2px dashed rgba(124,58,237,0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
position: relative;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.file-drop:hover,
|
||||||
|
.file-drop.drag-over {
|
||||||
|
border-color: rgba(124,58,237,0.7);
|
||||||
|
background: rgba(124,58,237,0.05);
|
||||||
|
}
|
||||||
|
.file-drop input[type="file"] {
|
||||||
|
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
|
||||||
|
}
|
||||||
|
.file-drop-icon { font-size: 1.8rem; margin-bottom: 8px; display: block; }
|
||||||
|
.file-drop strong { color: #a78bfa; }
|
||||||
|
.file-name-preview {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #34d399;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.btn-submit:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(167,139,250,0.3); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Theme Store</h1>
|
||||||
|
<p>Beautiful, ready-to-use themes for your application</p>
|
||||||
|
<div class="header-row">
|
||||||
|
<span class="header-badge"><?= count($themes) ?> free themes</span>
|
||||||
|
<button class="btn-upload-header" onclick="openUploadModal()">
|
||||||
|
<span>+</span> Upload Theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if ($flash_success): ?>
|
||||||
|
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
|
||||||
|
<div class="flash flash-success">✓ <?= esc($flash_success) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($flash_error): ?>
|
||||||
|
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
|
||||||
|
<div class="flash flash-error">⚠ <?= esc($flash_error) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="grid-wrapper">
|
||||||
|
<div class="grid">
|
||||||
|
<?php foreach ($themes as $theme): ?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-preview">
|
||||||
|
<?php foreach (array_values($theme['colors']) as $hex): ?>
|
||||||
|
<div class="swatch" style="background:<?= esc($hex) ?>"></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($theme['colors'])): ?>
|
||||||
|
<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="card-name"><?= esc($theme['display_name']) ?></span>
|
||||||
|
<span class="card-version">v<?= esc($theme['version']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-author">by <?= esc($theme['author']) ?></div>
|
||||||
|
<p class="card-desc"><?= esc($theme['description']) ?></p>
|
||||||
|
<div class="card-tags">
|
||||||
|
<?php foreach ($theme['tags'] as $tag): ?>
|
||||||
|
<span class="tag"><?= esc($tag) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-download" onclick="installTheme(<?= htmlspecialchars(json_encode([
|
||||||
|
'id' => $theme['id'],
|
||||||
|
'display_name' => $theme['display_name'],
|
||||||
|
'description' => $theme['description'],
|
||||||
|
'author' => $theme['author'],
|
||||||
|
'version' => $theme['version'],
|
||||||
|
'download_url' => $theme['download_url'],
|
||||||
|
'colors' => $theme['colors'],
|
||||||
|
'tags' => $theme['tags'],
|
||||||
|
'vars' => $theme['vars'],
|
||||||
|
]), ENT_QUOTES, 'UTF-8') ?>)">
|
||||||
|
⇓ Install Theme
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-details"
|
||||||
|
onclick="openDetailsModal(<?= htmlspecialchars(json_encode([
|
||||||
|
'id' => $theme['id'],
|
||||||
|
'display_name' => $theme['display_name'],
|
||||||
|
'description' => $theme['description'],
|
||||||
|
'author' => $theme['author'],
|
||||||
|
'version' => $theme['version'],
|
||||||
|
'download_url' => $theme['download_url'],
|
||||||
|
'colors' => $theme['colors'],
|
||||||
|
'tags' => $theme['tags'],
|
||||||
|
'vars' => $theme['vars'],
|
||||||
|
]), ENT_QUOTES, 'UTF-8') ?>)">
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Details Modal ── -->
|
||||||
|
<div class="modal-backdrop" id="details-backdrop" onclick="closeDetailsOnBackdrop(event)">
|
||||||
|
<div class="details-modal" id="details-modal">
|
||||||
|
|
||||||
|
<div class="modal-preview-stripe" id="dm-stripe"></div>
|
||||||
|
|
||||||
|
<div class="modal-tabs">
|
||||||
|
<button class="modal-tab active" id="tab-details" onclick="switchTab('details')">
|
||||||
|
✎ Details
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" id="tab-preview" onclick="switchTab('preview')">
|
||||||
|
▶ Live Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<!-- Details panel -->
|
||||||
|
<div class="tab-panel active" id="panel-details">
|
||||||
|
<div class="details-panel">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title" id="dm-title"></span>
|
||||||
|
<button class="modal-close" onclick="closeDetailsModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-meta" id="dm-meta"></div>
|
||||||
|
<p class="modal-desc" id="dm-desc"></p>
|
||||||
|
|
||||||
|
<div id="dm-colors-section">
|
||||||
|
<div class="section-label">Colour Palette</div>
|
||||||
|
<div class="colors-grid" id="dm-colors"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-label" style="margin-bottom:10px">Tags</div>
|
||||||
|
<div class="modal-tags" id="dm-tags"></div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-download-lg" id="dm-download" onclick="installCurrentTheme()">
|
||||||
|
⇓ Install Theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview panel -->
|
||||||
|
<div class="tab-panel" id="panel-preview" style="height:100%">
|
||||||
|
<div class="preview-panel" id="preview-panel-inner">
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<div class="preview-dot" style="background:#ff5f57"></div>
|
||||||
|
<div class="preview-dot" style="background:#febc2e"></div>
|
||||||
|
<div class="preview-dot" style="background:#28c840"></div>
|
||||||
|
<div class="preview-url" id="preview-url">localhost:5173 — with theme applied</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-iframe-wrap">
|
||||||
|
<div class="preview-loading" id="preview-loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Loading preview…</span>
|
||||||
|
</div>
|
||||||
|
<iframe id="preview-iframe" title="Theme Preview" onload="iframeLoaded()"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- .modal-body -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Upload Modal ── -->
|
||||||
|
<div class="modal-backdrop" id="upload-backdrop" onclick="closeUploadOnBackdrop(event)">
|
||||||
|
<div class="upload-modal">
|
||||||
|
<div class="upload-header">
|
||||||
|
<span class="upload-title">Upload Custom Theme</span>
|
||||||
|
<button class="modal-close" onclick="closeUploadModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="upload-body">
|
||||||
|
<form method="post" action="<?= site_url('themes/upload') ?>" enctype="multipart/form-data">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Display Name *</label>
|
||||||
|
<input type="text" name="display_name" placeholder="e.g. Neon Sunset" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea name="description" placeholder="Describe your theme's mood and colours…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>CSS File *</label>
|
||||||
|
<div class="file-drop" id="file-drop">
|
||||||
|
<input type="file" name="theme_css" accept=".css" required onchange="previewFileName(this)">
|
||||||
|
<span class="file-drop-icon">💾</span>
|
||||||
|
<div>Drop your <strong>.css</strong> file here<br>or <strong>click to browse</strong></div>
|
||||||
|
<div class="file-name-preview" id="file-name-preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-submit">↑ Upload Theme</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
© <?= date('Y') ?> Theme Store — All themes are free to use
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentThemeId = null;
|
||||||
|
let currentThemeVars = {};
|
||||||
|
let currentThemeData = null;
|
||||||
|
let previewLoaded = false;
|
||||||
|
|
||||||
|
/* ── Theme Installation ── */
|
||||||
|
function installTheme(theme) {
|
||||||
|
// Convert theme data to match Todo-App format
|
||||||
|
const themeData = {
|
||||||
|
name: theme.display_name,
|
||||||
|
description: theme.description,
|
||||||
|
preview: Object.values(theme.colors || {}).length ? Object.values(theme.colors) : ['#ffffff', '#f0f0f0', '#007acc'],
|
||||||
|
vars: theme.vars || {},
|
||||||
|
source: 'theme-store'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Installing theme:', themeData);
|
||||||
|
|
||||||
|
// Send install message to parent Todo-App
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'THEME_DOWNLOAD_REQUEST',
|
||||||
|
data: themeData
|
||||||
|
}, '*');
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
showInstallFeedback(theme.display_name);
|
||||||
|
} else {
|
||||||
|
// Fallback: redirect to the frontend with the theme data
|
||||||
|
const installUrl = "http://localhost:5173/#theme-install:" + encodeURIComponent(JSON.stringify(themeData));
|
||||||
|
window.location.href = installUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installCurrentTheme() {
|
||||||
|
if (currentThemeData) {
|
||||||
|
installTheme(currentThemeData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInstallFeedback(themeName) {
|
||||||
|
// Create a temporary success message
|
||||||
|
const feedback = document.createElement('div');
|
||||||
|
feedback.className = 'install-feedback';
|
||||||
|
feedback.innerHTML = `✅ "${themeName}" is being installed...`;
|
||||||
|
feedback.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 10000;
|
||||||
|
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(feedback);
|
||||||
|
|
||||||
|
// Remove after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
feedback.style.animation = 'slideOut 0.3s ease-out';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(feedback);
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animations
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slideOut {
|
||||||
|
from { transform: translateX(0); opacity: 1; }
|
||||||
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
/* ── Details modal ── */
|
||||||
|
function openDetailsModal(theme) {
|
||||||
|
currentThemeId = theme.id;
|
||||||
|
currentThemeVars = theme.vars || {};
|
||||||
|
currentThemeData = theme; // Store full theme data for installation
|
||||||
|
previewLoaded = false;
|
||||||
|
|
||||||
|
const colors = theme.colors || {};
|
||||||
|
const tags = theme.tags || [];
|
||||||
|
|
||||||
|
document.getElementById('dm-title').textContent = theme.display_name;
|
||||||
|
document.getElementById('dm-meta').textContent = 'by ' + theme.author + ' · v' + theme.version;
|
||||||
|
document.getElementById('dm-desc').textContent = theme.description;
|
||||||
|
document.getElementById('preview-url').textContent = 'localhost — ' + theme.display_name + ' applied';
|
||||||
|
|
||||||
|
// stripe
|
||||||
|
const stripe = document.getElementById('dm-stripe');
|
||||||
|
const colorValues = Object.values(colors);
|
||||||
|
stripe.innerHTML = colorValues.length
|
||||||
|
? colorValues.map(c => `<div class="swatch" style="background:${c}"></div>`).join('')
|
||||||
|
: '<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>';
|
||||||
|
|
||||||
|
// colour chips
|
||||||
|
const colorsSection = document.getElementById('dm-colors-section');
|
||||||
|
const grid = document.getElementById('dm-colors');
|
||||||
|
if (Object.keys(colors).length) {
|
||||||
|
colorsSection.style.display = '';
|
||||||
|
grid.innerHTML = Object.entries(colors).map(([name, hex]) => `
|
||||||
|
<div class="color-chip">
|
||||||
|
<div class="color-chip-swatch" style="background:${hex}"></div>
|
||||||
|
<div class="color-chip-info">
|
||||||
|
<span class="color-chip-name">${name}</span>
|
||||||
|
<span class="color-chip-hex">${hex}</span>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
} else {
|
||||||
|
colorsSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// tags
|
||||||
|
document.getElementById('dm-tags').innerHTML =
|
||||||
|
tags.length ? tags.map(t => `<span class="tag">${t}</span>`).join('') : '<span style="color:#475569;font-size:0.82rem">No tags</span>';
|
||||||
|
|
||||||
|
// reset to details tab
|
||||||
|
switchTab('details');
|
||||||
|
|
||||||
|
document.getElementById('details-backdrop').classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailsModal() {
|
||||||
|
document.getElementById('details-backdrop').classList.remove('open');
|
||||||
|
document.getElementById('preview-iframe').src = '';
|
||||||
|
document.getElementById('preview-loading').style.display = 'flex';
|
||||||
|
currentThemeVars = {};
|
||||||
|
previewLoaded = false;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailsOnBackdrop(e) {
|
||||||
|
if (e.target === document.getElementById('details-backdrop')) closeDetailsModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
|
||||||
|
document.getElementById('tab-' + name).classList.add('active');
|
||||||
|
document.getElementById('panel-' + name).classList.add('active');
|
||||||
|
|
||||||
|
if (name === 'preview') {
|
||||||
|
document.getElementById('preview-panel-inner').classList.add('active');
|
||||||
|
if (!previewLoaded && currentThemeId) {
|
||||||
|
document.getElementById('preview-loading').style.display = 'flex';
|
||||||
|
const themeData = {
|
||||||
|
name: currentThemeData.display_name,
|
||||||
|
vars: currentThemeVars
|
||||||
|
};
|
||||||
|
const encoded = btoa(JSON.stringify(themeData));
|
||||||
|
document.getElementById('preview-iframe').src = 'http://localhost:5173/?__theme_preview=' + encoded + '&__preview_mode=true';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('preview-panel-inner').classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function iframeLoaded() {
|
||||||
|
previewLoaded = true;
|
||||||
|
document.getElementById('preview-loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Upload modal ── */
|
||||||
|
function openUploadModal() {
|
||||||
|
document.getElementById('upload-backdrop').classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function closeUploadModal() {
|
||||||
|
document.getElementById('upload-backdrop').classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
function closeUploadOnBackdrop(e) {
|
||||||
|
if (e.target === document.getElementById('upload-backdrop')) closeUploadModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewFileName(input) {
|
||||||
|
const el = document.getElementById('file-name-preview');
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
el.textContent = '✓ ' + input.files[0].name;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag-over styling
|
||||||
|
const drop = document.getElementById('file-drop');
|
||||||
|
drop.addEventListener('dragover', () => drop.classList.add('drag-over'));
|
||||||
|
drop.addEventListener('dragleave', () => drop.classList.remove('drag-over'));
|
||||||
|
drop.addEventListener('drop', () => drop.classList.remove('drag-over'));
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeDetailsModal(); closeUploadModal(); } });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -30,13 +30,13 @@
|
|||||||
# DATABASE
|
# DATABASE
|
||||||
#--------------------------------------------------------------------
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
# database.default.hostname = localhost
|
database.default.hostname = localhost
|
||||||
# database.default.database = ci4
|
database.default.database = ci4
|
||||||
# database.default.username = root
|
database.default.username = root
|
||||||
# database.default.password = root
|
database.default.password = root
|
||||||
# database.default.DBDriver = MySQLi
|
database.default.DBDriver = MySQLi
|
||||||
# database.default.DBPrefix =
|
# database.default.DBPrefix =
|
||||||
# database.default.port = 3306
|
database.default.port = 3306
|
||||||
|
|
||||||
# If you use MySQLi as tests, first update the values of Config\Database::$tests.
|
# If you use MySQLi as tests, first update the values of Config\Database::$tests.
|
||||||
# database.tests.hostname = localhost
|
# database.tests.hostname = localhost
|
||||||
69
example.env
Normal file
69
example.env
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#--------------------------------------------------------------------
|
||||||
|
# Example Environment Configuration file
|
||||||
|
#
|
||||||
|
# This file can be used as a starting point for your own
|
||||||
|
# custom .env files, and contains most of the possible settings
|
||||||
|
# available in a default install.
|
||||||
|
#
|
||||||
|
# By default, all of the settings are commented out. If you want
|
||||||
|
# to override the setting, you must un-comment it by removing the '#'
|
||||||
|
# at the beginning of the line.
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
# ENVIRONMENT
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
CI_ENVIRONMENT = development
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
# APP
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
# app.baseURL = ''
|
||||||
|
# If you have trouble with `.`, you could also use `_`.
|
||||||
|
# app_baseURL = ''
|
||||||
|
# app.forceGlobalSecureRequests = false
|
||||||
|
# app.CSPEnabled = false
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
# DATABASE
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
database.default.hostname = 127.0.0.1
|
||||||
|
database.default.database = TodoApp
|
||||||
|
database.default.username = root
|
||||||
|
database.default.password =
|
||||||
|
database.default.DBDriver = MySQLi
|
||||||
|
# database.default.DBPrefix =
|
||||||
|
database.default.port = 3306
|
||||||
|
|
||||||
|
# If you use MySQLi as tests, first update the values of Config\Database::$tests.
|
||||||
|
# database.tests.hostname = localhost
|
||||||
|
# database.tests.database = ci4_test
|
||||||
|
# database.tests.username = root
|
||||||
|
# database.tests.password = root
|
||||||
|
# database.tests.DBDriver = MySQLi
|
||||||
|
# database.tests.DBPrefix =
|
||||||
|
# database.tests.charset = utf8mb4
|
||||||
|
# database.tests.DBCollat = utf8mb4_general_ci
|
||||||
|
# database.tests.port = 3306
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
# ENCRYPTION
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
# encryption.key =
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
# SESSION
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
# session.driver = 'CodeIgniter\Session\Handlers\FileHandler'
|
||||||
|
# session.savePath = null
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
# LOGGER
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
# logger.threshold = 4
|
||||||
@@ -30,7 +30,6 @@ Options -Indexes
|
|||||||
# such as an image or css document, if this isn't true it sends the
|
# such as an image or css document, if this isn't true it sends the
|
||||||
# request to the front controller, index.php
|
# request to the front controller, index.php
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
|
||||||
RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA]
|
RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA]
|
||||||
|
|
||||||
# Ensure Authorization header is passed along
|
# Ensure Authorization header is passed along
|
||||||
|
|||||||
122
public/example_login.html
Normal file
122
public/example_login.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Login & Register</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form id="loginForm">
|
||||||
|
<div>
|
||||||
|
<label for="loginEmail">Email:</label>
|
||||||
|
<input type="email" id="loginEmail" name="email" required>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div>
|
||||||
|
<label for="loginPassword">Password:</label>
|
||||||
|
<input type="password" id="loginPassword" name="password" required>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h1>Register</h1>
|
||||||
|
<form id="registerForm">
|
||||||
|
<div>
|
||||||
|
<label for="regEmail">Email:</label>
|
||||||
|
<input type="email" id="regEmail" name="email" required>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div>
|
||||||
|
<label for="regPassword">Password:</label>
|
||||||
|
<input type="password" id="regPassword" name="password" required>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div>
|
||||||
|
<label for="regName">Name:</label>
|
||||||
|
<input type="text" id="regName" name="name" required>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Response</h2>
|
||||||
|
<pre id="response"></pre>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const email = document.getElementById('loginEmail').value;
|
||||||
|
const password = document.getElementById('loginPassword').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
password: password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
document.getElementById('response').textContent = JSON.stringify(data, null, 2);
|
||||||
|
|
||||||
|
if (data.success && data.data.api_key) {
|
||||||
|
localStorage.setItem('apiKey', data.data.api_key);
|
||||||
|
alert('API Key saved to localStorage: ' + data.data.api_key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('response').textContent = 'Error: ' + error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('registerForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const email = document.getElementById('regEmail').value;
|
||||||
|
const password = document.getElementById('regPassword').value;
|
||||||
|
const name = document.getElementById('regName').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
data = { error: 'Invalid JSON response', raw: text, status: response.status, statusText: response.statusText };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
data = { ...data, httpError: `HTTP ${response.status}: ${response.statusText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('response').textContent = JSON.stringify(data, null, 2);
|
||||||
|
|
||||||
|
if (data.success && data.data.api_key) {
|
||||||
|
localStorage.setItem('apiKey', data.data.api_key);
|
||||||
|
alert('API Key saved to localStorage: ' + data.data.api_key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('response').textContent = 'Error: ' + error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
public/themes/2341342134-1441f7.css
Executable file
38
public/themes/2341342134-1441f7.css
Executable file
File diff suppressed because one or more lines are too long
16
public/themes/arctic-frost.css
Executable file
16
public/themes/arctic-frost.css
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
/* Arctic Frost Theme — MinimalStudio v3.0.0 */
|
||||||
|
:root {
|
||||||
|
--color-primary: #2176AE;
|
||||||
|
--color-secondary: #57C4E5;
|
||||||
|
--color-background: #F8FBFF;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-text: #1C2B3A;
|
||||||
|
--color-accent: #A8DADC;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||||
|
a, .link { color: var(--color-primary); }
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: var(--color-secondary); }
|
||||||
|
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 1px 6px rgba(33,118,174,0.08); padding: 20px; border: 1px solid #DDE8F0; }
|
||||||
|
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||||
37
public/themes/extract-test-theme-5fae6e.css
Executable file
37
public/themes/extract-test-theme-5fae6e.css
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
/* @todo-theme-meta
|
||||||
|
{
|
||||||
|
"name": "Extract Test Theme",
|
||||||
|
"id": "custom-1778676985034",
|
||||||
|
"group": "Custom",
|
||||||
|
"preview": [
|
||||||
|
"#f5f5f5",
|
||||||
|
"#ffffff",
|
||||||
|
"#274f69"
|
||||||
|
],
|
||||||
|
"hasWallpaper": false
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--surface-muted: #fafafa;
|
||||||
|
--border: #d0d0d0;
|
||||||
|
--line: #b9b9b5;
|
||||||
|
--text: #1f1f1f;
|
||||||
|
--text-muted: #686866;
|
||||||
|
--text-strong: #111111;
|
||||||
|
--accent: #274f69;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--accent-soft: #d6e4ec;
|
||||||
|
--sidebar-bg: #ffffff;
|
||||||
|
--sidebar-border: #d0d0d0;
|
||||||
|
--sidebar-text: #222222;
|
||||||
|
--sidebar-text-muted: #686866;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #cfcfcf;
|
||||||
|
--modal-bg: #ffffff;
|
||||||
|
--chip: #d8d8d8;
|
||||||
|
--success: #dff7e7;
|
||||||
|
}
|
||||||
16
public/themes/forest-grove.css
Executable file
16
public/themes/forest-grove.css
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
/* Forest Grove Theme — NaturePalette v1.0.5 */
|
||||||
|
:root {
|
||||||
|
--color-primary: #2D6A4F;
|
||||||
|
--color-secondary: #52B788;
|
||||||
|
--color-background: #F0F7EE;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-text: #1B2E22;
|
||||||
|
--color-accent: #B7E4C7;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||||
|
a, .link { color: var(--color-primary); }
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: var(--color-secondary); }
|
||||||
|
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(45,106,79,0.12); padding: 20px; }
|
||||||
|
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||||
38
public/themes/manual-game-update-2-e1a77a.css
Executable file
38
public/themes/manual-game-update-2-e1a77a.css
Executable file
File diff suppressed because one or more lines are too long
38
public/themes/manual-game-update-7cc79d.css
Executable file
38
public/themes/manual-game-update-7cc79d.css
Executable file
File diff suppressed because one or more lines are too long
16
public/themes/midnight-void.css
Executable file
16
public/themes/midnight-void.css
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
/* Midnight Void Theme — ThemeForge v2.0.1 */
|
||||||
|
:root {
|
||||||
|
--color-primary: #7C3AED;
|
||||||
|
--color-secondary: #A78BFA;
|
||||||
|
--color-background: #0D0D1A;
|
||||||
|
--color-surface: #1A1A2E;
|
||||||
|
--color-text: #E2E8F0;
|
||||||
|
--color-accent: #F472B6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||||
|
a, .link { color: var(--color-secondary); }
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: var(--color-accent); }
|
||||||
|
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(124,58,237,0.25); padding: 20px; }
|
||||||
|
.tag { background: var(--color-accent); color: #0D0D1A; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||||
16
public/themes/obsidian-rose.css
Executable file
16
public/themes/obsidian-rose.css
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
/* Obsidian Rose Theme — ChromaCraft v1.3.0 */
|
||||||
|
:root {
|
||||||
|
--color-primary: #C9184A;
|
||||||
|
--color-secondary: #FF4D6D;
|
||||||
|
--color-background: #0A0A0F;
|
||||||
|
--color-surface: #1C1C28;
|
||||||
|
--color-text: #F1E3E4;
|
||||||
|
--color-accent: #B5838D;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||||
|
a, .link { color: var(--color-secondary); }
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: var(--color-secondary); }
|
||||||
|
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(201,24,74,0.2); padding: 20px; }
|
||||||
|
.tag { background: var(--color-accent); color: #0A0A0F; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||||
16
public/themes/ocean-breeze.css
Executable file
16
public/themes/ocean-breeze.css
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
/* Ocean Breeze Theme — ThemeForge v1.2.0 */
|
||||||
|
:root {
|
||||||
|
--color-primary: #0077B6;
|
||||||
|
--color-secondary: #00B4D8;
|
||||||
|
--color-background: #E0F4FF;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-text: #1A2B3C;
|
||||||
|
--color-accent: #48CAE4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||||
|
a, .link { color: var(--color-primary); }
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: var(--color-secondary); }
|
||||||
|
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,119,182,0.1); padding: 20px; }
|
||||||
|
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||||
37
public/themes/red-extract-theme-a3aabe.css
Executable file
37
public/themes/red-extract-theme-a3aabe.css
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
/* @todo-theme-meta
|
||||||
|
{
|
||||||
|
"name": "Red Extract Theme",
|
||||||
|
"id": "custom-1778677606575",
|
||||||
|
"group": "Custom",
|
||||||
|
"preview": [
|
||||||
|
"#f5f5f5",
|
||||||
|
"#ffffff",
|
||||||
|
"#274f69"
|
||||||
|
],
|
||||||
|
"hasWallpaper": false
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--surface-muted: #fafafa;
|
||||||
|
--border: #d0d0d0;
|
||||||
|
--line: #b9b9b5;
|
||||||
|
--text: #1f1f1f;
|
||||||
|
--text-muted: #686866;
|
||||||
|
--text-strong: #111111;
|
||||||
|
--accent: #274f69;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--accent-soft: #d6e4ec;
|
||||||
|
--sidebar-bg: #ffffff;
|
||||||
|
--sidebar-border: #d0d0d0;
|
||||||
|
--sidebar-text: #222222;
|
||||||
|
--sidebar-text-muted: #686866;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #cfcfcf;
|
||||||
|
--modal-bg: #ffffff;
|
||||||
|
--chip: #d8d8d8;
|
||||||
|
--success: #dff7e7;
|
||||||
|
}
|
||||||
16
public/themes/sunset-ember.css
Executable file
16
public/themes/sunset-ember.css
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
/* Sunset Ember Theme — ChromaCraft v1.1.2 */
|
||||||
|
:root {
|
||||||
|
--color-primary: #D62828;
|
||||||
|
--color-secondary: #F77F00;
|
||||||
|
--color-background: #FFF5E4;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-text: #2D1B00;
|
||||||
|
--color-accent: #FCBF49;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||||
|
a, .link { color: var(--color-primary); }
|
||||||
|
.btn-primary { background: var(--color-secondary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: var(--color-primary); }
|
||||||
|
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(214,40,40,0.1); padding: 20px; }
|
||||||
|
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||||
37
public/themes/test-theme-103fb1.css
Executable file
37
public/themes/test-theme-103fb1.css
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
/* @todo-theme-meta
|
||||||
|
{
|
||||||
|
"name": "Test Theme",
|
||||||
|
"id": "custom-1778671955013",
|
||||||
|
"group": "Custom",
|
||||||
|
"preview": [
|
||||||
|
"#f5f5f5",
|
||||||
|
"#ffffff",
|
||||||
|
"#274f69"
|
||||||
|
],
|
||||||
|
"hasWallpaper": false
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--surface-muted: #fafafa;
|
||||||
|
--border: #d0d0d0;
|
||||||
|
--line: #b9b9b5;
|
||||||
|
--text: #1f1f1f;
|
||||||
|
--text-muted: #686866;
|
||||||
|
--text-strong: #111111;
|
||||||
|
--accent: #274f69;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--accent-soft: #d6e4ec;
|
||||||
|
--sidebar-bg: #ffffff;
|
||||||
|
--sidebar-border: #d0d0d0;
|
||||||
|
--sidebar-text: #222222;
|
||||||
|
--sidebar-text-muted: #686866;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #cfcfcf;
|
||||||
|
--modal-bg: #ffffff;
|
||||||
|
--chip: #d8d8d8;
|
||||||
|
--success: #dff7e7;
|
||||||
|
}
|
||||||
37
public/themes/test-theme-6fcabb.css
Executable file
37
public/themes/test-theme-6fcabb.css
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
/* @todo-theme-meta
|
||||||
|
{
|
||||||
|
"name": "Test Theme",
|
||||||
|
"id": "custom-1778671955013",
|
||||||
|
"group": "Custom",
|
||||||
|
"preview": [
|
||||||
|
"#f5f5f5",
|
||||||
|
"#ffffff",
|
||||||
|
"#274f69"
|
||||||
|
],
|
||||||
|
"hasWallpaper": false
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--surface-muted: #fafafa;
|
||||||
|
--border: #d0d0d0;
|
||||||
|
--line: #b9b9b5;
|
||||||
|
--text: #1f1f1f;
|
||||||
|
--text-muted: #686866;
|
||||||
|
--text-strong: #111111;
|
||||||
|
--accent: #274f69;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--accent-soft: #d6e4ec;
|
||||||
|
--sidebar-bg: #ffffff;
|
||||||
|
--sidebar-border: #d0d0d0;
|
||||||
|
--sidebar-text: #222222;
|
||||||
|
--sidebar-text-muted: #686866;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #cfcfcf;
|
||||||
|
--modal-bg: #ffffff;
|
||||||
|
--chip: #d8d8d8;
|
||||||
|
--success: #dff7e7;
|
||||||
|
}
|
||||||
37
public/themes/themestore-theme-by-came-0da6fd.css
Executable file
37
public/themes/themestore-theme-by-came-0da6fd.css
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
/* @todo-theme-meta
|
||||||
|
{
|
||||||
|
"name": "THemestore theme by Came",
|
||||||
|
"id": "custom-1778674717129",
|
||||||
|
"group": "Custom",
|
||||||
|
"preview": [
|
||||||
|
"#17acde",
|
||||||
|
"#222020",
|
||||||
|
"#00bbff"
|
||||||
|
],
|
||||||
|
"hasWallpaper": false
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #17acde;
|
||||||
|
--surface: #222020;
|
||||||
|
--surface-strong: #2bc582;
|
||||||
|
--surface-muted: #38363a;
|
||||||
|
--border: #000000;
|
||||||
|
--line: #ffffff;
|
||||||
|
--text: #d12e57;
|
||||||
|
--text-muted: #2f84c1;
|
||||||
|
--text-strong: #ff0000;
|
||||||
|
--accent: #00bbff;
|
||||||
|
--accent-text: #570000;
|
||||||
|
--accent-soft: #005f85;
|
||||||
|
--sidebar-bg: #004370;
|
||||||
|
--sidebar-border: #2a5070;
|
||||||
|
--sidebar-text: #d8f0ff;
|
||||||
|
--sidebar-text-muted: #7ab0d0;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #a0cce0;
|
||||||
|
--modal-bg: #ffffff;
|
||||||
|
--chip: #b0d8ec;
|
||||||
|
--success: #d0f0e0;
|
||||||
|
}
|
||||||
38
public/themes/woiefhwoaeiglwejighfiwk-69eed7.css
Normal file
38
public/themes/woiefhwoaeiglwejighfiwk-69eed7.css
Normal file
File diff suppressed because one or more lines are too long
1
public/todo-preview
Symbolic link
1
public/todo-preview
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/came/Nextcloud/arch-work/Projects/Todo-App/dist
|
||||||
0
writable/.htaccess
Normal file → Executable file
0
writable/.htaccess
Normal file → Executable file
0
writable/cache/index.html
vendored
Normal file → Executable file
0
writable/cache/index.html
vendored
Normal file → Executable file
0
writable/debugbar/index.html
Normal file → Executable file
0
writable/debugbar/index.html
Normal file → Executable file
0
writable/index.html
Normal file → Executable file
0
writable/index.html
Normal file → Executable file
0
writable/logs/index.html
Normal file → Executable file
0
writable/logs/index.html
Normal file → Executable file
0
writable/session/index.html
Normal file → Executable file
0
writable/session/index.html
Normal file → Executable file
0
writable/uploads/index.html
Normal file → Executable file
0
writable/uploads/index.html
Normal file → Executable file
Reference in New Issue
Block a user