mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Compare commits
25 Commits
feature/co
...
0b30c66307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b30c66307 | ||
|
|
d038ebc2e3 | ||
|
|
e19828868f | ||
|
|
3615d029ea | ||
|
|
bf05b5d295 | ||
|
|
3b65f482c7 | ||
|
|
7cea9e5ea4 | ||
|
|
f01e04fbad | ||
|
|
3ab93381f5 | ||
|
|
5454644a31 | ||
|
|
caf81ea4e2 | ||
|
|
f27498dc26 | ||
|
|
bb09f3d024 | ||
|
|
daa6ec8b1e | ||
|
|
43f0a742b6 | ||
|
|
02f77a15a7 | ||
|
|
e125ac34d7 | ||
|
|
fb9ff9d56b | ||
|
|
7c81586d3f | ||
|
|
3438888314 | ||
|
|
af21317040 | ||
|
|
092bb53324 | ||
|
|
6cbb6a2e3e | ||
|
|
deba81fadb | ||
|
|
b2dab73f17 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -124,3 +124,10 @@ _modules/*
|
|||||||
|
|
||||||
/results/
|
/results/
|
||||||
/phpunit*.xml
|
/phpunit*.xml
|
||||||
|
.env
|
||||||
|
env
|
||||||
|
.claude/
|
||||||
|
.claude/*
|
||||||
|
|
||||||
|
# Generated docs
|
||||||
|
/public/api-docs.html
|
||||||
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
|
||||||
553
README.md
553
README.md
@@ -1 +1,552 @@
|
|||||||
# Todo-App-Backend
|
# Todo App Backend
|
||||||
|
|
||||||
|
A RESTful API backend for a todo application built with **CodeIgniter 4**.
|
||||||
|
Supports user authentication (API key + JWT), CRUD for todos/categories/projects,
|
||||||
|
recurring tasks, activity logging, and a theme marketplace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [API Documentation](#api-documentation)
|
||||||
|
- [Authentication](#authentication)
|
||||||
|
- [API Overview](#api-overview)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Database](#database)
|
||||||
|
- [Development](#development)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- PHP ^8.2
|
||||||
|
- MySQL 8+ (or MariaDB 10.5+)
|
||||||
|
- Composer
|
||||||
|
- `ext-intl`, `ext-mbstring`
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and enter the project
|
||||||
|
cd Todo-App-Backend
|
||||||
|
|
||||||
|
# 2. Install dependencies
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# 3. Configure your environment
|
||||||
|
cp env.example .env
|
||||||
|
# Edit .env — set database credentials and app.baseURL
|
||||||
|
|
||||||
|
# 4. Run database migrations
|
||||||
|
php spark migrate
|
||||||
|
|
||||||
|
# 5. (Optional) Seed sample data
|
||||||
|
php spark db:seed SampleDataSeeder
|
||||||
|
|
||||||
|
# 6. Start the development server
|
||||||
|
php spark serve
|
||||||
|
|
||||||
|
# The API is now available at http://localhost:8080/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a new user
|
||||||
|
curl -s -X POST http://localhost:8080/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"demo@example.com","password":"password123","name":"Demo User"}'
|
||||||
|
|
||||||
|
# Save the returned api_key, then:
|
||||||
|
curl -s http://localhost:8080/api/v1/todos \
|
||||||
|
-H "X-API-Key: todo_your_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
The API is fully documented using the **OpenAPI 3.0** specification.
|
||||||
|
|
||||||
|
| Resource | Location |
|
||||||
|
|----------|----------|
|
||||||
|
| OpenAPI spec (canonical) | [`openapi/openapi.yaml`](openapi/openapi.yaml) |
|
||||||
|
| Generated HTML docs | `public/api-docs.html` (generated, see below) |
|
||||||
|
| Swagger/Postman import | Use `openapi/openapi.yaml` directly |
|
||||||
|
|
||||||
|
### Generating API Docs
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate HTML documentation page
|
||||||
|
php spark generate:api-docs
|
||||||
|
|
||||||
|
# Validate spec only (no file written)
|
||||||
|
php spark generate:api-docs --watch
|
||||||
|
|
||||||
|
# Open http://localhost:8080/api-docs.html after generating
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated HTML uses [Redoc](https://redocly.com/redoc) for rendering and is
|
||||||
|
fully self-contained (the spec is embedded as a base64 data URI).
|
||||||
|
|
||||||
|
### Importing into Tools
|
||||||
|
|
||||||
|
- **Postman**: File → Import → choose `openapi/openapi.yaml`
|
||||||
|
- **Insomnia**: Import → From File → choose `openapi/openapi.yaml`
|
||||||
|
- **Swagger Editor**: Paste the contents of `openapi/openapi.yaml`
|
||||||
|
- **cURL/HTTPie**: Examples are in the OpenAPI spec under each endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The API supports two authentication methods:
|
||||||
|
|
||||||
|
### 1. API Key Authentication (Primary)
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: todo_abc123def456...
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by most protected endpoints. Keys are obtained on registration or can be
|
||||||
|
created via `POST /user/api-keys`. Keys can be scoped (`read`, `write`) and
|
||||||
|
optionally expire.
|
||||||
|
|
||||||
|
### 2. JWT Bearer Authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer eyJ0eXAiOiJKV1Qi...
|
||||||
|
```
|
||||||
|
|
||||||
|
Available via the `/auth/jwt/*` endpoints. Tokens are valid for 1 hour and can
|
||||||
|
be refreshed via `/auth/jwt/refresh`.
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. **Register** → receive API key + key prefix
|
||||||
|
2. **Login** → receive the same or existing API key
|
||||||
|
3. Include `X-API-Key` header on all protected requests
|
||||||
|
4. Optionally use JWT endpoints for short-lived bearer tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
All endpoints live under the `/api/v1` prefix.
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/auth/register` | Register a new user |
|
||||||
|
| POST | `/auth/login` | Login and get API key |
|
||||||
|
| POST | `/auth/api-key` | Create additional API key (legacy) |
|
||||||
|
| POST | `/auth/jwt/register` | Register and receive JWT + API key |
|
||||||
|
| POST | `/auth/jwt/login` | Login and receive JWT |
|
||||||
|
| POST | `/auth/jwt/refresh` | Refresh an existing JWT |
|
||||||
|
| GET | `/marketplace/themes` | List published themes |
|
||||||
|
| GET | `/marketplace/themes/{id}` | Get a single theme |
|
||||||
|
|
||||||
|
### Protected Endpoints (API key required)
|
||||||
|
|
||||||
|
#### User
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/user/profile` | Get your profile |
|
||||||
|
| PUT | `/user/profile` | Update your profile |
|
||||||
|
| GET | `/user/api-keys` | List your API keys |
|
||||||
|
| POST | `/user/api-keys` | Create a new API key |
|
||||||
|
| DELETE | `/user/api-keys/{id}` | Revoke an API key |
|
||||||
|
|
||||||
|
#### Categories
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/categories` | List categories (paginated, sortable) |
|
||||||
|
| POST | `/categories` | Create a category |
|
||||||
|
| GET | `/categories/{id}` | Get a category |
|
||||||
|
| PUT | `/categories/{id}` | Update a category |
|
||||||
|
| DELETE | `/categories/{id}` | Delete a category |
|
||||||
|
|
||||||
|
#### Projects
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/projects` | List projects (paginated, sortable) |
|
||||||
|
| POST | `/projects` | Create a project |
|
||||||
|
| GET | `/projects/{id}` | Get a project |
|
||||||
|
| PUT | `/projects/{id}` | Update a project |
|
||||||
|
| DELETE | `/projects/{id}` | Delete a project |
|
||||||
|
|
||||||
|
#### Todos
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/todos` | List todos (paginated, sortable, filterable) |
|
||||||
|
| POST | `/todos` | Create a todo |
|
||||||
|
| GET | `/todos/{id}` | Get a todo |
|
||||||
|
| PUT | `/todos/{id}` | Update a todo |
|
||||||
|
| DELETE | `/todos/{id}` | Delete a todo |
|
||||||
|
| POST | `/todos/{id}/categories` | Link a category |
|
||||||
|
| DELETE | `/todos/{id}/categories/{catId}` | Unlink a category |
|
||||||
|
|
||||||
|
#### Recurring Tasks
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/recurring-tasks` | List recurring tasks (paginated, sortable, filterable) |
|
||||||
|
| POST | `/recurring-tasks` | Create a recurring task |
|
||||||
|
| GET | `/recurring-tasks/{id}` | Get a recurring task |
|
||||||
|
| PUT | `/recurring-tasks/{id}` | Update a recurring task |
|
||||||
|
| DELETE | `/recurring-tasks/{id}` | Delete a recurring task |
|
||||||
|
| POST | `/recurring-tasks/{id}/categories` | Link a category |
|
||||||
|
| DELETE | `/recurring-tasks/{id}/categories/{catId}` | Unlink a category |
|
||||||
|
|
||||||
|
#### Activity Logs
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/activity-logs` | List activity logs |
|
||||||
|
| GET | `/activity-logs/{id}` | Get a single log entry |
|
||||||
|
|
||||||
|
#### User Themes
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/user/themes` | List installed themes |
|
||||||
|
| POST | `/user/themes` | Install a theme |
|
||||||
|
| PUT | `/user/themes/{id}` | Update theme settings |
|
||||||
|
| DELETE | `/user/themes/{id}` | Uninstall a theme |
|
||||||
|
|
||||||
|
### Common Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description | Example |
|
||||||
|
|-----------|------|-------------|---------|
|
||||||
|
| `page` | int | Page number (default: 1) | `?page=2` |
|
||||||
|
| `per_page` | int | Items per page (default: 50, max: 200) | `?per_page=10` |
|
||||||
|
| `sort` | string | Sort fields, `-` for descending, comma-separated | `?sort=-created_at,title` |
|
||||||
|
| `status` | string | Filter by status (todos) | `?status=open` |
|
||||||
|
| `favorite` | bool | Filter favorites (categories) | `?favorite=1` |
|
||||||
|
| `limit` | int | Max items (activity logs, default: 50) | `?limit=100` |
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
|
||||||
|
Sortable fields vary per resource. Prefix a field with `-` for descending:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/todos?sort=-created_at,title
|
||||||
|
GET /api/v1/categories?sort=name
|
||||||
|
GET /api/v1/projects?sort=-created_at
|
||||||
|
GET /api/v1/recurring-tasks?sort=title,-created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
All responses follow a consistent envelope:
|
||||||
|
|
||||||
|
**Success:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Todos retrieved successfully",
|
||||||
|
"data": [ ... ],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 50,
|
||||||
|
"total": 123,
|
||||||
|
"last_page": 3,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Validation failed",
|
||||||
|
"errors": {
|
||||||
|
"title": "The todo title is required."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 200 | Success |
|
||||||
|
| 201 | Created |
|
||||||
|
| 400 | Bad request |
|
||||||
|
| 401 | Unauthorized (missing/invalid API key) |
|
||||||
|
| 403 | Forbidden (insufficient scope) |
|
||||||
|
| 404 | Not found |
|
||||||
|
| 409 | Conflict (duplicate) |
|
||||||
|
| 422 | Validation failed |
|
||||||
|
| 500 | Server error |
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
Paginated responses include a `pagination` object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 50,
|
||||||
|
"total": 123,
|
||||||
|
"last_page": 3,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Todo Status Values
|
||||||
|
|
||||||
|
- `open`
|
||||||
|
- `in_progress`
|
||||||
|
- `completed`
|
||||||
|
- `archived`
|
||||||
|
|
||||||
|
### Recurring Task Schedule Values
|
||||||
|
|
||||||
|
- `daily`
|
||||||
|
- `weekly`
|
||||||
|
- `monthly`
|
||||||
|
- `custom` (requires `custom_days` array, e.g. `["mon","wed","fri"]`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests via composer
|
||||||
|
composer run test
|
||||||
|
|
||||||
|
# Or use phpunit directly
|
||||||
|
./vendor/bin/phpunit
|
||||||
|
|
||||||
|
# Run only API tests
|
||||||
|
./vendor/bin/phpunit tests/api
|
||||||
|
|
||||||
|
# With code coverage
|
||||||
|
./vendor/bin/phpunit --coverage-text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Setup
|
||||||
|
|
||||||
|
Integration tests use your configured MySQL database. Make sure migrations are applied first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php spark migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
For a dedicated test database, uncomment the test DB config in `phpunit.xml.dist`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<env name="database.tests.hostname" value="localhost"/>
|
||||||
|
<env name="database.tests.database" value="todo_app_test"/>
|
||||||
|
<env name="database.tests.username" value="root"/>
|
||||||
|
<env name="database.tests.password" value=""/>
|
||||||
|
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create it and migrate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -e "CREATE DATABASE IF NOT EXISTS todo_app_test;"
|
||||||
|
php spark migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Suite
|
||||||
|
|
||||||
|
| Directory | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `tests/api/ApiTest.php` | Full API integration tests (auth, CRUD, filtering, error handling) |
|
||||||
|
| `tests/unit/` | Unit tests for individual components |
|
||||||
|
| `tests/database/` | Database migration and seed tests |
|
||||||
|
| `tests/session/` | Session-related tests |
|
||||||
|
|
||||||
|
The API test suite covers:
|
||||||
|
|
||||||
|
- Registration and login
|
||||||
|
- Authentication errors (missing key, invalid credentials)
|
||||||
|
- Full CRUD for all resources (categories, projects, todos, recurring tasks)
|
||||||
|
- Category-todo / category-recurring-task linking
|
||||||
|
- Status and sort filtering
|
||||||
|
- Activity logging verification
|
||||||
|
- Ownership isolation (cross-user access denied)
|
||||||
|
- Validation error responses
|
||||||
|
- Pagination structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── app/
|
||||||
|
│ ├── Commands/ # Spark CLI commands
|
||||||
|
│ │ ├── TestModels.php # Model testing command
|
||||||
|
│ │ └── GenerateApiDocs.php # OpenAPI → HTML docs generator
|
||||||
|
│ ├── Config/ # Application configuration
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ │ ├── Api/
|
||||||
|
│ │ │ ├── BaseController.php # Shared API helpers (pagination, JWT, responses)
|
||||||
|
│ │ │ └── V1/
|
||||||
|
│ │ │ ├── AuthController.php
|
||||||
|
│ │ │ ├── CategoryController.php
|
||||||
|
│ │ │ ├── ProjectController.php
|
||||||
|
│ │ │ ├── TodoController.php
|
||||||
|
│ │ │ ├── RecurringTaskController.php
|
||||||
|
│ │ │ ├── UserController.php
|
||||||
|
│ │ │ ├── ActivityLogController.php
|
||||||
|
│ │ │ ├── MarketplaceController.php
|
||||||
|
│ │ │ └── UserThemeController.php
|
||||||
|
│ │ ├── BaseController.php
|
||||||
|
│ │ ├── Home.php
|
||||||
|
│ │ └── ThemeStore.php
|
||||||
|
│ ├── Database/
|
||||||
|
│ │ ├── Migrations/ # 16 migration files (users → api_auth_keys)
|
||||||
|
│ │ └── Seeds/ # Sample data, themes, AI providers
|
||||||
|
│ ├── Filters/
|
||||||
|
│ │ └── ApiAuthFilter.php # API key authentication filter
|
||||||
|
│ ├── Models/ # Database models (TodoModel, CategoryModel, etc.)
|
||||||
|
│ └── Views/ # Error pages, welcome message, theme store
|
||||||
|
├── openapi/
|
||||||
|
│ └── openapi.yaml # Canonical OpenAPI 3.0 specification
|
||||||
|
├── public/
|
||||||
|
│ ├── api-docs.html # Generated API documentation (gitignored?)
|
||||||
|
│ ├── index.php # Front controller
|
||||||
|
│ └── themes/ # Uploaded theme CSS files
|
||||||
|
├── tests/
|
||||||
|
│ ├── api/ApiTest.php # Full API integration tests
|
||||||
|
│ ├── unit/ # Unit tests
|
||||||
|
│ ├── database/ # Database tests
|
||||||
|
│ └── _support/ # Test helpers, models, seeds
|
||||||
|
├── writable/ # Logs, cache, uploads
|
||||||
|
├── composer.json
|
||||||
|
├── env.example
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
### Schema Overview
|
||||||
|
|
||||||
|
The database consists of 12 tables:
|
||||||
|
|
||||||
|
| Table | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `users` | User accounts (email, password hash, settings) |
|
||||||
|
| `api_auth_keys` | API keys (hashed, scoped, expirable) |
|
||||||
|
| `categories` | User-defined categories (with hex color) |
|
||||||
|
| `projects` | User-defined projects |
|
||||||
|
| `todos` | Tasks with status, due dates, project links |
|
||||||
|
| `todo_categories` | Many-to-many: todos ↔ categories |
|
||||||
|
| `recurring_tasks` | Recurring task templates (daily/weekly/etc.) |
|
||||||
|
| `recurring_task_categories` | Many-to-many: recurring_tasks ↔ categories |
|
||||||
|
| `activity_logs` | Audit trail (CRUD events, login, etc.) |
|
||||||
|
| `marketplace_themes` | Published theme definitions |
|
||||||
|
| `user_themes` | Per-user theme installations |
|
||||||
|
| `ai_chats / ai_messages / ai_providers / user_ai_settings / user_api_keys` | AI assistant features |
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all pending migrations
|
||||||
|
php spark migrate
|
||||||
|
|
||||||
|
# Roll back all migrations
|
||||||
|
php spark migrate:rollback
|
||||||
|
|
||||||
|
# Seed sample data
|
||||||
|
php spark db:seed SampleDataSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding a New Endpoint
|
||||||
|
|
||||||
|
1. Add the route in `app/Config/Routes.php`
|
||||||
|
2. Create the controller method (extends `App\Controllers\Api\BaseController`)
|
||||||
|
3. Create the model (extends `CodeIgniter\Model`)
|
||||||
|
4. Write migration if needed
|
||||||
|
5. Update `openapi/openapi.yaml` with the new endpoint
|
||||||
|
6. Run `php spark generate:api-docs` to regenerate HTML docs
|
||||||
|
7. Write tests in `tests/api/ApiTest.php`
|
||||||
|
|
||||||
|
### Updating Documentation
|
||||||
|
|
||||||
|
The **single source of truth** is `openapi/openapi.yaml`. After any API change:
|
||||||
|
|
||||||
|
1. Update the YAML spec
|
||||||
|
2. Run `php spark generate:api-docs`
|
||||||
|
3. Commit both files
|
||||||
|
|
||||||
|
### Available Spark Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php spark list # List all available commands
|
||||||
|
php spark generate:api-docs # Generate HTML docs from OpenAPI spec
|
||||||
|
php spark generate:api-docs --watch # Validate spec only
|
||||||
|
php spark migrate # Run database migrations
|
||||||
|
php spark db:seed # Seed the database
|
||||||
|
composer run test # Run all tests
|
||||||
|
php spark test:models # Test models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Workflows
|
||||||
|
|
||||||
|
**New todo with category:**
|
||||||
|
```bash
|
||||||
|
KEY="todo_your_key_here"
|
||||||
|
|
||||||
|
# Create category
|
||||||
|
CAT=$(curl -s -X POST http://localhost:8080/api/v1/categories \
|
||||||
|
-H "X-API-Key: $KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Work","color":"#3B82F6"}')
|
||||||
|
CAT_ID=$(echo $CAT | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||||
|
|
||||||
|
# Create todo with that category
|
||||||
|
curl -s -X POST http://localhost:8080/api/v1/todos \
|
||||||
|
-H "X-API-Key: $KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"title\":\"Finish report\",\"status\":\"open\",\"category_id\":\"$CAT_ID\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter and sort todos:**
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/api/v1/todos?status=open&sort=-due_date,title&per_page=5" \
|
||||||
|
-H "X-API-Key: $KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Keep the OpenAPI spec (`openapi/openapi.yaml`) in sync with code changes
|
||||||
|
2. Run `php spark generate:api-docs --watch` to validate your YAML changes
|
||||||
|
3. Write tests for new endpoints
|
||||||
|
4. Run the full test suite before pushing
|
||||||
|
5. Follow CodeIgniter 4 conventions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
208
app/Commands/GenerateApiDocs.php
Normal file
208
app/Commands/GenerateApiDocs.php
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GenerateApiDocs
|
||||||
|
*
|
||||||
|
* Generates a standalone HTML documentation page from the OpenAPI spec.
|
||||||
|
* Uses Redoc (CDN) for rendering.
|
||||||
|
*
|
||||||
|
* Usage: php spark generate:api-docs
|
||||||
|
* php spark generate:api-docs --watch (validate only, no write)
|
||||||
|
* php spark generate:api-docs --serve (print live server URL)
|
||||||
|
*/
|
||||||
|
class GenerateApiDocs extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Documentation';
|
||||||
|
protected $name = 'generate:api-docs';
|
||||||
|
protected $description = 'Generate API documentation HTML from openapi/openapi.yaml';
|
||||||
|
protected $usage = 'generate:api-docs';
|
||||||
|
protected $arguments = [];
|
||||||
|
protected $options = [
|
||||||
|
'--watch' => 'Validate YAML only, do not write HTML',
|
||||||
|
'--serve' => 'Print the URL at which the docs are served',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$projectRoot = ROOTPATH;
|
||||||
|
$openapiFile = $projectRoot . 'openapi/openapi.yaml';
|
||||||
|
$outputFile = $projectRoot . 'public/api-docs.html';
|
||||||
|
|
||||||
|
// ── Validate YAML exists ──────────────────────────────────────────
|
||||||
|
if (!file_exists($openapiFile)) {
|
||||||
|
CLI::error('[ERROR] openapi/openapi.yaml not found at: ' . $openapiFile);
|
||||||
|
CLI::write('Create it first, then run this command again.', 'yellow');
|
||||||
|
return EXIT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
$yamlContent = file_get_contents($openapiFile);
|
||||||
|
if (empty($yamlContent)) {
|
||||||
|
CLI::error('[ERROR] openapi/openapi.yaml is empty.');
|
||||||
|
return EXIT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic structural validation (line count, presence of openapi/info/paths)
|
||||||
|
$lines = explode("\n", $yamlContent);
|
||||||
|
$hasOpenapi = preg_match('/^openapi:/m', $yamlContent);
|
||||||
|
$hasInfo = preg_match('/^info:/m', $yamlContent);
|
||||||
|
$hasPaths = preg_match('/^paths:/m', $yamlContent);
|
||||||
|
|
||||||
|
CLI::write(sprintf(' Spec file: %s', $openapiFile), 'green');
|
||||||
|
CLI::write(sprintf(' Size: %d bytes', strlen($yamlContent)), 'green');
|
||||||
|
CLI::write(sprintf(' Lines: %d', count($lines)), 'green');
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
if (!$hasOpenapi) $errors[] = 'Missing "openapi:" version declaration';
|
||||||
|
if (!$hasInfo) $errors[] = 'Missing "info:" section';
|
||||||
|
if (!$hasPaths) $errors[] = 'Missing "paths:" section';
|
||||||
|
|
||||||
|
$totalPaths = 0;
|
||||||
|
if (preg_match_all('/^\s{2}\/[a-z]/m', $yamlContent, $matches)) {
|
||||||
|
$totalPaths = count($matches[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write(sprintf(' Endpoints: %d', $totalPaths), 'green');
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
CLI::error('[VALIDATION] ' . count($errors) . ' issue(s) found:');
|
||||||
|
foreach ($errors as $err) {
|
||||||
|
CLI::write(' - ' . $err, 'red');
|
||||||
|
}
|
||||||
|
return EXIT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('[VALIDATION] OpenAPI spec looks valid.', 'green');
|
||||||
|
|
||||||
|
// ── --watch mode: stop here ────────────────────────────────────────
|
||||||
|
if (isset($params['watch']) || array_key_exists('watch', $params)) {
|
||||||
|
CLI::write('Watch mode — no files written.', 'yellow');
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate HTML ────────────────────────────────────────────────
|
||||||
|
$apiTitle = 'Todo App API Documentation';
|
||||||
|
|
||||||
|
// Escape YAML for embedding as a JS template literal.
|
||||||
|
// Safe: escape backtick, backslash, and template substitution.
|
||||||
|
$escapedYaml = str_replace(
|
||||||
|
['\\', '`', '${'],
|
||||||
|
['\\\\', '\\`', '\\${'],
|
||||||
|
$yamlContent
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{$apiTitle}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.css" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Inter', -apple-system, sans-serif; background: #f8f9fa; }
|
||||||
|
.topbar {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
padding: 16px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.topbar h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
|
||||||
|
.topbar .subtitle { font-size: 13px; color: #94a3b8; margin-top: 2px; }
|
||||||
|
.topbar .badge {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
#redoc-container { min-height: calc(100vh - 64px); }
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60vh;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.loading::after {
|
||||||
|
content: '';
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: 10px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>{$apiTitle}</h1>
|
||||||
|
<div class="subtitle">Todo App Backend — OpenAPI 3.0</div>
|
||||||
|
</div>
|
||||||
|
<div class="badge">Generated: GENERATED_DATE</div>
|
||||||
|
</div>
|
||||||
|
<div class="loading" id="loading">Loading API documentation...</div>
|
||||||
|
<div id="redoc-container"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
||||||
|
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||||
|
<script>
|
||||||
|
var yamlText = `YAML_CONTENT`;
|
||||||
|
var spec = jsyaml.load(yamlText);
|
||||||
|
Redoc.init(
|
||||||
|
spec,
|
||||||
|
{
|
||||||
|
scrollYOffset: 64,
|
||||||
|
hideDownloadButton: false,
|
||||||
|
expandResponses: "200,201",
|
||||||
|
hideSingleRequestSampleTab: false,
|
||||||
|
sortPropsAlphabetically: false,
|
||||||
|
requiredPropsFirst: true,
|
||||||
|
showObjectSchemaExamples: true,
|
||||||
|
theme: {
|
||||||
|
colors: { primary: { main: '#3b82f6' } },
|
||||||
|
sidebar: { backgroundColor: '#ffffff', width: '280px' },
|
||||||
|
rightPanel: { backgroundColor: '#1e293b' }
|
||||||
|
},
|
||||||
|
nativeScrollbars: true
|
||||||
|
},
|
||||||
|
document.getElementById('redoc-container')
|
||||||
|
);
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
$html = str_replace(
|
||||||
|
['YAML_CONTENT', 'GENERATED_DATE'],
|
||||||
|
[$escapedYaml, date('Y-m-d H:i:s')],
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($outputFile, $html);
|
||||||
|
|
||||||
|
CLI::write(sprintf('[DONE] Docs generated: %s', $outputFile), 'green');
|
||||||
|
CLI::write(sprintf(' Size: %d bytes', filesize($outputFile)), 'green');
|
||||||
|
|
||||||
|
if (isset($params['serve']) || array_key_exists('serve', $params)) {
|
||||||
|
$baseUrl = CLI::getOption('base-url') ?? 'http://localhost:8080';
|
||||||
|
CLI::write(sprintf(' Open in browser: %s/api-docs.html', $baseUrl), 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
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', 'Accept', 'Fetch'],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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', 'FETCH'],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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',
|
||||||
|
|||||||
@@ -5,4 +5,113 @@ use CodeIgniter\Router\RouteCollection;
|
|||||||
/**
|
/**
|
||||||
* @var RouteCollection $routes
|
* @var RouteCollection $routes
|
||||||
*/
|
*/
|
||||||
$routes->get('/', 'Home::index');
|
$routes->get('/', static function () {
|
||||||
|
return redirect()->to('/themes');
|
||||||
|
});
|
||||||
|
$routes->get('/themes', 'ThemeStore::index');
|
||||||
|
$routes->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 () {
|
||||||
|
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||||
|
header('Vary: Origin');
|
||||||
|
return response()->setStatusCode(204);
|
||||||
|
});
|
||||||
|
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||||
|
$routes->options('/themes/upload', static function () {
|
||||||
|
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||||
|
header('Vary: Origin');
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
268
app/Controllers/ThemeStore.php
Normal file
268
app/Controllers/ThemeStore.php
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<?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')) {
|
||||||
|
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Vary: Origin');
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Vary: Origin');
|
||||||
|
|
||||||
|
$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
|
||||||
2186
openapi/openapi.yaml
Normal file
2186
openapi/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
<phpunit
|
<phpunit
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||||
bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php"
|
bootstrap="tests/bootstrap.php"
|
||||||
backupGlobals="false"
|
backupGlobals="false"
|
||||||
beStrictAboutOutputDuringTests="true"
|
beStrictAboutOutputDuringTests="true"
|
||||||
colors="true"
|
colors="true"
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="App">
|
<testsuite name="App">
|
||||||
<directory>./tests</directory>
|
<directory>./tests</directory>
|
||||||
|
<exclude>./tests/database</exclude>
|
||||||
|
<exclude>./tests/session</exclude>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<logging>
|
<logging>
|
||||||
@@ -51,13 +53,18 @@
|
|||||||
<!-- Directory containing the front controller (index.php) -->
|
<!-- Directory containing the front controller (index.php) -->
|
||||||
<const name="PUBLICPATH" value="./public/"/>
|
<const name="PUBLICPATH" value="./public/"/>
|
||||||
<!-- Database configuration -->
|
<!-- Database configuration -->
|
||||||
<!-- Uncomment to provide your own database for testing
|
<!-- MySQLi test database matching the .env credentials.
|
||||||
<env name="database.tests.hostname" value="localhost"/>
|
The tests group defaults to SQLite3 which is not available.
|
||||||
<env name="database.tests.database" value="tests"/>
|
Change the database name to a separate test DB to avoid
|
||||||
<env name="database.tests.username" value="tests_user"/>
|
overwriting live data. -->
|
||||||
<env name="database.tests.password" value=""/>
|
<env name="database.tests.hostname" value="127.0.0.1"/>
|
||||||
<env name="database.tests.DBDriver" value="MySQLi"/>
|
<env name="database.tests.database" value="TodoApp"/>
|
||||||
<env name="database.tests.DBPrefix" value="tests_"/>
|
<env name="database.tests.username" value="root"/>
|
||||||
-->
|
<env name="database.tests.password" value=""/>
|
||||||
|
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||||
|
<env name="database.tests.DBPrefix" value=""/>
|
||||||
|
<!-- Note: DBPrefix removed so queries match the migrated tables.
|
||||||
|
CI4 default tests DBPrefix = "db_" would query db_users,
|
||||||
|
db_todos etc. which don't exist on this database. -->
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -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
Normal file
38
public/themes/2341342134-1441f7.css
Normal file
File diff suppressed because one or more lines are too long
16
public/themes/arctic-frost.css
Normal file
16
public/themes/arctic-frost.css
Normal 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; }
|
||||||
38
public/themes/dine-mera-3652ad.css
Normal file
38
public/themes/dine-mera-3652ad.css
Normal file
File diff suppressed because one or more lines are too long
37
public/themes/extract-test-theme-5fae6e.css
Normal file
37
public/themes/extract-test-theme-5fae6e.css
Normal 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
Normal file
16
public/themes/forest-grove.css
Normal 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
Normal file
38
public/themes/manual-game-update-2-e1a77a.css
Normal file
File diff suppressed because one or more lines are too long
38
public/themes/manual-game-update-7cc79d.css
Normal file
38
public/themes/manual-game-update-7cc79d.css
Normal file
File diff suppressed because one or more lines are too long
16
public/themes/midnight-void.css
Normal file
16
public/themes/midnight-void.css
Normal 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
Normal file
16
public/themes/obsidian-rose.css
Normal 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
Normal file
16
public/themes/ocean-breeze.css
Normal 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
Normal file
37
public/themes/red-extract-theme-a3aabe.css
Normal 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
Normal file
16
public/themes/sunset-ember.css
Normal 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
Normal file
37
public/themes/test-theme-103fb1.css
Normal 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
Normal file
37
public/themes/test-theme-6fcabb.css
Normal 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
Normal file
37
public/themes/themestore-theme-by-came-0da6fd.css
Normal 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
|
||||||
251
tests/api/ModelTest.php
Normal file
251
tests/api/ModelTest.php
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use CodeIgniter\Test\DatabaseTestTrait;
|
||||||
|
use App\Models\TodoModel;
|
||||||
|
use App\Models\CategoryModel;
|
||||||
|
use App\Models\ProjectModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model Unit Tests
|
||||||
|
*
|
||||||
|
* Tests the Todo App Backend models directly.
|
||||||
|
* Requires a working MySQL database with migrations applied.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ModelTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
use DatabaseTestTrait;
|
||||||
|
|
||||||
|
protected $migrate = false;
|
||||||
|
protected $refresh = false;
|
||||||
|
|
||||||
|
private static string $userId = '';
|
||||||
|
private static string $todoId = '';
|
||||||
|
private static string $categoryId = '';
|
||||||
|
private static string $projectId = '';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
parent::setUpBeforeClass();
|
||||||
|
|
||||||
|
// Pick the first real user from the database
|
||||||
|
$userModel = new UserModel();
|
||||||
|
$user = $userModel->first();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
self::$userId = $user['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
if (empty(self::$userId)) {
|
||||||
|
$this->markTestSkipped('No users in database. Run migrations and register a user first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// TODO MODEL
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
public function testTodoModelInsertAndFind(): void
|
||||||
|
{
|
||||||
|
$model = new TodoModel();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->uuid(),
|
||||||
|
'user_id' => self::$userId,
|
||||||
|
'title' => 'Test todo',
|
||||||
|
'status' => 'open',
|
||||||
|
'due_date' => '2025-12-31',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertNotFalse($model->insert($data), 'Todo insert should succeed');
|
||||||
|
|
||||||
|
self::$todoId = $data['id'];
|
||||||
|
|
||||||
|
$found = $model->find($data['id']);
|
||||||
|
$this->assertNotNull($found, 'Todo should be findable');
|
||||||
|
$this->assertSame('Test todo', $found['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTodoModelValidation(): void
|
||||||
|
{
|
||||||
|
$model = new TodoModel();
|
||||||
|
|
||||||
|
$result = $model->insert([
|
||||||
|
'id' => $this->uuid(),
|
||||||
|
'user_id' => self::$userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse($result, 'Insert without title should fail');
|
||||||
|
$this->assertNotEmpty($model->errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTodoModelUpdate(): void
|
||||||
|
{
|
||||||
|
$model = new TodoModel();
|
||||||
|
|
||||||
|
$updated = $model->update(self::$todoId, [
|
||||||
|
'status' => 'completed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotFalse($updated);
|
||||||
|
|
||||||
|
$found = $model->find(self::$todoId);
|
||||||
|
$this->assertNotNull($found);
|
||||||
|
$this->assertSame('completed', $found['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTodoModelFindByUser(): void
|
||||||
|
{
|
||||||
|
$model = new TodoModel();
|
||||||
|
|
||||||
|
$results = $model->where('user_id', self::$userId)->findAll();
|
||||||
|
$this->assertGreaterThanOrEqual(1, count($results));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTodoModelDelete(): void
|
||||||
|
{
|
||||||
|
$model = new TodoModel();
|
||||||
|
$model->delete(self::$todoId);
|
||||||
|
|
||||||
|
$found = $model->find(self::$todoId);
|
||||||
|
$this->assertNull($found, 'Todo should be deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CATEGORY MODEL
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
public function testCategoryModelInsertAndFind(): void
|
||||||
|
{
|
||||||
|
$model = new CategoryModel();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->uuid(),
|
||||||
|
'user_id' => self::$userId,
|
||||||
|
'name' => 'Work',
|
||||||
|
'color' => '#3B82F6',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertNotFalse($model->insert($data));
|
||||||
|
|
||||||
|
self::$categoryId = $data['id'];
|
||||||
|
|
||||||
|
$found = $model->find($data['id']);
|
||||||
|
$this->assertNotNull($found);
|
||||||
|
$this->assertSame('Work', $found['name']);
|
||||||
|
$this->assertSame('#3B82F6', $found['color']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCategoryValidationMissingColor(): void
|
||||||
|
{
|
||||||
|
$model = new CategoryModel();
|
||||||
|
|
||||||
|
$result = $model->insert([
|
||||||
|
'id' => $this->uuid(),
|
||||||
|
'user_id' => self::$userId,
|
||||||
|
'name' => 'No Color',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCategoryValidationInvalidColor(): void
|
||||||
|
{
|
||||||
|
$model = new CategoryModel();
|
||||||
|
|
||||||
|
$result = $model->insert([
|
||||||
|
'id' => $this->uuid(),
|
||||||
|
'user_id' => self::$userId,
|
||||||
|
'name' => 'Bad Color',
|
||||||
|
'color' => 'not-a-hex',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCategoryModelUpdate(): void
|
||||||
|
{
|
||||||
|
$model = new CategoryModel();
|
||||||
|
$model->update(self::$categoryId, ['name' => 'Updated Work']);
|
||||||
|
|
||||||
|
$found = $model->find(self::$categoryId);
|
||||||
|
$this->assertSame('Updated Work', $found['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCategoryModelDelete(): void
|
||||||
|
{
|
||||||
|
$model = new CategoryModel();
|
||||||
|
$model->delete(self::$categoryId);
|
||||||
|
|
||||||
|
$found = $model->find(self::$categoryId);
|
||||||
|
$this->assertNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PROJECT MODEL
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
public function testProjectModelInsertAndFind(): void
|
||||||
|
{
|
||||||
|
$model = new ProjectModel();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $this->uuid(),
|
||||||
|
'user_id' => self::$userId,
|
||||||
|
'name' => 'Test Project',
|
||||||
|
'color' => '#8B5CF6',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertNotFalse($model->insert($data));
|
||||||
|
|
||||||
|
self::$projectId = $data['id'];
|
||||||
|
|
||||||
|
$found = $model->find($data['id']);
|
||||||
|
$this->assertNotNull($found);
|
||||||
|
$this->assertSame('Test Project', $found['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProjectModelUpdate(): void
|
||||||
|
{
|
||||||
|
$model = new ProjectModel();
|
||||||
|
$model->update(self::$projectId, [
|
||||||
|
'description' => 'A test project description',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$found = $model->find(self::$projectId);
|
||||||
|
$this->assertSame('A test project description', $found['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProjectModelDelete(): void
|
||||||
|
{
|
||||||
|
$model = new ProjectModel();
|
||||||
|
$model->delete(self::$projectId);
|
||||||
|
|
||||||
|
$found = $model->find(self::$projectId);
|
||||||
|
$this->assertNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private function uuid(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tests/bootstrap.php
Normal file
19
tests/bootstrap.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Bootstrap
|
||||||
|
*
|
||||||
|
* Sets up the testing environment before the framework boots.
|
||||||
|
* Disables the debug toolbar that would wrap JSON API responses in HTML.
|
||||||
|
* The framework's own Test/bootstrap.php handles ENVIRONMENT constant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable debug toolbar before the framework defines CI_DEBUG
|
||||||
|
defined('CI_DEBUG') || define('CI_DEBUG', false);
|
||||||
|
|
||||||
|
// Ensure CI_ENVIRONMENT env var is set (framework checks this)
|
||||||
|
putenv('CI_ENVIRONMENT=testing');
|
||||||
|
$_SERVER['CI_ENVIRONMENT'] = 'testing';
|
||||||
|
|
||||||
|
// Load the framework's test bootstrap (defines ENVIRONMENT constant)
|
||||||
|
require __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';
|
||||||
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