Initial commit: SeedProject API framework
PHP MVC seed project with router, database layer, auth helpers, LLM provider integrations, Stripe gateway, and console commands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVcDB9cRQZRRmUivMHRQGL
This commit is contained in:
commit
a447b17e69
83 changed files with 24845 additions and 0 deletions
44
.env.example
Normal file
44
.env.example
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Application
|
||||||
|
URL=
|
||||||
|
PROJECT_NAME=
|
||||||
|
DEBUG=
|
||||||
|
SECUREAPI=
|
||||||
|
WELCOME=
|
||||||
|
INDEV=
|
||||||
|
|
||||||
|
# Database — primary
|
||||||
|
DB_TYPE=
|
||||||
|
DB_HOST=
|
||||||
|
DB_USER=
|
||||||
|
DB_NAME=
|
||||||
|
DB_PASS=
|
||||||
|
|
||||||
|
# Database — legalwriter
|
||||||
|
DB2_HOST=
|
||||||
|
DB2_NAME=
|
||||||
|
DB2_USER=
|
||||||
|
DB2_PASS=
|
||||||
|
|
||||||
|
# Email (MailerSend)
|
||||||
|
EMAILHOST=
|
||||||
|
EMAILUSER=
|
||||||
|
EMAILPASSWORD=
|
||||||
|
FROMEMAIL=
|
||||||
|
MAILERSEND_API_KEY=
|
||||||
|
|
||||||
|
# Stripe — managed via Admin > Integrations (sp_settings); these are emergency fallbacks only
|
||||||
|
STRIPE_SECRET=
|
||||||
|
STRIPE_PUBLIC=
|
||||||
|
|
||||||
|
# Security / Hashing
|
||||||
|
HASH_PASSWORD_KEY=
|
||||||
|
HASH_API_KEY=
|
||||||
|
ENCRYPT_KEY=
|
||||||
|
CONSUMER_PROFILE_SALT=
|
||||||
|
AUTHORIZATION=
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
GIT_REPO=
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
/vendor/
|
||||||
|
|
||||||
|
# Logs & runtime
|
||||||
|
/system/*.log
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# ACME / server
|
||||||
|
/.well-known/acme-challenge/
|
||||||
|
.user.ini
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
12
.htaccess
Normal file
12
.htaccess
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
#RewriteCond %{HTTPS} !=on
|
||||||
|
#RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301,NE]
|
||||||
|
Header always set Content-Security-Policy "upgrade-insecure-requests;"
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-l
|
||||||
|
RewriteRule ^(.+)$ index.php?url=$1 [QSA,L]
|
||||||
|
|
||||||
|
# enable PHP error logging
|
||||||
16
.memory/changelog.md
Normal file
16
.memory/changelog.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# SeedProject Changelog
|
||||||
|
|
||||||
|
## 2026-04-27 — Cleanup and permissions
|
||||||
|
|
||||||
|
- **`.claude/settings.local.json`** — added `COMPOSER_ALLOW_SUPERUSER=1 composer install` to allowed commands; reordered hooks block
|
||||||
|
- **`SeedProjectNaked.zip`** — removed stale zip artifact from repo
|
||||||
|
|
||||||
|
## 2026-04-27 — Fix HTTP 500 on boot
|
||||||
|
|
||||||
|
### PHP 8.3 Compatibility
|
||||||
|
- **`core/Database.php`** — renamed `query()` to `execQuery()` to resolve fatal signature mismatch with `PDO::query()` in PHP 8.3; updated internal calls in `retrieve()` and `pagination()`
|
||||||
|
- **`app/Components/Role.php`** — updated `deletePerm()` to call `execQuery()` instead of `query()`
|
||||||
|
|
||||||
|
### Composer / Autoloader
|
||||||
|
- **`composer.json`** — created from scratch (was missing); added `autoload.classmap` for `core/`, `app/Helpers`, `app/Controllers`, `app/Components`, `app/Gateways`, `system/` so framework classes load via composer
|
||||||
|
- **`vendor/`** — ran `composer dump-autoload` to regenerate autoload files with proper classmap
|
||||||
2392
.memory/documentation.md
Normal file
2392
.memory/documentation.md
Normal file
File diff suppressed because it is too large
Load diff
313
.memory/llm.md
Normal file
313
.memory/llm.md
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
# LLM Integration Documentation
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/LLM/
|
||||||
|
├── LLMProvider.php — Abstract base class (contract for all providers)
|
||||||
|
├── LLMManager.php — Factory: loads org config from DB, returns provider instance
|
||||||
|
├── svcOpenAI.php — OpenAI (fully implemented)
|
||||||
|
├── svcAnthropic.php — Anthropic/Claude (fully implemented)
|
||||||
|
├── svcGemini.php — Google Gemini (fully implemented)
|
||||||
|
├── svcMistral.php — Mistral AI (fully implemented)
|
||||||
|
└── svcDeepSeek.php — DeepSeek (fully implemented)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Storage — sp_orgs_meta
|
||||||
|
|
||||||
|
LLM configs are stored per-org in `sp_orgs_meta`:
|
||||||
|
|
||||||
|
| column | description |
|
||||||
|
|------------|------------------------------------|
|
||||||
|
| orgid | Foreign key to sp_orgs |
|
||||||
|
| keyval | Provider key (e.g. `llmOpenAI`) |
|
||||||
|
| metval | JSON config blob |
|
||||||
|
| active | 1 = enabled, 0 = disabled |
|
||||||
|
|
||||||
|
### keyval names
|
||||||
|
| Provider | keyval |
|
||||||
|
|------------|----------------|
|
||||||
|
| OpenAI | `llmOpenAI` |
|
||||||
|
| Anthropic | `llmAnthropic` |
|
||||||
|
| Gemini | `llmGemini` |
|
||||||
|
| Mistral | `llmMistral` |
|
||||||
|
| DeepSeek | `llmDeepSeek` |
|
||||||
|
|
||||||
|
### metval JSON structure (all providers)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"secretKey": "sk-...",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": 0.7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## LLMManager — Factory Class
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\LLM\LLMManager;
|
||||||
|
|
||||||
|
// Get a configured, ready-to-use provider instance
|
||||||
|
$llm = LLMManager::forOrg($orgId, 'openai');
|
||||||
|
|
||||||
|
// List all configured providers for an org
|
||||||
|
$providers = LLMManager::getAvailableProviders($orgId);
|
||||||
|
// returns: ['openai', 'gemini']
|
||||||
|
|
||||||
|
// Save or update a provider config
|
||||||
|
LLMManager::saveOrgConfig($orgId, 'openai', [
|
||||||
|
'secretKey' => 'sk-...',
|
||||||
|
'model' => 'gpt-4o',
|
||||||
|
'max_tokens' => 4096,
|
||||||
|
'temperature' => 0.7,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## LLMProvider — Base Class Interface
|
||||||
|
|
||||||
|
All providers expose the same methods:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Send a prompt, get full response
|
||||||
|
$response = $llm->sendPrompt(array $messages, array $options = []);
|
||||||
|
// Returns: ['success' => bool, 'content' => string, 'usage' => array, 'error' => string]
|
||||||
|
|
||||||
|
// Stream a prompt, callback per chunk
|
||||||
|
$result = $llm->streamPrompt(array $messages, array $options, callable $callback);
|
||||||
|
// Returns: ['success' => bool, 'error' => string]
|
||||||
|
|
||||||
|
// Get available models
|
||||||
|
$models = $llm->getModels();
|
||||||
|
// Returns: [['id' => 'gpt-4o', 'label' => 'GPT-4o'], ...]
|
||||||
|
|
||||||
|
// Test API key + connectivity
|
||||||
|
$status = $llm->testConnection();
|
||||||
|
// Returns: ['success' => bool, 'latency_ms' => int, 'error' => string]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fluent setters (chainable)
|
||||||
|
```php
|
||||||
|
$llm->setModel('gpt-4o')
|
||||||
|
->setMaxTokens(2048)
|
||||||
|
->setTemperature(0.5)
|
||||||
|
->setSystemPrompt('You are a helpful assistant.')
|
||||||
|
->setTimeout(60);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples (PHP)
|
||||||
|
|
||||||
|
### Basic chat
|
||||||
|
```php
|
||||||
|
$llm = LLMManager::forOrg($orgId, 'openai');
|
||||||
|
$response = $llm->sendPrompt([
|
||||||
|
['role' => 'user', 'content' => 'Summarize this contract: ...']
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response['success']) {
|
||||||
|
echo $response['content'];
|
||||||
|
// $response['usage'] = ['prompt_tokens' => 120, 'completion_tokens' => 80, 'total_tokens' => 200]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With system prompt and custom options
|
||||||
|
```php
|
||||||
|
$llm = LLMManager::forOrg($orgId, 'anthropic');
|
||||||
|
$llm->setSystemPrompt('You are a legal document assistant.');
|
||||||
|
|
||||||
|
$response = $llm->sendPrompt(
|
||||||
|
[['role' => 'user', 'content' => 'Draft an NDA for two parties.']],
|
||||||
|
['model' => 'claude-sonnet-4-6', 'max_tokens' => 2048, 'temperature' => 0.3]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming (PHP — CLI or long-running process)
|
||||||
|
```php
|
||||||
|
$llm = LLMManager::forOrg($orgId, 'gemini');
|
||||||
|
$llm->streamPrompt(
|
||||||
|
[['role' => 'user', 'content' => 'Write a report on...']],
|
||||||
|
[],
|
||||||
|
function(string $chunk) {
|
||||||
|
echo $chunk;
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-turn conversation
|
||||||
|
```php
|
||||||
|
$messages = [
|
||||||
|
['role' => 'user', 'content' => 'My name is Carlos.'],
|
||||||
|
['role' => 'assistant', 'content' => 'Nice to meet you, Carlos!'],
|
||||||
|
['role' => 'user', 'content' => 'What is my name?'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $llm->sendPrompt($messages);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints — appi/controllers/llm.php
|
||||||
|
|
||||||
|
All endpoints are authenticated via `Auth::API()`.
|
||||||
|
Internal AJAX requests (X-Requested-With: XMLHttpRequest) pass through automatically.
|
||||||
|
External requests require `?apikey=` param.
|
||||||
|
|
||||||
|
### POST /appi/llm/chat
|
||||||
|
Full response.
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"provider": "openai",
|
||||||
|
"org_id": 100,
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}],
|
||||||
|
"options": {"model": "gpt-4o", "max_tokens": 1024, "system_prompt": "..."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"provider": "openai",
|
||||||
|
"content": "Hello! How can I help you?",
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /appi/llm/stream
|
||||||
|
SSE streaming. Each chunk sent as:
|
||||||
|
```
|
||||||
|
event: chunk
|
||||||
|
data: {"chunk":"partial text here"}
|
||||||
|
|
||||||
|
event: done
|
||||||
|
data: "[DONE]"
|
||||||
|
|
||||||
|
event: error
|
||||||
|
data: {"error":"something went wrong"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /appi/llm/models?provider=openai&org_id=100
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"provider": "openai",
|
||||||
|
"models": [{"id": "gpt-4o", "label": "GPT-4o"}, ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /appi/llm/providers?org_id=100
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"org_id": 100,
|
||||||
|
"providers": ["openai", "gemini"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /appi/llm/test
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{"provider": "openai", "org_id": 100}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{"success": true, "provider": "openai", "latency_ms": 342, "error": ""}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend JS Client — public/assets/js/llm-client.js
|
||||||
|
|
||||||
|
Load in any page that needs LLM functionality:
|
||||||
|
```php
|
||||||
|
$this->JavaScript[] = '/public/assets/js/llm-client.js';
|
||||||
|
$this->view->JavaScript = $this->JavaScript;
|
||||||
|
```
|
||||||
|
|
||||||
|
### LLMClient.chat()
|
||||||
|
```js
|
||||||
|
const res = await LLMClient.chat({
|
||||||
|
provider: 'openai',
|
||||||
|
orgId: 100,
|
||||||
|
messages: [{ role: 'user', content: 'Hello' }],
|
||||||
|
options: { model: 'gpt-4o', system_prompt: 'You are helpful.' }
|
||||||
|
});
|
||||||
|
console.log(res.content);
|
||||||
|
```
|
||||||
|
|
||||||
|
### LLMClient.stream()
|
||||||
|
```js
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
|
||||||
|
await LLMClient.stream({
|
||||||
|
provider: 'anthropic',
|
||||||
|
orgId: 100,
|
||||||
|
messages: [{ role: 'user', content: 'Write a report on...' }],
|
||||||
|
options: { model: 'claude-sonnet-4-6' },
|
||||||
|
onChunk: (chunk) => { output.innerHTML += chunk; },
|
||||||
|
onDone: () => { console.log('Stream complete'); },
|
||||||
|
onError: (err) => { console.error('Error:', err); }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### LLMClient.getModels()
|
||||||
|
```js
|
||||||
|
const { models } = await LLMClient.getModels('openai', 100);
|
||||||
|
// models = [{ id: 'gpt-4o', label: 'GPT-4o' }, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### LLMClient.getProviders()
|
||||||
|
```js
|
||||||
|
const { providers } = await LLMClient.getProviders(100);
|
||||||
|
// providers = ['openai', 'gemini']
|
||||||
|
```
|
||||||
|
|
||||||
|
### LLMClient.test()
|
||||||
|
```js
|
||||||
|
const { success, latency_ms, error } = await LLMClient.test('openai', 100);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider API Differences (internals)
|
||||||
|
|
||||||
|
| Provider | Auth Header | System Prompt | Role names | Quirks |
|
||||||
|
|------------|-------------------------|-----------------------|-------------------------|---------------------------------|
|
||||||
|
| OpenAI | `Authorization: Bearer` | `role: system` msg | user / assistant | No temperature on o1/o3 models |
|
||||||
|
| Anthropic | `x-api-key` | Top-level `system` key| user / assistant | Requires `anthropic-version` header |
|
||||||
|
| Gemini | `?key=` query param | `systemInstruction` | user / model | Messages use `parts: [{text}]` |
|
||||||
|
| Mistral | `Authorization: Bearer` | `role: system` msg | user / assistant | None |
|
||||||
|
| DeepSeek | `Authorization: Bearer` | `role: system` msg | user / assistant | No temperature on R1 (reasoner) |
|
||||||
|
|
||||||
|
## Available Models
|
||||||
|
|
||||||
|
### OpenAI
|
||||||
|
- `gpt-4o` — GPT-4o (default)
|
||||||
|
- `gpt-4o-mini` — GPT-4o Mini
|
||||||
|
- `gpt-4-turbo` — GPT-4 Turbo
|
||||||
|
- `o1` — o1
|
||||||
|
- `o1-mini` — o1 Mini
|
||||||
|
- `o3-mini` — o3 Mini
|
||||||
|
|
||||||
|
### Anthropic
|
||||||
|
- `claude-sonnet-4-6` — Claude Sonnet 4.6 (default)
|
||||||
|
- `claude-opus-4-6` — Claude Opus 4.6
|
||||||
|
- `claude-haiku-4-5-20251001` — Claude Haiku 4.5
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
- `gemini-2.5-flash` — Gemini 2.5 Flash (default)
|
||||||
|
- `gemini-2.5-flash-lite` — Gemini 2.5 Flash Lite
|
||||||
|
- `gemini-2.0-flash` — Gemini 2.0 Flash
|
||||||
|
- `gemini-1.5-pro` — Gemini 1.5 Pro
|
||||||
|
|
||||||
|
### Mistral
|
||||||
|
- `mistral-large-latest` — Mistral Large (default)
|
||||||
|
- `mistral-small-latest` — Mistral Small
|
||||||
|
- `codestral-latest` — Codestral
|
||||||
|
- `open-mistral-nemo` — Mistral Nemo
|
||||||
|
|
||||||
|
### DeepSeek
|
||||||
|
- `deepseek-chat` — DeepSeek Chat V3 (default)
|
||||||
|
- `deepseek-reasoner` — DeepSeek Reasoner R1
|
||||||
|
|
||||||
|
## Adding a New Provider
|
||||||
|
|
||||||
|
1. Create `app/LLM/svcNewProvider.php` extending `LLMProvider`
|
||||||
|
2. Implement: `__construct(array $config)`, `sendPrompt()`, `streamPrompt()`, `getModels()`, `testConnection()`
|
||||||
|
3. Register in `LLMManager.php`:
|
||||||
|
- Add to `$providers` array: `'newprovider' => svcNewProvider::class`
|
||||||
|
- Add to `$metaKeys` array: `'newprovider' => 'llmNewProvider'`
|
||||||
|
4. Add JSON config row to `sp_orgs_meta` with `keyval = 'llmNewProvider'`
|
||||||
808
.reference_files/schema.sql
Normal file
808
.reference_files/schema.sql
Normal file
|
|
@ -0,0 +1,808 @@
|
||||||
|
-- cpenginev2 schema dump
|
||||||
|
-- Generated: 2026-03-29 21:00:53
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS=0;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_agent_messages
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_agent_messages` (
|
||||||
|
`msgid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`sessionid` int(11) NOT NULL,
|
||||||
|
`agentid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`role` enum('user','assistant') NOT NULL,
|
||||||
|
`content` longtext DEFAULT NULL,
|
||||||
|
`latency_ms` int(11) DEFAULT NULL,
|
||||||
|
`input_tokens` int(11) DEFAULT NULL,
|
||||||
|
`output_tokens` int(11) DEFAULT NULL,
|
||||||
|
`cost_usd` decimal(10,6) DEFAULT NULL,
|
||||||
|
`createdate` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`msgid`),
|
||||||
|
KEY `idx_sessionid` (`sessionid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_agent_sessions
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_agent_sessions` (
|
||||||
|
`sessionid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`agentid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`orgid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`crid` int(11) DEFAULT NULL COMMENT 'cpe_creditpulls.crid — NULL = demo/mock mode',
|
||||||
|
`provider` varchar(50) DEFAULT NULL COMMENT 'anthropic, openai, etc.',
|
||||||
|
`model` varchar(100) DEFAULT NULL,
|
||||||
|
`message_count` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`createdate` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`updatedate` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`sessionid`),
|
||||||
|
KEY `idx_agentid` (`agentid`),
|
||||||
|
KEY `idx_crid` (`crid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_aiassistant
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_aiassistant` (
|
||||||
|
`aid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`agentid` int(11) DEFAULT 0,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`crid` int(11) DEFAULT 0,
|
||||||
|
`promptid` int(11) DEFAULT 0,
|
||||||
|
`response` mediumtext DEFAULT NULL,
|
||||||
|
`jsonrequest` mediumtext DEFAULT NULL,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`aid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_creditpulls
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_creditpulls` (
|
||||||
|
`crid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(1) DEFAULT 0,
|
||||||
|
`agentid` int(1) DEFAULT 0,
|
||||||
|
`priceid` varchar(255) DEFAULT '0',
|
||||||
|
`invid` int(5) DEFAULT 0,
|
||||||
|
`profid` varchar(255) DEFAULT '0',
|
||||||
|
`custname` varchar(255) DEFAULT '0',
|
||||||
|
`custdob` varchar(255) DEFAULT '0',
|
||||||
|
`last4ss` varchar(255) DEFAULT '0',
|
||||||
|
`success` int(5) DEFAULT 0,
|
||||||
|
`apicall` int(1) NOT NULL DEFAULT 0,
|
||||||
|
`cpejson` text DEFAULT NULL,
|
||||||
|
`createdate` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`status` int(5) DEFAULT 0,
|
||||||
|
`active` int(1) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`crid`),
|
||||||
|
KEY `idx_org_history_id_orgid` (`crid`,`orgid`),
|
||||||
|
KEY `idx_org_history_id_orgid_custID` (`crid`,`orgid`)
|
||||||
|
) ENGINE=MyISAM AUTO_INCREMENT=1140 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_creditpulls_temp
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_creditpulls_temp` (
|
||||||
|
`cpeid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`evni` varchar(255) DEFAULT '0',
|
||||||
|
`rawjson` mediumtext DEFAULT NULL,
|
||||||
|
`directory` varchar(255) DEFAULT '0',
|
||||||
|
`jsonfilename` varchar(255) DEFAULT '0',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`cpeid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=120 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_org_pricing
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_org_pricing` (
|
||||||
|
`priceid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) NOT NULL,
|
||||||
|
`package_name` varchar(100) DEFAULT 'Credit Report Package',
|
||||||
|
`retail_price` decimal(6,2) NOT NULL COMMENT 'Total price client pays',
|
||||||
|
`our_price` decimal(6,2) NOT NULL COMMENT 'What all services cost us combined',
|
||||||
|
`includes_fico` tinyint(1) DEFAULT 0,
|
||||||
|
`includes_vantage` tinyint(1) DEFAULT 0,
|
||||||
|
`includes_idscan` tinyint(1) DEFAULT 0,
|
||||||
|
`includes_directory` tinyint(1) DEFAULT 0,
|
||||||
|
`active` tinyint(1) DEFAULT 1,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`priceid`),
|
||||||
|
UNIQUE KEY `orgid` (`orgid`)
|
||||||
|
) ENGINE=MyISAM AUTO_INCREMENT=109 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_orgs_documents
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_orgs_documents` (
|
||||||
|
`docid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`document` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT '0',
|
||||||
|
`type` varchar(255) DEFAULT '0',
|
||||||
|
`location` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT '0',
|
||||||
|
`status` int(11) DEFAULT 0,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`docid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_products
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_products` (
|
||||||
|
`prodid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`provider` varchar(20) DEFAULT '0',
|
||||||
|
`product_code` varchar(20) NOT NULL DEFAULT '0' COMMENT 'CR, FICO, VANTAGE, IDSCAN, DIRECTORY',
|
||||||
|
`product_name` varchar(100) NOT NULL DEFAULT '0',
|
||||||
|
`our_cost` decimal(6,2) NOT NULL DEFAULT 0.00 COMMENT 'What we pay Equifax/TransUnion',
|
||||||
|
`is_base` tinyint(1) DEFAULT 0 COMMENT '1 for Credit Report base, 0 for add-ons',
|
||||||
|
`active` tinyint(1) DEFAULT 1,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`prodid`),
|
||||||
|
UNIQUE KEY `product_code` (`product_code`)
|
||||||
|
) ENGINE=MyISAM AUTO_INCREMENT=6 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: cpe_tempreports
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `cpe_tempreports` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`agentid` int(11) DEFAULT NULL,
|
||||||
|
`orgid` int(11) DEFAULT NULL,
|
||||||
|
`template` varchar(255) DEFAULT '0',
|
||||||
|
`ceatedate` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(1) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_activitylog
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_activitylog` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`userid` int(11) DEFAULT NULL,
|
||||||
|
`status` varchar(255) DEFAULT NULL,
|
||||||
|
`trigval` varchar(255) DEFAULT NULL,
|
||||||
|
`controller` varchar(64) DEFAULT NULL,
|
||||||
|
`action` varchar(64) DEFAULT NULL,
|
||||||
|
`method` varchar(8) DEFAULT NULL,
|
||||||
|
`useragent` varchar(512) DEFAULT NULL,
|
||||||
|
`device_type` varchar(16) DEFAULT NULL,
|
||||||
|
`browser` varchar(64) DEFAULT NULL,
|
||||||
|
`os` varchar(64) DEFAULT NULL,
|
||||||
|
`msgval` text DEFAULT NULL,
|
||||||
|
`device` varchar(255) DEFAULT NULL,
|
||||||
|
`host` varchar(255) DEFAULT NULL,
|
||||||
|
`location` varchar(255) DEFAULT NULL,
|
||||||
|
`ipaddress` varchar(255) DEFAULT '000.000.000',
|
||||||
|
`public` int(11) DEFAULT NULL,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_activitylog_userid_trigval_date` (`userid`,`trigval`,`createdate` DESC)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=420 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_api_requests
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_api_requests` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`apikeyid` varchar(11) DEFAULT '0',
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`requesting_ip` varchar(255) DEFAULT '0',
|
||||||
|
`request` varchar(255) DEFAULT '0',
|
||||||
|
`service` varchar(255) DEFAULT '0',
|
||||||
|
`apitype` varchar(255) DEFAULT '0' COMMENT 'tracker, or api',
|
||||||
|
`sitepage` varchar(255) DEFAULT '0',
|
||||||
|
`useragent` varchar(255) DEFAULT '0',
|
||||||
|
`sessionid` varchar(255) DEFAULT '0',
|
||||||
|
`sessiontime` varchar(255) DEFAULT '0',
|
||||||
|
`visitduration` varchar(255) DEFAULT '0',
|
||||||
|
`devicetype` varchar(255) DEFAULT '0',
|
||||||
|
`domain` varchar(255) DEFAULT '0',
|
||||||
|
`paramsvar` varchar(255) DEFAULT '0',
|
||||||
|
`referral` varchar(255) DEFAULT '0',
|
||||||
|
`language` varchar(255) DEFAULT '0',
|
||||||
|
`ismobile` varchar(255) DEFAULT '0',
|
||||||
|
`hascookies` varchar(255) DEFAULT '0',
|
||||||
|
`scrolldepth` varchar(255) DEFAULT '0',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_userid_createdate` (`userid`,`createdate`)
|
||||||
|
) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_api_usage
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_api_usage` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`userid` int(11) NOT NULL,
|
||||||
|
`requests` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`report_date` date NOT NULL,
|
||||||
|
`report_type` enum('daily','monthly','yearly') NOT NULL,
|
||||||
|
`created_at` datetime DEFAULT current_timestamp(),
|
||||||
|
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_user_date_type` (`userid`,`report_date`,`report_type`),
|
||||||
|
KEY `idx_report_date` (`report_date`),
|
||||||
|
KEY `idx_report_type` (`report_type`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_broker_commissions
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_broker_commissions` (
|
||||||
|
`commission_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`crid` int(11) DEFAULT 0,
|
||||||
|
`pull_date` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`retail_price` decimal(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`commission_type` varchar(255) DEFAULT '0',
|
||||||
|
`commission_rate` decimal(10,4) NOT NULL DEFAULT 0.0000,
|
||||||
|
`flat_fee_amount` decimal(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`commission_amount` decimal(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`payout_eligible_date` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`payout_id` int(11) DEFAULT 0,
|
||||||
|
`paid_date` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`status` int(11) DEFAULT 0,
|
||||||
|
`notes` varchar(255) DEFAULT '0',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`commission_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_broker_orgs
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_broker_orgs` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`commission_override_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT '0',
|
||||||
|
`commission_override_rate` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT '0',
|
||||||
|
`flat_fee_override` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT '0',
|
||||||
|
`start_date` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`end_date` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`assigned_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT '0',
|
||||||
|
`notes` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT '0',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_broker_payouts
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_broker_payouts` (
|
||||||
|
`payout_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`userid` int(11) DEFAULT NULL,
|
||||||
|
`payout_period_start` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`payout_period_end` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`total_commissions` decimal(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`total_amount` decimal(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`payment_method` varchar(255) DEFAULT NULL,
|
||||||
|
`payment_reference` varchar(255) DEFAULT NULL,
|
||||||
|
`payment_date` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`status` int(11) DEFAULT NULL,
|
||||||
|
`processed_by` varchar(255) DEFAULT NULL,
|
||||||
|
`notes` varchar(255) DEFAULT NULL,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`updatedate` datetime DEFAULT '0000-00-00 00:00:00' ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`payout_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_countries
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_countries` (
|
||||||
|
`country_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`code` char(2) NOT NULL COMMENT 'Two-letter country code (ISO 3166-1 alpha-2)',
|
||||||
|
`name` varchar(64) NOT NULL COMMENT 'English country name',
|
||||||
|
`full_name` varchar(128) NOT NULL COMMENT 'Full English country name',
|
||||||
|
`iso3` char(3) NOT NULL COMMENT 'Three-letter country code (ISO 3166-1 alpha-3)',
|
||||||
|
`number` smallint(3) unsigned zerofill NOT NULL COMMENT 'Three-digit country number (ISO 3166-1 numeric)',
|
||||||
|
`continent_code` char(2) NOT NULL,
|
||||||
|
`display_order` int(11) NOT NULL DEFAULT 900,
|
||||||
|
PRIMARY KEY (`country_id`),
|
||||||
|
UNIQUE KEY `idx_code` (`code`) USING BTREE,
|
||||||
|
KEY `idx_continent_code` (`continent_code`) USING BTREE,
|
||||||
|
CONSTRAINT `sp_countries_ibfk_1` FOREIGN KEY (`continent_code`) REFERENCES `snpi_continents` (`code`) ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=247 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_uca1400_ai_ci ROW_FORMAT=COMPACT;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_cron_tasks
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_cron_tasks` (
|
||||||
|
`cronid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`keyval` varchar(255) DEFAULT '0',
|
||||||
|
`varval` varchar(255) DEFAULT '0',
|
||||||
|
`task` varchar(255) DEFAULT '0',
|
||||||
|
`response` text DEFAULT NULL,
|
||||||
|
`completed` datetime DEFAULT '0000-00-00 00:00:00' ON UPDATE current_timestamp(),
|
||||||
|
`created` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`cronid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_email_templates
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_email_templates` (
|
||||||
|
`emailtemplateid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`type` mediumtext NOT NULL,
|
||||||
|
`slug` varchar(100) NOT NULL,
|
||||||
|
`language` varchar(40) DEFAULT NULL,
|
||||||
|
`name` mediumtext NOT NULL,
|
||||||
|
`subject` mediumtext NOT NULL,
|
||||||
|
`message` mediumtext NOT NULL,
|
||||||
|
`fromname` mediumtext NOT NULL,
|
||||||
|
`fromemail` varchar(100) DEFAULT NULL,
|
||||||
|
`plaintext` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`active` tinyint(4) NOT NULL DEFAULT 0,
|
||||||
|
`order` int(11) NOT NULL,
|
||||||
|
PRIMARY KEY (`emailtemplateid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=82 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_master
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_master` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(255) DEFAULT '''0''',
|
||||||
|
`jvalue` text DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_menu_items
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_menu_items` (
|
||||||
|
`item_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`menu_id` int(10) unsigned NOT NULL,
|
||||||
|
`parent_id` int(10) unsigned DEFAULT NULL,
|
||||||
|
`label` varchar(100) NOT NULL,
|
||||||
|
`icon` varchar(100) DEFAULT NULL,
|
||||||
|
`type` enum('link','header','divider') DEFAULT 'link',
|
||||||
|
`url` varchar(255) DEFAULT NULL,
|
||||||
|
`perm_id` int(10) unsigned DEFAULT NULL,
|
||||||
|
`role_id` int(10) unsigned DEFAULT NULL,
|
||||||
|
`match_prefix` tinyint(4) DEFAULT 0,
|
||||||
|
`sort_order` int(11) DEFAULT 0,
|
||||||
|
`active` tinyint(4) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`item_id`),
|
||||||
|
KEY `menu_id` (`menu_id`),
|
||||||
|
KEY `parent_id` (`parent_id`),
|
||||||
|
KEY `perm_id` (`perm_id`),
|
||||||
|
KEY `fk_menu_item_role` (`role_id`),
|
||||||
|
CONSTRAINT `fk_menu_item_role` FOREIGN KEY (`role_id`) REFERENCES `sp_roles` (`role_id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `sp_menu_items_ibfk_1` FOREIGN KEY (`perm_id`) REFERENCES `sp_permissions` (`perm_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=54 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_menus
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_menus` (
|
||||||
|
`menu_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`slug` varchar(50) NOT NULL,
|
||||||
|
`name` varchar(100) NOT NULL,
|
||||||
|
`active` tinyint(4) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`menu_id`),
|
||||||
|
UNIQUE KEY `slug` (`slug`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_orgs
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_orgs` (
|
||||||
|
`orgid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`catid` int(11) DEFAULT 1,
|
||||||
|
`name` varchar(255) DEFAULT '0',
|
||||||
|
`address` varchar(255) DEFAULT '0',
|
||||||
|
`address2` varchar(255) DEFAULT '0',
|
||||||
|
`city` varchar(255) DEFAULT '0',
|
||||||
|
`state` varchar(255) DEFAULT '0',
|
||||||
|
`zip` int(11) DEFAULT 0,
|
||||||
|
`country` varchar(255) DEFAULT NULL,
|
||||||
|
`countrycode` varchar(11) DEFAULT NULL,
|
||||||
|
`phone` varchar(255) DEFAULT '0',
|
||||||
|
`email` varchar(255) DEFAULT '0',
|
||||||
|
`logo` varchar(255) DEFAULT '0',
|
||||||
|
`website` varchar(255) DEFAULT '0',
|
||||||
|
`json_social_media` text DEFAULT NULL,
|
||||||
|
`summary` text DEFAULT NULL,
|
||||||
|
`description` text DEFAULT NULL,
|
||||||
|
`updated_date` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`taxid` varchar(255) DEFAULT '0',
|
||||||
|
`defaultorg` int(11) DEFAULT 0,
|
||||||
|
`creditlimit` varchar(20) DEFAULT '0',
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`orgid`)
|
||||||
|
) ENGINE=MyISAM AUTO_INCREMENT=199 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_orgs_category
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_orgs_category` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'https://developer.bigcommerce.com/api-reference/9cc3a53863922-get-all-categories',
|
||||||
|
`name` varchar(255) DEFAULT '0',
|
||||||
|
`parentid` int(11) DEFAULT 0,
|
||||||
|
`description` text DEFAULT NULL,
|
||||||
|
`pagetitle` varchar(255) DEFAULT '0',
|
||||||
|
`image` varchar(255) DEFAULT '0',
|
||||||
|
`searchkeywords` text DEFAULT NULL,
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=MyISAM AUTO_INCREMENT=10 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_orgs_meta
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_orgs_meta` (
|
||||||
|
`orgmetaid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`keyval` varchar(255) DEFAULT '0',
|
||||||
|
`metval` text DEFAULT NULL,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(1) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`orgmetaid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=218 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_orgs_users_join
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_orgs_users_join` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`orgrole` int(11) DEFAULT 0 COMMENT 'orgrole is temp.. Not sure how i want to handle this.. i don''t want multiple role tables.',
|
||||||
|
`orgadmin` int(11) DEFAULT 0 COMMENT 'orgadmin are account that have access to modifying the org information.',
|
||||||
|
`guest` int(11) DEFAULT 0 COMMENT 'if user is a guest, or invited into the org. OrgAdmin can''t delete or modify the account. Another way of thinking about it is ownership. Org Doesn''t have Ownership of the account. Can''t modify it.',
|
||||||
|
`created_date` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=MyISAM AUTO_INCREMENT=37 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_permissions
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_permissions` (
|
||||||
|
`perm_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`perm_controller` varchar(50) NOT NULL DEFAULT '0',
|
||||||
|
`perm_action` varchar(50) NOT NULL DEFAULT '0',
|
||||||
|
`name` varchar(255) DEFAULT '0',
|
||||||
|
`viewroleid` varchar(255) DEFAULT '0' COMMENT 'role that can see these options',
|
||||||
|
`menu` int(11) DEFAULT 0,
|
||||||
|
`menuorder` int(11) DEFAULT 0,
|
||||||
|
PRIMARY KEY (`perm_id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_plugins
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_plugins` (
|
||||||
|
`plugin_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`slug` varchar(64) NOT NULL,
|
||||||
|
`name` varchar(128) NOT NULL,
|
||||||
|
`version` varchar(20) NOT NULL DEFAULT '1.0.0',
|
||||||
|
`description` text DEFAULT NULL,
|
||||||
|
`author` varchar(128) DEFAULT NULL,
|
||||||
|
`enabled` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`installed_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`plugin_id`),
|
||||||
|
UNIQUE KEY `slug` (`slug`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_referral
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_referral` (
|
||||||
|
`refid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`refcode` varchar(255) DEFAULT '0',
|
||||||
|
`refurl` varchar(255) DEFAULT '0',
|
||||||
|
`ipaddress` varchar(255) DEFAULT '0',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`refid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_role_perm
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_role_perm` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`role_id` int(10) unsigned NOT NULL DEFAULT 0,
|
||||||
|
`perm_id` int(10) unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `role_id` (`role_id`),
|
||||||
|
KEY `perm_id` (`perm_id`),
|
||||||
|
CONSTRAINT `sp_role_perm_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `sp_roles` (`role_id`),
|
||||||
|
CONSTRAINT `sp_role_perm_ibfk_2` FOREIGN KEY (`perm_id`) REFERENCES `sp_permissions` (`perm_id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=56 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_roles
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_roles` (
|
||||||
|
`role_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`role_name` varchar(50) NOT NULL,
|
||||||
|
`description` varchar(255) DEFAULT NULL,
|
||||||
|
`view` varchar(100) DEFAULT NULL,
|
||||||
|
`admin` int(11) DEFAULT NULL,
|
||||||
|
`onteam` int(11) DEFAULT NULL,
|
||||||
|
`menu` text DEFAULT NULL,
|
||||||
|
`defaultrole` int(11) DEFAULT NULL COMMENT 'default roleid is for the system to know which id to default the user to.',
|
||||||
|
`hidemenu` int(11) DEFAULT NULL,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`role_id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=115 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_saas_invoice_activity
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_saas_invoice_activity` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`invid` int(11) NOT NULL,
|
||||||
|
`orgid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`event_type` varchar(64) NOT NULL COMMENT 'created, payment_attempted, payment_succeeded, payment_failed, viewed, etc.',
|
||||||
|
`label` varchar(255) NOT NULL,
|
||||||
|
`note` varchar(512) DEFAULT NULL,
|
||||||
|
`amount` decimal(10,2) DEFAULT NULL,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_invid` (`invid`),
|
||||||
|
KEY `idx_orgid` (`orgid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_saas_invoice_items
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_saas_invoice_items` (
|
||||||
|
`itemid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`invid` int(11) DEFAULT 0,
|
||||||
|
`priceid` varchar(255) DEFAULT '0',
|
||||||
|
`description` varchar(255) DEFAULT '0',
|
||||||
|
`amount` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`quantity` int(11) DEFAULT 0,
|
||||||
|
`void` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`total` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`itemid`),
|
||||||
|
KEY `idx_invid` (`invid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=65 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_saas_invoices
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_saas_invoices` (
|
||||||
|
`invid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`subid` varchar(255) DEFAULT '0',
|
||||||
|
`sinvid` varchar(255) DEFAULT '0',
|
||||||
|
`stripe_pmtid` varchar(255) NOT NULL DEFAULT '' COMMENT 'pm_xxx that was charged',
|
||||||
|
`description` varchar(255) DEFAULT '0',
|
||||||
|
`amountdue` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`paid` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`paidlast4` varchar(255) DEFAULT '0',
|
||||||
|
`paidtype` varchar(255) DEFAULT '0',
|
||||||
|
`balance` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`discount` decimal(10,2) DEFAULT 0.00,
|
||||||
|
`hostedinv` varchar(255) DEFAULT '0',
|
||||||
|
`effective_at` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`due_date` datetime DEFAULT NULL,
|
||||||
|
`paid_at` datetime DEFAULT NULL,
|
||||||
|
`canceled_at` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`status` varchar(255) DEFAULT 'draft' COMMENT 'this will be status like Pending, Paid, Refunded etc..',
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`invid`),
|
||||||
|
KEY `idx_orgid` (`orgid`),
|
||||||
|
KEY `idx_subid` (`subid`(191)),
|
||||||
|
KEY `idx_sinvid` (`sinvid`(191)),
|
||||||
|
KEY `idx_status` (`status`(30))
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=70 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_saas_plans
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_saas_plans` (
|
||||||
|
`planid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`code` varchar(255) DEFAULT '0',
|
||||||
|
`codemonth` varchar(255) DEFAULT NULL,
|
||||||
|
`codeyear` varchar(255) DEFAULT NULL,
|
||||||
|
`name` varchar(255) DEFAULT NULL,
|
||||||
|
`description` varchar(255) DEFAULT NULL,
|
||||||
|
`html` mediumtext DEFAULT NULL,
|
||||||
|
`monthly` decimal(6,2) DEFAULT 0.00,
|
||||||
|
`yearly` decimal(6,2) DEFAULT 0.00,
|
||||||
|
`price` decimal(6,2) DEFAULT 0.00,
|
||||||
|
`limit_minute` int(11) DEFAULT 0,
|
||||||
|
`credits` int(11) DEFAULT 0,
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`planid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_saas_pmtmethods
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_saas_pmtmethods` (
|
||||||
|
`pmtid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`stripe_cus_id` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`spmid` varchar(255) DEFAULT '0' COMMENT 'Stripe Payment Method ID',
|
||||||
|
`cardname` varchar(255) DEFAULT '0',
|
||||||
|
`cardtype` varchar(255) DEFAULT '0',
|
||||||
|
`last4` varchar(255) DEFAULT '0',
|
||||||
|
`exp_month` tinyint(2) NOT NULL DEFAULT 0,
|
||||||
|
`exp_year` smallint(4) NOT NULL DEFAULT 0,
|
||||||
|
`billingzip` varchar(20) NOT NULL DEFAULT '',
|
||||||
|
`is_primary` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`pmtid`),
|
||||||
|
UNIQUE KEY `idx_spmid` (`spmid`(191)),
|
||||||
|
KEY `idx_orgid` (`orgid`),
|
||||||
|
KEY `idx_userid` (`userid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=65 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_saas_stripe_response
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_saas_stripe_response` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`event_type` varchar(100) NOT NULL DEFAULT '' COMMENT 'e.g. charge.succeeded, invoice.paid',
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`name` varchar(255) DEFAULT '0',
|
||||||
|
`email` varchar(255) DEFAULT '0',
|
||||||
|
`strid` varchar(255) DEFAULT '0',
|
||||||
|
`requtype` varchar(255) DEFAULT '0',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
`chargeid` varchar(255) DEFAULT '0',
|
||||||
|
`chargecode` varchar(255) DEFAULT '0',
|
||||||
|
`chargereason` varchar(255) DEFAULT '0',
|
||||||
|
`chargemsg` varchar(255) DEFAULT '0',
|
||||||
|
`cardtype` varchar(255) DEFAULT '0',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_orgid` (`orgid`),
|
||||||
|
KEY `idx_event_type` (`event_type`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_saas_subscription
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_saas_subscription` (
|
||||||
|
`subid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`planid` int(11) DEFAULT 0,
|
||||||
|
`stsubid` varchar(255) DEFAULT '0',
|
||||||
|
`status` varchar(30) NOT NULL DEFAULT 'active' COMMENT 'active | canceled | past_due | trialing | paused',
|
||||||
|
`trial_ends_at` datetime DEFAULT NULL,
|
||||||
|
`current_period_start` datetime DEFAULT NULL,
|
||||||
|
`current_period_end` datetime DEFAULT NULL,
|
||||||
|
`cancels_at` date DEFAULT '0000-00-00',
|
||||||
|
`cancelled_at` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`reasoncancelled` varchar(255) DEFAULT '0',
|
||||||
|
`credits_usage` int(11) DEFAULT 0,
|
||||||
|
`bonus_credits` int(11) DEFAULT 0,
|
||||||
|
`nextbilling` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`updatedate` datetime DEFAULT '0000-00-00 00:00:00' ON UPDATE current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 0,
|
||||||
|
PRIMARY KEY (`subid`),
|
||||||
|
KEY `idx_orgid` (`orgid`),
|
||||||
|
KEY `idx_userid` (`userid`),
|
||||||
|
KEY `idx_status` (`status`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=487 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_settings
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_settings` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`keyval` varchar(255) DEFAULT '0',
|
||||||
|
`group` varchar(100) NOT NULL DEFAULT 'general',
|
||||||
|
`metval` text DEFAULT NULL,
|
||||||
|
`status` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`encrypted` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_keyval` (`keyval`),
|
||||||
|
KEY `idx_group` (`group`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_states
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_states` (
|
||||||
|
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`code` char(2) NOT NULL,
|
||||||
|
`name` varchar(64) NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `id` (`id`)
|
||||||
|
) ENGINE=MyISAM AUTO_INCREMENT=60 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_users
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_users` (
|
||||||
|
`userid` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(50) DEFAULT '0',
|
||||||
|
`password` varchar(64) DEFAULT '0',
|
||||||
|
`email` varchar(255) DEFAULT '0',
|
||||||
|
`avatar` varchar(255) DEFAULT '0',
|
||||||
|
`autho` varchar(255) DEFAULT '0' COMMENT 'this is for google authorization',
|
||||||
|
`mobile` varchar(255) DEFAULT '0' COMMENT 'otp authorization or 2step',
|
||||||
|
`address` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`city` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`state` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`zip` varchar(20) NOT NULL DEFAULT '',
|
||||||
|
`country` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`fname` varchar(255) DEFAULT '0',
|
||||||
|
`lname` varchar(255) DEFAULT '0',
|
||||||
|
`role_id` int(10) unsigned DEFAULT 2,
|
||||||
|
`sysadmin` int(11) DEFAULT 0,
|
||||||
|
`lastlogin` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`created_date` datetime DEFAULT current_timestamp(),
|
||||||
|
`updated_date` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
`ipaddress` varchar(255) DEFAULT '000.000.000.000',
|
||||||
|
`pwcodedate` datetime DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`pwcode` varchar(255) DEFAULT '0',
|
||||||
|
`verify` varchar(255) DEFAULT '0',
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`userid`),
|
||||||
|
UNIQUE KEY `username` (`username`),
|
||||||
|
KEY `role_id` (`role_id`),
|
||||||
|
CONSTRAINT `sp_users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `sp_roles` (`role_id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2566 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_users_api
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_users_api` (
|
||||||
|
`apiid` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`orgid` int(11) DEFAULT 0,
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`projectid` int(11) DEFAULT 0,
|
||||||
|
`apikey` varchar(100) DEFAULT NULL,
|
||||||
|
`apiname` varchar(255) DEFAULT '0',
|
||||||
|
`website` varchar(255) DEFAULT '0',
|
||||||
|
`description` text DEFAULT NULL,
|
||||||
|
`whitelist` text DEFAULT NULL,
|
||||||
|
`lockdomain` int(11) DEFAULT 0,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
`active` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`apiid`),
|
||||||
|
UNIQUE KEY `ApiDups` (`apikey`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=5500 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: sp_users_meta
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `sp_users_meta` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`userid` int(11) DEFAULT 0,
|
||||||
|
`keyval` varchar(255) DEFAULT '0',
|
||||||
|
`metval` text DEFAULT NULL,
|
||||||
|
`createdate` datetime DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=212 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table: tmp_session
|
||||||
|
-- ----------------------------
|
||||||
|
CREATE TABLE `tmp_session` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`ip` varchar(255) DEFAULT '000.000.000.000',
|
||||||
|
`lastcall` datetime DEFAULT current_timestamp(),
|
||||||
|
`created_date` datetime DEFAULT current_timestamp(),
|
||||||
|
`count` int(11) DEFAULT 1,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS=1;
|
||||||
158
app/Components/Role.php
Normal file
158
app/Components/Role.php
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<?php namespace App\Components;
|
||||||
|
|
||||||
|
use Model as Model;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Role extends Model {
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function deniedAction($permission, $controller) {
|
||||||
|
$controller = strtolower($controller);
|
||||||
|
foreach($permission as $permContrller => $PerMethod) {
|
||||||
|
if($permContrller == $controller) {
|
||||||
|
$AllowAccess = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($AllowAccess == 1) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
print_array('Deny Access ' . $controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function AccessGranted($permission, $method) {
|
||||||
|
//print_array($permission);
|
||||||
|
|
||||||
|
if($permission[0] == "*") { return true; } else {
|
||||||
|
if (in_array($method, $permission)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
die('Access Denied - ' . $method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//////////////////////// Manage Role Application ////////////////////////////
|
||||||
|
|
||||||
|
public function Group() {
|
||||||
|
$Return = $this->db->select("SELECT COUNT(*) AS accounts, a.role_id, b.role_name AS RoleName, b.createdate AS CreateDate FROM usergen a JOIN roles b ON a.role_id=b.role_id GROUP BY a.role_id");
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPerms($data=""){
|
||||||
|
if($data){
|
||||||
|
$Search .= "WHERE 1";
|
||||||
|
foreach($data as $key => $val) :
|
||||||
|
if(!$val) continue;
|
||||||
|
$string = strtolower($val);
|
||||||
|
$Search .= " AND {$key} LIKE '%{$string}%'";
|
||||||
|
endforeach;
|
||||||
|
}
|
||||||
|
$Return = $this->db->select("SELECT * FROM permissions {$Search} ORDER BY perm_controller ASC");
|
||||||
|
// sendlog("SELECT * FROM permissions {$Search} ORDER BY perm_controller ASC");
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insertPerm($data){
|
||||||
|
$data['perm_controller'] = strtolower($data['perm_controller']);
|
||||||
|
$data['perm_action'] = strtolower($data['perm_action']);
|
||||||
|
|
||||||
|
$Return = $this->db->insert('permissions', $data);
|
||||||
|
// sendlog($Return);
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function deletePerm($id) {
|
||||||
|
$Return = $this->db->query("DELETE FROM permissions WHERE perm_id='{$id}'");
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function updatePerm($data, $pid){
|
||||||
|
$Return = $this->db->update('permissions', $data, "perm_id='{$pid}'");
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// insert a new role
|
||||||
|
public function insertRole($data) {
|
||||||
|
$rdata['role_name'] = $data['groupname'];
|
||||||
|
$rdata['controller'] = $data['grouprole'];
|
||||||
|
$Return = $this->db->insert('roles', $rdata);
|
||||||
|
$getID = json_decode($Return);
|
||||||
|
|
||||||
|
foreach($data['permission'] as $permid) :
|
||||||
|
$permData['role_id']=$getID->ID ;
|
||||||
|
$permData['perm_id']=$permid;
|
||||||
|
$RolePerms = $this->db->insert('role_perm', $permData);
|
||||||
|
endforeach;
|
||||||
|
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert array of roles for specified user id
|
||||||
|
public function insertUserRoles($userid, $roles) {
|
||||||
|
$sql = "INSERT INTO user_role (userid, role_id) VALUES (:userid, :role_id)";
|
||||||
|
$sth = $GLOBALS["DB"]->prepare($sql);
|
||||||
|
$sth->bindParam(":userid", $userid, PDO::PARAM_STR);
|
||||||
|
$sth->bindParam(":role_id", $role_id, PDO::PARAM_INT);
|
||||||
|
foreach ($roles as $role_id) {
|
||||||
|
$sth->execute();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete array of roles, and all associations
|
||||||
|
public function deleteRoles($roles) {
|
||||||
|
$sql = "DELETE t1, t2, t3 FROM roles as t1
|
||||||
|
JOIN user_role as t2 on t1.role_id = t2.role_id
|
||||||
|
JOIN role_perm as t3 on t1.role_id = t3.role_id
|
||||||
|
WHERE t1.role_id = :role_id";
|
||||||
|
$sth = $GLOBALS["DB"]->prepare($sql);
|
||||||
|
$sth->bindParam(":role_id", $role_id, PDO::PARAM_INT);
|
||||||
|
foreach ($roles as $role_id) {
|
||||||
|
$sth->execute();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// delete ALL roles for specified user id
|
||||||
|
public function deleteUserRoles($userid) {
|
||||||
|
$sql = "DELETE FROM user_role WHERE userid = :userid";
|
||||||
|
$sth = $GLOBALS["DB"]->prepare($sql);
|
||||||
|
return $sth->execute(array(":userid" => $userid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if a user has a specific role
|
||||||
|
public function hasRole($role_name) {
|
||||||
|
return isset($this->roles[$role_name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public static function createRole(){
|
||||||
|
die('testing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function roleType(){
|
||||||
|
$SQL = "SELECT role_id, role_name FROM roles";
|
||||||
|
$Return = $this->db->select($SQL);
|
||||||
|
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} // End Class
|
||||||
31
app/Components/Template.php
Normal file
31
app/Components/Template.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<? namespace App\Components;
|
||||||
|
|
||||||
|
use Model as Model;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Template extends Model
|
||||||
|
{
|
||||||
|
public function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function updateUser() {
|
||||||
|
echo "UpdateUser";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function deleteUser() {
|
||||||
|
echo "delete User";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsers() {
|
||||||
|
echo "Retrieve Users";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
31
app/Controllers/AppController.php
Normal file
31
app/Controllers/AppController.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Components\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppController — SaaS-level base controller.
|
||||||
|
*
|
||||||
|
* All UI controllers extend this instead of the core Controller directly.
|
||||||
|
* Owns app-wide concerns: session user profile injection, shared view data, etc.
|
||||||
|
* Core framework files (core/Controller.php) are never modified.
|
||||||
|
*/
|
||||||
|
class AppController extends Controller {
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
// Inject the authenticated user's full profile into every view
|
||||||
|
if (!empty($_SESSION['login']['userid'])) {
|
||||||
|
$profile = User::profile($_SESSION['login']['userid']);
|
||||||
|
$this->view->UserProfile = $profile;
|
||||||
|
|
||||||
|
// Build tokenized slug for account settings link: "first-last.encryptedId"
|
||||||
|
if ($profile) {
|
||||||
|
$namePart = strtolower(trim(($profile['fname'] ?? '') . '-' . ($profile['lname'] ?? ''), '-'));
|
||||||
|
$namePart = preg_replace('/[^a-z0-9\-]/', '', $namePart);
|
||||||
|
$token = Functions::encryptData((string)$profile['userid'], HASH_PASSWORD_KEY);
|
||||||
|
$this->view->UserSlug = $namePart . '.' . $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/Core/Email.php
Normal file
82
app/Core/Email.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php namespace App\Core;
|
||||||
|
|
||||||
|
|
||||||
|
//Import PHPMailer classes into the global namespace
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
use PHPMailer\PHPMailer\Exception;
|
||||||
|
|
||||||
|
use Model as Model;
|
||||||
|
|
||||||
|
|
||||||
|
class Email extends Model
|
||||||
|
{
|
||||||
|
|
||||||
|
public $SendToEmail;
|
||||||
|
public $BodyMessage;
|
||||||
|
public $Template;
|
||||||
|
public $Subject;
|
||||||
|
public $EmailParam;
|
||||||
|
public $Attachment;
|
||||||
|
public $ccEmail;
|
||||||
|
|
||||||
|
|
||||||
|
public function SendEmail() {
|
||||||
|
$mail = new PHPMailer(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
//Server settings
|
||||||
|
$mail->SMTPDebug = 0; // Enable verbose debug output
|
||||||
|
$mail->isSMTP(); // Set mailer to use SMTP
|
||||||
|
$mail->Host = EMAILHOST; // Specify main and backup SMTP servers
|
||||||
|
$mail->SMTPAuth = true; // Enable SMTP authentication
|
||||||
|
$mail->Username = EMAILUSER; // SMTP username
|
||||||
|
$mail->Password = EMAILPASS; // SMTP password
|
||||||
|
$mail->SMTPSecure = 'tls'; // Enable TLS encryption, `ssl` also accepted
|
||||||
|
$mail->Port = 587; // TCP port to connect to
|
||||||
|
|
||||||
|
//Recipients
|
||||||
|
$mail->setFrom('noreply@snoopi.io', 'Snoopi.io');
|
||||||
|
//Set an alternative reply-to address
|
||||||
|
$mail->addReplyTo('support@snoopi.io', 'Snoopi.io');
|
||||||
|
|
||||||
|
#TODO: Need to have it loop multiple email addresses
|
||||||
|
$mail->AddBCC('carlosja80@gmail.com ', 'Carlos Arias'); // Add a recipient
|
||||||
|
|
||||||
|
if($this->ccEmail) {
|
||||||
|
foreach($this->ccEmail as $ccdemail) {
|
||||||
|
$mail->AddCC(trim($ccdemail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$mail->addAddress(trim($this->SendToEmail)); // Name is optional
|
||||||
|
|
||||||
|
//Attachments
|
||||||
|
if($this->Attachment) $mail->addAttachment($this->Attachment);
|
||||||
|
//$mail->addAttachment('/tmp/image.jpg', 'new.jpg'); // Optional name
|
||||||
|
|
||||||
|
//Content
|
||||||
|
$mail->isHTML(true); // Set email format to HTML
|
||||||
|
$mail->Subject = $this->Subject;
|
||||||
|
$mail->Body = $this->BodyMessage;
|
||||||
|
$mail->AltBody = 'This is the body in plain text for non-HTML mail clients';
|
||||||
|
|
||||||
|
|
||||||
|
$HTMLMessage = file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/templates/emails/' . strtolower($this->Template) . '.html');
|
||||||
|
foreach($this->EmailParam as $Key => $Value) {
|
||||||
|
$HTMLMessage = str_replace('%'. strtoupper($Key) .'%', $Value, $HTMLMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#Example: $HTMLMessage = str_replace('%ACTIVATION%', $this->EmailParam['activation'] , $HTMLMessage);
|
||||||
|
|
||||||
|
|
||||||
|
$mail->MsgHTML($HTMLMessage);
|
||||||
|
|
||||||
|
$mail->send();
|
||||||
|
return true;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo 'Message could not be sent.';
|
||||||
|
echo 'Mailer Error: ' . $mail->ErrorInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} // End Class
|
||||||
152
app/Gateways/Stripe.php
Normal file
152
app/Gateways/Stripe.php
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php namespace App\Gateways;
|
||||||
|
|
||||||
|
|
||||||
|
use Model as Model;
|
||||||
|
|
||||||
|
class Stripe extends Model
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
public function __construct($key) {
|
||||||
|
parent::__construct();
|
||||||
|
\Stripe\Stripe::setApiKey($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# This is just to create a customer with a card verification
|
||||||
|
// Token is Required to create a customer into stripe.
|
||||||
|
public function CreateAccount($token, $name, $email, $description){
|
||||||
|
try {
|
||||||
|
$Customer = \Stripe\Customer::create(array(
|
||||||
|
"description" => $description,
|
||||||
|
"name" => $name,
|
||||||
|
"source" => $token,
|
||||||
|
"email" => $email
|
||||||
|
));
|
||||||
|
return ($Customer);
|
||||||
|
} catch (\Stripe\Error\Card $e){
|
||||||
|
return ($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Retrieve Subscription Information
|
||||||
|
public function RetrieveSubscription($str){
|
||||||
|
try {
|
||||||
|
$subscription = \Stripe\Subscription::retrieve($str);
|
||||||
|
return($subscription);
|
||||||
|
} catch (\Stripe\Error\Base $e) {
|
||||||
|
return ($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#Create Subscription
|
||||||
|
# https://stripe.com/docs/api/subscriptions/create
|
||||||
|
// a Customer ID is required - this is retrieve from the CreateAccount method.
|
||||||
|
// a PlanID is required. This is grabbed from stripe plans (https://dashboard.stripe.com/subscriptions/products)
|
||||||
|
// My Recommendation is to use Method ($this->getPlans) to get the plans. Stripe doesn't make it easy to get the planid
|
||||||
|
|
||||||
|
public function CreateSubscription($CustID, $PlanID){
|
||||||
|
$CustomerSubscription = \Stripe\Subscription::create(array(
|
||||||
|
"customer" => $CustID,
|
||||||
|
"items" => array(
|
||||||
|
array(
|
||||||
|
"plan" => $PlanID,
|
||||||
|
"quantity" => 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
return ($CustomerSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Cancels Subscription
|
||||||
|
// Subscription ID is required
|
||||||
|
// https://stripe.com/docs/api/subscriptions/cancel
|
||||||
|
|
||||||
|
public function CancelSubscription($str){
|
||||||
|
try {
|
||||||
|
$subscription = \Stripe\Subscription::retrieve($str);
|
||||||
|
$subscription->cancel();
|
||||||
|
return($subscription);
|
||||||
|
} catch (\Stripe\Error\Base $e) {
|
||||||
|
return ($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Update Subscription.
|
||||||
|
# subscription code required from stripe.
|
||||||
|
// Provide with a new PlanID
|
||||||
|
// https://stripe.com/docs/api/subscriptions/update
|
||||||
|
|
||||||
|
public function UpdateSubscription($SubID, $PlanID){
|
||||||
|
$subscription = \Stripe\Subscription::retrieve($SubID);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$UpdateSubscription = \Stripe\Subscription::update($SubID, [
|
||||||
|
'cancel_at_period_end' => false,
|
||||||
|
'proration_behavior' => 'always_invoice',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'id' => $subscription->items->data[0]->id,
|
||||||
|
'plan' => $PlanID,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return($UpdateSubscription);
|
||||||
|
|
||||||
|
} catch (\Stripe\Error\Base $e) {
|
||||||
|
return ($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#This get customer information everything about the customer from Stripe
|
||||||
|
// Just Need Customer id which looks like cust_348398439
|
||||||
|
// https://stripe.com/docs/api/customers/retrieve
|
||||||
|
public function getCustomerInfo($str){
|
||||||
|
$Return = \Stripe\Customer::retrieve($str);
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#Retrieve a list of plans
|
||||||
|
public function getPlans($limit = 10){
|
||||||
|
$Return = \Stripe\Plan::all(["limit" => $limit]);
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Retrieves the Plans detail information
|
||||||
|
public function ProductInfo($planId){
|
||||||
|
$Return = \Stripe\Plan::retrieve($planId);
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Update Credit Card Info
|
||||||
|
// Coming Soon
|
||||||
|
public function UpdateCard($token, $custID){
|
||||||
|
try {
|
||||||
|
$cu = \Stripe\Customer::update(
|
||||||
|
$custID, // stored in your application
|
||||||
|
[
|
||||||
|
'source' => $token // obtained with Checkout
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $cu;
|
||||||
|
//$Message['Success'] = "Your card details have been updated!";
|
||||||
|
}
|
||||||
|
catch(\Stripe\Error\Card $e) {
|
||||||
|
return ($e->getMessage());
|
||||||
|
// $e->getJsonBody(); // show fulll error message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // End Class
|
||||||
119
app/Helpers/Auth.php
Normal file
119
app/Helpers/Auth.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Auth {
|
||||||
|
|
||||||
|
// ─── API Authentication ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function API() {
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
|
// Ajax requests from inside the server pass through
|
||||||
|
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// External requests require API key
|
||||||
|
if ($_SERVER['REMOTE_ADDR'] != $_SERVER['SERVER_ADDR']) {
|
||||||
|
if (SECUREAPI) {
|
||||||
|
$headers = apache_request_headers();
|
||||||
|
if ($headers['X-Authorization'] != AUTHORIZATION) die('Access Denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ApiKey = $_REQUEST['apikey'];
|
||||||
|
$ApiUser = \Db::getRow("SELECT * FROM sp_users_api WHERE apikey = '{$ApiKey}'");
|
||||||
|
if (!$ApiUser) {
|
||||||
|
Logger::Entry([
|
||||||
|
'userid' => 0,
|
||||||
|
'trigval' => 'security.api_auth_failed',
|
||||||
|
'msgval' => 'Invalid API key presented',
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
$Return = ['code' => '401', 'msg' => 'Unauthorized: Api Invalid'];
|
||||||
|
header('Content-Type: application/json;charset=UTF-8');
|
||||||
|
echo json_encode($Return);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$Return['code'] = '1';
|
||||||
|
$Return['userid'] = $ApiUser['userid'];
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session Login Check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function handleLogin() {
|
||||||
|
$logged = $_SESSION['login'];
|
||||||
|
if ($logged == false) {
|
||||||
|
session_destroy();
|
||||||
|
header('location: /login');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $logged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Load Permissions into Session ───────────────────────────────────────
|
||||||
|
// Call this once after successful login.
|
||||||
|
|
||||||
|
public static function loadPermissions(int $roleId): void {
|
||||||
|
$rows = \Db::select("
|
||||||
|
SELECT p.perm_controller, p.perm_action
|
||||||
|
FROM sp_role_perm rp
|
||||||
|
JOIN sp_permissions p ON rp.perm_id = p.perm_id
|
||||||
|
WHERE rp.role_id = ?
|
||||||
|
", [$roleId]);
|
||||||
|
|
||||||
|
$permissions = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$permissions[] = $row['perm_controller'] . '.' . $row['perm_action'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['permissions'] = $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Permission Checks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the current user has the given permission.
|
||||||
|
* Permission format: 'controller.action' e.g. 'users.create'
|
||||||
|
*/
|
||||||
|
public static function can(string $permission): bool {
|
||||||
|
$permissions = $_SESSION['permissions'] ?? [];
|
||||||
|
return in_array($permission, $permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halts execution with a 403 JSON response if the user lacks the permission.
|
||||||
|
* Use in API controllers.
|
||||||
|
*/
|
||||||
|
public static function requirePermission(string $permission): void {
|
||||||
|
if (!self::can($permission)) {
|
||||||
|
Logger::Entry([
|
||||||
|
'userid' => $_SESSION['login']['userid'] ?? 0,
|
||||||
|
'trigval' => 'security.permission_denied',
|
||||||
|
'msgval' => 'Permission denied: ' . $permission,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all permissions for the current user as an array.
|
||||||
|
*/
|
||||||
|
public static function getPermissions(): array {
|
||||||
|
return $_SESSION['permissions'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a JS-safe array of the current user's permissions.
|
||||||
|
* Use in header partial to inject into window.AppUser.
|
||||||
|
*/
|
||||||
|
public static function getPermissionsJson(): string {
|
||||||
|
return json_encode(self::getPermissions());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
366
app/Helpers/DB.php
Normal file
366
app/Helpers/DB.php
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Db
|
||||||
|
{
|
||||||
|
private static $_connections = [];
|
||||||
|
private static $_activeConnection = null;
|
||||||
|
|
||||||
|
protected static $_fetchMode = \PDO::FETCH_ASSOC;
|
||||||
|
protected static $_driverOptions = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8; SET time_zone = 'America/New_York'"
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set connection information
|
||||||
|
*
|
||||||
|
* @example Db::setConnectionInfo('mysql', 'dbname', 'username', 'password', 'hostname', 'connectionName');
|
||||||
|
*/
|
||||||
|
public static function setConnectionInfo(
|
||||||
|
$driver,
|
||||||
|
$dbname,
|
||||||
|
$username,
|
||||||
|
$password,
|
||||||
|
$hostname = 'localhost',
|
||||||
|
$connectionName = 'default'
|
||||||
|
) {
|
||||||
|
self::$_connections[$connectionName] = [
|
||||||
|
'driver' => $driver,
|
||||||
|
'connectionString' => "{$driver}:dbname={$dbname};host={$hostname}",
|
||||||
|
'username' => $username,
|
||||||
|
'password' => $password,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (self::$_activeConnection === null) {
|
||||||
|
self::$_activeConnection = $connectionName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different connection
|
||||||
|
*/
|
||||||
|
public static function useConnection($connectionName)
|
||||||
|
{
|
||||||
|
if (!isset(self::$_connections[$connectionName])) {
|
||||||
|
throw new \Exception("Connection '{$connectionName}' does not exist.");
|
||||||
|
}
|
||||||
|
self::$_activeConnection = $connectionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current PDO object
|
||||||
|
*/
|
||||||
|
public static function getPDOObject()
|
||||||
|
{
|
||||||
|
return self::_getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a statement and returns the first row
|
||||||
|
*/
|
||||||
|
public static function getRow($sql, $params = [])
|
||||||
|
{
|
||||||
|
$statement = self::_query($sql, $params);
|
||||||
|
return $statement->fetch(self::$_fetchMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a statement and returns all rows
|
||||||
|
*/
|
||||||
|
public static function select($sql, $params = [])
|
||||||
|
{
|
||||||
|
$statement = self::_query($sql, $params);
|
||||||
|
return $statement->fetchAll(self::$_fetchMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new row into the database
|
||||||
|
*/
|
||||||
|
public static function insert($table, $data)
|
||||||
|
{
|
||||||
|
$pdo = self::_getConnection();
|
||||||
|
|
||||||
|
ksort($data);
|
||||||
|
$fieldNames = implode('`, `', array_keys($data));
|
||||||
|
$fieldValues = ':' . implode(', :', array_keys($data));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sth = $pdo->prepare("INSERT INTO $table (`$fieldNames`) VALUES ($fieldValues)");
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$sth->bindValue(":$key", $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sth->execute();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'Code' => 1,
|
||||||
|
'Message' => 'Created',
|
||||||
|
'ID' => $pdo->lastInsertId()
|
||||||
|
]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
return json_encode([
|
||||||
|
'Code' => 0,
|
||||||
|
'Message' => 'Error Creating Entry: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing rows in the database
|
||||||
|
*/
|
||||||
|
public static function update($table, $data, $where, $whereParams = [])
|
||||||
|
{
|
||||||
|
$pdo = self::_getConnection();
|
||||||
|
|
||||||
|
ksort($data);
|
||||||
|
$usesPositionalWhere = str_contains($where, '?');
|
||||||
|
|
||||||
|
if ($usesPositionalWhere) {
|
||||||
|
$fieldDetails = implode(', ', array_map(function($key) {
|
||||||
|
return "`$key` = ?";
|
||||||
|
}, array_keys($data)));
|
||||||
|
} else {
|
||||||
|
$fieldDetails = implode(', ', array_map(function($key) {
|
||||||
|
return "`$key` = :set_$key";
|
||||||
|
}, array_keys($data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sth = $pdo->prepare("UPDATE $table SET $fieldDetails WHERE $where");
|
||||||
|
|
||||||
|
if ($usesPositionalWhere) {
|
||||||
|
// Single positional style: SET values first, WHERE values after.
|
||||||
|
$params = array_values($data);
|
||||||
|
if (!empty($whereParams)) {
|
||||||
|
$params = array_merge($params, array_values($whereParams));
|
||||||
|
}
|
||||||
|
$sth->execute($params);
|
||||||
|
} else {
|
||||||
|
// Single named style for SET; allow named WHERE params if supplied.
|
||||||
|
$params = [];
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$params["set_$key"] = $value;
|
||||||
|
}
|
||||||
|
foreach ($whereParams as $key => $value) {
|
||||||
|
$params[ltrim((string)$key, ':')] = $value;
|
||||||
|
}
|
||||||
|
$sth->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $sth->rowCount();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'Code' => ($count > 0) ? 1 : 0,
|
||||||
|
'Rows' => $count,
|
||||||
|
'Message' => ($count > 0) ? 'Updated' : 'No Records Updated'
|
||||||
|
]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
return json_encode([
|
||||||
|
'Code' => $e->errorInfo[1],
|
||||||
|
'Message' => 'Error Updating Database: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a statement and returns number of affected rows
|
||||||
|
*/
|
||||||
|
public static function execute($sql, $params = [])
|
||||||
|
{
|
||||||
|
$statement = self::_query($sql, $params);
|
||||||
|
return $statement->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a statement and returns a single value
|
||||||
|
*/
|
||||||
|
public static function getValue($sql, $params = [])
|
||||||
|
{
|
||||||
|
$statement = self::_query($sql, $params);
|
||||||
|
return $statement->fetchColumn(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set PDO fetch mode
|
||||||
|
*/
|
||||||
|
public static function setFetchMode($fetchMode)
|
||||||
|
{
|
||||||
|
self::$_fetchMode = $fetchMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin a transaction
|
||||||
|
*/
|
||||||
|
public static function beginTransaction()
|
||||||
|
{
|
||||||
|
self::_getConnection()->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit a transaction
|
||||||
|
*/
|
||||||
|
public static function commitTransaction()
|
||||||
|
{
|
||||||
|
self::_getConnection()->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback a transaction
|
||||||
|
*/
|
||||||
|
public static function rollbackTransaction()
|
||||||
|
{
|
||||||
|
self::_getConnection()->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible aliases used across controllers.
|
||||||
|
*/
|
||||||
|
public static function commit()
|
||||||
|
{
|
||||||
|
self::commitTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rollback()
|
||||||
|
{
|
||||||
|
self::rollbackTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set PDO driver options
|
||||||
|
*/
|
||||||
|
public static function setDriverOptions(array $options)
|
||||||
|
{
|
||||||
|
self::$_driverOptions = $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the PDO connection
|
||||||
|
*/
|
||||||
|
private static function _getConnection()
|
||||||
|
{
|
||||||
|
$connectionInfo = self::$_connections[self::$_activeConnection];
|
||||||
|
|
||||||
|
if (!isset($connectionInfo['pdo'])) {
|
||||||
|
$connectionInfo['pdo'] = new \PDO(
|
||||||
|
$connectionInfo['connectionString'],
|
||||||
|
$connectionInfo['username'],
|
||||||
|
$connectionInfo['password'],
|
||||||
|
self::$_driverOptions
|
||||||
|
);
|
||||||
|
self::$_connections[self::$_activeConnection] = $connectionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $connectionInfo['pdo'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare and execute a PDO statement
|
||||||
|
*/
|
||||||
|
private static function _query($sql, $params = [])
|
||||||
|
{
|
||||||
|
$pdo = self::_getConnection();
|
||||||
|
$statement = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
if (!$statement) {
|
||||||
|
$errorInfo = $pdo->errorInfo();
|
||||||
|
throw new \PDOException("Database error [{$errorInfo[0]}]: {$errorInfo[2]}, driver error code is $errorInfo[1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$statement->execute($params) || $statement->errorCode() != '00000') {
|
||||||
|
$errorInfo = $statement->errorInfo();
|
||||||
|
throw new \PDOException("Database error [{$errorInfo[0]}]: {$errorInfo[2]}, driver error code is $errorInfo[1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete rows from the database
|
||||||
|
*/
|
||||||
|
public static function delete($table, $where)
|
||||||
|
{
|
||||||
|
$pdo = self::_getConnection();
|
||||||
|
|
||||||
|
// Build the WHERE clause dynamically
|
||||||
|
$whereConditions = implode(' AND ', array_map(function($key) {
|
||||||
|
return "`$key` = :$key";
|
||||||
|
}, array_keys($where)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sth = $pdo->prepare("DELETE FROM $table WHERE $whereConditions");
|
||||||
|
|
||||||
|
// Bind the parameters from the where array
|
||||||
|
foreach ($where as $key => $value) {
|
||||||
|
$sth->bindValue(":$key", $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sth->execute();
|
||||||
|
$count = $sth->rowCount();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'Code' => ($count > 0) ? 1 : 0,
|
||||||
|
'Rows' => $count,
|
||||||
|
'Message' => ($count > 0) ? 'Deleted' : 'No Records Deleted'
|
||||||
|
]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
return json_encode([
|
||||||
|
'Code' => $e->errorInfo[1],
|
||||||
|
'Message' => 'Error Deleting From Database: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function batchInsert($table, array $data)
|
||||||
|
{
|
||||||
|
if (empty($data)) {
|
||||||
|
return json_encode([
|
||||||
|
'Code' => 0,
|
||||||
|
'Message' => 'No data provided for insertion'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = self::_getConnection();
|
||||||
|
|
||||||
|
// Assume all rows have the same structure as the first row
|
||||||
|
$firstRow = reset($data);
|
||||||
|
$columns = array_keys($firstRow);
|
||||||
|
$columnString = '`' . implode('`, `', $columns) . '`';
|
||||||
|
|
||||||
|
// Create placeholders for each row
|
||||||
|
$rowPlaceholder = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')';
|
||||||
|
$valuePlaceholders = implode(', ', array_fill(0, count($data), $rowPlaceholder));
|
||||||
|
|
||||||
|
$sql = "INSERT INTO $table ($columnString) VALUES $valuePlaceholders";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
// Flatten the data array and bind values
|
||||||
|
$values = [];
|
||||||
|
foreach ($data as $row) {
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
$values[] = $row[$column];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->execute($values);
|
||||||
|
|
||||||
|
$insertedCount = $stmt->rowCount();
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'Code' => 1,
|
||||||
|
'Message' => 'Batch insert successful',
|
||||||
|
'InsertedRows' => $insertedCount
|
||||||
|
]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
return json_encode([
|
||||||
|
'Code' => 0,
|
||||||
|
'Message' => 'Error performing batch insert: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Helpers/Debug.php
Normal file
22
app/Helpers/Debug.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
class Debug {
|
||||||
|
|
||||||
|
public static function print_array($f, $kill=false) {
|
||||||
|
echo '<pre>';
|
||||||
|
print_r($f);
|
||||||
|
echo '</pre>';
|
||||||
|
|
||||||
|
|
||||||
|
if($kill){
|
||||||
|
die('-- Debugging --');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function sendlog($string){
|
||||||
|
error_log($string, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // End Class
|
||||||
84
app/Helpers/Format.php
Normal file
84
app/Helpers/Format.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
class Format {
|
||||||
|
|
||||||
|
public static function Date($d, $format = 'm-d-Y') {
|
||||||
|
return date($format , strtotime($d));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function Phone($phoneNumber) {
|
||||||
|
$phoneNumber = preg_replace('/[^0-9]/','',$phoneNumber);
|
||||||
|
|
||||||
|
if(strlen($phoneNumber) > 10) {
|
||||||
|
$countryCode = substr($phoneNumber, 0, strlen($phoneNumber)-10);
|
||||||
|
$areaCode = substr($phoneNumber, -10, 3);
|
||||||
|
$nextThree = substr($phoneNumber, -7, 3);
|
||||||
|
$lastFour = substr($phoneNumber, -4, 4);
|
||||||
|
|
||||||
|
$phoneNumber = '+'.$countryCode.' ('.$areaCode.') '.$nextThree.'-'.$lastFour;
|
||||||
|
}
|
||||||
|
else if(strlen($phoneNumber) == 10) {
|
||||||
|
$areaCode = substr($phoneNumber, 0, 3);
|
||||||
|
$nextThree = substr($phoneNumber, 3, 3);
|
||||||
|
$lastFour = substr($phoneNumber, 6, 4);
|
||||||
|
|
||||||
|
$phoneNumber = '('.$areaCode.') '.$nextThree.'-'.$lastFour;
|
||||||
|
}
|
||||||
|
else if(strlen($phoneNumber) == 7) {
|
||||||
|
$nextThree = substr($phoneNumber, 0, 3);
|
||||||
|
$lastFour = substr($phoneNumber, 3, 4);
|
||||||
|
|
||||||
|
$phoneNumber = $nextThree.'-'.$lastFour;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatCurrency($amount='', $thousands='') {
|
||||||
|
// Convert to float and divide by 100
|
||||||
|
$formattedAmount = number_format($amount / 100, 2, '.', $thousands);
|
||||||
|
|
||||||
|
return $formattedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Equifax date strings to MM/DD/YYYY display format.
|
||||||
|
* Handles: YYYYMMDD, YYYY-MM-DD, MM/YYYY, MM/DD/YYYY passthrough.
|
||||||
|
* Returns null on empty/null input.
|
||||||
|
*/
|
||||||
|
public static function eqFormatDate($date) {
|
||||||
|
if (empty($date)) return null;
|
||||||
|
$date = trim((string)$date);
|
||||||
|
|
||||||
|
// YYYYMMDD (8 digits)
|
||||||
|
if (preg_match('/^\d{8}$/', $date)) {
|
||||||
|
$d = \DateTime::createFromFormat('Ymd', $date);
|
||||||
|
return $d ? $d->format('m/d/Y') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
$d = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
return $d ? $d->format('m/d/Y') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MM/YYYY — partial date, use first of month
|
||||||
|
if (preg_match('/^\d{2}\/\d{4}$/', $date)) {
|
||||||
|
$d = \DateTime::createFromFormat('m/Y', $date);
|
||||||
|
return $d ? $d->format('m/01/Y') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MM/DD/YYYY — already correct format, passthrough
|
||||||
|
if (preg_match('/^\d{2}\/\d{2}\/\d{4}$/', $date)) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try strtotime
|
||||||
|
$ts = strtotime($date);
|
||||||
|
return $ts ? date('m/d/Y', $ts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // End Class
|
||||||
287
app/Helpers/Functions.php
Normal file
287
app/Helpers/Functions.php
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
class Functions {
|
||||||
|
|
||||||
|
public static function getRoles($f='') {
|
||||||
|
foreach($f as $key => $fQuery) {
|
||||||
|
$SQLWhere .= "AND {$key} like '%{$fQuery}%' ";
|
||||||
|
}
|
||||||
|
$Return = \Db::getResult("select * from sp_roles WHERE 1 {$SQLWhere}");
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getUserInfo($userid=''){
|
||||||
|
if(!$userid) die('UserID Required...');
|
||||||
|
$Return = \Db::getRow("SELECT * FROM sp_users a JOIN sp_roles b ON a.role_id=b.role_id WHERE userid='{$userid}'");
|
||||||
|
return $Return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getStates(){
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getCountries(){
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isSysAdmin() {
|
||||||
|
// \Session::get('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user exist in the database by email.
|
||||||
|
* @param $f
|
||||||
|
* @return array|false
|
||||||
|
*/
|
||||||
|
public static function CheckUserExist($f){
|
||||||
|
$j = \Db::getRow("select * from sp_users where email ='{$f}'");
|
||||||
|
return (($j) ? $j : false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get either a Gravatar URL or complete image tag for a specified email address.
|
||||||
|
*
|
||||||
|
* @param string $email The email address
|
||||||
|
* @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ]
|
||||||
|
* @param string $d Default imageset to use [ 404 | mp | identicon | monsterid | wavatar ]
|
||||||
|
* @param string $r Maximum rating (inclusive) [ g | pg | r | x ]
|
||||||
|
* @param boole $img True to return a complete IMG tag False for just the URL
|
||||||
|
* @param array $atts Optional, additional key/value attributes to include in the IMG tag
|
||||||
|
* @return String containing either just a URL or a complete image tag
|
||||||
|
* @source https://gravatar.com/site/implement/images/php/
|
||||||
|
*/
|
||||||
|
public static function getAvatar($email, $s = 80, $d = 'mp', $r = 'g', $img = false, $atts = array()) {
|
||||||
|
|
||||||
|
$url = 'https://www.gravatar.com/avatar/';
|
||||||
|
$url .= md5( strtolower( trim( $email ) ) );
|
||||||
|
$url .= "?s=$s&d=$d&r=$r";
|
||||||
|
if ( $img ) {
|
||||||
|
$url = '<img src="' . $url . '"';
|
||||||
|
foreach ( $atts as $key => $val )
|
||||||
|
$url .= ' ' . $key . '="' . $val . '"';
|
||||||
|
$url .= ' />';
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create Encryption for security in the application
|
||||||
|
*
|
||||||
|
* @param string $st The string that needs to be encrypted
|
||||||
|
* Usage: echo \Functions::Encrypt("Carlos");
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function Encrypt($st){
|
||||||
|
$encrypted = openssl_encrypt($st, 'AES-128-CTR', HASH_PASSWORD_KEY, OPENSSL_RAW_DATA, '1234567890123456');
|
||||||
|
return base64_encode($encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Create Encryption for security in the application
|
||||||
|
*
|
||||||
|
* @param string $st The string that needs to be encrypted
|
||||||
|
* Usage: echo \Functions::Decrypt("a/pxWERALz2YMd4l32U3ew==");
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function Decrypt($st){
|
||||||
|
$decrypted = openssl_decrypt(base64_decode($st), 'AES-128-CTR', HASH_PASSWORD_KEY, OPENSSL_RAW_DATA, '1234567890123456');
|
||||||
|
return $decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function DefualtRole(){
|
||||||
|
Return \Db::getRow("SELECT role_id, role_name FROM sp_roles WHERE defaultrole='1'");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function encryptData($data, $key) {
|
||||||
|
$ivLength = openssl_cipher_iv_length($cipher = 'AES-256-CBC');
|
||||||
|
$iv = openssl_random_pseudo_bytes($ivLength);
|
||||||
|
$encrypted = openssl_encrypt($data, $cipher, $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
// Convert to hex to ensure binary safety, then to base64 to make it URL-friendly
|
||||||
|
$encryptedBase64 = base64_encode($iv . $encrypted);
|
||||||
|
// Replace URL-unfriendly characters from base64 encoding
|
||||||
|
$urlSafeEncrypted = strtr($encryptedBase64, '+/', '-_');
|
||||||
|
// Optionally remove '=' if present
|
||||||
|
$urlSafeEncrypted = rtrim($urlSafeEncrypted, '=');
|
||||||
|
return $urlSafeEncrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function decryptData($urlSafeEncrypted, $key) {
|
||||||
|
$ivLength = openssl_cipher_iv_length($cipher = 'AES-256-CBC');
|
||||||
|
// Reverse the URL-safe transformations
|
||||||
|
$base64Encrypted = strtr($urlSafeEncrypted, '-_', '+/');
|
||||||
|
// Decode from base64 to binary
|
||||||
|
$binaryData = base64_decode($base64Encrypted);
|
||||||
|
$iv = substr($binaryData, 0, $ivLength);
|
||||||
|
$encrypted = substr($binaryData, $ivLength);
|
||||||
|
$decrypted = openssl_decrypt($encrypted, $cipher, $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return $decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*// Usage
|
||||||
|
$key = 'your-256-bit-secret-key'; // Make sure to use a secure key
|
||||||
|
$originalData = "Your secret data";
|
||||||
|
|
||||||
|
$encryptedData = encryptData($originalData, $key);
|
||||||
|
echo "Encrypted: " . $encryptedData . "\n";
|
||||||
|
|
||||||
|
$decryptedData = decryptData($encryptedData, $key);
|
||||||
|
echo "Decrypted: " . $decryptedData . "\n";
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an org from a tokenized slug (e.g. "acme-corp.AbcToken").
|
||||||
|
* Decrypts the token, validates it, and returns the active org row.
|
||||||
|
* Calls die() with an error message on any failure.
|
||||||
|
*
|
||||||
|
* @param string $slug
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function resolveOrg($slug) {
|
||||||
|
if (!$slug || strpos($slug, '.') === false) die('Invalid link');
|
||||||
|
[, $token] = explode('.', $slug, 2);
|
||||||
|
$orgid = self::decryptData($token, HASH_PASSWORD_KEY);
|
||||||
|
if (!$orgid || !is_numeric($orgid)) die('Invalid link');
|
||||||
|
$org = \Db::getRow("SELECT * FROM sp_orgs WHERE orgid = ? AND active = 1", [$orgid]);
|
||||||
|
if (!$org) die('Organization not found');
|
||||||
|
return $org;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a user from a tokenized slug (e.g. "john-doe.AbcToken").
|
||||||
|
* Decrypts the token, validates it, and returns the user row (with role).
|
||||||
|
* Calls die() with an error message on any failure.
|
||||||
|
*
|
||||||
|
* @param string $slug
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function resolveUser($slug) {
|
||||||
|
if (!$slug || strpos($slug, '.') === false) die('Invalid link');
|
||||||
|
[, $token] = explode('.', $slug, 2);
|
||||||
|
$userid = self::decryptData($token, HASH_PASSWORD_KEY);
|
||||||
|
if (!$userid || !is_numeric($userid)) die('Invalid link');
|
||||||
|
$user = \Db::getRow("SELECT u.*, r.role_name FROM sp_users u LEFT JOIN sp_roles r ON u.role_id = r.role_id WHERE u.userid = ?", [$userid]);
|
||||||
|
if (!$user) die('User not found');
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This gets the system default organization.
|
||||||
|
// Ideally for single organization applications with users going straight into the org or for api calls that have no business name assigned to the field.
|
||||||
|
public static function DefaultOrg(){
|
||||||
|
Return \Db::getRow("SELECT orgid, catid, name FROM sp_orgs WHERE defaultorg='1'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function randomPassword() {
|
||||||
|
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
|
||||||
|
$pass = array(); //remember to declare $pass as an array
|
||||||
|
$alphaLength = strlen($alphabet) - 1; //put the length -1 in cache
|
||||||
|
for ($i = 0; $i < 8; $i++) {
|
||||||
|
$n = rand(0, $alphaLength);
|
||||||
|
$pass[] = $alphabet[$n];
|
||||||
|
}
|
||||||
|
return implode($pass); //turn the array into a string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public static function ip_in_range($ip, $range) {
|
||||||
|
if (strpos($range, '/') == false)
|
||||||
|
$range .= '/32';
|
||||||
|
|
||||||
|
// $range is in IP/CIDR format eg 127.0.0.1/24
|
||||||
|
list($range, $netmask) = explode('/', $range, 2);
|
||||||
|
$range_decimal = ip2long($range);
|
||||||
|
$ip_decimal = ip2long($ip);
|
||||||
|
$wildcard_decimal = pow(2, (32 - $netmask)) - 1;
|
||||||
|
$netmask_decimal = ~ $wildcard_decimal;
|
||||||
|
return (($ip_decimal & $netmask_decimal) == ($range_decimal & $netmask_decimal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function CheckCloudFlare($ip) {
|
||||||
|
$cf_ips = array(
|
||||||
|
'173.245.48.0/20',
|
||||||
|
'103.21.244.0/22',
|
||||||
|
'103.22.200.0/22',
|
||||||
|
'103.31.4.0/22',
|
||||||
|
'141.101.64.0/18',
|
||||||
|
'108.162.192.0/18',
|
||||||
|
'190.93.240.0/20',
|
||||||
|
'188.114.96.0/20',
|
||||||
|
'197.234.240.0/22',
|
||||||
|
'198.41.128.0/17',
|
||||||
|
'162.158.0.0/15',
|
||||||
|
'104.16.0.0/13',
|
||||||
|
'104.24.0.0/14',
|
||||||
|
'172.64.0.0/13',
|
||||||
|
'131.0.72.0/22'
|
||||||
|
);
|
||||||
|
$is_cf_ip = false;
|
||||||
|
foreach ($cf_ips as $cf_ip) {
|
||||||
|
if (self::ip_in_range($ip, $cf_ip)) {
|
||||||
|
|
||||||
|
$is_cf_ip = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} return $is_cf_ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function in_array_all($needles, $haystack) {
|
||||||
|
return empty(array_diff($needles, $haystack));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasOrgAdminAccess($userArray) {
|
||||||
|
foreach ($userArray as $org) {
|
||||||
|
if ($org['OrgAdmin'] == 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static function getDomainIP($domain) {
|
||||||
|
// Validate domain format
|
||||||
|
if (!filter_var($domain, FILTER_VALIDATE_DOMAIN)) {
|
||||||
|
return "Invalid domain format";
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Method 1: Get single IP using gethostbyname()
|
||||||
|
$ip = gethostbyname($domain);
|
||||||
|
if ($ip !== $domain) {
|
||||||
|
$result['primary_ip'] = $ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Get all DNS A records
|
||||||
|
$dns_records = dns_get_record($domain, DNS_A);
|
||||||
|
if (!empty($dns_records)) {
|
||||||
|
$result['dns_records'] = array_column($dns_records, 'ip');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Get all IPv4 addresses
|
||||||
|
if (checkdnsrr($domain, 'A')) {
|
||||||
|
$ip_array = gethostbynamel($domain);
|
||||||
|
if ($ip_array) {
|
||||||
|
$result['all_ipv4'] = $ip_array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($result)) {
|
||||||
|
return "Could not resolve domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return "Error: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
} // End Class
|
||||||
|
|
||||||
16
app/Helpers/GlobalConst.php
Normal file
16
app/Helpers/GlobalConst.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class GlobalConst {
|
||||||
|
|
||||||
|
|
||||||
|
public static function getCountries($s=""){
|
||||||
|
$SQLReturn = \DB::select("SELECT * FROM snpi_countries WHERE 1");
|
||||||
|
return $SQLReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getStates($s=""){
|
||||||
|
$SQLReturn = \DB::select("SELECT * FROM snpi_states WHERE 1");
|
||||||
|
return $SQLReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // End Class
|
||||||
67
app/Helpers/Hash.php
Normal file
67
app/Helpers/Hash.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Hash
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param string $algo The algorithm (md5, sha1, whirlpool, etc)
|
||||||
|
* @param string $data The data to encode
|
||||||
|
* @param string $salt The salt (This should be the same throughout the system probably)
|
||||||
|
* @return string The hashed/salted data
|
||||||
|
*/
|
||||||
|
public static function create($algo, $data, $salt)
|
||||||
|
{
|
||||||
|
$context = hash_init($algo, HASH_HMAC, $salt);
|
||||||
|
hash_update($context, $data);
|
||||||
|
|
||||||
|
return hash_final($context);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createApi(){
|
||||||
|
$key = implode('-', str_split(substr(strtolower(md5(microtime().rand(1000, 9999))), 0, 30), 6));
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formData(){
|
||||||
|
$key = implode('-', str_split(substr(strtolower(md5(microtime().rand(1000, 9999))), 0, 15), 5));
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a date-of-birth string to YYYYMMDD for consistent hashing.
|
||||||
|
* Handles: YYYY-MM-DD (from HTML date input), MM/DD/YYYY, MMDDYYYY, YYYYMMDD.
|
||||||
|
*/
|
||||||
|
public static function normalizeDob($dob) {
|
||||||
|
if (empty($dob)) return '';
|
||||||
|
$dob = trim((string)$dob);
|
||||||
|
|
||||||
|
// Already YYYYMMDD
|
||||||
|
if (preg_match('/^\d{8}$/', $dob)) return $dob;
|
||||||
|
|
||||||
|
// YYYY-MM-DD (HTML date input)
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob)) {
|
||||||
|
return str_replace('-', '', $dob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MM/DD/YYYY
|
||||||
|
if (preg_match('/^\d{2}\/\d{2}\/\d{4}$/', $dob)) {
|
||||||
|
$d = \DateTime::createFromFormat('m/d/Y', $dob);
|
||||||
|
return $d ? $d->format('Ymd') : $dob;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
$ts = strtotime($dob);
|
||||||
|
return $ts ? date('Ymd', $ts) : $dob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a stable consumer profile ID from DOB + SSN.
|
||||||
|
* Returns first 32 chars of HMAC-SHA256.
|
||||||
|
*/
|
||||||
|
public static function createConsumerProfileId($dob, $ssn, $salt) {
|
||||||
|
$normalized = self::normalizeDob($dob) . preg_replace('/[^0-9]/', '', $ssn);
|
||||||
|
return substr(hash_hmac('sha256', $normalized, $salt), 0, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Helpers/Logger.php
Normal file
72
app/Helpers/Logger.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Log Information to Database
|
||||||
|
* Auto-populates: host, method, useragent, device_type, browser, os, ipaddress, location.
|
||||||
|
* Callers may pass controller/action explicitly.
|
||||||
|
*/
|
||||||
|
public static function Entry($logData) {
|
||||||
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
$parsed = self::parseUserAgent($ua);
|
||||||
|
|
||||||
|
$logData['location'] = $logData['location'] ?? 'na';
|
||||||
|
$logData['ipaddress'] = $logData['ipaddress'] ?: ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||||
|
$logData['host'] = $_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
$logData['method'] = $logData['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? '');
|
||||||
|
$logData['useragent'] = $logData['useragent'] ?? substr($ua, 0, 512);
|
||||||
|
$logData['device_type'] = $logData['device_type'] ?? $parsed['device_type'];
|
||||||
|
$logData['browser'] = $logData['browser'] ?? $parsed['browser'];
|
||||||
|
$logData['os'] = $logData['os'] ?? $parsed['os'];
|
||||||
|
// controller/action passed explicitly by caller
|
||||||
|
|
||||||
|
return \Db::insert('sp_activitylog', $logData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse a User-Agent string into device_type, browser, os.
|
||||||
|
*/
|
||||||
|
private static function parseUserAgent(string $ua): array {
|
||||||
|
$u = strtolower($ua);
|
||||||
|
|
||||||
|
// Device type
|
||||||
|
if (preg_match('/tablet|ipad|kindle|playbook|silk|(android(?!.*mobile))/i', $ua)) {
|
||||||
|
$device_type = 'tablet';
|
||||||
|
} elseif (preg_match('/mobile|iphone|ipod|android.*mobile|blackberry|opera mini|iemobile/i', $ua)) {
|
||||||
|
$device_type = 'mobile';
|
||||||
|
} else {
|
||||||
|
$device_type = 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser (order matters — Edge/Opera before Chrome/Safari)
|
||||||
|
if (str_contains($u, 'edg/') || str_contains($u, 'edge/')) $browser = 'Edge';
|
||||||
|
elseif (str_contains($u, 'opr/') || str_contains($u, 'opera')) $browser = 'Opera';
|
||||||
|
elseif (str_contains($u, 'chrome') && !str_contains($u, 'chromium')) $browser = 'Chrome';
|
||||||
|
elseif (str_contains($u, 'firefox') || str_contains($u, 'fxios')) $browser = 'Firefox';
|
||||||
|
elseif (str_contains($u, 'safari') && !str_contains($u, 'chrome')) $browser = 'Safari';
|
||||||
|
elseif (str_contains($u, 'msie') || str_contains($u, 'trident')) $browser = 'IE';
|
||||||
|
else $browser = 'Other';
|
||||||
|
|
||||||
|
// OS
|
||||||
|
if (str_contains($u, 'iphone') || str_contains($u, 'ipod')) $os = 'iOS';
|
||||||
|
elseif (str_contains($u, 'ipad')) $os = 'iPadOS';
|
||||||
|
elseif (str_contains($u, 'android')) $os = 'Android';
|
||||||
|
elseif (str_contains($u, 'windows')) $os = 'Windows';
|
||||||
|
elseif (str_contains($u, 'macintosh') || str_contains($u, 'mac os x')) $os = 'macOS';
|
||||||
|
elseif (str_contains($u, 'cros')) $os = 'ChromeOS';
|
||||||
|
elseif (str_contains($u, 'linux')) $os = 'Linux';
|
||||||
|
else $os = 'Other';
|
||||||
|
|
||||||
|
return compact('device_type', 'browser', 'os');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Log Information to a File
|
||||||
|
*/
|
||||||
|
public static function LogtoFile($string){
|
||||||
|
error_log($string, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // End Class
|
||||||
146
app/Helpers/Menu.php
Normal file
146
app/Helpers/Menu.php
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Menu {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a menu item URL by preferred labels within a menu slug.
|
||||||
|
* Useful for linking static template buttons to Menu Builder-managed URLs.
|
||||||
|
*/
|
||||||
|
public static function getItemUrl(string $slug, array $labels, string $fallback = '#'): string {
|
||||||
|
if (empty($labels)) return $fallback;
|
||||||
|
|
||||||
|
$rows = Db::select("
|
||||||
|
SELECT mi.label, mi.url
|
||||||
|
FROM sp_menu_items mi
|
||||||
|
JOIN sp_menus m ON m.menu_id = mi.menu_id
|
||||||
|
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
|
||||||
|
AND mi.type = 'link' AND mi.url IS NOT NULL
|
||||||
|
", [$slug]);
|
||||||
|
|
||||||
|
if (!$rows) return $fallback;
|
||||||
|
|
||||||
|
$urlByLabel = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = strtolower(trim((string)$row['label']));
|
||||||
|
$urlByLabel[$key] = (string)$row['url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($labels as $label) {
|
||||||
|
$key = strtolower(trim((string)$label));
|
||||||
|
if (isset($urlByLabel[$key]) && trim($urlByLabel[$key]) !== '') {
|
||||||
|
return $urlByLabel[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full nested menu tree for the given slug,
|
||||||
|
* filtered by the current user's permissions (loaded from their role).
|
||||||
|
*/
|
||||||
|
public static function getTree(string $slug): array {
|
||||||
|
$rows = Db::select("
|
||||||
|
SELECT mi.*, p.perm_controller, p.perm_action
|
||||||
|
FROM sp_menu_items mi
|
||||||
|
JOIN sp_menus m ON m.menu_id = mi.menu_id
|
||||||
|
LEFT JOIN sp_permissions p ON p.perm_id = mi.perm_id
|
||||||
|
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
|
||||||
|
ORDER BY mi.sort_order ASC
|
||||||
|
", [$slug]);
|
||||||
|
|
||||||
|
if (!$rows) return [];
|
||||||
|
|
||||||
|
$isSysAdmin = !empty($_SESSION['login']['sysadmin']);
|
||||||
|
if (!$isSysAdmin) {
|
||||||
|
$userPerms = $_SESSION['permissions'] ?? [];
|
||||||
|
$rows = array_values(array_filter($rows, function($item) use ($userPerms) {
|
||||||
|
if (!$item['perm_id']) return true;
|
||||||
|
$key = $item['perm_controller'] . '.' . $item['perm_action'];
|
||||||
|
return in_array($key, $userPerms);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUrl = strtok($_SERVER['REQUEST_URI'], '?');
|
||||||
|
return self::buildTree($rows, $currentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves dynamic URL tokens for the logged-in user.
|
||||||
|
*
|
||||||
|
* Supported tokens (use in the menu builder URL field):
|
||||||
|
* %ORG_TOKEN% → encrypted slug of the user's company → /company/%ORG_TOKEN%
|
||||||
|
* %USER_TOKEN% → encrypted slug of the logged-in user → /account/%USER_TOKEN%/edit
|
||||||
|
*/
|
||||||
|
private static function resolveUrlTokens(string $url): string {
|
||||||
|
if (strpos($url, '%') === false) return $url;
|
||||||
|
|
||||||
|
if (strpos($url, '%ORG_TOKEN%') !== false) {
|
||||||
|
$orgId = $_SESSION['login']['orgid'] ?? 0;
|
||||||
|
if ($orgId) {
|
||||||
|
$token = Functions::encryptData((string)$orgId, HASH_PASSWORD_KEY);
|
||||||
|
$url = str_replace('%ORG_TOKEN%', 'org.' . $token, $url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($url, '%USER_TOKEN%') !== false) {
|
||||||
|
$userId = $_SESSION['login']['userid'] ?? 0;
|
||||||
|
if ($userId) {
|
||||||
|
$token = Functions::encryptData((string)$userId, HASH_PASSWORD_KEY);
|
||||||
|
$url = str_replace('%USER_TOKEN%', 'user.' . $token, $url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildTree(array $rows, string $currentUrl, int $parentId = 0): array {
|
||||||
|
$branch = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ((int)$row['parent_id'] === $parentId) {
|
||||||
|
$row['children'] = self::buildTree($rows, $currentUrl, (int)$row['item_id']);
|
||||||
|
|
||||||
|
$rawUrl = $row['url'] ?? '';
|
||||||
|
$itemUrl = rtrim(self::resolveUrlTokens($rawUrl), '/');
|
||||||
|
$row['url'] = $itemUrl; // write resolved URL back for the renderer
|
||||||
|
$curUrl = rtrim($currentUrl, '/');
|
||||||
|
|
||||||
|
// URLs containing dynamic tokens (e.g. %ORG_TOKEN%) are encrypted with a
|
||||||
|
// random IV, so the resolved URL differs on every request and can never
|
||||||
|
// equal the token already present in the browser's address bar.
|
||||||
|
// Instead, match on the static path segment that precedes the token.
|
||||||
|
if (strpos($rawUrl, '%') !== false) {
|
||||||
|
$staticPrefix = rtrim(preg_replace('/%[^%]+%.*$/', '', $rawUrl), '/');
|
||||||
|
$row['is_active'] = $staticPrefix !== '' && (
|
||||||
|
$curUrl === $staticPrefix ||
|
||||||
|
str_starts_with($curUrl, $staticPrefix . '/')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$row['is_active'] = $itemUrl !== '' && $curUrl === $itemUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($row['children'])) {
|
||||||
|
$row['is_open'] = $row['is_active'] || self::hasActiveChild($row['children']);
|
||||||
|
} elseif ($row['match_prefix'] && $itemUrl !== '') {
|
||||||
|
$row['is_active'] = str_starts_with($currentUrl, $itemUrl);
|
||||||
|
$row['is_open'] = $row['is_active'];
|
||||||
|
} else {
|
||||||
|
$row['is_open'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip section headers with no visible children
|
||||||
|
if ($row['type'] === 'header' && empty($row['children'])) continue;
|
||||||
|
|
||||||
|
$branch[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasActiveChild(array $children): bool {
|
||||||
|
foreach ($children as $child) {
|
||||||
|
if ($child['is_active'] || $child['is_open']) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Helpers/Numbers.php
Normal file
52
app/Helpers/Numbers.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
# Deprecate this
|
||||||
|
|
||||||
|
class Numbers {
|
||||||
|
|
||||||
|
public static function ValidPhone($str) {
|
||||||
|
if(preg_match('/^(\d{1,4}[ -]?)?(\d{6,10}|(\d{1,5}[ -]?\d{1,5}))(?![\d-])/', $str)) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function onlyNumbers($c){
|
||||||
|
return preg_replace('/\D/', '', $c);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatDate($d, $format = 'm-d-Y') {
|
||||||
|
return date($format , strtotime($d));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function formatPhoneNumber($phoneNumber) {
|
||||||
|
$phoneNumber = preg_replace('/[^0-9]/','',$phoneNumber);
|
||||||
|
|
||||||
|
if(strlen($phoneNumber) > 10) {
|
||||||
|
$countryCode = substr($phoneNumber, 0, strlen($phoneNumber)-10);
|
||||||
|
$areaCode = substr($phoneNumber, -10, 3);
|
||||||
|
$nextThree = substr($phoneNumber, -7, 3);
|
||||||
|
$lastFour = substr($phoneNumber, -4, 4);
|
||||||
|
|
||||||
|
$phoneNumber = '+'.$countryCode.' ('.$areaCode.') '.$nextThree.'-'.$lastFour;
|
||||||
|
}
|
||||||
|
else if(strlen($phoneNumber) == 10) {
|
||||||
|
$areaCode = substr($phoneNumber, 0, 3);
|
||||||
|
$nextThree = substr($phoneNumber, 3, 3);
|
||||||
|
$lastFour = substr($phoneNumber, 6, 4);
|
||||||
|
|
||||||
|
$phoneNumber = '('.$areaCode.') '.$nextThree.'-'.$lastFour;
|
||||||
|
}
|
||||||
|
else if(strlen($phoneNumber) == 7) {
|
||||||
|
$nextThree = substr($phoneNumber, 0, 3);
|
||||||
|
$lastFour = substr($phoneNumber, 3, 4);
|
||||||
|
|
||||||
|
$phoneNumber = $nextThree.'-'.$lastFour;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // End Class
|
||||||
177
app/Helpers/Pagination.php
Normal file
177
app/Helpers/Pagination.php
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Pagination extends Model
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* set the number of items per page.
|
||||||
|
*
|
||||||
|
* @var numeric
|
||||||
|
*/
|
||||||
|
private $_perPage;
|
||||||
|
/**
|
||||||
|
* set get parameter for fetching the page number
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $_instance;
|
||||||
|
/**
|
||||||
|
* sets the page number.
|
||||||
|
*
|
||||||
|
* @var numeric
|
||||||
|
*/
|
||||||
|
private $_page;
|
||||||
|
/**
|
||||||
|
* set the limit for the data source
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $_limit;
|
||||||
|
/**
|
||||||
|
* set the total number of records/items.
|
||||||
|
*
|
||||||
|
* @var numeric
|
||||||
|
*/
|
||||||
|
private $_totalRows = 0;
|
||||||
|
/**
|
||||||
|
* __construct
|
||||||
|
*
|
||||||
|
* pass values when class is istantiated
|
||||||
|
*
|
||||||
|
* @param numeric $_perPage sets the number of iteems per page
|
||||||
|
* @param numeric $_instance sets the instance for the GET parameter
|
||||||
|
*/
|
||||||
|
public function __construct($perPage,$instance){
|
||||||
|
$this->_instance = $instance;
|
||||||
|
$this->_perPage = $perPage;
|
||||||
|
$this->set_instance();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* get_start
|
||||||
|
*
|
||||||
|
* creates the starting point for limiting the dataset
|
||||||
|
* @return numeric
|
||||||
|
*/
|
||||||
|
public function get_start(){
|
||||||
|
return ($this->_page * $this->_perPage) - $this->_perPage;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* set_instance
|
||||||
|
*
|
||||||
|
* sets the instance parameter, if numeric value is 0 then set to 1
|
||||||
|
*
|
||||||
|
* @var numeric
|
||||||
|
*/
|
||||||
|
private function set_instance(){
|
||||||
|
$this->_page = (int) (!isset($_GET[$this->_instance]) ? 1 : $_GET[$this->_instance]);
|
||||||
|
$this->_page = ($this->_page == 0 ? 1 : $this->_page);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* set_total
|
||||||
|
*
|
||||||
|
* collect a numberic value and assigns it to the totalRows
|
||||||
|
*
|
||||||
|
* @var numeric
|
||||||
|
*/
|
||||||
|
public function set_total($_totalRows){
|
||||||
|
$this->_totalRows = $_totalRows;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* get_limit
|
||||||
|
*
|
||||||
|
* returns the limit for the data source, calling the get_start method and passing in the number of items perp page
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function get_limit(){
|
||||||
|
return "LIMIT ".$this->get_start().",$this->_perPage";
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* page_links
|
||||||
|
*
|
||||||
|
* create the html links for navigating through the dataset
|
||||||
|
*
|
||||||
|
* @var sting $path optionally set the path for the link
|
||||||
|
* @var sting $ext optionally pass in extra parameters to the GET
|
||||||
|
* @return string returns the html menu
|
||||||
|
*/
|
||||||
|
public function page_links($path='?',$ext=null)
|
||||||
|
{
|
||||||
|
$adjacents = "2";
|
||||||
|
$prev = $this->_page - 1;
|
||||||
|
$next = $this->_page + 1;
|
||||||
|
$lastpage = ceil($this->_totalRows/$this->_perPage);
|
||||||
|
$lpm1 = $lastpage - 1;
|
||||||
|
$pagination = "";
|
||||||
|
if($lastpage > 1)
|
||||||
|
{
|
||||||
|
$pagination .= "<ul class='pagination'>";
|
||||||
|
if ($this->_page > 1)
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$prev"."$ext'>Previous</a></li>";
|
||||||
|
else
|
||||||
|
$pagination.= "<span class='disabled'>Previous</span>";
|
||||||
|
if ($lastpage < 7 + ($adjacents * 2))
|
||||||
|
{
|
||||||
|
for ($counter = 1; $counter <= $lastpage; $counter++)
|
||||||
|
{
|
||||||
|
if ($counter == $this->_page)
|
||||||
|
$pagination.= "<li><span class='current'>$counter</span></li>";
|
||||||
|
else
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif($lastpage > 5 + ($adjacents * 2))
|
||||||
|
{
|
||||||
|
if($this->_page < 1 + ($adjacents * 2))
|
||||||
|
{
|
||||||
|
for ($counter = 1; $counter < 4 + ($adjacents * 2); $counter++)
|
||||||
|
{
|
||||||
|
if ($counter == $this->_page)
|
||||||
|
$pagination.= "<li><span class='current'>$counter</span></li>";
|
||||||
|
else
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
|
||||||
|
}
|
||||||
|
$pagination.= "...";
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$lpm1"."$ext'>$lpm1</a></li>";
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$lastpage"."$ext'>$lastpage</a></li>";
|
||||||
|
}
|
||||||
|
elseif($lastpage - ($adjacents * 2) > $this->_page && $this->_page > ($adjacents * 2))
|
||||||
|
{
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=1"."$ext'>1</a></li>";
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=2"."$ext'>2</a></li>";
|
||||||
|
$pagination.= "...";
|
||||||
|
for ($counter = $this->_page - $adjacents; $counter <= $this->_page + $adjacents; $counter++)
|
||||||
|
{
|
||||||
|
if ($counter == $this->_page)
|
||||||
|
$pagination.= "<span class='current'>$counter</span>";
|
||||||
|
else
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
|
||||||
|
}
|
||||||
|
$pagination.= "..";
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$lpm1"."$ext'>$lpm1</a></li>";
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$lastpage"."$ext'>$lastpage</a></li>";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=1"."$ext'>1</a></li>";
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=2"."$ext'>2</a></li>";
|
||||||
|
$pagination.= "..";
|
||||||
|
for ($counter = $lastpage - (2 + ($adjacents * 2)); $counter <= $lastpage; $counter++)
|
||||||
|
{
|
||||||
|
if ($counter == $this->_page)
|
||||||
|
$pagination.= "<span class='current'>$counter</span>";
|
||||||
|
else
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$counter"."$ext'>$counter</a></li>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->_page < $counter - 1)
|
||||||
|
$pagination.= "<li><a href='".$path."$this->_instance=$next"."$ext'>Next</a></li>";
|
||||||
|
else
|
||||||
|
$pagination.= "<li><span class='disabled'>Next</span></li>";
|
||||||
|
$pagination.= "</ul>\n";
|
||||||
|
}
|
||||||
|
return $pagination;
|
||||||
|
}
|
||||||
|
}
|
||||||
353
app/Helpers/PluginManager.php
Normal file
353
app/Helpers/PluginManager.php
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginManager — WordPress-style plugin engine for SeedProject.
|
||||||
|
*
|
||||||
|
* Plugins live in plugins/{slug}/ and are integrated via symlinks into
|
||||||
|
* public/controllers/, public/views/plugins/, and public/assets/plugins/.
|
||||||
|
* No core framework files need modification beyond a single boot() call.
|
||||||
|
*/
|
||||||
|
class PluginManager {
|
||||||
|
|
||||||
|
private static string $pluginsDir;
|
||||||
|
private static string $controllersDir;
|
||||||
|
private static string $viewsDir;
|
||||||
|
private static string $assetsDir;
|
||||||
|
|
||||||
|
private static function init(): void {
|
||||||
|
static $initialized = false;
|
||||||
|
if ($initialized) return;
|
||||||
|
$base = dirname(__DIR__, 2); // project root
|
||||||
|
self::$pluginsDir = $base . '/plugins';
|
||||||
|
self::$controllersDir = $base . '/public/controllers';
|
||||||
|
self::$viewsDir = $base . '/public/views/plugins';
|
||||||
|
self::$assetsDir = $base . '/public/assets/plugins';
|
||||||
|
$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boot (called once per request from Router::Routing) ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register AltoRouter routes for all enabled plugins.
|
||||||
|
* Called from Router::Routing() before $router->match().
|
||||||
|
*/
|
||||||
|
public static function boot(AltoRouter $router): void {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
$plugins = Db::select(
|
||||||
|
"SELECT slug FROM sp_plugins WHERE enabled = 1",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$plugins) return;
|
||||||
|
|
||||||
|
foreach ($plugins as $row) {
|
||||||
|
$slug = $row['slug'];
|
||||||
|
$manifestPath = self::$pluginsDir . '/' . $slug . '/plugin.php';
|
||||||
|
if (!file_exists($manifestPath)) continue;
|
||||||
|
|
||||||
|
$manifest = require $manifestPath;
|
||||||
|
foreach ($manifest['routes'] ?? [] as $rule) {
|
||||||
|
// Each rule: [method, pattern, target, name]
|
||||||
|
if (count($rule) >= 3) {
|
||||||
|
$router->map($rule[0], $rule[1], $rule[2], $rule[3] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Install ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a plugin: create symlinks, run install.php, upsert DB row (enabled=0).
|
||||||
|
*/
|
||||||
|
public static function install(string $slug): array {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
$slug = self::sanitizeSlug($slug);
|
||||||
|
if (!$slug) return ['success' => false, 'error' => 'Invalid slug'];
|
||||||
|
|
||||||
|
$pluginDir = self::$pluginsDir . '/' . $slug;
|
||||||
|
if (!is_dir($pluginDir)) {
|
||||||
|
return ['success' => false, 'error' => "Plugin directory not found: {$slug}"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestPath = $pluginDir . '/plugin.php';
|
||||||
|
if (!file_exists($manifestPath)) {
|
||||||
|
return ['success' => false, 'error' => "plugin.php manifest missing for: {$slug}"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = require $manifestPath;
|
||||||
|
|
||||||
|
// Create symlinks
|
||||||
|
$symlinkResult = self::createSymlinks($slug, $manifest);
|
||||||
|
if (!$symlinkResult['success']) return $symlinkResult;
|
||||||
|
|
||||||
|
// Run install.php
|
||||||
|
$installScript = $pluginDir . '/install.php';
|
||||||
|
if (file_exists($installScript)) {
|
||||||
|
try {
|
||||||
|
require $installScript;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return ['success' => false, 'error' => 'install.php failed: ' . $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert sp_plugins row (enabled=0 — admin must explicitly enable)
|
||||||
|
$existing = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]);
|
||||||
|
if ($existing) {
|
||||||
|
Db::update('sp_plugins', [
|
||||||
|
'name' => $manifest['name'] ?? $slug,
|
||||||
|
'version' => $manifest['version'] ?? '1.0.0',
|
||||||
|
'description' => $manifest['description'] ?? '',
|
||||||
|
'author' => $manifest['author'] ?? '',
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
], 'slug = ?', [$slug]);
|
||||||
|
} else {
|
||||||
|
Db::insert('sp_plugins', [
|
||||||
|
'slug' => $slug,
|
||||||
|
'name' => $manifest['name'] ?? $slug,
|
||||||
|
'version' => $manifest['version'] ?? '1.0.0',
|
||||||
|
'description' => $manifest['description'] ?? '',
|
||||||
|
'author' => $manifest['author'] ?? '',
|
||||||
|
'enabled' => 0,
|
||||||
|
'installed_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => "Plugin '{$slug}' installed. Enable it to activate."];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Enable ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a plugin: ensure symlinks exist, set enabled=1.
|
||||||
|
*/
|
||||||
|
public static function enable(string $slug): array {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
$slug = self::sanitizeSlug($slug);
|
||||||
|
$row = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]);
|
||||||
|
if (!$row) return ['success' => false, 'error' => "Plugin '{$slug}' is not installed"];
|
||||||
|
|
||||||
|
$manifest = self::loadManifest($slug);
|
||||||
|
if (!$manifest) return ['success' => false, 'error' => "Cannot load manifest for '{$slug}'"];
|
||||||
|
|
||||||
|
$symlinkResult = self::createSymlinks($slug, $manifest);
|
||||||
|
if (!$symlinkResult['success']) return $symlinkResult;
|
||||||
|
|
||||||
|
Db::update('sp_plugins', ['enabled' => 1, 'updated_at' => date('Y-m-d H:i:s')], 'slug = ?', [$slug]);
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => "Plugin '{$slug}' enabled"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Disable ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a plugin: remove symlinks, set enabled=0.
|
||||||
|
* Symlink removal causes natural 404 without any guard code.
|
||||||
|
*/
|
||||||
|
public static function disable(string $slug): array {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
$slug = self::sanitizeSlug($slug);
|
||||||
|
$row = Db::getRow("SELECT plugin_id FROM sp_plugins WHERE slug = ?", [$slug]);
|
||||||
|
if (!$row) return ['success' => false, 'error' => "Plugin '{$slug}' is not installed"];
|
||||||
|
|
||||||
|
self::removeSymlinks($slug);
|
||||||
|
|
||||||
|
Db::update('sp_plugins', ['enabled' => 0, 'updated_at' => date('Y-m-d H:i:s')], 'slug = ?', [$slug]);
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => "Plugin '{$slug}' disabled"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Uninstall ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall a plugin: remove symlinks, run uninstall.php, delete DB row.
|
||||||
|
*/
|
||||||
|
public static function uninstall(string $slug): array {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
$slug = self::sanitizeSlug($slug);
|
||||||
|
|
||||||
|
self::removeSymlinks($slug);
|
||||||
|
|
||||||
|
$uninstallScript = self::$pluginsDir . '/' . $slug . '/uninstall.php';
|
||||||
|
if (file_exists($uninstallScript)) {
|
||||||
|
try {
|
||||||
|
require $uninstallScript;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Log but don't abort — clean up DB anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::delete('sp_plugins', ['slug' => $slug]);
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => "Plugin '{$slug}' uninstalled"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Discover ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan plugins/ directory and merge with DB rows.
|
||||||
|
* Returns array of plugin info for the admin UI.
|
||||||
|
*/
|
||||||
|
public static function discover(): array {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
// Get all DB rows keyed by slug
|
||||||
|
$dbRows = [];
|
||||||
|
$rows = Db::select("SELECT * FROM sp_plugins", []);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$dbRows[$row['slug']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plugins = [];
|
||||||
|
|
||||||
|
if (!is_dir(self::$pluginsDir)) return $plugins;
|
||||||
|
|
||||||
|
$dirs = glob(self::$pluginsDir . '/*/plugin.php');
|
||||||
|
if (!$dirs) return $plugins;
|
||||||
|
|
||||||
|
foreach ($dirs as $manifestPath) {
|
||||||
|
$slug = basename(dirname($manifestPath));
|
||||||
|
$manifest = require $manifestPath;
|
||||||
|
$dbRow = $dbRows[$slug] ?? null;
|
||||||
|
|
||||||
|
$plugins[] = [
|
||||||
|
'slug' => $slug,
|
||||||
|
'name' => $manifest['name'] ?? $slug,
|
||||||
|
'version' => $manifest['version'] ?? '1.0.0',
|
||||||
|
'description' => $manifest['description'] ?? '',
|
||||||
|
'author' => $manifest['author'] ?? '',
|
||||||
|
'installed' => $dbRow !== null,
|
||||||
|
'enabled' => (bool)($dbRow['enabled'] ?? false),
|
||||||
|
'installed_at' => $dbRow['installed_at'] ?? null,
|
||||||
|
'plugin_id' => $dbRow['plugin_id'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function pluginPath(string $slug): string {
|
||||||
|
self::init();
|
||||||
|
return self::$pluginsDir . '/' . $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitizeSlug(string $slug): string {
|
||||||
|
return preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($slug)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadManifest(string $slug): ?array {
|
||||||
|
self::init();
|
||||||
|
$path = self::$pluginsDir . '/' . $slug . '/plugin.php';
|
||||||
|
return file_exists($path) ? require $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy plugin files into the framework directories.
|
||||||
|
* public/controllers/{slug}.php ← plugins/{slug}/{Slug}Controller.php
|
||||||
|
* public/views/plugins/{slug}/ ← plugins/{slug}/views/{slug}/
|
||||||
|
* public/assets/plugins/{slug}/ ← plugins/{slug}/assets/
|
||||||
|
*/
|
||||||
|
private static function createSymlinks(string $slug, array $manifest): array {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
$pluginDir = self::$pluginsDir . '/' . $slug;
|
||||||
|
$controllerClass = ucfirst($slug) . 'Controller';
|
||||||
|
|
||||||
|
// Ensure parent dirs exist
|
||||||
|
foreach ([self::$viewsDir, self::$assetsDir] as $dir) {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy controller file
|
||||||
|
$controllerSrc = $pluginDir . '/' . $controllerClass . '.php';
|
||||||
|
$controllerDest = self::$controllersDir . '/' . $slug . '.php';
|
||||||
|
if (!file_exists($controllerSrc)) {
|
||||||
|
return ['success' => false, 'error' => "Controller not found: {$controllerClass}.php"];
|
||||||
|
}
|
||||||
|
if (!copy($controllerSrc, $controllerDest)) {
|
||||||
|
return ['success' => false, 'error' => "Failed to copy controller to: {$controllerDest}"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy views directory (optional)
|
||||||
|
$viewsSrc = $pluginDir . '/views/' . $slug;
|
||||||
|
$viewsDest = self::$viewsDir . '/' . $slug;
|
||||||
|
if (is_dir($viewsSrc)) {
|
||||||
|
$result = self::copyDir($viewsSrc, $viewsDest);
|
||||||
|
if (!$result) {
|
||||||
|
return ['success' => false, 'error' => "Failed to copy views to: {$viewsDest}"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy assets directory (optional)
|
||||||
|
$assetsSrc = $pluginDir . '/assets';
|
||||||
|
$assetsDest = self::$assetsDir . '/' . $slug;
|
||||||
|
if (is_dir($assetsSrc)) {
|
||||||
|
$result = self::copyDir($assetsSrc, $assetsDest);
|
||||||
|
if (!$result) {
|
||||||
|
return ['success' => false, 'error' => "Failed to copy assets to: {$assetsDest}"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove copied plugin files from framework directories.
|
||||||
|
*/
|
||||||
|
private static function removeSymlinks(string $slug): void {
|
||||||
|
self::init();
|
||||||
|
|
||||||
|
$controllerFile = self::$controllersDir . '/' . $slug . '.php';
|
||||||
|
if (file_exists($controllerFile)) {
|
||||||
|
unlink($controllerFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([self::$viewsDir . '/' . $slug, self::$assetsDir . '/' . $slug] as $dir) {
|
||||||
|
if (is_dir($dir)) {
|
||||||
|
self::removeDir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively copy a directory.
|
||||||
|
*/
|
||||||
|
private static function copyDir(string $src, string $dest): bool {
|
||||||
|
if (!is_dir($dest)) {
|
||||||
|
mkdir($dest, 0755, true);
|
||||||
|
}
|
||||||
|
foreach (scandir($src) as $item) {
|
||||||
|
if ($item === '.' || $item === '..') continue;
|
||||||
|
$s = $src . '/' . $item;
|
||||||
|
$d = $dest . '/' . $item;
|
||||||
|
if (is_dir($s)) {
|
||||||
|
if (!self::copyDir($s, $d)) return false;
|
||||||
|
} else {
|
||||||
|
if (!copy($s, $d)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively delete a directory.
|
||||||
|
*/
|
||||||
|
private static function removeDir(string $dir): void {
|
||||||
|
foreach (scandir($dir) as $item) {
|
||||||
|
if ($item === '.' || $item === '..') continue;
|
||||||
|
$path = $dir . '/' . $item;
|
||||||
|
is_dir($path) ? self::removeDir($path) : unlink($path);
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Helpers/Router.php
Normal file
31
app/Helpers/Router.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/** Reference */
|
||||||
|
/**
|
||||||
|
* // Match all request URIs
|
||||||
|
[i] // Match an integer
|
||||||
|
[i:id] // Match an integer as 'id'
|
||||||
|
[a:action] // Match alphanumeric characters as 'action'
|
||||||
|
[h:key] // Match hexadecimal characters as 'key'
|
||||||
|
[:action] // Match anything up to the next / or end of the URI as 'action'
|
||||||
|
[create|edit:action] // Match either 'create' or 'edit' as 'action'
|
||||||
|
[*] // Catch all (lazy, stops at the next trailing slash)
|
||||||
|
[*:trailing] // Catch all as 'trailing' (lazy)
|
||||||
|
[**:trailing] // Catch all (possessive - will match the rest of the URI)
|
||||||
|
.[:format]? // Match an optional parameter 'format' - a / or . before the block is also optional
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Router {
|
||||||
|
public static function Routing() {
|
||||||
|
$router = new AltoRouter();
|
||||||
|
$router->setBasePath('');
|
||||||
|
|
||||||
|
// Rules Set Here
|
||||||
|
// $router->map('GET|POST', '/orgs/[*:orgid]', array('c' => 'orgs', 'a' => 'index'));
|
||||||
|
|
||||||
|
PluginManager::boot($router);
|
||||||
|
|
||||||
|
return $router->match();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Helpers/Validate.php
Normal file
26
app/Helpers/Validate.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
class Validate {
|
||||||
|
|
||||||
|
public static function Email($f) {
|
||||||
|
if($f) {
|
||||||
|
if (!filter_var($f, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$Error['Code']='';
|
||||||
|
$Error['msg'] = "Email Required";
|
||||||
|
return (json_encode($Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function sendlog($string){
|
||||||
|
error_log($string, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // End Class
|
||||||
200
app/Helpers/helpers.md
Normal file
200
app/Helpers/helpers.md
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
# Helpers — `app/Helpers/`
|
||||||
|
|
||||||
|
All reusable, stateless utility functions live here as `public static` methods.
|
||||||
|
Never duplicate logic as private controller methods.
|
||||||
|
|
||||||
|
## Convention
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Always call as static — no instance needed
|
||||||
|
HelperClass::methodName($arg);
|
||||||
|
```
|
||||||
|
|
||||||
|
If no existing class fits, create a new `PascalCase.php` static class here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `DB.php` — Database
|
||||||
|
|
||||||
|
All queries use prepared statements. Never interpolate values into SQL.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
```php
|
||||||
|
Db::select($sql, $params) // returns array of rows
|
||||||
|
Db::getRow($sql, $params) // returns single row or null
|
||||||
|
Db::insert($table, $data) // returns last insert ID
|
||||||
|
Db::update($table, $data, $where, $whereParams) // returns affected row count
|
||||||
|
Db::delete($table, $where, $whereParams) // returns affected row count
|
||||||
|
Db::execute($sql, $params) // for custom queries (UPDATE/INSERT/etc.)
|
||||||
|
Db::beginTransaction()
|
||||||
|
Db::commit()
|
||||||
|
Db::rollback()
|
||||||
|
Db::useConnection('legalwriter') // switch DB connection
|
||||||
|
Db::useConnection('default') // switch back
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```php
|
||||||
|
// SELECT multiple rows
|
||||||
|
$users = Db::select("SELECT * FROM sp_users WHERE active = :active", [':active' => 1]);
|
||||||
|
|
||||||
|
// SELECT single row
|
||||||
|
$user = Db::getRow("SELECT * FROM sp_users WHERE id = :id", [':id' => $id]);
|
||||||
|
if (!$user) { die('Not found'); }
|
||||||
|
|
||||||
|
// INSERT
|
||||||
|
$userId = Db::insert('sp_users', [
|
||||||
|
'name' => 'Jane Doe',
|
||||||
|
'email' => 'jane@example.com',
|
||||||
|
'created_at' => date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
Db::update('sp_users',
|
||||||
|
['name' => 'Jane Smith', 'updated_at' => date('Y-m-d H:i:s')],
|
||||||
|
'id = :id',
|
||||||
|
[':id' => $userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
Db::delete('sp_users', 'id = :id', [':id' => $userId]);
|
||||||
|
|
||||||
|
// Soft delete (preferred)
|
||||||
|
Db::update('sp_users', ['deleted_at' => date('Y-m-d H:i:s')], 'id = :id', [':id' => $userId]);
|
||||||
|
|
||||||
|
// Custom query
|
||||||
|
Db::execute("UPDATE sp_users SET last_login = NOW() WHERE id = :id", [':id' => $id]);
|
||||||
|
|
||||||
|
// Transaction
|
||||||
|
Db::beginTransaction();
|
||||||
|
try {
|
||||||
|
$orderId = Db::insert('sp_orders', ['user_id' => $userId, 'total' => 100]);
|
||||||
|
Db::execute("UPDATE sp_users SET balance = balance - 100 WHERE id = :id", [':id' => $userId]);
|
||||||
|
Db::commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex Query Patterns
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Aggregation
|
||||||
|
$stats = Db::getRow("SELECT COUNT(*) as total, SUM(amount) as revenue FROM sp_orders WHERE status = 'completed'");
|
||||||
|
echo $stats['total'];
|
||||||
|
|
||||||
|
// Subquery
|
||||||
|
$users = Db::select("
|
||||||
|
SELECT u.*,
|
||||||
|
(SELECT COUNT(*) FROM sp_orders WHERE user_id = u.id) as order_count
|
||||||
|
FROM sp_users u WHERE u.active = 1
|
||||||
|
");
|
||||||
|
|
||||||
|
// Dynamic filter building
|
||||||
|
$query = "SELECT * FROM sp_clients WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
if (!empty($_GET['q'])) {
|
||||||
|
$query .= " AND (name LIKE :q OR email LIKE :q)";
|
||||||
|
$params[':q'] = '%' . $_GET['q'] . '%';
|
||||||
|
}
|
||||||
|
if (!empty($_GET['status'])) {
|
||||||
|
$query .= " AND status = :status";
|
||||||
|
$params[':status'] = $_GET['status'];
|
||||||
|
}
|
||||||
|
$clients = Db::select($query, $params);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$perPage = 20;
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$total = Db::getRow("SELECT COUNT(*) as count FROM sp_clients");
|
||||||
|
$rows = Db::select("SELECT * FROM sp_clients LIMIT :limit OFFSET :offset",
|
||||||
|
[':limit' => $perPage, ':offset' => $offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use EXISTS instead of COUNT for existence checks (faster)
|
||||||
|
$exists = Db::getRow("SELECT 1 FROM sp_users WHERE email = :email LIMIT 1", [':email' => $email]);
|
||||||
|
if ($exists) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Auth.php` — Authentication
|
||||||
|
|
||||||
|
```php
|
||||||
|
Auth::handleLogin() // redirect to /login if not authenticated
|
||||||
|
Auth::can('users.create') // returns bool — check permission
|
||||||
|
Auth::requirePermission('users.delete') // halts with 403 JSON if denied
|
||||||
|
Auth::isLoggedIn() // returns bool
|
||||||
|
Auth::getUserId() // returns current user ID from session
|
||||||
|
Auth::getUser() // returns current user row
|
||||||
|
Auth::login($userId) // set session login state
|
||||||
|
Auth::logout() // destroy session
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Session.php` — Session Management
|
||||||
|
|
||||||
|
```php
|
||||||
|
Session::start()
|
||||||
|
Session::set($key, $value)
|
||||||
|
Session::get($key, $default = null)
|
||||||
|
Session::has($key)
|
||||||
|
Session::delete($key)
|
||||||
|
Session::destroy()
|
||||||
|
Session::flash($key, $value) // set one-time flash message
|
||||||
|
Session::getFlash($key) // get and delete flash message
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Validator.php` — Input Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
Validator::required($value) // not empty
|
||||||
|
Validator::email($value) // valid email format
|
||||||
|
Validator::minLength($value, $min)
|
||||||
|
Validator::maxLength($value, $max)
|
||||||
|
Validator::numeric($value)
|
||||||
|
Validator::alpha($value)
|
||||||
|
Validator::alphanumeric($value)
|
||||||
|
Validator::url($value)
|
||||||
|
Validator::date($value)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Functions.php` — General Purpose
|
||||||
|
|
||||||
|
Key reusable helpers:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Decrypt tokenized org slug → fetch active org row (dies on invalid)
|
||||||
|
Functions::resolveOrg($slug)
|
||||||
|
|
||||||
|
// Decrypt tokenized user slug → fetch user+role row (dies on invalid)
|
||||||
|
Functions::resolveUser($slug)
|
||||||
|
|
||||||
|
// AES-256-CBC URL-safe encrypt / decrypt
|
||||||
|
Functions::encryptData($data, $key)
|
||||||
|
Functions::decryptData($token, $key)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Hash.php` — Cryptography
|
||||||
|
|
||||||
|
Crypto utilities (hashing, token generation, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding New Helpers
|
||||||
|
|
||||||
|
1. Find the most semantically appropriate existing class.
|
||||||
|
2. Add a `public static function` to it.
|
||||||
|
3. If nothing fits, create `app/Helpers/NewHelper.php` as a static class.
|
||||||
|
4. Call from any controller or other helper — no instantiation needed.
|
||||||
8
app/Helpers/helpers.php
Normal file
8
app/Helpers/helpers.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (!function_exists('redirect')) {
|
||||||
|
function redirect(string $url): void {
|
||||||
|
header('Location: ' . $url);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
799
app/LLM/Anthropic.php
Normal file
799
app/LLM/Anthropic.php
Normal file
|
|
@ -0,0 +1,799 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
use Anthropic\Client;
|
||||||
|
use Anthropic\Messages\MessageParam;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class Anthropic
|
||||||
|
{
|
||||||
|
private Client $client;
|
||||||
|
private string $defaultModel = 'claude-sonnet-4-5-20250929';
|
||||||
|
private int $defaultMaxTokens = 4096;
|
||||||
|
private ?string $apiKey;
|
||||||
|
private string $templatePath;
|
||||||
|
|
||||||
|
public const MODELS = [
|
||||||
|
'opus' => 'claude-opus-4-20250514',
|
||||||
|
'sonnet' => 'claude-sonnet-4-5-20250929',
|
||||||
|
'haiku' => 'claude-haiku-4-5-20251001',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
public function __construct(?string $apiKey = null, ?string $templatePath = null, int $timeout = 300) {
|
||||||
|
$this->apiKey = $apiKey ?? ANTHROPIC_API_KEY;
|
||||||
|
$this->templatePath = $templatePath ?? dirname(__DIR__, 2) . '/templates/prompts/';
|
||||||
|
|
||||||
|
|
||||||
|
if (empty($this->apiKey)) {
|
||||||
|
throw new Exception('Anthropic API key is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($this->templatePath)) {
|
||||||
|
throw new Exception("Template directory not found: {$this->templatePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable($this->templatePath)) {
|
||||||
|
throw new Exception("Template directory is not readable: {$this->templatePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
set_time_limit($timeout);
|
||||||
|
ini_set('default_socket_timeout', (string) $timeout);
|
||||||
|
|
||||||
|
$this->client = new Client(apiKey: $this->apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CONNECTION & HEALTH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client->messages->create(
|
||||||
|
model: $this->defaultModel,
|
||||||
|
maxTokens: 10,
|
||||||
|
messages: [
|
||||||
|
MessageParam::with(role: 'user', content: 'Reply with only: OK')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$latency = round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Connected to Anthropic API',
|
||||||
|
'model' => $this->defaultModel,
|
||||||
|
'response' => trim($response->content[0]->text),
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'usage' => [
|
||||||
|
'input_tokens' => $response->usage->inputTokens,
|
||||||
|
'output_tokens' => $response->usage->outputTokens,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Connection failed',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'error_type' => get_class($e)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConnected(): bool
|
||||||
|
{
|
||||||
|
return $this->testConnection()['success'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): array
|
||||||
|
{
|
||||||
|
$connection = $this->testConnection();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'connected' => $connection['success'],
|
||||||
|
'model' => $this->defaultModel,
|
||||||
|
'max_tokens' => $this->defaultMaxTokens,
|
||||||
|
'api_key_preview' => substr($this->apiKey, 0, 10) . '...' . substr($this->apiKey, -4),
|
||||||
|
'template_path' => $this->templatePath,
|
||||||
|
'connection_details' => $connection
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// TEMPLATE LOADING
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function loadTemplate(string $filename): string
|
||||||
|
{
|
||||||
|
$path = $this->templatePath . '/' . $filename;
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
throw new Exception("Template not found: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
throw new Exception("Failed to read template: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTemplatePath(string $path): self
|
||||||
|
{
|
||||||
|
$this->templatePath = rtrim($path, '/');
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemplatePath(): string
|
||||||
|
{
|
||||||
|
return $this->templatePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// BASIC MESSAGING
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function message(string $prompt, ?string $systemPrompt = null, array $options = []): string
|
||||||
|
{
|
||||||
|
$response = $this->messageWithMeta($prompt, $systemPrompt, $options);
|
||||||
|
return $response['content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messageWithMeta(string $prompt, ?string $systemPrompt = null, array $options = []): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$messages = $this->buildMessages($prompt, $options['conversation'] ?? []);
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'model' => $options['model'] ?? $this->defaultModel,
|
||||||
|
'maxTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
'messages' => $messages,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($systemPrompt) {
|
||||||
|
$params['system'] = $systemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['temperature'])) {
|
||||||
|
$params['temperature'] = $options['temperature'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['stop_sequences'])) {
|
||||||
|
$params['stopSequences'] = $options['stop_sequences'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->messages->create(...$params);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->content[0]->text,
|
||||||
|
'model' => $response->model,
|
||||||
|
'stop_reason' => $response->stopReason,
|
||||||
|
'latency_ms' => round((microtime(true) - $startTime) * 1000),
|
||||||
|
'usage' => [
|
||||||
|
'input_tokens' => $response->usage->inputTokens,
|
||||||
|
'output_tokens' => $response->usage->outputTokens,
|
||||||
|
'total_tokens' => $response->usage->inputTokens + $response->usage->outputTokens,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function conversation(array $messages, ?string $systemPrompt = null, array $options = []): array
|
||||||
|
{
|
||||||
|
$messageParams = [];
|
||||||
|
foreach ($messages as $msg) {
|
||||||
|
$messageParams[] = MessageParam::with(role: $msg['role'], content: $msg['content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'model' => $options['model'] ?? $this->defaultModel,
|
||||||
|
'maxTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
'messages' => $messageParams,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($systemPrompt) {
|
||||||
|
$params['system'] = $systemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->messages->create(...$params);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->content[0]->text,
|
||||||
|
'usage' => [
|
||||||
|
'input_tokens' => $response->usage->inputTokens,
|
||||||
|
'output_tokens' => $response->usage->outputTokens,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CACHED MESSAGING
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message with prompt caching - template is cached, variables are not
|
||||||
|
*/
|
||||||
|
public function messageWithCache(
|
||||||
|
string $prompt,
|
||||||
|
string $cachedContext,
|
||||||
|
?string $dynamicContext = null,
|
||||||
|
array $options = []
|
||||||
|
): array {
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$systemBlocks = [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $cachedContext,
|
||||||
|
'cache_control' => ['type' => 'ephemeral']
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($dynamicContext) {
|
||||||
|
$systemBlocks[] = [
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $dynamicContext
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->messages->create(
|
||||||
|
model: $options['model'] ?? $this->defaultModel,
|
||||||
|
maxTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
system: $systemBlocks,
|
||||||
|
messages: [
|
||||||
|
MessageParam::with(role: 'user', content: $prompt)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
|
||||||
|
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->content[0]->text,
|
||||||
|
'model' => $response->model,
|
||||||
|
'latency_ms' => round((microtime(true) - $startTime) * 1000),
|
||||||
|
'usage' => [
|
||||||
|
'input_tokens' => $response->usage->inputTokens,
|
||||||
|
'output_tokens' => $response->usage->outputTokens,
|
||||||
|
'cache_creation_tokens' => $cacheCreation,
|
||||||
|
'cache_read_tokens' => $cacheRead,
|
||||||
|
],
|
||||||
|
'cache_hit' => $cacheRead > 0,
|
||||||
|
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCacheStatus(int $created, int $read): string
|
||||||
|
{
|
||||||
|
if ($read > 0) return 'hit';
|
||||||
|
if ($created > 0) return 'created';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ARTICLE GENERATION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the dynamic variables block (topic, word count, etc.)
|
||||||
|
* This is NOT cached - changes per article
|
||||||
|
*/
|
||||||
|
public function buildArticleVariables(array $config): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
if (!empty($config['topic'])) {
|
||||||
|
$lines[] = "TOPIC: {$config['topic']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['domain'])) {
|
||||||
|
$lines[] = "DOMAIN: {$config['domain']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['industry'])) {
|
||||||
|
$lines[] = "INDUSTRY: {$config['industry']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['target_audience'])) {
|
||||||
|
$lines[] = "AUDIENCE: {$config['target_audience']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['knowledge_level'])) {
|
||||||
|
$lines[] = "KNOWLEDGE_LEVEL: {$config['knowledge_level']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['tone'])) {
|
||||||
|
$lines[] = "TONE: {$config['tone']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['word_count'])) {
|
||||||
|
$lines[] = "WORD_COUNT: {$config['word_count']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['location'])) {
|
||||||
|
$lines[] = "LOCATION: {$config['location']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = "CURRENT_DATE: " . date('Y-m-d');
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate article using template + variables (legacy single-file method)
|
||||||
|
* Template (with business profile) is cached
|
||||||
|
* Variables (topic, word count, etc.) are dynamic
|
||||||
|
*/
|
||||||
|
public function generateArticle(string $templateFile, array $config): array
|
||||||
|
{
|
||||||
|
// Load template - this includes the master prompt + business profile
|
||||||
|
// This part gets cached
|
||||||
|
$template = $this->loadTemplate($templateFile);
|
||||||
|
|
||||||
|
// Build dynamic variables - this changes per article
|
||||||
|
$variables = $this->buildArticleVariables($config);
|
||||||
|
|
||||||
|
return $this->messageWithCache(
|
||||||
|
prompt: $config['prompt'] ?? 'BEGIN GENERATION NOW.',
|
||||||
|
cachedContext: $template,
|
||||||
|
dynamicContext: $variables,
|
||||||
|
options: [
|
||||||
|
'max_tokens' => $config['max_tokens'] ?? 8192,
|
||||||
|
'model' => $config['model'] ?? $this->defaultModel
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate article using 3-block structure with prompt caching
|
||||||
|
*
|
||||||
|
* Block 1: Business Profile (cached - client specific)
|
||||||
|
* Block 2: Article Instructions (cached - shared across all clients)
|
||||||
|
* Block 3: Config Variables (dynamic - not cached)
|
||||||
|
*
|
||||||
|
* Cache behavior:
|
||||||
|
* - Blocks 1 & 2 are cached together as a prefix
|
||||||
|
* - When processing multiple articles for same client, cache hits on blocks 1+2
|
||||||
|
* - Block 3 varies per article, never cached
|
||||||
|
*
|
||||||
|
* @param string $businessProfile Client-specific business profile content
|
||||||
|
* @param string $articleInstructions Shared writing instructions content
|
||||||
|
* @param array $config Article configuration (topic, word_count, etc.)
|
||||||
|
* - 'enable_web_search' => bool (default: false)
|
||||||
|
* - 'reference_urls' => array of URLs to research
|
||||||
|
* @return array Response with content, usage, and cache status
|
||||||
|
*/
|
||||||
|
// =============================================================================
|
||||||
|
// UPDATED Anthropic::generateArticleWithBlocks()
|
||||||
|
// =============================================================================
|
||||||
|
public function generateArticleWithBlocks(
|
||||||
|
string $businessProfile,
|
||||||
|
string $articleInstructions,
|
||||||
|
array $projectConfig,
|
||||||
|
string $referenceContent = '',
|
||||||
|
bool $useWebSearch = false
|
||||||
|
): array {
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// Build article variables (Block 3)
|
||||||
|
$articleVariables = $this->buildArticleVariables($projectConfig);
|
||||||
|
|
||||||
|
// DEEPCRAWL MODE: Inject pre-fetched content into Block 3
|
||||||
|
if (!empty($referenceContent)) {
|
||||||
|
$articleVariables .= "\n\n" . $referenceContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WEBSEARCH MODE: Add URL hints for Claude to search
|
||||||
|
if ($useWebSearch && !empty($projectConfig['reference_urls'])) {
|
||||||
|
$urlHints = "\n\n<search_guidance>\n";
|
||||||
|
$urlHints .= "Consider searching these sources for authoritative information:\n";
|
||||||
|
foreach ($projectConfig['reference_urls'] as $ref) {
|
||||||
|
$url = is_array($ref) ? $ref['url'] : $ref;
|
||||||
|
$notes = is_array($ref) ? ($ref['notes'] ?? '') : '';
|
||||||
|
$urlHints .= "- {$url}";
|
||||||
|
if ($notes) $urlHints .= " ({$notes})";
|
||||||
|
$urlHints .= "\n";
|
||||||
|
}
|
||||||
|
$urlHints .= "</search_guidance>";
|
||||||
|
$articleVariables .= $urlHints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// BUILD THE FULL PROMPT (for logging)
|
||||||
|
// =========================================================================
|
||||||
|
$block1 = "<business_profile>\n{$businessProfile}\n</business_profile>";
|
||||||
|
$block2 = "<article_instructions>\n{$articleInstructions}\n</article_instructions>";
|
||||||
|
$block3 = "<article_request>\n{$articleVariables}\n</article_request>";
|
||||||
|
|
||||||
|
$fullPrompt = $block1 . "\n\n" . $block2 . "\n\n" . $block3;
|
||||||
|
|
||||||
|
// Build messages
|
||||||
|
$messages = [
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => [
|
||||||
|
// Block 1: Business Profile (cached)
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $block1,
|
||||||
|
'cache_control' => ['type' => 'ephemeral']
|
||||||
|
],
|
||||||
|
// Block 2: Article Instructions (cached)
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $block2,
|
||||||
|
'cache_control' => ['type' => 'ephemeral']
|
||||||
|
],
|
||||||
|
// Block 3: Article Variables + References (dynamic)
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $block3
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Call API
|
||||||
|
if ($useWebSearch) {
|
||||||
|
$response = $this->client->messages->create(
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
maxTokens: 8192,
|
||||||
|
messages: $messages,
|
||||||
|
tools: [
|
||||||
|
[
|
||||||
|
'type' => 'web_search_20250305',
|
||||||
|
'name' => 'web_search',
|
||||||
|
]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$response = $this->client->messages->create(
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
maxTokens: 8192,
|
||||||
|
messages: $messages
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
|
||||||
|
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->content[0]->text,
|
||||||
|
'model' => $response->model,
|
||||||
|
'stop_reason' => $response->stopReason,
|
||||||
|
'latency_ms' => round((microtime(true) - $startTime) * 1000),
|
||||||
|
'usage' => [
|
||||||
|
'input_tokens' => $response->usage->inputTokens,
|
||||||
|
'output_tokens' => $response->usage->outputTokens,
|
||||||
|
'cache_creation_tokens' => $cacheCreation,
|
||||||
|
'cache_read_tokens' => $cacheRead,
|
||||||
|
],
|
||||||
|
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead),
|
||||||
|
'prompt_sent' => $fullPrompt, // NEW: Full prompt for logging
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build instruction for reference URLs
|
||||||
|
*/
|
||||||
|
private function buildReferenceUrlsInstruction(array $urls): string
|
||||||
|
{
|
||||||
|
$instruction = "REFERENCE URLS FOR RESEARCH:\n";
|
||||||
|
$instruction .= "Search and reference the following URLs for accurate, current information:\n\n";
|
||||||
|
|
||||||
|
foreach ($urls as $index => $url) {
|
||||||
|
$num = $index + 1;
|
||||||
|
if (is_array($url)) {
|
||||||
|
// URL with description: ['url' => '...', 'description' => '...']
|
||||||
|
$instruction .= "{$num}. {$url['url']}\n";
|
||||||
|
if (!empty($url['description'])) {
|
||||||
|
$instruction .= " Purpose: {$url['description']}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple URL string
|
||||||
|
$instruction .= "{$num}. {$url}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$instruction .= "\nUse web search to fetch current information from these sources. ";
|
||||||
|
$instruction .= "Cite specific facts, statistics, or requirements found on these pages. ";
|
||||||
|
$instruction .= "You may also search for additional supporting information as needed.";
|
||||||
|
|
||||||
|
return $instruction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from response blocks (handles mixed content with tool use)
|
||||||
|
*/
|
||||||
|
private function extractTextContent(array $contentBlocks): string
|
||||||
|
{
|
||||||
|
$textParts = [];
|
||||||
|
|
||||||
|
foreach ($contentBlocks as $block) {
|
||||||
|
if (isset($block->type) && $block->type === 'text') {
|
||||||
|
$textParts[] = $block->text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $textParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate article using multi-turn conversation style
|
||||||
|
*
|
||||||
|
* This approach chunks the prompt into conversational turns:
|
||||||
|
* - Turn 1: User sends business profile
|
||||||
|
* - Turn 2: Assistant acknowledges persona
|
||||||
|
* - Turn 3: User sends article instructions
|
||||||
|
* - Turn 4: Assistant confirms understanding
|
||||||
|
* - Turn 5: User sends config and triggers generation
|
||||||
|
*
|
||||||
|
* Benefits: May improve instruction following for complex prompts
|
||||||
|
* Tradeoff: Extra tokens for assistant acknowledgments
|
||||||
|
*
|
||||||
|
* @param string $businessProfile Client-specific business profile content
|
||||||
|
* @param string $articleInstructions Shared writing instructions content
|
||||||
|
* @param array $config Article configuration (topic, word_count, etc.)
|
||||||
|
* @return array Response with content, usage, and cache status
|
||||||
|
*/
|
||||||
|
public function generateArticleMultiTurn(
|
||||||
|
string $businessProfile,
|
||||||
|
string $articleInstructions,
|
||||||
|
array $config
|
||||||
|
): array {
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// Build dynamic config variables
|
||||||
|
$configVariables = $this->buildArticleVariables($config);
|
||||||
|
|
||||||
|
$response = $this->client->messages->create(
|
||||||
|
model: $config['model'] ?? $this->defaultModel,
|
||||||
|
maxTokens: $config['max_tokens'] ?? 8192,
|
||||||
|
messages: [
|
||||||
|
// Turn 1: Business Profile
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => "Here is the business profile you will write as:\n\n" . $businessProfile,
|
||||||
|
'cache_control' => ['type' => 'ephemeral'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Turn 2: Assistant acknowledges persona
|
||||||
|
[
|
||||||
|
'role' => 'assistant',
|
||||||
|
'content' => [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => "I understand. I am now embodying this business identity and will write from their perspective, using their voice, expertise, and experience as defined in the profile.",
|
||||||
|
'cache_control' => ['type' => 'ephemeral'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Turn 3: Article Instructions
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => "Here are the article writing instructions:\n\n" . $articleInstructions,
|
||||||
|
'cache_control' => ['type' => 'ephemeral'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Turn 4: Assistant confirms understanding
|
||||||
|
[
|
||||||
|
'role' => 'assistant',
|
||||||
|
'content' => [
|
||||||
|
[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => "Ready. I will follow these writing rules: early answer with recommendation, 'This breaks down when...' statement, field notes from practitioner experience, no blacklisted phrases, boundary conditions section, and output valid JSON only.",
|
||||||
|
'cache_control' => ['type' => 'ephemeral'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Turn 5: Config Variables + Generate
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $configVariables . "\n\nBEGIN GENERATION.",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
|
||||||
|
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->content[0]->text,
|
||||||
|
'model' => $response->model,
|
||||||
|
'stop_reason' => $response->stopReason,
|
||||||
|
'latency_ms' => round((microtime(true) - $startTime) * 1000),
|
||||||
|
'usage' => [
|
||||||
|
'input_tokens' => $response->usage->inputTokens,
|
||||||
|
'output_tokens' => $response->usage->outputTokens,
|
||||||
|
'cache_creation_tokens' => $cacheCreation,
|
||||||
|
'cache_read_tokens' => $cacheRead,
|
||||||
|
],
|
||||||
|
'cache_hit' => $cacheRead > 0,
|
||||||
|
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate multiple articles (same template = cache hits after first)
|
||||||
|
*/
|
||||||
|
public function generateArticleBatch(string $templateFile, array $articles): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($articles as $index => $config) {
|
||||||
|
$results[] = [
|
||||||
|
'index' => $index,
|
||||||
|
'topic' => $config['topic'] ?? 'Unknown',
|
||||||
|
'result' => $this->generateArticle($templateFile, $config)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// UTILITY METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private function buildMessages(string $prompt, array $conversation = []): array
|
||||||
|
{
|
||||||
|
$messages = [];
|
||||||
|
|
||||||
|
foreach ($conversation as $msg) {
|
||||||
|
$messages[] = MessageParam::with(role: $msg['role'], content: $msg['content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages[] = MessageParam::with(role: 'user', content: $prompt);
|
||||||
|
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estimateTokens(string $text): int
|
||||||
|
{
|
||||||
|
return (int) ceil(strlen($text) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if content meets minimum cache threshold (1024 tokens for Sonnet)
|
||||||
|
*/
|
||||||
|
public function meetsCacheMinimum(string $text): bool
|
||||||
|
{
|
||||||
|
return $this->estimateTokens($text) >= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estimateCost(int $inputTokens, int $outputTokens, ?string $model = null): array
|
||||||
|
{
|
||||||
|
$model = $model ?? $this->defaultModel;
|
||||||
|
|
||||||
|
$pricing = [
|
||||||
|
'claude-opus-4-20250514' => ['input' => 15.00, 'output' => 75.00],
|
||||||
|
'claude-sonnet-4-5-20250929' => ['input' => 3.00, 'output' => 15.00],
|
||||||
|
'claude-haiku-4-5-20251001' => ['input' => 0.80, 'output' => 4.00],
|
||||||
|
];
|
||||||
|
|
||||||
|
$rates = $pricing[$model] ?? $pricing['claude-sonnet-4-5-20250929'];
|
||||||
|
|
||||||
|
$inputCost = ($inputTokens / 1_000_000) * $rates['input'];
|
||||||
|
$outputCost = ($outputTokens / 1_000_000) * $rates['output'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'input_cost' => round($inputCost, 6),
|
||||||
|
'output_cost' => round($outputCost, 6),
|
||||||
|
'total_cost' => round($inputCost + $outputCost, 6),
|
||||||
|
'model' => $model
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatCost(array $usage): string {
|
||||||
|
$cost = $this->estimateCostWithCache($usage);
|
||||||
|
|
||||||
|
$output = "Cost Breakdown:\n";
|
||||||
|
$output .= " Input: $" . number_format($cost['input_cost'], 4) . "\n";
|
||||||
|
$output .= " Output: $" . number_format($cost['output_cost'], 4) . "\n";
|
||||||
|
|
||||||
|
if ($cost['cache_write_cost'] > 0) {
|
||||||
|
$output .= " Cache Write: $" . number_format($cost['cache_write_cost'], 4) . "\n";
|
||||||
|
}
|
||||||
|
if ($cost['cache_read_cost'] > 0) {
|
||||||
|
$output .= " Cache Read: $" . number_format($cost['cache_read_cost'], 4) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$output .= " ─────────────\n";
|
||||||
|
$output .= " Total: $" . number_format($cost['total_cost'], 4) . "\n";
|
||||||
|
|
||||||
|
if ($cost['savings'] > 0) {
|
||||||
|
$output .= " Saved: $" . number_format($cost['savings'], 4) . " ({$cost['savings_percent']}%)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estimateCostWithCache(array $usage, ?string $model = null): array
|
||||||
|
{
|
||||||
|
$model = $model ?? $this->defaultModel;
|
||||||
|
|
||||||
|
$pricing = [
|
||||||
|
'claude-opus-4-20250514' => ['input' => 15.00, 'output' => 75.00, 'cache_write' => 18.75, 'cache_read' => 1.50],
|
||||||
|
'claude-sonnet-4-5-20250929' => ['input' => 3.00, 'output' => 15.00, 'cache_write' => 3.75, 'cache_read' => 0.30],
|
||||||
|
'claude-haiku-4-5-20251001' => ['input' => 0.80, 'output' => 4.00, 'cache_write' => 1.00, 'cache_read' => 0.08],
|
||||||
|
];
|
||||||
|
|
||||||
|
$rates = $pricing[$model] ?? $pricing['claude-sonnet-4-5-20250929'];
|
||||||
|
|
||||||
|
$inputCost = (($usage['input_tokens'] ?? 0) / 1_000_000) * $rates['input'];
|
||||||
|
$outputCost = (($usage['output_tokens'] ?? 0) / 1_000_000) * $rates['output'];
|
||||||
|
$cacheWriteCost = (($usage['cache_creation_tokens'] ?? 0) / 1_000_000) * $rates['cache_write'];
|
||||||
|
$cacheReadCost = (($usage['cache_read_tokens'] ?? 0) / 1_000_000) * $rates['cache_read'];
|
||||||
|
|
||||||
|
$totalCost = $inputCost + $outputCost + $cacheWriteCost + $cacheReadCost;
|
||||||
|
|
||||||
|
$cacheTokens = ($usage['cache_creation_tokens'] ?? 0) + ($usage['cache_read_tokens'] ?? 0);
|
||||||
|
$costWithoutCache = (($usage['input_tokens'] + $cacheTokens) / 1_000_000) * $rates['input'] + $outputCost;
|
||||||
|
$savings = $costWithoutCache - $totalCost;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'input_cost' => round($inputCost, 6),
|
||||||
|
'output_cost' => round($outputCost, 6),
|
||||||
|
'cache_write_cost' => round($cacheWriteCost, 6),
|
||||||
|
'cache_read_cost' => round($cacheReadCost, 6),
|
||||||
|
'total_cost' => round($totalCost, 6),
|
||||||
|
'cost_without_cache' => round($costWithoutCache, 6),
|
||||||
|
'savings' => round($savings, 6),
|
||||||
|
'savings_percent' => $costWithoutCache > 0 ? round(($savings / $costWithoutCache) * 100, 2) : 0,
|
||||||
|
'model' => $model
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function setModel(string $model): self
|
||||||
|
{
|
||||||
|
$this->defaultModel = $model;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function useModel(string $preset): self
|
||||||
|
{
|
||||||
|
if (isset(self::MODELS[$preset])) {
|
||||||
|
$this->defaultModel = self::MODELS[$preset];
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMaxTokens(int $tokens): self
|
||||||
|
{
|
||||||
|
$this->defaultMaxTokens = $tokens;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModel(): string
|
||||||
|
{
|
||||||
|
return $this->defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClient(): Client
|
||||||
|
{
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvailableModels(): array
|
||||||
|
{
|
||||||
|
return self::MODELS;
|
||||||
|
}
|
||||||
|
}
|
||||||
749
app/LLM/GeminiService.php
Normal file
749
app/LLM/GeminiService.php
Normal file
|
|
@ -0,0 +1,749 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Gemini as GeminiClient;
|
||||||
|
use Gemini\Data\Content;
|
||||||
|
use Gemini\Data\GenerationConfig;
|
||||||
|
use Gemini\Data\Tool;
|
||||||
|
use Gemini\Data\GoogleSearch;
|
||||||
|
use Gemini\Enums\ResponseMimeType;
|
||||||
|
|
||||||
|
class GeminiService {
|
||||||
|
private $client;
|
||||||
|
private string $defaultModel = 'gemini-3-pro-preview';
|
||||||
|
private int $defaultMaxTokens = 8192;
|
||||||
|
private ?string $apiKey;
|
||||||
|
private string $templatePath;
|
||||||
|
|
||||||
|
// Model presets for different use cases
|
||||||
|
// - pro: Best for humanizing text, creative nuance, SEO strategy, and fact-checking
|
||||||
|
// - flash: Best for bulk processing, initial drafts, AEO structure checks
|
||||||
|
// - flash-lite: Cost saver for simple tasks like meta-tag generation or keyword extraction
|
||||||
|
public const MODELS = [
|
||||||
|
'pro' => 'gemini-3-pro-preview', // Best for humanizing, SEO strategy
|
||||||
|
'flash' => 'gemini-3-flash-preview', // Faster, good for SEO review pass
|
||||||
|
'flash-lite' => 'gemini-2.5-flash-lite', // Cost saver for simple tasks
|
||||||
|
'flash-2.5' => 'gemini-2.5-flash', // Legacy flash model
|
||||||
|
];
|
||||||
|
|
||||||
|
// Human-readable labels for UI dropdowns
|
||||||
|
public const MODEL_LABELS = [
|
||||||
|
'gemini-3-pro-preview' => 'Gemini 3 Pro (Best for Humanizing)',
|
||||||
|
'gemini-3-flash-preview' => 'Gemini 3 Flash (Fast & Smart)',
|
||||||
|
'gemini-2.5-flash' => 'Gemini 2.5 Flash (Legacy)',
|
||||||
|
'gemini-2.5-flash-lite' => 'Gemini 2.5 Flash Lite (Budget)',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
public function __construct(?string $apiKey = null, ?string $templatePath = null, int $timeout = 300)
|
||||||
|
{
|
||||||
|
$this->apiKey = $apiKey ?? (defined('GEMINI_API_KEY') ? GEMINI_API_KEY : null);
|
||||||
|
|
||||||
|
if (empty($this->apiKey)) {
|
||||||
|
throw new Exception('Gemini API key is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->templatePath = $templatePath ?? '/www/wwwroot/appCarlos/templates/prompts';
|
||||||
|
|
||||||
|
if (!is_dir($this->templatePath)) {
|
||||||
|
throw new Exception("Template directory not found: {$this->templatePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable($this->templatePath)) {
|
||||||
|
throw new Exception("Template directory is not readable: {$this->templatePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
set_time_limit($timeout);
|
||||||
|
ini_set('default_socket_timeout', (string) $timeout);
|
||||||
|
|
||||||
|
$this->client = GeminiClient::client($this->apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CONNECTION & HEALTH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client
|
||||||
|
->generativeModel(model: $this->defaultModel)
|
||||||
|
->generateContent('Reply with only: OK');
|
||||||
|
|
||||||
|
$latency = round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
// Extract usage from response
|
||||||
|
$usage = $this->extractUsage($response);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Connected to Gemini API',
|
||||||
|
'model' => $this->defaultModel,
|
||||||
|
'response' => trim($response->text()),
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'usage' => $usage,
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Connection failed',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'error_type' => get_class($e),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConnected(): bool
|
||||||
|
{
|
||||||
|
return $this->testConnection()['success'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): array
|
||||||
|
{
|
||||||
|
$connection = $this->testConnection();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'connected' => $connection['success'],
|
||||||
|
'model' => $this->defaultModel,
|
||||||
|
'max_tokens' => $this->defaultMaxTokens,
|
||||||
|
'api_key_preview' => substr($this->apiKey, 0, 10) . '...' . substr($this->apiKey, -4),
|
||||||
|
'template_path' => $this->templatePath,
|
||||||
|
'connection_details' => $connection,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// TEMPLATE LOADING
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function loadTemplate(string $filename): string
|
||||||
|
{
|
||||||
|
$path = $this->templatePath . '/' . $filename;
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
throw new Exception("Template not found: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
throw new Exception("Failed to read template: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTemplatePath(string $path): self
|
||||||
|
{
|
||||||
|
$this->templatePath = rtrim($path, '/');
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemplatePath(): string
|
||||||
|
{
|
||||||
|
return $this->templatePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// BASIC MESSAGING
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function message(string $prompt, ?string $systemPrompt = null, array $options = []): string
|
||||||
|
{
|
||||||
|
$response = $this->messageWithMeta($prompt, $systemPrompt, $options);
|
||||||
|
return $response['content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEMINI SERVICE UPDATE
|
||||||
|
*
|
||||||
|
* Replace the existing messageWithMeta() method with this updated version
|
||||||
|
* that supports JSON response mode via 'response_mime_type' option.
|
||||||
|
*
|
||||||
|
* Location: App\Components\GeminiService
|
||||||
|
* Method: messageWithMeta()
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function messageWithMeta(string $prompt, ?string $systemPrompt = null, array $options = []): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$model = $this->client->generativeModel(
|
||||||
|
model: $options['model'] ?? $this->defaultModel
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add system instruction if provided
|
||||||
|
if ($systemPrompt) {
|
||||||
|
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build generation config
|
||||||
|
$configParams = [
|
||||||
|
'maxOutputTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($options['response_mime_type']) && $options['response_mime_type'] === 'application/json') {
|
||||||
|
$configParams['responseMimeType'] = ResponseMimeType::APPLICATION_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['temperature'])) {
|
||||||
|
$configParams['temperature'] = $options['temperature'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['stop_sequences'])) {
|
||||||
|
$configParams['stopSequences'] = $options['stop_sequences'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support JSON response mode
|
||||||
|
if (isset($options['response_mime_type']) && $options['response_mime_type'] === 'application/json') {
|
||||||
|
$configParams['responseMimeType'] = ResponseMimeType::APPLICATION_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = $model->withGenerationConfig(new GenerationConfig(...$configParams));
|
||||||
|
|
||||||
|
// Enable Google Search if requested
|
||||||
|
if ($options['use_search'] ?? false) {
|
||||||
|
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $model->generateContent($prompt);
|
||||||
|
|
||||||
|
$usage = $this->extractUsage($response);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->text(),
|
||||||
|
'model' => $options['model'] ?? $this->defaultModel,
|
||||||
|
'stop_reason' => $response->candidates[0]->finishReason ?? null,
|
||||||
|
'latency_ms' => round((microtime(true) - $startTime) * 1000),
|
||||||
|
'usage' => $usage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ARTICLE GENERATION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the dynamic variables block (topic, word count, etc.)
|
||||||
|
*/
|
||||||
|
public function buildArticleVariables(array $config): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
if (!empty($config['topic'])) {
|
||||||
|
$lines[] = "TOPIC: {$config['topic']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['domain'])) {
|
||||||
|
$lines[] = "DOMAIN: {$config['domain']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['industry'])) {
|
||||||
|
$lines[] = "INDUSTRY: {$config['industry']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['target_audience'])) {
|
||||||
|
$lines[] = "AUDIENCE: {$config['target_audience']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['knowledge_level'])) {
|
||||||
|
$lines[] = "KNOWLEDGE_LEVEL: {$config['knowledge_level']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['tone'])) {
|
||||||
|
$lines[] = "TONE: {$config['tone']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['word_count'])) {
|
||||||
|
$lines[] = "WORD_COUNT: {$config['word_count']}";
|
||||||
|
}
|
||||||
|
if (!empty($config['location'])) {
|
||||||
|
$lines[] = "LOCATION: {$config['location']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate article using template + variables
|
||||||
|
* Uses Google Search for fact verification
|
||||||
|
*/
|
||||||
|
public function generateArticle(string $templateFile, array $config): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// Load template - this includes the master prompt + business profile
|
||||||
|
$template = $this->loadTemplate($templateFile);
|
||||||
|
|
||||||
|
// Build dynamic variables
|
||||||
|
$variables = $this->buildArticleVariables($config);
|
||||||
|
|
||||||
|
// Combine template with variables
|
||||||
|
$systemPrompt = $template . "\n\n" . $variables;
|
||||||
|
|
||||||
|
// Build the model with configuration
|
||||||
|
$model = $this->client->generativeModel(
|
||||||
|
model: $config['model'] ?? $this->defaultModel
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add system instruction (template + variables)
|
||||||
|
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
|
||||||
|
|
||||||
|
// Add generation config
|
||||||
|
$model = $model->withGenerationConfig(new GenerationConfig(
|
||||||
|
maxOutputTokens: $config['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Enable Google Search for fact verification
|
||||||
|
if ($config['use_search'] ?? true) {
|
||||||
|
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate content
|
||||||
|
$userPrompt = $config['prompt'] ?? 'BEGIN GENERATION NOW.';
|
||||||
|
$response = $model->generateContent($userPrompt);
|
||||||
|
|
||||||
|
$usage = $this->extractUsage($response);
|
||||||
|
$latency = round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
// Check if grounding was used
|
||||||
|
$groundingUsed = $this->checkGroundingUsed($response);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->text(),
|
||||||
|
'model' => $config['model'] ?? $this->defaultModel,
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'grounding_used' => $groundingUsed,
|
||||||
|
'usage' => $usage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate article with structured JSON output
|
||||||
|
*/
|
||||||
|
public function generateArticleStructured(string $templateFile, array $config): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// Load template
|
||||||
|
$template = $this->loadTemplate($templateFile);
|
||||||
|
|
||||||
|
// Build dynamic variables
|
||||||
|
$variables = $this->buildArticleVariables($config);
|
||||||
|
|
||||||
|
// Define JSON structure for article
|
||||||
|
$jsonSchema = $config['json_schema'] ?? [
|
||||||
|
'title' => 'Article title',
|
||||||
|
'meta_description' => 'SEO meta description',
|
||||||
|
'content' => 'Full article content in HTML format',
|
||||||
|
'sections' => [
|
||||||
|
['heading' => 'Section heading', 'content' => 'Section content']
|
||||||
|
],
|
||||||
|
'tags' => ['relevant', 'tags'],
|
||||||
|
'sources' => [
|
||||||
|
['title' => 'Source title', 'url' => 'Source URL']
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
// Combine template with variables and JSON instruction
|
||||||
|
$systemPrompt = $template . "\n\n" . $variables . "\n\n" .
|
||||||
|
"IMPORTANT: Respond ONLY with valid JSON using this structure:\n" . $schemaJson;
|
||||||
|
|
||||||
|
// Build the model
|
||||||
|
$model = $this->client->generativeModel(
|
||||||
|
model: $config['model'] ?? $this->defaultModel
|
||||||
|
);
|
||||||
|
|
||||||
|
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
|
||||||
|
|
||||||
|
// Enable JSON output
|
||||||
|
$model = $model->withGenerationConfig(new GenerationConfig(
|
||||||
|
maxOutputTokens: $config['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
responseMimeType: ResponseMimeType::APPLICATION_JSON,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Enable Google Search
|
||||||
|
if ($config['use_search'] ?? true) {
|
||||||
|
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
|
||||||
|
}
|
||||||
|
|
||||||
|
$userPrompt = $config['prompt'] ?? 'BEGIN GENERATION NOW.';
|
||||||
|
$response = $model->generateContent($userPrompt);
|
||||||
|
|
||||||
|
$usage = $this->extractUsage($response);
|
||||||
|
$latency = round((microtime(true) - $startTime) * 1000);
|
||||||
|
$groundingUsed = $this->checkGroundingUsed($response);
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
$articleData = null;
|
||||||
|
try {
|
||||||
|
$articleData = $response->json();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Fall back to text if JSON parsing fails
|
||||||
|
$articleData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->text(),
|
||||||
|
'article' => $articleData,
|
||||||
|
'model' => $config['model'] ?? $this->defaultModel,
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'grounding_used' => $groundingUsed,
|
||||||
|
'usage' => $usage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate multiple articles
|
||||||
|
*/
|
||||||
|
public function generateArticleBatch(string $templateFile, array $articles): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($articles as $index => $config) {
|
||||||
|
$results[] = [
|
||||||
|
'index' => $index,
|
||||||
|
'topic' => $config['topic'] ?? 'Unknown',
|
||||||
|
'result' => $this->generateArticle($templateFile, $config),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ARTICLE REVIEW
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review an article for quality and factual accuracy
|
||||||
|
*/
|
||||||
|
public function reviewArticle(string $articleContent, array $options = []): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$criteria = $options['criteria'] ?? [
|
||||||
|
'grammar_spelling',
|
||||||
|
'factual_accuracy',
|
||||||
|
'readability',
|
||||||
|
'seo_optimization',
|
||||||
|
'engagement',
|
||||||
|
'structure',
|
||||||
|
'tone_consistency',
|
||||||
|
];
|
||||||
|
|
||||||
|
$criteriaList = implode(', ', $criteria);
|
||||||
|
|
||||||
|
$systemPrompt = <<<PROMPT
|
||||||
|
You are an expert editor and content quality analyst. Your task is to thoroughly review articles
|
||||||
|
and provide detailed, actionable feedback. Use Google Search to verify any factual claims made
|
||||||
|
in the article. Be thorough but constructive.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$jsonSchema = [
|
||||||
|
'overall_score' => 85,
|
||||||
|
'summary' => 'Brief overall assessment',
|
||||||
|
'criteria_scores' => [
|
||||||
|
'example_criterion' => [
|
||||||
|
'score' => 90,
|
||||||
|
'feedback' => 'Detailed feedback',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'strengths' => ['List of article strengths'],
|
||||||
|
'improvements' => [
|
||||||
|
[
|
||||||
|
'priority' => 'high|medium|low',
|
||||||
|
'issue' => 'Issue description',
|
||||||
|
'suggestion' => 'How to fix it',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'fact_check' => [
|
||||||
|
[
|
||||||
|
'claim' => 'Claim from the article',
|
||||||
|
'verified' => true,
|
||||||
|
'source' => 'Source or note',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
$userPrompt = <<<PROMPT
|
||||||
|
Review the following article and provide a comprehensive quality assessment.
|
||||||
|
|
||||||
|
ARTICLE TO REVIEW:
|
||||||
|
{$articleContent}
|
||||||
|
|
||||||
|
Evaluate on these criteria: {$criteriaList}
|
||||||
|
|
||||||
|
Use Google Search to verify factual claims.
|
||||||
|
|
||||||
|
Respond with JSON in this structure:
|
||||||
|
{$schemaJson}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$model = $this->client->generativeModel(
|
||||||
|
model: $options['model'] ?? $this->defaultModel
|
||||||
|
);
|
||||||
|
|
||||||
|
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
|
||||||
|
|
||||||
|
$model = $model->withGenerationConfig(new GenerationConfig(
|
||||||
|
maxOutputTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
responseMimeType: ResponseMimeType::APPLICATION_JSON,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Enable Google Search for fact-checking
|
||||||
|
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
|
||||||
|
|
||||||
|
$response = $model->generateContent($userPrompt);
|
||||||
|
|
||||||
|
$usage = $this->extractUsage($response);
|
||||||
|
$latency = round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
$reviewData = null;
|
||||||
|
try {
|
||||||
|
$reviewData = $response->json();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$reviewData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->text(),
|
||||||
|
'review' => $reviewData,
|
||||||
|
'model' => $options['model'] ?? $this->defaultModel,
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'usage' => $usage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// RESEARCH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Research a topic using Google Search
|
||||||
|
*/
|
||||||
|
public function research(string $query, array $options = []): array
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$depth = $options['depth'] ?? 2;
|
||||||
|
|
||||||
|
$depthInstructions = match ($depth) {
|
||||||
|
1 => 'Provide a quick overview with 3-5 key points.',
|
||||||
|
2 => 'Provide a comprehensive overview with detailed findings.',
|
||||||
|
3 => 'Provide an exhaustive analysis covering all aspects.',
|
||||||
|
default => 'Provide a comprehensive overview.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$systemPrompt = <<<PROMPT
|
||||||
|
You are a research assistant. Research topics thoroughly using Google Search.
|
||||||
|
Verify information across multiple sources. Be accurate and cite sources.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$jsonSchema = [
|
||||||
|
'topic' => 'The research topic',
|
||||||
|
'summary' => 'Executive summary',
|
||||||
|
'key_findings' => [
|
||||||
|
['finding' => 'Key finding', 'confidence' => 'high|medium|low'],
|
||||||
|
],
|
||||||
|
'statistics' => [
|
||||||
|
['stat' => 'Statistic', 'source' => 'Source'],
|
||||||
|
],
|
||||||
|
'sources' => [
|
||||||
|
['title' => 'Source title', 'url' => 'URL', 'credibility' => 'high|medium|low'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
$userPrompt = <<<PROMPT
|
||||||
|
Research: {$query}
|
||||||
|
|
||||||
|
{$depthInstructions}
|
||||||
|
|
||||||
|
Use Google Search to find current and accurate information.
|
||||||
|
|
||||||
|
Respond with JSON:
|
||||||
|
{$schemaJson}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$model = $this->client->generativeModel(
|
||||||
|
model: $options['model'] ?? $this->defaultModel
|
||||||
|
);
|
||||||
|
|
||||||
|
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
|
||||||
|
|
||||||
|
$model = $model->withGenerationConfig(new GenerationConfig(
|
||||||
|
maxOutputTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
|
||||||
|
responseMimeType: ResponseMimeType::APPLICATION_JSON,
|
||||||
|
));
|
||||||
|
|
||||||
|
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
|
||||||
|
|
||||||
|
$response = $model->generateContent($userPrompt);
|
||||||
|
|
||||||
|
$usage = $this->extractUsage($response);
|
||||||
|
$latency = round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
$researchData = null;
|
||||||
|
try {
|
||||||
|
$researchData = $response->json();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$researchData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => $response->text(),
|
||||||
|
'research' => $researchData,
|
||||||
|
'model' => $options['model'] ?? $this->defaultModel,
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'usage' => $usage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// UTILITY METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract usage information from response
|
||||||
|
*/
|
||||||
|
private function extractUsage($response): array
|
||||||
|
{
|
||||||
|
$usage = [
|
||||||
|
'input_tokens' => 0,
|
||||||
|
'output_tokens' => 0,
|
||||||
|
'total_tokens' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try to get usage metadata from response
|
||||||
|
if (isset($response->usageMetadata)) {
|
||||||
|
$usage['input_tokens'] = $response->usageMetadata->promptTokenCount ?? 0;
|
||||||
|
$usage['output_tokens'] = $response->usageMetadata->candidatesTokenCount ?? 0;
|
||||||
|
$usage['total_tokens'] = $response->usageMetadata->totalTokenCount ??
|
||||||
|
($usage['input_tokens'] + $usage['output_tokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if grounding/search was used in the response
|
||||||
|
*/
|
||||||
|
private function checkGroundingUsed($response): bool
|
||||||
|
{
|
||||||
|
// Check for grounding metadata in response
|
||||||
|
if (isset($response->candidates[0]->groundingMetadata)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estimateTokens(string $text): int
|
||||||
|
{
|
||||||
|
return (int) ceil(strlen($text) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estimateCost(array $usage, ?string $model = null): array
|
||||||
|
{
|
||||||
|
$model = $model ?? $this->defaultModel;
|
||||||
|
|
||||||
|
// Gemini pricing per 1M tokens (as of January 2026)
|
||||||
|
$pricing = [
|
||||||
|
'gemini-3-pro-preview' => ['input' => 2.00, 'output' => 12.00],
|
||||||
|
'gemini-3-flash-preview' => ['input' => 0.20, 'output' => 0.80],
|
||||||
|
'gemini-2.5-flash' => ['input' => 0.15, 'output' => 0.60],
|
||||||
|
'gemini-2.5-flash-lite' => ['input' => 0.075, 'output' => 0.30],
|
||||||
|
];
|
||||||
|
|
||||||
|
$rates = $pricing[$model] ?? $pricing['gemini-3-pro-preview'];
|
||||||
|
|
||||||
|
$inputCost = (($usage['input_tokens'] ?? 0) / 1_000_000) * $rates['input'];
|
||||||
|
$outputCost = (($usage['output_tokens'] ?? 0) / 1_000_000) * $rates['output'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'input_cost' => round($inputCost, 6),
|
||||||
|
'output_cost' => round($outputCost, 6),
|
||||||
|
'total_cost' => round($inputCost + $outputCost, 6),
|
||||||
|
'model' => $model,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatCost(array $usage): string
|
||||||
|
{
|
||||||
|
$cost = $this->estimateCost($usage);
|
||||||
|
|
||||||
|
$output = "Cost Breakdown:\n";
|
||||||
|
$output .= " Input: $" . number_format($cost['input_cost'], 4) . "\n";
|
||||||
|
$output .= " Output: $" . number_format($cost['output_cost'], 4) . "\n";
|
||||||
|
$output .= " ─────────────\n";
|
||||||
|
$output .= " Total: $" . number_format($cost['total_cost'], 4) . "\n";
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CONFIGURATION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function setModel(string $model): self
|
||||||
|
{
|
||||||
|
$this->defaultModel = $model;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function useModel(string $preset): self
|
||||||
|
{
|
||||||
|
if (isset(self::MODELS[$preset])) {
|
||||||
|
$this->defaultModel = self::MODELS[$preset];
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMaxTokens(int $tokens): self
|
||||||
|
{
|
||||||
|
$this->defaultMaxTokens = $tokens;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModel(): string
|
||||||
|
{
|
||||||
|
return $this->defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClient()
|
||||||
|
{
|
||||||
|
return $this->client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvailableModels(): array
|
||||||
|
{
|
||||||
|
return self::MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model labels for UI dropdowns
|
||||||
|
*/
|
||||||
|
public static function getModelLabels(): array
|
||||||
|
{
|
||||||
|
return self::MODEL_LABELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model ID from preset name
|
||||||
|
*/
|
||||||
|
public static function getModelId(string $preset): string
|
||||||
|
{
|
||||||
|
return self::MODELS[$preset] ?? $preset;
|
||||||
|
}
|
||||||
|
}
|
||||||
254
app/LLM/LLMManager.php
Normal file
254
app/LLM/LLMManager.php
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads LLM provider config and returns a configured, ready-to-use provider instance.
|
||||||
|
*
|
||||||
|
* Two scopes:
|
||||||
|
* Application-level — stored in sp_settings (group='llm'), admin-managed, shared across all orgs.
|
||||||
|
* Org-level — stored in sp_orgs_meta, per-org overrides (optional future use).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $llm = LLMManager::forApp('anthropic'); // application-level (sp_settings)
|
||||||
|
* $llm = LLMManager::forOrg($orgId, 'openai'); // org-level (sp_orgs_meta)
|
||||||
|
*/
|
||||||
|
class LLMManager
|
||||||
|
{
|
||||||
|
// Maps provider key to provider class
|
||||||
|
private static array $providers = [
|
||||||
|
'openai' => svcOpenAI::class,
|
||||||
|
'anthropic' => svcAnthropic::class,
|
||||||
|
'gemini' => svcGemini::class,
|
||||||
|
'mistral' => svcMistral::class,
|
||||||
|
'deepseek' => svcDeepSeek::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maps provider key to sp_settings.keyval (application-level)
|
||||||
|
private static array $settingsKeys = [
|
||||||
|
'openai' => 'llm_openai',
|
||||||
|
'anthropic' => 'llm_anthropic',
|
||||||
|
'gemini' => 'llm_gemini',
|
||||||
|
'mistral' => 'llm_mistral',
|
||||||
|
'deepseek' => 'llm_deepseek',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maps provider key to sp_orgs_meta.keyval (org-level overrides)
|
||||||
|
private static array $metaKeys = [
|
||||||
|
'openai' => 'llmOpenAI',
|
||||||
|
'anthropic' => 'llmAnthropic',
|
||||||
|
'gemini' => 'llmGemini',
|
||||||
|
'mistral' => 'llmMistral',
|
||||||
|
'deepseek' => 'llmDeepSeek',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Provider preference order for auto-selection
|
||||||
|
public static array $priority = ['anthropic', 'openai', 'gemini', 'mistral', 'deepseek'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a provider directly from a pre-loaded config array.
|
||||||
|
* Use this when you already have the config and don't need another DB lookup.
|
||||||
|
*
|
||||||
|
* @throws \Exception if provider unknown
|
||||||
|
*/
|
||||||
|
public static function make(string $provider, array $config): LLMProvider
|
||||||
|
{
|
||||||
|
$provider = strtolower($provider);
|
||||||
|
if (!isset(self::$providers[$provider])) {
|
||||||
|
throw new \Exception("Unknown LLM provider: '$provider'");
|
||||||
|
}
|
||||||
|
$class = self::$providers[$provider];
|
||||||
|
return new $class($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Application-level (sp_settings) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a configured provider instance from the application-level sp_settings table.
|
||||||
|
*
|
||||||
|
* @throws \Exception if provider unknown or not configured
|
||||||
|
*/
|
||||||
|
public static function forApp(string $provider): LLMProvider
|
||||||
|
{
|
||||||
|
$provider = strtolower($provider);
|
||||||
|
|
||||||
|
if (!isset(self::$providers[$provider])) {
|
||||||
|
throw new \Exception("Unknown LLM provider: '$provider'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = self::getAppConfig($provider);
|
||||||
|
|
||||||
|
if (empty($config)) {
|
||||||
|
throw new \Exception("No '$provider' configuration found. Add one under Admin → Integrations.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = self::$providers[$provider];
|
||||||
|
return new $class($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and normalize a provider's config from sp_settings.
|
||||||
|
* Returns [] if not found or api_key is empty.
|
||||||
|
*/
|
||||||
|
public static function getAppConfig(string $provider): array
|
||||||
|
{
|
||||||
|
$provider = strtolower($provider);
|
||||||
|
$settingsKey = self::$settingsKeys[$provider] ?? null;
|
||||||
|
if (!$settingsKey) return [];
|
||||||
|
|
||||||
|
$row = \Db::getRow(
|
||||||
|
"SELECT metval FROM sp_settings WHERE `group` = 'llm' AND keyval = ? LIMIT 1",
|
||||||
|
[$settingsKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$row || empty($row['metval'])) return [];
|
||||||
|
|
||||||
|
$raw = json_decode($row['metval'], true);
|
||||||
|
if (!is_array($raw) || empty($raw['api_key'])) return [];
|
||||||
|
|
||||||
|
// Normalize sp_settings field names to LLMProvider expected names
|
||||||
|
return array_filter([
|
||||||
|
'secretKey' => $raw['api_key'],
|
||||||
|
'model' => $raw['model'] ?? null,
|
||||||
|
'max_tokens' => $raw['max_tokens'] ?? null,
|
||||||
|
'temperature' => $raw['temperature'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the list of application-level providers that have a non-empty api_key.
|
||||||
|
*
|
||||||
|
* @return array e.g. ['anthropic', 'openai']
|
||||||
|
*/
|
||||||
|
public static function getAppProviders(): array
|
||||||
|
{
|
||||||
|
$available = [];
|
||||||
|
|
||||||
|
foreach (self::$settingsKeys as $provider => $keyval) {
|
||||||
|
$row = \Db::getRow(
|
||||||
|
"SELECT metval FROM sp_settings WHERE `group` = 'llm' AND keyval = ? LIMIT 1",
|
||||||
|
[$keyval]
|
||||||
|
);
|
||||||
|
if (!$row || empty($row['metval'])) continue;
|
||||||
|
$cfg = json_decode($row['metval'], true);
|
||||||
|
if (!empty($cfg['api_key'])) {
|
||||||
|
$available[] = $provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the first configured application-level provider (by priority), or null.
|
||||||
|
*/
|
||||||
|
public static function getDefaultAppProvider(): ?string
|
||||||
|
{
|
||||||
|
$available = self::getAppProviders();
|
||||||
|
foreach (self::$priority as $p) {
|
||||||
|
if (in_array($p, $available)) return $p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Org-level (sp_orgs_meta) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a configured provider instance from sp_orgs_meta (org-level override).
|
||||||
|
*
|
||||||
|
* @throws \Exception if provider unknown or not configured for the org
|
||||||
|
*/
|
||||||
|
public static function forOrg(int $orgId, string $provider): LLMProvider
|
||||||
|
{
|
||||||
|
$provider = strtolower($provider);
|
||||||
|
|
||||||
|
if (!isset(self::$providers[$provider])) {
|
||||||
|
throw new \Exception("Unknown LLM provider: '$provider'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = self::getOrgConfig($orgId, $provider);
|
||||||
|
|
||||||
|
if (empty($config)) {
|
||||||
|
throw new \Exception("No '$provider' config found for org $orgId");
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = self::$providers[$provider];
|
||||||
|
return new $class($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and decode a provider config from sp_orgs_meta for the given org.
|
||||||
|
*/
|
||||||
|
public static function getOrgConfig(int $orgId, string $provider): array
|
||||||
|
{
|
||||||
|
$provider = strtolower($provider);
|
||||||
|
$metaKey = self::$metaKeys[$provider] ?? null;
|
||||||
|
if (!$metaKey) return [];
|
||||||
|
|
||||||
|
$row = \Db::getRow(
|
||||||
|
"SELECT metval FROM sp_orgs_meta WHERE orgid = ? AND keyval = ? AND active = 1 LIMIT 1",
|
||||||
|
[$orgId, $metaKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$row || empty($row['metval'])) return [];
|
||||||
|
|
||||||
|
$config = json_decode($row['metval'], true);
|
||||||
|
return is_array($config) ? $config : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return which providers are configured for the given org (sp_orgs_meta only).
|
||||||
|
*/
|
||||||
|
public static function getAvailableProviders(int $orgId): array
|
||||||
|
{
|
||||||
|
$metaKeys = array_values(self::$metaKeys);
|
||||||
|
$rows = \Db::select(
|
||||||
|
"SELECT keyval FROM sp_orgs_meta WHERE orgid = ? AND keyval IN ('" . implode("','", $metaKeys) . "') AND active = 1",
|
||||||
|
[$orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
$flip = array_flip(self::$metaKeys);
|
||||||
|
$available = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (isset($flip[$row['keyval']])) {
|
||||||
|
$available[] = $flip[$row['keyval']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save or update a provider config for an org in sp_orgs_meta.
|
||||||
|
*/
|
||||||
|
public static function saveOrgConfig(int $orgId, string $provider, array $config): void
|
||||||
|
{
|
||||||
|
$provider = strtolower($provider);
|
||||||
|
$metaKey = self::$metaKeys[$provider] ?? null;
|
||||||
|
|
||||||
|
if (!$metaKey) {
|
||||||
|
throw new \Exception("Unknown LLM provider: '$provider'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = \Db::getRow(
|
||||||
|
"SELECT orgmetaid FROM sp_orgs_meta WHERE orgid = ? AND keyval = ? LIMIT 1",
|
||||||
|
[$orgId, $metaKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
\Db::update('sp_orgs_meta',
|
||||||
|
['metval' => json_encode($config)],
|
||||||
|
'orgmetaid = ?',
|
||||||
|
[$existing['orgmetaid']]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
\Db::insert('sp_orgs_meta', [
|
||||||
|
'orgid' => $orgId,
|
||||||
|
'keyval' => $metaKey,
|
||||||
|
'metval' => json_encode($config),
|
||||||
|
'active' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/LLM/LLMProvider.php
Normal file
191
app/LLM/LLMProvider.php
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for all LLM provider services.
|
||||||
|
* Every svc*.php must extend this and implement all abstract methods.
|
||||||
|
*/
|
||||||
|
abstract class LLMProvider
|
||||||
|
{
|
||||||
|
protected string $apiKey = '';
|
||||||
|
protected string $model = '';
|
||||||
|
protected string $systemPrompt = '';
|
||||||
|
protected int $maxTokens = 4096;
|
||||||
|
protected float $temperature = 0.7;
|
||||||
|
protected int $timeout = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a prompt and return the full response.
|
||||||
|
*
|
||||||
|
* @param array $messages [['role' => 'user', 'content' => '...']]
|
||||||
|
* @param array $options Override defaults (model, max_tokens, temperature, etc.)
|
||||||
|
* @return array ['success' => bool, 'content' => string, 'usage' => array, 'error' => string]
|
||||||
|
*/
|
||||||
|
abstract public function sendPrompt(array $messages, array $options = []): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a prompt response chunk by chunk.
|
||||||
|
*
|
||||||
|
* @param array $messages
|
||||||
|
* @param array $options
|
||||||
|
* @param callable $callback Called with each chunk: function(string $chunk)
|
||||||
|
* @return array ['success' => bool, 'error' => string]
|
||||||
|
*/
|
||||||
|
abstract public function streamPrompt(array $messages, array $options, callable $callback): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return available models for this provider.
|
||||||
|
*
|
||||||
|
* @return array [['id' => '...', 'label' => '...']]
|
||||||
|
*/
|
||||||
|
abstract public function getModels(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the API key is valid and the provider is reachable.
|
||||||
|
*
|
||||||
|
* @return array ['success' => bool, 'latency_ms' => int, 'error' => string]
|
||||||
|
*/
|
||||||
|
abstract public function testConnection(): array;
|
||||||
|
|
||||||
|
// ─── Fluent Setters ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getModel(): string
|
||||||
|
{
|
||||||
|
return $this->model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setModel(string $model): static
|
||||||
|
{
|
||||||
|
$this->model = $model;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMaxTokens(int $tokens): static
|
||||||
|
{
|
||||||
|
$this->maxTokens = $tokens;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSystemPrompt(string $prompt): static
|
||||||
|
{
|
||||||
|
$this->systemPrompt = $prompt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTemperature(float $temp): static
|
||||||
|
{
|
||||||
|
$this->temperature = $temp;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTimeout(int $seconds): static
|
||||||
|
{
|
||||||
|
$this->timeout = $seconds;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a standard error response.
|
||||||
|
*/
|
||||||
|
protected function errorResponse(string $message): array
|
||||||
|
{
|
||||||
|
return ['success' => false, 'content' => '', 'usage' => [], 'error' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a standard success response.
|
||||||
|
*/
|
||||||
|
protected function successResponse(string $content, array $usage = []): array
|
||||||
|
{
|
||||||
|
return ['success' => true, 'content' => $content, 'usage' => $usage, 'error' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a cURL request and return the decoded JSON response.
|
||||||
|
*/
|
||||||
|
protected function curlPost(string $url, array $headers, array $payload): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
throw new \Exception("cURL error: $error");
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
|
if ($code >= 400) {
|
||||||
|
$msg = $decoded['error']['message'] ?? $decoded['message'] ?? "HTTP $code error";
|
||||||
|
throw new \Exception($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a streaming cURL request, calling $callback for each SSE data chunk.
|
||||||
|
*/
|
||||||
|
protected function curlStream(string $url, array $headers, array $payload, callable $callback): void
|
||||||
|
{
|
||||||
|
$payload['stream'] = true;
|
||||||
|
|
||||||
|
$errorBody = '';
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => false,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_WRITEFUNCTION => function($ch, $data) use ($callback, &$errorBody) {
|
||||||
|
$lines = explode("\n", $data);
|
||||||
|
$hasSseData = false;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (str_starts_with($line, 'data: ')) {
|
||||||
|
$hasSseData = true;
|
||||||
|
$json = substr($line, 6);
|
||||||
|
if ($json === '[DONE]') break;
|
||||||
|
$chunk = json_decode($json, true);
|
||||||
|
if ($chunk) $callback($chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no SSE data lines were found this chunk may be an error body
|
||||||
|
if (!$hasSseData) {
|
||||||
|
$errorBody .= $data;
|
||||||
|
}
|
||||||
|
return strlen($data);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
curl_exec($ch);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
throw new \Exception("cURL stream error: $curlError");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode >= 400) {
|
||||||
|
$decoded = json_decode(trim($errorBody), true);
|
||||||
|
$msg = $decoded['error']['message'] ?? $decoded['message'] ?? "API error (HTTP $httpCode)";
|
||||||
|
throw new \Exception($msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/LLM/OpenAIsvc.php
Normal file
11
app/LLM/OpenAIsvc.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class OpenAIsvc {
|
||||||
|
public function __construct($apiKey = null) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/LLM/svcAnthropic.php
Normal file
181
app/LLM/svcAnthropic.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anthropic (Claude) provider implementation.
|
||||||
|
*
|
||||||
|
* Config keys (from sp_orgs_meta metval JSON):
|
||||||
|
* secretKey — required — Anthropic API key (sk-ant-...)
|
||||||
|
* model — optional — default: claude-sonnet-4-6
|
||||||
|
* max_tokens — optional — default: 4096
|
||||||
|
* temperature — optional — default: 0.7
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $llm = LLMManager::forOrg($orgId, 'anthropic');
|
||||||
|
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
|
||||||
|
* echo $res['content'];
|
||||||
|
*/
|
||||||
|
class svcAnthropic extends LLMProvider
|
||||||
|
{
|
||||||
|
private const API_BASE = 'https://api.anthropic.com/v1';
|
||||||
|
private const API_CHAT = self::API_BASE . '/messages';
|
||||||
|
private const API_VERSION = '2023-06-01';
|
||||||
|
|
||||||
|
private static array $availableModels = [
|
||||||
|
['id' => 'claude-opus-4-6', 'label' => 'Claude Opus 4.6'],
|
||||||
|
['id' => 'claude-sonnet-4-6', 'label' => 'Claude Sonnet 4.6'],
|
||||||
|
['id' => 'claude-haiku-4-5-20251001', 'label' => 'Claude Haiku 4.5'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
if (empty($config['secretKey'])) {
|
||||||
|
throw new \Exception('Anthropic secretKey is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKey = $config['secretKey'];
|
||||||
|
$this->model = $config['model'] ?? 'claude-sonnet-4-6';
|
||||||
|
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
|
||||||
|
$this->temperature = (float)($config['temperature'] ?? 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat prompt and return the full response.
|
||||||
|
*/
|
||||||
|
public function sendPrompt(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
$response = $this->curlPost(self::API_CHAT, $this->headers(!empty($options['cache_system'])), $payload);
|
||||||
|
|
||||||
|
$content = $response['content'][0]['text'] ?? '';
|
||||||
|
$usage = $response['usage'] ?? [];
|
||||||
|
|
||||||
|
return $this->successResponse($content, [
|
||||||
|
'prompt_tokens' => $usage['input_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $usage['output_tokens'] ?? 0,
|
||||||
|
'total_tokens' => ($usage['input_tokens'] ?? 0) + ($usage['output_tokens'] ?? 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a chat prompt, calling $callback with each text chunk.
|
||||||
|
*
|
||||||
|
* @param callable $callback function(string $chunk) — called with each partial text
|
||||||
|
*/
|
||||||
|
public function streamPrompt(array $messages, array $options, callable $callback): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
$streamError = null;
|
||||||
|
|
||||||
|
$this->curlStream(self::API_CHAT, $this->headers(!empty($options['cache_system'])), $payload, function(array $chunk) use ($callback, &$streamError) {
|
||||||
|
$type = $chunk['type'] ?? '';
|
||||||
|
|
||||||
|
if ($type === 'content_block_delta') {
|
||||||
|
$delta = $chunk['delta']['text'] ?? '';
|
||||||
|
if ($delta !== '') {
|
||||||
|
$callback($delta);
|
||||||
|
}
|
||||||
|
} elseif ($type === 'error') {
|
||||||
|
// Anthropic sends inline error events during streaming
|
||||||
|
$streamError = $chunk['error']['message'] ?? 'Anthropic streaming error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($streamError) {
|
||||||
|
return ['success' => false, 'error' => $streamError];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return supported Claude models.
|
||||||
|
*/
|
||||||
|
public function getModels(): array
|
||||||
|
{
|
||||||
|
return self::$availableModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the API key with a minimal single-token request.
|
||||||
|
*/
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $this->model,
|
||||||
|
'max_tokens' => 1,
|
||||||
|
'messages' => [['role' => 'user', 'content' => 'hi']],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->curlPost(self::API_CHAT, $this->headers(), $payload);
|
||||||
|
|
||||||
|
$latency = (int)((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function headers(bool $withCache = false): array
|
||||||
|
{
|
||||||
|
$h = [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'x-api-key: ' . $this->apiKey,
|
||||||
|
'anthropic-version: ' . self::API_VERSION,
|
||||||
|
];
|
||||||
|
if ($withCache) {
|
||||||
|
$h[] = 'anthropic-beta: prompt-caching-2024-07-31';
|
||||||
|
}
|
||||||
|
return $h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPayload(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
$model = $options['model'] ?? $this->model;
|
||||||
|
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
|
||||||
|
$temperature = (float)($options['temperature'] ?? $this->temperature);
|
||||||
|
$cacheSystem = !empty($options['cache_system']);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $model,
|
||||||
|
'max_tokens' => $maxTokens,
|
||||||
|
'temperature' => $temperature,
|
||||||
|
'messages' => $messages,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Anthropic uses a top-level 'system' key.
|
||||||
|
// When cache_system is set, use block format with cache_control so
|
||||||
|
// the large static report payload is cached between turns.
|
||||||
|
if ($this->systemPrompt !== '') {
|
||||||
|
if ($cacheSystem) {
|
||||||
|
$payload['system'] = [[
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => $this->systemPrompt,
|
||||||
|
'cache_control' => ['type' => 'ephemeral'],
|
||||||
|
]];
|
||||||
|
} else {
|
||||||
|
$payload['system'] = $this->systemPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/LLM/svcDeepSeek.php
Normal file
177
app/LLM/svcDeepSeek.php
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeepSeek provider implementation.
|
||||||
|
*
|
||||||
|
* DeepSeek is fully OpenAI API-compatible, so the structure mirrors
|
||||||
|
* svcOpenAI with DeepSeek-specific endpoints and models.
|
||||||
|
*
|
||||||
|
* Config keys (from sp_orgs_meta metval JSON):
|
||||||
|
* secretKey — required — DeepSeek API key
|
||||||
|
* model — optional — default: deepseek-chat
|
||||||
|
* max_tokens — optional — default: 4096
|
||||||
|
* temperature — optional — default: 0.7
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $llm = LLMManager::forOrg($orgId, 'deepseek');
|
||||||
|
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
|
||||||
|
* echo $res['content'];
|
||||||
|
*/
|
||||||
|
class svcDeepSeek extends LLMProvider
|
||||||
|
{
|
||||||
|
private const API_BASE = 'https://api.deepseek.com/v1';
|
||||||
|
private const API_CHAT = self::API_BASE . '/chat/completions';
|
||||||
|
private const API_MODELS = self::API_BASE . '/models';
|
||||||
|
|
||||||
|
private static array $availableModels = [
|
||||||
|
['id' => 'deepseek-chat', 'label' => 'DeepSeek Chat (V3)'],
|
||||||
|
['id' => 'deepseek-reasoner', 'label' => 'DeepSeek Reasoner (R1)'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
if (empty($config['secretKey'])) {
|
||||||
|
throw new \Exception('DeepSeek secretKey is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKey = $config['secretKey'];
|
||||||
|
$this->model = $config['model'] ?? 'deepseek-chat';
|
||||||
|
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
|
||||||
|
$this->temperature = (float)($config['temperature'] ?? 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat prompt and return the full response.
|
||||||
|
*/
|
||||||
|
public function sendPrompt(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
$response = $this->curlPost(self::API_CHAT, $this->headers(), $payload);
|
||||||
|
|
||||||
|
$content = $response['choices'][0]['message']['content'] ?? '';
|
||||||
|
$usage = $response['usage'] ?? [];
|
||||||
|
|
||||||
|
// DeepSeek Reasoner includes reasoning_content separately
|
||||||
|
$reasoning = $response['choices'][0]['message']['reasoning_content'] ?? '';
|
||||||
|
|
||||||
|
return $this->successResponse($content, [
|
||||||
|
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $usage['completion_tokens'] ?? 0,
|
||||||
|
'total_tokens' => $usage['total_tokens'] ?? 0,
|
||||||
|
'reasoning_tokens' => $usage['completion_tokens_details']['reasoning_tokens'] ?? 0,
|
||||||
|
'reasoning_content' => $reasoning,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a chat prompt, calling $callback with each text chunk.
|
||||||
|
*
|
||||||
|
* @param callable $callback function(string $chunk)
|
||||||
|
*/
|
||||||
|
public function streamPrompt(array $messages, array $options, callable $callback): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
|
||||||
|
$this->curlStream(self::API_CHAT, $this->headers(), $payload, function(array $chunk) use ($callback) {
|
||||||
|
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
|
||||||
|
if ($delta !== '') {
|
||||||
|
$callback($delta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ['success' => true, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return supported DeepSeek models.
|
||||||
|
*/
|
||||||
|
public function getModels(): array
|
||||||
|
{
|
||||||
|
return self::$availableModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the API key via the models endpoint.
|
||||||
|
*/
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
$ch = curl_init(self::API_MODELS);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $this->headers(),
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$latency = (int)((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
if ($error) throw new \Exception("cURL error: $error");
|
||||||
|
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
|
if ($code !== 200) {
|
||||||
|
$msg = $decoded['error']['message'] ?? "HTTP $code";
|
||||||
|
throw new \Exception($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function headers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: Bearer ' . $this->apiKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPayload(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
$model = $options['model'] ?? $this->model;
|
||||||
|
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
|
||||||
|
$temperature = (float)($options['temperature'] ?? $this->temperature);
|
||||||
|
|
||||||
|
if ($this->systemPrompt !== '') {
|
||||||
|
array_unshift($messages, ['role' => 'system', 'content' => $this->systemPrompt]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => $messages,
|
||||||
|
'max_tokens' => $maxTokens,
|
||||||
|
'temperature' => $temperature,
|
||||||
|
];
|
||||||
|
|
||||||
|
// deepseek-reasoner does not support temperature
|
||||||
|
if ($model === 'deepseek-reasoner') {
|
||||||
|
unset($payload['temperature']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/LLM/svcGemini.php
Normal file
172
app/LLM/svcGemini.php
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Gemini provider implementation.
|
||||||
|
*
|
||||||
|
* Config keys (from sp_orgs_meta metval JSON):
|
||||||
|
* secretKey — required — Google AI API key
|
||||||
|
* model — optional — default: gemini-2.5-flash
|
||||||
|
* max_tokens — optional — default: 4096
|
||||||
|
* temperature — optional — default: 0.7
|
||||||
|
*
|
||||||
|
* Note: Gemini uses a different API structure from OpenAI-compatible providers.
|
||||||
|
* Messages use 'parts' arrays and roles are 'user' / 'model' (not 'assistant').
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $llm = LLMManager::forOrg($orgId, 'gemini');
|
||||||
|
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
|
||||||
|
* echo $res['content'];
|
||||||
|
*/
|
||||||
|
class svcGemini extends LLMProvider
|
||||||
|
{
|
||||||
|
private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
private const API_CHAT = ':generateContent';
|
||||||
|
private const API_STREAM = ':streamGenerateContent';
|
||||||
|
|
||||||
|
private static array $availableModels = [
|
||||||
|
['id' => 'gemini-2.5-flash', 'label' => 'Gemini 2.5 Flash'],
|
||||||
|
['id' => 'gemini-2.5-flash-lite', 'label' => 'Gemini 2.5 Flash Lite'],
|
||||||
|
['id' => 'gemini-2.0-flash', 'label' => 'Gemini 2.0 Flash'],
|
||||||
|
['id' => 'gemini-1.5-pro', 'label' => 'Gemini 1.5 Pro'],
|
||||||
|
['id' => 'gemini-1.5-flash', 'label' => 'Gemini 1.5 Flash'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
if (empty($config['secretKey'])) {
|
||||||
|
throw new \Exception('Gemini secretKey is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKey = $config['secretKey'];
|
||||||
|
$this->model = $config['model'] ?? 'gemini-2.5-flash';
|
||||||
|
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
|
||||||
|
$this->temperature = (float)($config['temperature'] ?? 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat prompt and return the full response.
|
||||||
|
*/
|
||||||
|
public function sendPrompt(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$model = $options['model'] ?? $this->model;
|
||||||
|
$url = self::API_BASE . '/' . $model . self::API_CHAT . '?key=' . $this->apiKey;
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
|
||||||
|
$response = $this->curlPost($url, $this->headers(), $payload);
|
||||||
|
|
||||||
|
$content = $response['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||||
|
$usage = $response['usageMetadata'] ?? [];
|
||||||
|
|
||||||
|
return $this->successResponse($content, [
|
||||||
|
'prompt_tokens' => $usage['promptTokenCount'] ?? 0,
|
||||||
|
'completion_tokens' => $usage['candidatesTokenCount'] ?? 0,
|
||||||
|
'total_tokens' => $usage['totalTokenCount'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a chat prompt, calling $callback with each text chunk.
|
||||||
|
*
|
||||||
|
* @param callable $callback function(string $chunk)
|
||||||
|
*/
|
||||||
|
public function streamPrompt(array $messages, array $options, callable $callback): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$model = $options['model'] ?? $this->model;
|
||||||
|
$url = self::API_BASE . '/' . $model . self::API_STREAM . '?key=' . $this->apiKey . '&alt=sse';
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
|
||||||
|
$this->curlStream($url, $this->headers(), $payload, function(array $chunk) use ($callback) {
|
||||||
|
$text = $chunk['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||||
|
if ($text !== '') {
|
||||||
|
$callback($text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ['success' => true, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return supported Gemini models.
|
||||||
|
*/
|
||||||
|
public function getModels(): array
|
||||||
|
{
|
||||||
|
return self::$availableModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the API key with a minimal request.
|
||||||
|
*/
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$start = microtime(true);
|
||||||
|
$model = $this->model;
|
||||||
|
$url = self::API_BASE . '/' . $model . self::API_CHAT . '?key=' . $this->apiKey;
|
||||||
|
$payload = [
|
||||||
|
'contents' => [['role' => 'user', 'parts' => [['text' => 'hi']]]],
|
||||||
|
'generationConfig' => ['maxOutputTokens' => 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->curlPost($url, $this->headers(), $payload);
|
||||||
|
|
||||||
|
$latency = (int)((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function headers(): array
|
||||||
|
{
|
||||||
|
return ['Content-Type: application/json'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPayload(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
|
||||||
|
$temperature = (float)($options['temperature'] ?? $this->temperature);
|
||||||
|
|
||||||
|
// Convert OpenAI-style messages to Gemini format
|
||||||
|
// Roles: 'user' stays 'user', 'assistant' becomes 'model'
|
||||||
|
$contents = [];
|
||||||
|
foreach ($messages as $msg) {
|
||||||
|
$contents[] = [
|
||||||
|
'role' => $msg['role'] === 'assistant' ? 'model' : 'user',
|
||||||
|
'parts' => [['text' => $msg['content']]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'contents' => $contents,
|
||||||
|
'generationConfig' => [
|
||||||
|
'maxOutputTokens' => $maxTokens,
|
||||||
|
'temperature' => $temperature,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Gemini uses a top-level 'systemInstruction' for system prompts
|
||||||
|
if ($this->systemPrompt !== '') {
|
||||||
|
$payload['systemInstruction'] = [
|
||||||
|
'parts' => [['text' => $this->systemPrompt]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/LLM/svcMistral.php
Normal file
169
app/LLM/svcMistral.php
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mistral AI provider implementation.
|
||||||
|
*
|
||||||
|
* Mistral uses an OpenAI-compatible API structure, making it
|
||||||
|
* the most straightforward integration after OpenAI itself.
|
||||||
|
*
|
||||||
|
* Config keys (from sp_orgs_meta metval JSON):
|
||||||
|
* secretKey — required — Mistral API key
|
||||||
|
* model — optional — default: mistral-large-latest
|
||||||
|
* max_tokens — optional — default: 4096
|
||||||
|
* temperature — optional — default: 0.7
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $llm = LLMManager::forOrg($orgId, 'mistral');
|
||||||
|
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
|
||||||
|
* echo $res['content'];
|
||||||
|
*/
|
||||||
|
class svcMistral extends LLMProvider
|
||||||
|
{
|
||||||
|
private const API_BASE = 'https://api.mistral.ai/v1';
|
||||||
|
private const API_CHAT = self::API_BASE . '/chat/completions';
|
||||||
|
private const API_MODELS = self::API_BASE . '/models';
|
||||||
|
|
||||||
|
private static array $availableModels = [
|
||||||
|
['id' => 'mistral-large-latest', 'label' => 'Mistral Large'],
|
||||||
|
['id' => 'mistral-medium-latest', 'label' => 'Mistral Medium'],
|
||||||
|
['id' => 'mistral-small-latest', 'label' => 'Mistral Small'],
|
||||||
|
['id' => 'codestral-latest', 'label' => 'Codestral'],
|
||||||
|
['id' => 'open-mistral-nemo', 'label' => 'Mistral Nemo'],
|
||||||
|
['id' => 'open-mixtral-8x22b', 'label' => 'Mixtral 8x22B'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
if (empty($config['secretKey'])) {
|
||||||
|
throw new \Exception('Mistral secretKey is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKey = $config['secretKey'];
|
||||||
|
$this->model = $config['model'] ?? 'mistral-large-latest';
|
||||||
|
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
|
||||||
|
$this->temperature = (float)($config['temperature'] ?? 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat prompt and return the full response.
|
||||||
|
*/
|
||||||
|
public function sendPrompt(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
$response = $this->curlPost(self::API_CHAT, $this->headers(), $payload);
|
||||||
|
|
||||||
|
$content = $response['choices'][0]['message']['content'] ?? '';
|
||||||
|
$usage = $response['usage'] ?? [];
|
||||||
|
|
||||||
|
return $this->successResponse($content, [
|
||||||
|
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $usage['completion_tokens'] ?? 0,
|
||||||
|
'total_tokens' => $usage['total_tokens'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a chat prompt, calling $callback with each text chunk.
|
||||||
|
*
|
||||||
|
* @param callable $callback function(string $chunk)
|
||||||
|
*/
|
||||||
|
public function streamPrompt(array $messages, array $options, callable $callback): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
|
||||||
|
$this->curlStream(self::API_CHAT, $this->headers(), $payload, function(array $chunk) use ($callback) {
|
||||||
|
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
|
||||||
|
if ($delta !== '') {
|
||||||
|
$callback($delta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ['success' => true, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return supported Mistral models.
|
||||||
|
*/
|
||||||
|
public function getModels(): array
|
||||||
|
{
|
||||||
|
return self::$availableModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the API key via the models endpoint.
|
||||||
|
*/
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
$ch = curl_init(self::API_MODELS);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $this->headers(),
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$latency = (int)((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
if ($error) throw new \Exception("cURL error: $error");
|
||||||
|
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
|
if ($code !== 200) {
|
||||||
|
$msg = $decoded['message'] ?? "HTTP $code";
|
||||||
|
throw new \Exception($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function headers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: Bearer ' . $this->apiKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPayload(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
$model = $options['model'] ?? $this->model;
|
||||||
|
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
|
||||||
|
$temperature = (float)($options['temperature'] ?? $this->temperature);
|
||||||
|
|
||||||
|
if ($this->systemPrompt !== '') {
|
||||||
|
array_unshift($messages, ['role' => 'system', 'content' => $this->systemPrompt]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => $messages,
|
||||||
|
'max_tokens' => $maxTokens,
|
||||||
|
'temperature' => $temperature,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/LLM/svcOpenAI.php
Normal file
175
app/LLM/svcOpenAI.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\LLM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI provider implementation.
|
||||||
|
*
|
||||||
|
* Config keys (from sp_orgs_meta metval JSON):
|
||||||
|
* secretKey — required — OpenAI API key (sk-...)
|
||||||
|
* model — optional — default: gpt-4o
|
||||||
|
* max_tokens — optional — default: 4096
|
||||||
|
* temperature— optional — default: 0.7
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* $llm = LLMManager::forOrg($orgId, 'openai');
|
||||||
|
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
|
||||||
|
* echo $res['content'];
|
||||||
|
*/
|
||||||
|
class svcOpenAI extends LLMProvider
|
||||||
|
{
|
||||||
|
private const API_BASE = 'https://api.openai.com/v1';
|
||||||
|
private const API_CHAT = self::API_BASE . '/chat/completions';
|
||||||
|
private const API_MODELS = self::API_BASE . '/models';
|
||||||
|
|
||||||
|
private static array $availableModels = [
|
||||||
|
['id' => 'gpt-4o', 'label' => 'GPT-4o'],
|
||||||
|
['id' => 'gpt-4o-mini', 'label' => 'GPT-4o Mini'],
|
||||||
|
['id' => 'gpt-4-turbo', 'label' => 'GPT-4 Turbo'],
|
||||||
|
['id' => 'gpt-4', 'label' => 'GPT-4'],
|
||||||
|
['id' => 'gpt-3.5-turbo', 'label' => 'GPT-3.5 Turbo'],
|
||||||
|
['id' => 'o1', 'label' => 'o1'],
|
||||||
|
['id' => 'o1-mini', 'label' => 'o1 Mini'],
|
||||||
|
['id' => 'o3-mini', 'label' => 'o3 Mini'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
if (empty($config['secretKey'])) {
|
||||||
|
throw new \Exception('OpenAI secretKey is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKey = $config['secretKey'];
|
||||||
|
$this->model = $config['model'] ?? 'gpt-4o';
|
||||||
|
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
|
||||||
|
$this->temperature = (float)($config['temperature'] ?? 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat prompt and return the full response.
|
||||||
|
*/
|
||||||
|
public function sendPrompt(array $messages, array $options = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
$response = $this->curlPost(self::API_CHAT, $this->headers(), $payload);
|
||||||
|
|
||||||
|
$content = $response['choices'][0]['message']['content'] ?? '';
|
||||||
|
$usage = $response['usage'] ?? [];
|
||||||
|
|
||||||
|
return $this->successResponse($content, [
|
||||||
|
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $usage['completion_tokens'] ?? 0,
|
||||||
|
'total_tokens' => $usage['total_tokens'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->errorResponse($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a chat prompt, calling $callback with each text chunk.
|
||||||
|
*
|
||||||
|
* @param callable $callback function(string $chunk) — called with each partial text
|
||||||
|
*/
|
||||||
|
public function streamPrompt(array $messages, array $options, callable $callback): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->buildPayload($messages, $options);
|
||||||
|
|
||||||
|
$this->curlStream(self::API_CHAT, $this->headers(), $payload, function(array $chunk) use ($callback) {
|
||||||
|
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
|
||||||
|
if ($delta !== '') {
|
||||||
|
$callback($delta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ['success' => true, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the static list of supported OpenAI models.
|
||||||
|
*/
|
||||||
|
public function getModels(): array
|
||||||
|
{
|
||||||
|
return self::$availableModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the API key by calling the models endpoint.
|
||||||
|
*/
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
$ch = curl_init(self::API_MODELS);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $this->headers(),
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$latency = (int)((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
if ($error) throw new \Exception("cURL error: $error");
|
||||||
|
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
|
if ($code !== 200) {
|
||||||
|
$msg = $decoded['error']['message'] ?? "HTTP $code";
|
||||||
|
throw new \Exception($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function headers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: Bearer ' . $this->apiKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPayload(array $messages, array $options): array
|
||||||
|
{
|
||||||
|
$model = $options['model'] ?? $this->model;
|
||||||
|
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
|
||||||
|
$temperature = (float)($options['temperature'] ?? $this->temperature);
|
||||||
|
|
||||||
|
// Prepend system prompt if set
|
||||||
|
if ($this->systemPrompt !== '') {
|
||||||
|
array_unshift($messages, ['role' => 'system', 'content' => $this->systemPrompt]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => $messages,
|
||||||
|
'max_tokens' => $maxTokens,
|
||||||
|
];
|
||||||
|
|
||||||
|
// o1/o3 models don't support temperature
|
||||||
|
if (!str_starts_with($model, 'o1') && !str_starts_with($model, 'o3')) {
|
||||||
|
$payload['temperature'] = $temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
commands/GreetCommand.php
Normal file
55
commands/GreetCommand.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class GreetCommand extends Command
|
||||||
|
{
|
||||||
|
protected $commandName = 'app:greet';
|
||||||
|
protected $commandDescription = "Greets Someone";
|
||||||
|
|
||||||
|
protected $commandArgumentName = "name";
|
||||||
|
protected $commandArgumentDescription = "Who do you want to greet?";
|
||||||
|
|
||||||
|
protected $commandOptionName = "cap"; // should be specified like "app:greet John --cap"
|
||||||
|
protected $commandOptionDescription = 'If set, it will greet in uppercase letters';
|
||||||
|
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->setName($this->commandName)
|
||||||
|
->setDescription($this->commandDescription)
|
||||||
|
->addArgument(
|
||||||
|
$this->commandArgumentName,
|
||||||
|
InputArgument::OPTIONAL,
|
||||||
|
$this->commandArgumentDescription
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
$this->commandOptionName,
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
$this->commandOptionDescription
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output)
|
||||||
|
{
|
||||||
|
$name = $input->getArgument($this->commandArgumentName);
|
||||||
|
|
||||||
|
if ($name) {
|
||||||
|
$text = 'Hello '.$name;
|
||||||
|
} else {
|
||||||
|
$text = 'Hello';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption($this->commandOptionName)) {
|
||||||
|
$text = strtoupper($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln($text);
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
commands/SeedMenuCommand.php
Normal file
134
commands/SeedMenuCommand.php
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class SeedMenuCommand extends Command
|
||||||
|
{
|
||||||
|
protected $commandName = 'SeedMenu';
|
||||||
|
protected $commandDescription = 'Create sp_menus + sp_menu_items tables and seed admin sidebar data';
|
||||||
|
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this->setName($this->commandName)
|
||||||
|
->setDescription($this->commandDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output)
|
||||||
|
{
|
||||||
|
$output->writeln('<info>Creating tables...</info>');
|
||||||
|
|
||||||
|
Db::execute("
|
||||||
|
CREATE TABLE IF NOT EXISTS sp_menus (
|
||||||
|
menu_id int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
slug varchar(50) NOT NULL,
|
||||||
|
name varchar(100) NOT NULL,
|
||||||
|
active tinyint DEFAULT 1,
|
||||||
|
PRIMARY KEY (menu_id),
|
||||||
|
UNIQUE KEY (slug)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
", []);
|
||||||
|
|
||||||
|
Db::execute("
|
||||||
|
CREATE TABLE IF NOT EXISTS sp_menu_items (
|
||||||
|
item_id int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
menu_id int unsigned NOT NULL,
|
||||||
|
parent_id int unsigned DEFAULT NULL,
|
||||||
|
label varchar(100) NOT NULL,
|
||||||
|
icon varchar(100) DEFAULT NULL,
|
||||||
|
type enum('link','header','divider') DEFAULT 'link',
|
||||||
|
url varchar(255) DEFAULT NULL,
|
||||||
|
perm_id int unsigned DEFAULT NULL,
|
||||||
|
match_prefix tinyint DEFAULT 0,
|
||||||
|
sort_order int DEFAULT 0,
|
||||||
|
active tinyint DEFAULT 1,
|
||||||
|
PRIMARY KEY (item_id),
|
||||||
|
KEY (menu_id),
|
||||||
|
KEY (parent_id),
|
||||||
|
CONSTRAINT FOREIGN KEY (perm_id) REFERENCES sp_permissions(perm_id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
", []);
|
||||||
|
|
||||||
|
$output->writeln('<info>Tables ready. Seeding data...</info>');
|
||||||
|
|
||||||
|
// Idempotent: skip if already seeded
|
||||||
|
$existing = Db::getRow("SELECT menu_id FROM sp_menus WHERE slug = 'admin_sidebar' LIMIT 1");
|
||||||
|
if ($existing) {
|
||||||
|
$output->writeln('<comment>admin_sidebar already exists — skipping seed. Run with --force to re-seed (delete rows manually first).</comment>');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Insert menu ──────────────────────────────────────────────────────────
|
||||||
|
$menuResult = json_decode(Db::insert('sp_menus', [
|
||||||
|
'slug' => 'admin_sidebar',
|
||||||
|
'name' => 'Admin Sidebar',
|
||||||
|
'active' => 1,
|
||||||
|
]), true);
|
||||||
|
|
||||||
|
$menuId = (int)$menuResult['ID'];
|
||||||
|
$output->writeln("Menu ID: {$menuId}");
|
||||||
|
|
||||||
|
// Helper: fetch perm_id by controller.action, return null if not found
|
||||||
|
$perm = function(string $controller, string $action) use ($output): ?int {
|
||||||
|
$row = Db::getRow(
|
||||||
|
"SELECT perm_id FROM sp_permissions WHERE perm_controller = ? AND perm_action = ? LIMIT 1",
|
||||||
|
[$controller, $action]
|
||||||
|
);
|
||||||
|
if (!$row) {
|
||||||
|
$output->writeln("<comment> Permission not found: {$controller}.{$action} — using NULL</comment>");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (int)$row['perm_id'];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: insert item, return item_id
|
||||||
|
$insertItem = function(array $data) use ($menuId): int {
|
||||||
|
$data['menu_id'] = $menuId;
|
||||||
|
$result = json_decode(Db::insert('sp_menu_items', $data), true);
|
||||||
|
return (int)$result['ID'];
|
||||||
|
};
|
||||||
|
|
||||||
|
$sort = 10;
|
||||||
|
|
||||||
|
// ── Parent: Manage Account ───────────────────────────────────────────────
|
||||||
|
$acctId = $insertItem([
|
||||||
|
'parent_id' => null,
|
||||||
|
'label' => 'Manage Account',
|
||||||
|
'icon' => 'ki-duotone ki-address-book',
|
||||||
|
'type' => 'link',
|
||||||
|
'url' => null,
|
||||||
|
'perm_id' => null,
|
||||||
|
'sort_order' => $sort += 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subSort = 0;
|
||||||
|
$insertItem(['parent_id' => $acctId, 'label' => 'Account Settings', 'type' => 'link', 'url' => '/account', 'perm_id' => null, 'sort_order' => $subSort += 10]);
|
||||||
|
$insertItem(['parent_id' => $acctId, 'label' => 'Billing', 'type' => 'link', 'url' => '/account/billing', 'perm_id' => null, 'sort_order' => $subSort += 10]);
|
||||||
|
$insertItem(['parent_id' => $acctId, 'label' => 'Invoices', 'type' => 'link', 'url' => '/account/invoices', 'perm_id' => null, 'sort_order' => $subSort += 10]);
|
||||||
|
$insertItem(['parent_id' => $acctId, 'label' => 'Integrations & API', 'type' => 'link', 'url' => '/account/integrations', 'perm_id' => null, 'sort_order' => $subSort += 10]);
|
||||||
|
$insertItem(['parent_id' => $acctId, 'label' => 'Activity Logs', 'type' => 'link', 'url' => '/account/activity', 'perm_id' => null, 'sort_order' => $subSort += 10]);
|
||||||
|
|
||||||
|
// ── Parent: Administrator ────────────────────────────────────────────────
|
||||||
|
$adminId = $insertItem([
|
||||||
|
'parent_id' => null,
|
||||||
|
'label' => 'Administrator',
|
||||||
|
'icon' => 'ki-duotone ki-shield-tick',
|
||||||
|
'type' => 'link',
|
||||||
|
'url' => null,
|
||||||
|
'perm_id' => null,
|
||||||
|
'sort_order' => $sort += 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subSort = 0;
|
||||||
|
$insertItem(['parent_id' => $adminId, 'label' => 'Overview', 'type' => 'link', 'url' => '/admin', 'perm_id' => $perm('admin', 'index'), 'sort_order' => $subSort += 10, 'match_prefix' => 0]);
|
||||||
|
$insertItem(['parent_id' => $adminId, 'label' => 'Manage Orgs', 'type' => 'link', 'url' => '/admin/orgs', 'perm_id' => $perm('admin', 'orgs'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
|
||||||
|
$insertItem(['parent_id' => $adminId, 'label' => 'Manage Users', 'type' => 'link', 'url' => '/admin/users', 'perm_id' => $perm('admin', 'users'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
|
||||||
|
$insertItem(['parent_id' => $adminId, 'label' => 'Integrations', 'type' => 'link', 'url' => '/admin/integrations', 'perm_id' => $perm('admin', 'integrations'), 'sort_order' => $subSort += 10]);
|
||||||
|
$insertItem(['parent_id' => $adminId, 'label' => 'User Roles', 'type' => 'link', 'url' => '/admin/roles', 'perm_id' => $perm('admin', 'roles'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
|
||||||
|
$insertItem(['parent_id' => $adminId, 'label' => 'Activity Logs', 'type' => 'link', 'url' => '/admin/activity', 'perm_id' => $perm('admin', 'activity'), 'sort_order' => $subSort += 10]);
|
||||||
|
$insertItem(['parent_id' => $adminId, 'label' => 'Menu Builder', 'type' => 'link', 'url' => '/admin/menu', 'perm_id' => $perm('admin', 'menu'), 'sort_order' => $subSort += 10, 'match_prefix' => 1]);
|
||||||
|
|
||||||
|
$output->writeln('<info>Seeded successfully.</info>');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
commands/commands.md
Normal file
119
commands/commands.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# CLI Commands — `commands/`
|
||||||
|
|
||||||
|
Scripts run via `php console <CommandName>`. No HTTP context — output goes to stdout.
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
`PascalCaseCommand.php` — e.g. `SyncDataCommand.php`
|
||||||
|
|
||||||
|
## Running Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php console SyncDataCommand
|
||||||
|
php console SyncDataCommand --date="2024-10-28" --force
|
||||||
|
php console CleanupDatabaseCommand --days=30 --dry-run
|
||||||
|
php console ProcessEmailQueueCommand --limit=50
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Structure
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// commands/SyncDataCommand.php
|
||||||
|
|
||||||
|
echo "=== Data Sync ===\n";
|
||||||
|
|
||||||
|
$options = getopt("", ["date:", "force"]);
|
||||||
|
$date = $options['date'] ?? 'today';
|
||||||
|
$force = isset($options['force']);
|
||||||
|
|
||||||
|
echo "Syncing for: $date\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$records = Db::select("SELECT * FROM sp_sync_queue WHERE synced_at IS NULL");
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (!$force) {
|
||||||
|
$exists = Db::getRow("SELECT 1 FROM sp_data WHERE external_id = :id LIMIT 1",
|
||||||
|
[':id' => $record['external_id']]
|
||||||
|
);
|
||||||
|
if ($exists) { echo "Skipping {$record['external_id']}\n"; continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::insert('sp_data', [
|
||||||
|
'external_id' => $record['external_id'],
|
||||||
|
'payload' => $record['payload'],
|
||||||
|
'synced_at' => date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
echo "Inserted {$record['external_id']}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Done.\n";
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "ERROR: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Pattern
|
||||||
|
|
||||||
|
Log individual failures and keep processing — exit with code 1 only on fatal errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$errors = [];
|
||||||
|
$processed = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
try {
|
||||||
|
processItem($item);
|
||||||
|
$processed++;
|
||||||
|
echo ".";
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$errors[] = ['id' => $item['id'], 'error' => $e->getMessage()];
|
||||||
|
echo "E";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n\nProcessed: $processed | Errors: " . count($errors) . "\n";
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
foreach ($errors as $err) {
|
||||||
|
echo "Item #{$err['id']}: {$err['error']}\n";
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dry-Run Pattern
|
||||||
|
|
||||||
|
```php
|
||||||
|
$dryRun = isset($options['dry-run']);
|
||||||
|
if ($dryRun) { echo "DRY RUN — no changes will be made\n"; }
|
||||||
|
|
||||||
|
$count = Db::getRow("SELECT COUNT(*) as n FROM sp_logs WHERE created_at < :date",
|
||||||
|
[':date' => $cutoffDate]
|
||||||
|
);
|
||||||
|
echo "Rows to delete: {$count['n']}\n";
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
Db::delete('sp_logs', 'created_at < :date', [':date' => $cutoffDate]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cron Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# Every 5 minutes
|
||||||
|
*/5 * * * * cd /www/wwwroot/appSeedProject && php console ProcessEmailQueueCommand >> /var/log/email-queue.log 2>&1
|
||||||
|
|
||||||
|
# Every hour
|
||||||
|
0 * * * * cd /www/wwwroot/appSeedProject && php console SyncDataCommand >> /var/log/sync.log 2>&1
|
||||||
|
|
||||||
|
# Nightly cleanup at 2 AM
|
||||||
|
0 2 * * * cd /www/wwwroot/appSeedProject && php console CleanupDatabaseCommand --days=30 >> /var/log/cleanup.log 2>&1
|
||||||
|
```
|
||||||
49
composer.json
Normal file
49
composer.json
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "seedproject/seedproject",
|
||||||
|
"require": {
|
||||||
|
"anthropic-ai/sdk": "^0.3.0",
|
||||||
|
"carbonphp/carbon-doctrine-types": "^2.1.0",
|
||||||
|
"getbrevo/brevo-php": "^1.0.2",
|
||||||
|
"guzzlehttp/promises": "^2.3.0",
|
||||||
|
"guzzlehttp/psr7": "^2.8.0",
|
||||||
|
"mashape/unirest-php": "^3.0.4",
|
||||||
|
"nyholm/psr7": "^1.8.2",
|
||||||
|
"php-http/discovery": "^1.20.0",
|
||||||
|
"php-http/multipart-stream-builder": "^1.4.2",
|
||||||
|
"phpmailer/phpmailer": "^6.12.0",
|
||||||
|
"psr/container": "^2.0.2",
|
||||||
|
"psr/http-client": "^1.0.3",
|
||||||
|
"psr/http-factory": "^1.1.0",
|
||||||
|
"psr/http-message": "^2.0",
|
||||||
|
"symfony/console": "^5.4.47",
|
||||||
|
"symfony/deprecation-contracts": "^3.6.0",
|
||||||
|
"symfony/polyfill-ctype": "^1.33.0",
|
||||||
|
"symfony/polyfill-intl-grapheme": "^1.33.0",
|
||||||
|
"symfony/polyfill-intl-normalizer": "^1.33.0",
|
||||||
|
"symfony/polyfill-mbstring": "^1.33.0",
|
||||||
|
"symfony/polyfill-php73": "^1.33.0",
|
||||||
|
"symfony/polyfill-php80": "^1.33.0",
|
||||||
|
"symfony/service-contracts": "^3.6.1",
|
||||||
|
"symfony/string": "^6.4.30"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"classmap": [
|
||||||
|
"core/",
|
||||||
|
"app/Helpers",
|
||||||
|
"app/Controllers",
|
||||||
|
"app/Components",
|
||||||
|
"app/Gateways",
|
||||||
|
"system/"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app",
|
||||||
|
"Models\\": "models"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"platform-check": false,
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1970
composer.lock
generated
Normal file
1970
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
41
config.php
Normal file
41
config.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
$dir = strtok(parse_url($_SERVER['SCRIPT_URI'], PHP_URL_PATH), '/');
|
||||||
|
|
||||||
|
define('URL', 'https://www.seedproject.com');
|
||||||
|
define('SITE_BASE', '/' . $dir);
|
||||||
|
define('ASSETS', '/' . $dir . '/assets');
|
||||||
|
define('LIBS', 'core/');
|
||||||
|
define('PROJECT_NAME', 'SeedProject');
|
||||||
|
define('PROJECT_LOGO', '/assets/SeedProject.png');
|
||||||
|
define('DEBUG', false);
|
||||||
|
define('SECUREAPI', false);
|
||||||
|
|
||||||
|
define('EMAILUSER', 'ADDEMAILUSER');
|
||||||
|
define('EMAILPASSWORD', 'ADDEMAILPASSWORD');
|
||||||
|
define('EMAILHOST', 'ADDEMAILHOST');
|
||||||
|
|
||||||
|
define('GOOGLE_CLIENT_SECRET', 'SECRET_KEY_HERE');
|
||||||
|
define('GOOGLE_CLIENT_ID', 'CLIENT_KEY_HERE');
|
||||||
|
|
||||||
|
|
||||||
|
define( 'HASH_PASSWORD_KEY', 'G%@xZY+-5% %5:+pK#1IUzM9l}3.]PsSJ=d.^SlD{+mQYVxK4eP_fZT->nRFl@t,');
|
||||||
|
define( 'HASH_API_KEY', ':-?7>?@[TP=oor}~<YU#EE,_dq2uwH{Zc4o0fg7*TI|rDe>BY 6UaJ!Vp$6Bw6tD');
|
||||||
|
define( 'TIMESTAMP', date('Y-m-d H:i:s'));
|
||||||
|
date_default_timezone_set('America/New_York');
|
||||||
|
|
||||||
|
define('DB_TYPE', 'mysql');
|
||||||
|
define('DB_HOST', 'LOCALHOST');
|
||||||
|
define('DB_NAME', 'DBNAME');
|
||||||
|
define('DB_USER', 'DBUSER');
|
||||||
|
define('DB_PASS', 'DBPASSWORD');
|
||||||
|
|
||||||
|
\Db::setConnectionInfo(
|
||||||
|
DB_TYPE,
|
||||||
|
DB_NAME,
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
DB_HOST,
|
||||||
|
'snoopi2024'
|
||||||
|
);
|
||||||
|
|
||||||
|
\Db::setConnectionInfo(DB_TYPE, DB_NAME, DB_USER, DB_PASS);
|
||||||
17
console
Normal file
17
console
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
# Documentation https://symfony.com/doc/current/console.html
|
||||||
|
# Everytime you added a new command you need to run: composer dump -o
|
||||||
|
# Usage: php console app:ticker ltcusdt
|
||||||
|
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Application;
|
||||||
|
|
||||||
|
$application = new Application();
|
||||||
|
|
||||||
|
# add our commands
|
||||||
|
$application->add(new GreetCommand());
|
||||||
|
//$application->add(new Sentinel());
|
||||||
|
//$application->add(new Engine());
|
||||||
|
$application->run();
|
||||||
253
core/AltoRouter.php
Normal file
253
core/AltoRouter.php
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class AltoRouter {
|
||||||
|
|
||||||
|
protected $routes = array();
|
||||||
|
protected $namedRoutes = array();
|
||||||
|
protected $basePath = '';
|
||||||
|
protected $matchTypes = array(
|
||||||
|
'i' => '[0-9]++',
|
||||||
|
'a' => '[0-9A-Za-z]++',
|
||||||
|
'h' => '[0-9A-Fa-f]++',
|
||||||
|
'*' => '.+?',
|
||||||
|
'**' => '.++',
|
||||||
|
'' => '[^/\.]++'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create router in one call from config.
|
||||||
|
*
|
||||||
|
* @param array $routes
|
||||||
|
* @param string $basePath
|
||||||
|
* @param array $matchTypes
|
||||||
|
*/
|
||||||
|
public function __construct($routes = array(), $basePath = '', $matchTypes = array()) {
|
||||||
|
$this->setBasePath($basePath);
|
||||||
|
$this->addMatchTypes($matchTypes);
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
call_user_func_array(array($this, 'map'), $route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base path.
|
||||||
|
* Useful if you are running your application from a subdirectory.
|
||||||
|
*/
|
||||||
|
public function setBasePath($basePath) {
|
||||||
|
$this->basePath = $basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add named match types. It uses array_merge so keys can be overwritten.
|
||||||
|
*
|
||||||
|
* @param array $matchTypes The key is the name and the value is the regex.
|
||||||
|
*/
|
||||||
|
public function addMatchTypes($matchTypes) {
|
||||||
|
$this->matchTypes = array_merge($this->matchTypes, $matchTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a route to a target
|
||||||
|
*
|
||||||
|
* @param string $method One of 4 HTTP Methods, or a pipe-separated list of multiple HTTP Methods (GET|POST|PUT|DELETE)
|
||||||
|
* @param string $route The route regex, custom regex must start with an @. You can use multiple pre-set regex filters, like [i:id]
|
||||||
|
* @param mixed $target The target where this route should point to. Can be anything.
|
||||||
|
* @param string $name Optional name of this route. Supply if you want to reverse route this url in your application.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function map($method, $route, $target, $name = null) {
|
||||||
|
|
||||||
|
$this->routes[] = array($method, $route, $target, $name);
|
||||||
|
|
||||||
|
if ($name) {
|
||||||
|
if (isset($this->namedRoutes[$name])) {
|
||||||
|
throw new \Exception("Can not redeclare route '{$name}'");
|
||||||
|
} else {
|
||||||
|
$this->namedRoutes[$name] = $route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reversed routing
|
||||||
|
*
|
||||||
|
* Generate the URL for a named route. Replace regexes with supplied parameters
|
||||||
|
*
|
||||||
|
* @param string $routeName The name of the route.
|
||||||
|
* @param array @params Associative array of parameters to replace placeholders with.
|
||||||
|
* @return string The URL of the route with named parameters in place.
|
||||||
|
*/
|
||||||
|
public function generate($routeName, array $params = array()) {
|
||||||
|
|
||||||
|
// Check if named route exists
|
||||||
|
if (!isset($this->namedRoutes[$routeName])) {
|
||||||
|
throw new \Exception("Route '{$routeName}' does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace named parameters
|
||||||
|
$route = $this->namedRoutes[$routeName];
|
||||||
|
|
||||||
|
// prepend base path to route url again
|
||||||
|
$url = $this->basePath . $route;
|
||||||
|
|
||||||
|
if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) {
|
||||||
|
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
list($block, $pre, $type, $param, $optional) = $match;
|
||||||
|
|
||||||
|
if ($pre) {
|
||||||
|
$block = substr($block, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($params[$param])) {
|
||||||
|
$url = str_replace($block, $params[$param], $url);
|
||||||
|
} elseif ($optional) {
|
||||||
|
$url = str_replace($pre . $block, '', $url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a given Request Url against stored routes
|
||||||
|
* @param string $requestUrl
|
||||||
|
* @param string $requestMethod
|
||||||
|
* @return array|boolean Array with route information on success, false on failure (no match).
|
||||||
|
*/
|
||||||
|
public function match($requestUrl = null, $requestMethod = null) {
|
||||||
|
|
||||||
|
$params = array();
|
||||||
|
$match = false;
|
||||||
|
|
||||||
|
// set Request Url if it isn't passed as parameter
|
||||||
|
if ($requestUrl === null) {
|
||||||
|
$requestUrl = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// strip base path from request url
|
||||||
|
$requestUrl = substr($requestUrl, strlen($this->basePath));
|
||||||
|
|
||||||
|
// Strip query string (?a=b) from Request Url
|
||||||
|
if (($strpos = strpos($requestUrl, '?')) !== false) {
|
||||||
|
$requestUrl = substr($requestUrl, 0, $strpos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// set Request Method if it isn't passed as a parameter
|
||||||
|
if ($requestMethod === null) {
|
||||||
|
$requestMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force request_order to be GP
|
||||||
|
// http://www.mail-archive.com/internals@lists.php.net/msg33119.html
|
||||||
|
$_REQUEST = array_merge($_GET, $_POST);
|
||||||
|
|
||||||
|
foreach ($this->routes as $handler) {
|
||||||
|
list($method, $_route, $target, $name) = $handler;
|
||||||
|
|
||||||
|
$methods = explode('|', $method);
|
||||||
|
$method_match = false;
|
||||||
|
|
||||||
|
// Check if request method matches. If not, abandon early. (CHEAP)
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
if (strcasecmp($requestMethod, $method) === 0) {
|
||||||
|
$method_match = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method did not match, continue to next route.
|
||||||
|
if (!$method_match)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Check for a wildcard (matches all)
|
||||||
|
if ($_route === '*') {
|
||||||
|
$match = true;
|
||||||
|
} elseif (isset($_route[0]) && $_route[0] === '@') {
|
||||||
|
$match = preg_match('`' . substr($_route, 1) . '`', $requestUrl, $params);
|
||||||
|
} else {
|
||||||
|
$route = null;
|
||||||
|
$regex = false;
|
||||||
|
$j = 0;
|
||||||
|
$n = isset($_route[0]) ? $_route[0] : null;
|
||||||
|
$i = 0;
|
||||||
|
|
||||||
|
// Find the longest non-regex substring and match it against the URI
|
||||||
|
while (true) {
|
||||||
|
if (!isset($_route[$i])) {
|
||||||
|
break;
|
||||||
|
} elseif (false === $regex) {
|
||||||
|
$c = $n;
|
||||||
|
$regex = $c === '[' || $c === '(' || $c === '.';
|
||||||
|
if (false === $regex && false !== isset($_route[$i + 1])) {
|
||||||
|
$n = $_route[$i + 1];
|
||||||
|
$regex = $n === '?' || $n === '+' || $n === '*' || $n === '{';
|
||||||
|
}
|
||||||
|
if (false === $regex && $c !== '/' && (!isset($requestUrl[$j]) || $c !== $requestUrl[$j])) {
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
$j++;
|
||||||
|
}
|
||||||
|
$route .= $_route[$i++];
|
||||||
|
}
|
||||||
|
|
||||||
|
$regex = $this->compileRoute($route);
|
||||||
|
$match = preg_match($regex, $requestUrl, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($match == true || $match > 0)) {
|
||||||
|
|
||||||
|
if ($params) {
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
|
if (is_numeric($key))
|
||||||
|
unset($params[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'target' => $target,
|
||||||
|
'params' => $params,
|
||||||
|
'name' => $name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile the regex for a given route (EXPENSIVE)
|
||||||
|
*/
|
||||||
|
private function compileRoute($route) {
|
||||||
|
if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) {
|
||||||
|
|
||||||
|
$matchTypes = $this->matchTypes;
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
list($block, $pre, $type, $param, $optional) = $match;
|
||||||
|
|
||||||
|
if (isset($matchTypes[$type])) {
|
||||||
|
$type = $matchTypes[$type];
|
||||||
|
}
|
||||||
|
if ($pre === '.') {
|
||||||
|
$pre = '\.';
|
||||||
|
}
|
||||||
|
|
||||||
|
//Older versions of PCRE require the 'P' in (?P<named>)
|
||||||
|
$pattern = '(?:'
|
||||||
|
. ($pre !== '' ? $pre : null)
|
||||||
|
. '('
|
||||||
|
. ($param !== '' ? "?P<$param>" : null)
|
||||||
|
. $type
|
||||||
|
. '))'
|
||||||
|
. ($optional !== '' ? '?' : null);
|
||||||
|
|
||||||
|
$route = str_replace($block, $pattern, $route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "`^$route$`";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
248
core/Bootstrap.php
Normal file
248
core/Bootstrap.php
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../system/ErrorHandler.php';
|
||||||
|
|
||||||
|
class Bootstrap {
|
||||||
|
|
||||||
|
private $_url = null;
|
||||||
|
private $_controller = null;
|
||||||
|
private $_controllerPath = 'controllers/'; // Always include trailing slash
|
||||||
|
private $_modelPath = 'models/'; // Always include trailing slash
|
||||||
|
private $_errorFile = 'error.php';
|
||||||
|
private $_defaultFile = 'index.php';
|
||||||
|
private $_defaultPath = 'public';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the Bootstrap
|
||||||
|
*
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function __construct(){
|
||||||
|
set_error_handler([ErrorHandler::class, 'handleError']);
|
||||||
|
set_exception_handler([ErrorHandler::class, 'handleException']);
|
||||||
|
register_shutdown_function([ErrorHandler::class, 'handleShutdown']);
|
||||||
|
|
||||||
|
// Turn off display_errors if you want to handle all error display through this mechanism
|
||||||
|
ini_set('display_errors', 'Off');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function init() {
|
||||||
|
// Sets the protected $_url
|
||||||
|
$this->_getUrl();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Load the default controller if no URL is set
|
||||||
|
// eg: Visit http://localhost it loads Default Controller
|
||||||
|
if (empty($this->_url[0])) {
|
||||||
|
$this->_loadDefaultController();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Router
|
||||||
|
$this->match = \Router::Routing();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if(DEBUG == true) {
|
||||||
|
register_shutdown_function(function () {
|
||||||
|
$err = error_get_last();
|
||||||
|
if (! is_null($err)) {
|
||||||
|
print 'Error#'.$err['message'].'<br>';
|
||||||
|
print 'Line#'.$err['line'].'<br>';
|
||||||
|
print 'File#'.$err['file'].'<br>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// This check whether there is a match
|
||||||
|
if (empty($this->match)) {
|
||||||
|
$this->_loadExistingController();
|
||||||
|
$this->_callControllerMethod();
|
||||||
|
} else {
|
||||||
|
$this->_loadRouter();
|
||||||
|
}
|
||||||
|
|
||||||
|
//$this->_loadExistingController();
|
||||||
|
//$this->_callControllerMethod();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Optional) Set a custom path to controllers
|
||||||
|
* @param string $path
|
||||||
|
*/
|
||||||
|
public function setControllerPath($path ='') {
|
||||||
|
\Helper::print_array($path);
|
||||||
|
$this->_controllerPath = trim($path, '') . '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Optional) Set a custom path to models
|
||||||
|
* @param string $path
|
||||||
|
*/
|
||||||
|
public function setModelPath($path ='') {
|
||||||
|
$this->_modelPath = trim($path, '') . '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Optional) Set a custom path to the error file
|
||||||
|
* @param string $path Use the file name of your controller, eg: error.php
|
||||||
|
*/
|
||||||
|
public function setErrorFile($path ='') {
|
||||||
|
$this->_errorFile = trim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Optional) Set a custom path to the error file
|
||||||
|
* @param string $path Use the file name of your controller, eg: index.php
|
||||||
|
*/
|
||||||
|
public function setDefaultFile($path = '') {
|
||||||
|
$this->_defaultFile = trim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Optional) Set a custom path to the error file
|
||||||
|
* @param string $path Use the file name of your controller, eg: index.php
|
||||||
|
*/
|
||||||
|
public function setDefaultPath($path = '') {
|
||||||
|
$this->_defaultPath = trim($path, ''); // Removed the trim for /
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the $_GET from 'url'
|
||||||
|
*/
|
||||||
|
private function _getUrl() {
|
||||||
|
$url = isset($_GET['url']) ? $_GET['url'] : null;
|
||||||
|
$url = rtrim($url, '/');
|
||||||
|
$url = filter_var($url, FILTER_SANITIZE_URL);
|
||||||
|
$this->_url = explode('/', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This loads if there is no GET parameter passed
|
||||||
|
*/
|
||||||
|
private function _loadDefaultController() {
|
||||||
|
require $this->_defaultPath . '/' . $this->_controllerPath . $this->_defaultFile;
|
||||||
|
$this->_controller = new Index();
|
||||||
|
$this->_controller->index();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an existing controller if there IS a GET parameter passed
|
||||||
|
*
|
||||||
|
* @return boolean|string
|
||||||
|
*/
|
||||||
|
private function _loadExistingController() {
|
||||||
|
$file = $this->_defaultPath . '/' . $this->_controllerPath . $this->_url[0] . '.php';
|
||||||
|
|
||||||
|
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require $file;
|
||||||
|
if($this->_url[1]) { $method = $this->_url[1]; } else { $method = "index"; }
|
||||||
|
$this->_controller = new $this->_url[0]($method);
|
||||||
|
$this->_controller->loadModel($this->_url[0], $this->_modelPath);
|
||||||
|
} else {
|
||||||
|
$this->_error();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads Router if there's a rule set for specific URL combinate
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
private function _loadRouter() {
|
||||||
|
/// Run the Router
|
||||||
|
|
||||||
|
|
||||||
|
$this->ControllerName = $this->match['target']['c'];
|
||||||
|
$this->MethodName = $this->match['target']['a'];
|
||||||
|
$this->URIParameters = $this->match['params'];
|
||||||
|
|
||||||
|
|
||||||
|
$file = $this->_defaultPath . '/' . $this->_controllerPath . $this->ControllerName . '.php';
|
||||||
|
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require $file;
|
||||||
|
|
||||||
|
$this->_controller = new $this->ControllerName($this->MethodName);
|
||||||
|
$this->_controller->loadModel($this->ControllerName, $this->_modelPath);
|
||||||
|
} else {
|
||||||
|
$this->_error();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Controller
|
||||||
|
$this->_controller->{$this->MethodName}($this->URIParameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a method is passed in the GET url parameter
|
||||||
|
*
|
||||||
|
* http://localhost/controller/method/(param)/(param)/(param)
|
||||||
|
* url[0] = Controller
|
||||||
|
* url[1] = Method (falls back to 'index' if not a valid method)
|
||||||
|
* url[2] = Param
|
||||||
|
* url[3] = Param
|
||||||
|
* url[4] = Param
|
||||||
|
*
|
||||||
|
* Fallback: /controller/param → controller->index(param)
|
||||||
|
*/
|
||||||
|
private function _callControllerMethod() {
|
||||||
|
$length = count($this->_url);
|
||||||
|
|
||||||
|
// If url[1] is not a valid method, treat it as a param to index()
|
||||||
|
if ($length > 1) {
|
||||||
|
if (!method_exists($this->_controller, $this->_url[1])) {
|
||||||
|
array_splice($this->_url, 1, 0, ['index']);
|
||||||
|
$length = count($this->_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine what to load
|
||||||
|
switch ($length) {
|
||||||
|
case 5:
|
||||||
|
//Controller->Method(Param1, Param2, Param3)
|
||||||
|
$this->_controller->{$this->_url[1]}($this->_url[2], $this->_url[3], $this->_url[4]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
//Controller->Method(Param1, Param2)
|
||||||
|
$this->_controller->{$this->_url[1]}($this->_url[2], $this->_url[3]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
//Controller->Method(Param1, Param2)
|
||||||
|
$this->_controller->{$this->_url[1]}($this->_url[2]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
//Controller->Method(Param1, Param2)
|
||||||
|
$this->_controller->{$this->_url[1]}();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$this->_controller->index();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display an error page if nothing exists
|
||||||
|
*
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
private function _error() {
|
||||||
|
require dirname(__DIR__) . '/' . $this->_defaultPath . '/' . $this->_controllerPath . $this->_errorFile;
|
||||||
|
$this->_controller = new _Error();
|
||||||
|
$this->_controller->index();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
29
core/Controller.php
Normal file
29
core/Controller.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Controller {
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
$this->view = new View();
|
||||||
|
$this->view->_c = $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param string $name Name of the model
|
||||||
|
* @param string $path Location of the models
|
||||||
|
*/
|
||||||
|
public function loadModel($name, $modelPath = 'models/') {
|
||||||
|
|
||||||
|
$path = $modelPath . $name.'_model.php';
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
require $modelPath .$name.'_model.php';
|
||||||
|
|
||||||
|
$modelName = $name . '_Model';
|
||||||
|
$this->model = new $modelName();
|
||||||
|
$this->view->_m = $this->model; // extends [model] access to views
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
321
core/Database.php
Normal file
321
core/Database.php
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Database extends PDO
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
public function __construct($DB_TYPE, $DB_HOST, $DB_NAME, $DB_USER, $DB_PASS)
|
||||||
|
{
|
||||||
|
parent::__construct($DB_TYPE.':host='.$DB_HOST.';dbname='.$DB_NAME.';charset=utf8' , $DB_USER, $DB_PASS);
|
||||||
|
parent::setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_LAZY); // Fetch_lazy - handles (num, assoc & object) without memory overhead
|
||||||
|
parent::setAttribute( PDO::ATTR_EMULATE_PREPARES, true );
|
||||||
|
parent::setAttribute(PDO::MYSQL_ATTR_INIT_COMMAND, "SET NAME'utf8'");
|
||||||
|
parent::setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
parent::setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* select
|
||||||
|
* @param string $sql An SQL string
|
||||||
|
* @param array $array Paramters to bind
|
||||||
|
* @param constant $fetchMode A PDO Fetch mode
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function select($sql, $array = array(), $fetchMode = PDO::FETCH_ASSOC)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$sth = $this->prepare($sql);
|
||||||
|
foreach ($array as $key => $value) {
|
||||||
|
$sth->bindValue("$key", $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sth->execute();
|
||||||
|
return $sth->fetchAll($fetchMode);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$Response['Code']='0';
|
||||||
|
$Response['Message']= 'Error Select Entry: ' . $e->getMessage();
|
||||||
|
|
||||||
|
|
||||||
|
echo json_encode($Response);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* insert
|
||||||
|
* @param string $table A name of table to insert into
|
||||||
|
* @param string $data An associative array
|
||||||
|
*/
|
||||||
|
public function insert($table, $data)
|
||||||
|
{
|
||||||
|
ksort($data);
|
||||||
|
|
||||||
|
$fieldNames = implode('`, `', array_keys($data));
|
||||||
|
$fieldValues = ':' . implode(', :', array_keys($data));
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sth = $this->prepare("INSERT INTO $table (`$fieldNames`) VALUES ($fieldValues)");
|
||||||
|
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$sth->bindValue(":$key", $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sth->execute();
|
||||||
|
$msg = $sth->errorInfo();
|
||||||
|
|
||||||
|
|
||||||
|
if(!$msg[1]) { $msgCode = 1; } else { $msgCode = $msg[1]; }
|
||||||
|
if(!$msg[2]) { $msgMessage = 'Created'; } else { $msgMessage = $msg[2]; }
|
||||||
|
|
||||||
|
$Response['Code']=$msgCode;
|
||||||
|
$Response['Message']=$msgMessage;
|
||||||
|
$Response['ID']= $this->lastInsertId();
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$Response['Code']='0';
|
||||||
|
$Response['Message']= 'Error Creating Entry: ' . $e->getMessage();
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($Response);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update
|
||||||
|
* @param string $table A name of table to insert into
|
||||||
|
* @param string $data An associative array
|
||||||
|
* @param string $where the WHERE query part
|
||||||
|
*/
|
||||||
|
public function update($table, $data, $where)
|
||||||
|
{
|
||||||
|
ksort($data);
|
||||||
|
|
||||||
|
$fieldDetails = NULL;
|
||||||
|
foreach($data as $key=> $value) {
|
||||||
|
$fieldDetails .= "`$key`=:$key,";
|
||||||
|
}
|
||||||
|
$fieldDetails = rtrim($fieldDetails, ',');
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
$sth = $this->prepare("UPDATE $table SET $fieldDetails WHERE $where");
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$sth->bindValue(":$key", $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$sth->execute();
|
||||||
|
$count = $sth->rowCount();
|
||||||
|
if($count){
|
||||||
|
$Response['Code']='1';
|
||||||
|
$Response['Rows']= $count;
|
||||||
|
$Response['Message']='Updated';
|
||||||
|
} else {
|
||||||
|
$Response['Code']='0';
|
||||||
|
$Response['Rows']= $count;
|
||||||
|
$Response['Message']='No Records Updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$Response['Code']='0';
|
||||||
|
$Response['Message']= 'Error Updating Database: ' . $e->getMessage();
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete
|
||||||
|
*
|
||||||
|
* @param string $table
|
||||||
|
* @param string $where
|
||||||
|
* @param integer $limit
|
||||||
|
* @return integer Affected Rows
|
||||||
|
*/
|
||||||
|
public function delete($table, $where, $limit = 1)
|
||||||
|
{
|
||||||
|
$sth = $this->prepare("DELETE FROM $table WHERE $where LIMIT $limit");
|
||||||
|
return $sth->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query
|
||||||
|
*
|
||||||
|
* @param string $sql
|
||||||
|
* @return returns array results
|
||||||
|
*/
|
||||||
|
public function execQuery($sql, $limit='') {
|
||||||
|
// echo $sql;
|
||||||
|
// print_array($limit);
|
||||||
|
if($limit) {
|
||||||
|
$LimitStart = $limit['start'];
|
||||||
|
$LimitEnd = $limit['end'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sth = $this->prepare($sql);
|
||||||
|
if($LimitStart) $sth->bindParam(1, $LimitStart,PDO::PARAM_INT);
|
||||||
|
if($LimitEnd) $sth->bindParam(2, $LimitEnd,PDO::PARAM_INT);
|
||||||
|
|
||||||
|
$sth->execute();
|
||||||
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query
|
||||||
|
*
|
||||||
|
* @param string $table
|
||||||
|
* @param array $arrQuery
|
||||||
|
* @return returns array results
|
||||||
|
* $
|
||||||
|
*/
|
||||||
|
public function wherein($table, $column, $arrQuery, $CustomSQL='') {
|
||||||
|
$SQLWhereIn = implode(',', $arrQuery);
|
||||||
|
if($CustomSQL) {
|
||||||
|
$CustomSQL = " AND " . $CustomSQL;
|
||||||
|
}
|
||||||
|
$sql = "SELECT SQL_CALC_FOUND_ROWS * FROM {$table} WHERE {$column} IN ({$SQLWhereIn}) $CustomSQL";
|
||||||
|
// print_array($sql);
|
||||||
|
$sth = $this->prepare($sql);
|
||||||
|
$sth->execute();
|
||||||
|
|
||||||
|
$count = $this->prepare('SELECT FOUND_ROWS() as Rows');
|
||||||
|
$count->execute();
|
||||||
|
$Counted = $count->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$Counted = $Counted[0]['Rows'];
|
||||||
|
|
||||||
|
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$Result['rowsfound'] = $Counted;
|
||||||
|
return $Result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* select
|
||||||
|
* @param string $sql An SQL string
|
||||||
|
* @param array $array Paramters to bind
|
||||||
|
* @param constant $fetchMode A PDO Fetch mode
|
||||||
|
* @return mixed
|
||||||
|
|
||||||
|
public function retrieve($table, $sql, $fetchMode = PDO::FETCH_ASSOC)
|
||||||
|
{
|
||||||
|
|
||||||
|
$sql = "SELECT * FROM business_profile WHERE 1 LIMIT 0, 100";
|
||||||
|
print_array($sql);
|
||||||
|
$sth = $this->prepare($sql);
|
||||||
|
$sth->execute();
|
||||||
|
|
||||||
|
$statement = $this->query('SELECT FOUND_ROWS() AS CountedRows');
|
||||||
|
print_array($statement);
|
||||||
|
$Counted = $Counted[0]['CountedRows'];
|
||||||
|
|
||||||
|
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$Result['rowsfound'] = $Counted;
|
||||||
|
return $Result;
|
||||||
|
// return $sth->fetchAll($fetchMode);
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* select
|
||||||
|
* @param string $sql An SQL string
|
||||||
|
* @param array $array Paramters to bind
|
||||||
|
* @param constant $fetchMode A PDO Fetch mode
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function retrieve($sql, $array = array(), $pagination = '')
|
||||||
|
{
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sth = $this->prepare($sql);
|
||||||
|
foreach ($array as $key => $value) {
|
||||||
|
$sth->bindValue("$key", $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sth->execute();
|
||||||
|
|
||||||
|
$statement = $this->execQuery('SELECT FOUND_ROWS() AS CountedRows');
|
||||||
|
$Counted = $statement[0]['CountedRows'];
|
||||||
|
|
||||||
|
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$Result['rowsfound'] = $Counted;
|
||||||
|
|
||||||
|
return $Result;
|
||||||
|
// return $sth->fetchAll($fetchMode);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$Result['Code']='0';
|
||||||
|
$Result['Message']= 'Error Select Entry: ' . $e->getMessage();
|
||||||
|
|
||||||
|
return json_encode($Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function pagination($sql, $page='0', $limit='10')
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$find = array("select * from", "LIMIT ?,?");
|
||||||
|
$NewSQL = str_ireplace($find, '', $sql);
|
||||||
|
$totalSQL = "SELECT count(*) as Counted FROM {$NewSQL}";
|
||||||
|
$statement = $this->execQuery($totalSQL);
|
||||||
|
$Counted = $statement[0]['Counted'];
|
||||||
|
|
||||||
|
if(!$page) { $page = '0'; }
|
||||||
|
if(!$limit) { $limit = 10; }
|
||||||
|
$TotalPages = floor($Counted / $limit);
|
||||||
|
if($page) { $page_first_result = ($page) * $limit; } else { $page_first_result = '0'; }
|
||||||
|
|
||||||
|
|
||||||
|
$sth = $this->prepare($sql);
|
||||||
|
$sth->execute([$page_first_result, $limit]);
|
||||||
|
// echo "{$page_first_result}, $limit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$Result['results'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$Result['total'] = $Counted;
|
||||||
|
$Result['limit'] = $limit;
|
||||||
|
$Result['page'] = $page;
|
||||||
|
$Result['pages'] = $TotalPages;
|
||||||
|
|
||||||
|
return $Result;
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$Result['Code']='0';
|
||||||
|
$Result['Message']= 'Error Select Entry: ' . $e->getMessage();
|
||||||
|
|
||||||
|
return json_encode($Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
76
core/ErrorHandler.php
Normal file
76
core/ErrorHandler.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class ErrorHandler {
|
||||||
|
public static function handleError($errno, $errstr, $errfile, $errline) {
|
||||||
|
if (!(error_reporting() & $errno)) {
|
||||||
|
// This error code is not included in error_reporting
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::logError($errno, $errstr, $errfile, $errline);
|
||||||
|
self::displayError($errno, $errstr, $errfile, $errline);
|
||||||
|
|
||||||
|
// Don't execute PHP internal error handler
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handleException($exception) {
|
||||||
|
self::logError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine());
|
||||||
|
self::displayError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine(), $exception->getTraceAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handleShutdown() {
|
||||||
|
$error = error_get_last();
|
||||||
|
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
|
||||||
|
self::logError($error['type'], $error['message'], $error['file'], $error['line']);
|
||||||
|
self::displayError($error['type'], $error['message'], $error['file'], $error['line']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function logError($errno, $errstr, $errfile, $errline) {
|
||||||
|
$message = date('[Y-m-d H:i:s]') . " Error: [$errno] $errstr in $errfile on line $errline\n";
|
||||||
|
error_log($message, 3, 'app_errors.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function displayError($errno, $errstr, $errfile, $errline, $trace = null) {
|
||||||
|
$errorTypes = [
|
||||||
|
E_ERROR => 'Fatal Error',
|
||||||
|
E_WARNING => 'Warning',
|
||||||
|
E_PARSE => 'Parse Error',
|
||||||
|
E_NOTICE => 'Notice',
|
||||||
|
E_CORE_ERROR => 'Core Error',
|
||||||
|
E_CORE_WARNING => 'Core Warning',
|
||||||
|
E_COMPILE_ERROR => 'Compile Error',
|
||||||
|
E_COMPILE_WARNING => 'Compile Warning',
|
||||||
|
E_USER_ERROR => 'User Error',
|
||||||
|
E_USER_WARNING => 'User Warning',
|
||||||
|
E_USER_NOTICE => 'User Notice',
|
||||||
|
E_STRICT => 'Strict Standards',
|
||||||
|
E_RECOVERABLE_ERROR => 'Recoverable Error',
|
||||||
|
E_DEPRECATED => 'Deprecated',
|
||||||
|
E_USER_DEPRECATED => 'User Deprecated',
|
||||||
|
];
|
||||||
|
|
||||||
|
$errorType = isset($errorTypes[$errno]) ? $errorTypes[$errno] : 'Unknown Error';
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
echo "<div style='background-color: #f8d7da; color: #721c24; padding: 10px; margin: 10px; border: 1px solid #f5c6cb; border-radius: 5px;'>";
|
||||||
|
echo "<h1 style='color: #721c24;'>$errorType Occurred</h1>";
|
||||||
|
echo "<p><strong>Message:</strong> $errstr</p>";
|
||||||
|
echo "<p><strong>File:</strong> $errfile</p>";
|
||||||
|
echo "<p><strong>Line:</strong> $errline</p>";
|
||||||
|
if ($trace) {
|
||||||
|
echo "<h2>Stack Trace:</h2>";
|
||||||
|
echo "<pre>$trace</pre>";
|
||||||
|
}
|
||||||
|
echo "<h2>Request Details:</h2>";
|
||||||
|
echo "<pre>";
|
||||||
|
echo "URL: " . $_SERVER['REQUEST_URI'] . "\n";
|
||||||
|
echo "Method: " . $_SERVER['REQUEST_METHOD'] . "\n";
|
||||||
|
echo "Time: " . date('Y-m-d H:i:s') . "\n";
|
||||||
|
echo "IP: " . $_SERVER['REMOTE_ADDR'] . "\n";
|
||||||
|
echo "</pre>";
|
||||||
|
echo "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
core/Model.php
Normal file
14
core/Model.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Model {
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
$this->db = new Database(DB_TYPE, DB_HOST, DB_NAME, DB_USER, DB_PASS);
|
||||||
|
$this->db->setAttribute( PDO::ATTR_EMULATE_PREPARES, false );
|
||||||
|
|
||||||
|
|
||||||
|
//$this->remotedb = new Database(DB_TYPE_REMOTE, DB_HOST_REMOTE, DB_NAME_REMOTE, DB_USER_REMOTE, DB_PASS_REMOTE);
|
||||||
|
//$this->remotedb->setAttribute( PDO::ATTR_EMULATE_PREPARES, false );
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
65
core/Session.php
Normal file
65
core/Session.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Session
|
||||||
|
{
|
||||||
|
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
@session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set($key, $value)
|
||||||
|
{
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get($key)
|
||||||
|
{
|
||||||
|
if (isset($_SESSION[$key]))
|
||||||
|
return $_SESSION[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function destroy()
|
||||||
|
{
|
||||||
|
//unset($_SESSION);
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Set a value or create a child under parent session.
|
||||||
|
*/
|
||||||
|
public static function pSet($parent, $key, $value)
|
||||||
|
{
|
||||||
|
$_SESSION[$parent][$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unset Just Child
|
||||||
|
*/
|
||||||
|
public static function cUnset($parent, $key)
|
||||||
|
{
|
||||||
|
unset($_SESSION[$parent][$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unset Parent and everything under it
|
||||||
|
*/
|
||||||
|
public static function pUnset($key)
|
||||||
|
{
|
||||||
|
unset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param parameter $key
|
||||||
|
* @return Session
|
||||||
|
*/
|
||||||
|
public static function pGet($parent, $key)
|
||||||
|
{
|
||||||
|
if (isset($_SESSION[$parent][$key]))
|
||||||
|
return $_SESSION[$parent][$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
core/View.php
Normal file
51
core/View.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class View {
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
//echo 'this is the view';
|
||||||
|
$this->path = VIEWS_PATH . '/views/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render($name, $type='site', $noInclude = true) {
|
||||||
|
if(empty($type)) { $type = 'site'; }
|
||||||
|
$name = strtolower($name);
|
||||||
|
extract((array) $this);
|
||||||
|
|
||||||
|
if ($noInclude == false) {
|
||||||
|
require $this->path . "" . $name . ".php";
|
||||||
|
} else {
|
||||||
|
include $this->path . "wrapper/{$type}/header.php";
|
||||||
|
require $this->path . $name . ".php";
|
||||||
|
include $this->path . "wrapper/{$type}/footer.php";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generates partial view. Good for straight JSON or XML responses for internal views.
|
||||||
|
* @param $name
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function PartialView($name, $param=false)
|
||||||
|
{
|
||||||
|
extract((array) $this);
|
||||||
|
ob_start();
|
||||||
|
include ($this->path . "partial/" . $name . ".php");
|
||||||
|
$name = ob_get_clean();
|
||||||
|
|
||||||
|
return ($name);
|
||||||
|
ob_end_flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function js($data){
|
||||||
|
print_array($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
public function render($name) {
|
||||||
|
require 'views/' . $name . '.php';
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
}
|
||||||
128
core/core.md
Normal file
128
core/core.md
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
# Core Framework — `core/`
|
||||||
|
|
||||||
|
Framework internals. **Rarely modified.** Contains the request lifecycle engine.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Bootstrap.php` | Bootstraps environment, loads config, starts routing |
|
||||||
|
| `Controller.php` | Base controller class — all UI controllers extend this (via `AppController`) |
|
||||||
|
| `View.php` | View renderer — handles `render()`, `PartialView()`, asset injection |
|
||||||
|
| `AltoRouter.php` | URL router used for complex custom routes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Request
|
||||||
|
→ public/index.php
|
||||||
|
→ config.php (loads .env, defines constants)
|
||||||
|
→ core/Bootstrap.php (environment setup, session, autoload)
|
||||||
|
→ AltoRouter / auto-routing
|
||||||
|
→ ControllerClass::method($param)
|
||||||
|
→ $this->view->render(...)
|
||||||
|
→ Response (HTML or JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-routing maps `/controller/method/param` directly to `ControllerClass::method($param)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Controller` (`core/Controller.php`)
|
||||||
|
|
||||||
|
Base class. Provides:
|
||||||
|
|
||||||
|
- `$this->view` — View instance
|
||||||
|
- `$this->JavaScript[]` — array of JS paths to inject
|
||||||
|
- `$this->Styles[]` — array of CSS paths to inject
|
||||||
|
- `redirect($url)` — HTTP redirect helper
|
||||||
|
|
||||||
|
**Do not modify this file.** App-wide customisation belongs in `app/Controllers/AppController.php`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `AppController` (`app/Controllers/AppController.php`)
|
||||||
|
|
||||||
|
Sits between `core/Controller` and all UI page controllers.
|
||||||
|
UI controllers extend `AppController`, not `Controller` directly.
|
||||||
|
|
||||||
|
**Current responsibilities:**
|
||||||
|
- Injects `$UserProfile` (logged-in user's full profile via `User::profile()`) into every view automatically.
|
||||||
|
|
||||||
|
**`use` statement scoping rule:**
|
||||||
|
- Namespaces needed on every page → `AppController`
|
||||||
|
- Namespaces needed by one controller → that controller only
|
||||||
|
- Namespaces needed by one method → inline as fully-qualified class name
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Controllers/AppController.php
|
||||||
|
use App\Components\User;
|
||||||
|
|
||||||
|
class AppController extends Controller {
|
||||||
|
function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
if (!empty($_SESSION['login']['userid'])) {
|
||||||
|
$this->view->UserProfile = User::profile($_SESSION['login']['userid']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `View` (`core/View.php`)
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- Any property set on `$this->view` becomes a variable in the rendered template.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `render($viewPath, $wrapper = '', $useWelcome = true)` — render a view
|
||||||
|
- `PartialView($partialName)` — load and return a partial as a string
|
||||||
|
|
||||||
|
**Render signatures used in practice:**
|
||||||
|
```php
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__); // default admin wrapper
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__, 'login'); // login wrapper
|
||||||
|
$this->view->render('clients/index', true, 'site'); // legacy style
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
### Auto-Routing (default)
|
||||||
|
|
||||||
|
| URL | Maps to |
|
||||||
|
|-----|---------|
|
||||||
|
| `/clients` | `Clients::index()` |
|
||||||
|
| `/clients/show/123` | `Clients::show(123)` |
|
||||||
|
| `/products/edit/456` | `Products::edit(456)` |
|
||||||
|
|
||||||
|
### AltoRouter (complex routes)
|
||||||
|
|
||||||
|
Define in `public/routes.php` or `api/routes.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Named route with typed parameter
|
||||||
|
$router->map('GET', '/users/[i:id]', 'Users#show', 'user_show');
|
||||||
|
|
||||||
|
// Multiple parameters
|
||||||
|
$router->map('GET', '/blog/[i:year]/[i:month]/[*:slug]', 'Blog#show', 'blog_post');
|
||||||
|
|
||||||
|
// POST
|
||||||
|
$router->map('POST', '/api/users/create', 'UsersAPI#create', 'api_user_create');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Match type tokens:**
|
||||||
|
- `[i:id]` — integer
|
||||||
|
- `[a:action]` — alphanumeric (A-Z, a-z, 0-9, -)
|
||||||
|
- `[h:key]` — hex
|
||||||
|
- `[*:trailing]` — catch-all (no slashes)
|
||||||
|
- `[**:path]` — catch-all including slashes
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Generate URL from named route
|
||||||
|
echo $router->generate('user_show', ['id' => 5]); // → /users/5
|
||||||
|
```
|
||||||
6
index.php
Normal file
6
index.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
require_once realpath(__DIR__) . '/public/index.php';
|
||||||
|
|
||||||
|
|
||||||
|
// Security
|
||||||
|
// var_dump(is_dir($_SERVER['DOCUMENT_ROOT'] . '/install'));
|
||||||
21
manifest.json
Normal file
21
manifest.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "SeedProject Framework",
|
||||||
|
"short_name" : "PWA",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope" : "./",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/ComiidaIcon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/ComiidaIcon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffee00",
|
||||||
|
"background_color": "#ffee00",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
10775
public/assets/css/bootstrap.css
vendored
Normal file
10775
public/assets/css/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
BIN
public/assets/imgs/WelcomeSeedProject.jpg
Normal file
BIN
public/assets/imgs/WelcomeSeedProject.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
7
public/assets/js/bootstrap.bundle.min.js
vendored
Normal file
7
public/assets/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
0
public/assets/js/custom.js
Normal file
0
public/assets/js/custom.js
Normal file
177
public/assets/js/index.js
Normal file
177
public/assets/js/index.js
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
|
||||||
|
const SIGNAL_DATA = [
|
||||||
|
{ type: 'BUY', price: '2034.50', tp: '2042.00', sl: '2029.00', confidence: 89, time: '2m ago' },
|
||||||
|
{ type: 'SELL', price: '2045.10', tp: '2038.50', sl: '2048.00', confidence: 92, time: '15m ago' },
|
||||||
|
{ type: 'BUY', price: '2022.80', tp: '2030.00', sl: '2018.50', confidence: 76, time: '42m ago' },
|
||||||
|
{ type: 'SELL', price: '2051.00', tp: '2044.20', sl: '2055.00', confidence: 84, time: '1h ago' },
|
||||||
|
{ type: 'BUY', price: '2036.20', tp: '2041.50', sl: '2033.00', confidence: 65, time: '1h ago' },
|
||||||
|
{ type: 'SELL', price: '2048.90', tp: '2040.00', sl: '2052.00', confidence: 95, time: '2h ago' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HISTORY_DATA = [
|
||||||
|
{ asset: 'XAUUSD', type: 'BUY', entry: '2,024.50', exit: '2,031.20', yield: '+670 pts', color: 'text-emerald-400', time: '2h ago' },
|
||||||
|
{ asset: 'XAUUSD', type: 'SELL', entry: '2,038.10', exit: '2,034.40', yield: '+370 pts', color: 'text-emerald-400', time: '4h ago' },
|
||||||
|
{ asset: 'XAUUSD', type: 'BUY', entry: '2,018.00', exit: '2,015.50', yield: '-250 pts', color: 'text-rose-400', time: '6h ago' },
|
||||||
|
{ asset: 'XAUUSD', type: 'BUY', entry: '2,012.20', exit: '2,020.80', yield: '+860 pts', color: 'text-emerald-400', time: '10h ago' },
|
||||||
|
];
|
||||||
|
|
||||||
|
class Particle {
|
||||||
|
constructor(sphereRadius) {
|
||||||
|
const theta = Math.random() * 2 * Math.PI;
|
||||||
|
const phi = Math.acos(2 * Math.random() - 1);
|
||||||
|
this.sphereRadius = sphereRadius;
|
||||||
|
this.x = sphereRadius * Math.sin(phi) * Math.cos(theta);
|
||||||
|
this.y = sphereRadius * Math.sin(phi) * Math.sin(theta);
|
||||||
|
this.z = sphereRadius * Math.cos(phi);
|
||||||
|
this.size = Math.random() * 1.8 + 0.6;
|
||||||
|
this.baseOpacity = Math.random() * 0.6 + 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate(angleX, angleY) {
|
||||||
|
let cosY = Math.cos(angleY), sinY = Math.sin(angleY);
|
||||||
|
let x = this.x * cosY - this.z * sinY;
|
||||||
|
let z = this.z * cosY + this.x * sinY;
|
||||||
|
this.x = x; this.z = z;
|
||||||
|
|
||||||
|
let cosX = Math.cos(angleX), sinX = Math.sin(angleX);
|
||||||
|
let y = this.y * cosX - this.z * sinX;
|
||||||
|
z = this.z * cosX + this.y * sinX;
|
||||||
|
this.y = y; this.z = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(ctx, centerX, centerY) {
|
||||||
|
const scale = 300 / (300 + this.z);
|
||||||
|
const x2d = this.x * scale + centerX;
|
||||||
|
const y2d = this.y * scale + centerY;
|
||||||
|
const opacity = Math.max(0, this.baseOpacity + (this.z / this.sphereRadius) * 0.4);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x2d, y2d, this.size * scale, 0, Math.PI * 2);
|
||||||
|
if (this.x > 40) ctx.fillStyle = `rgba(34, 211, 238, ${opacity})`;
|
||||||
|
else if (this.x < -40) ctx.fillStyle = `rgba(167, 139, 250, ${opacity})`;
|
||||||
|
else ctx.fillStyle = `rgba(248, 250, 252, ${opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initParticleSphere() {
|
||||||
|
const canvas = document.getElementById('particle-canvas');
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
const sphereRadius = 105;
|
||||||
|
const particleCount = 400;
|
||||||
|
const rotationSpeed = 0.003;
|
||||||
|
let particles = [];
|
||||||
|
let width, height;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.parentElement.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
width = rect.width;
|
||||||
|
height = rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
particles.sort((a, b) => b.z - a.z);
|
||||||
|
particles.forEach(p => {
|
||||||
|
p.rotate(rotationSpeed, rotationSpeed * 0.6);
|
||||||
|
p.draw(ctx, centerX, centerY);
|
||||||
|
});
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) particles.push(new Particle(sphereRadius));
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSignals() {
|
||||||
|
const grid = document.getElementById('signals-grid');
|
||||||
|
grid.innerHTML = SIGNAL_DATA.map(s => `
|
||||||
|
<div class="glass-panel rounded-2xl p-6 hover:border-white/20 transition-all duration-500 group cursor-default border border-white/5 flex flex-col">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl ${s.type === 'BUY' ? 'bg-emerald-400/10 border-emerald-400/20 text-emerald-400' : 'bg-rose-400/10 border-rose-400/20 text-rose-400'} flex items-center justify-center border">
|
||||||
|
${s.type === 'BUY' ? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>' : '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/></svg>'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] text-slate-500 uppercase tracking-widest mb-0.5">Vector Type</div>
|
||||||
|
<div class="text-lg font-bold tracking-tight ${s.type === 'BUY' ? 'text-emerald-400' : 'text-rose-400'}">${s.type} ORDER</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-slate-500 font-mono bg-white/5 border border-white/10 rounded px-2 py-1">${s.time}</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 mb-8">
|
||||||
|
<div class="flex justify-between items-center bg-white/[0.02] p-2 rounded-lg">
|
||||||
|
<span class="text-xs text-slate-500 font-light">Trigger Entry</span>
|
||||||
|
<span class="text-slate-200 font-mono font-medium">${s.price}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-2">
|
||||||
|
<span class="text-xs text-slate-500 font-light">Target TP</span>
|
||||||
|
<span class="text-emerald-400 font-mono font-medium">${s.tp}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-2">
|
||||||
|
<span class="text-xs text-slate-500 font-light">Risk SL</span>
|
||||||
|
<span class="text-rose-400 font-mono font-medium">${s.sl}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-auto pt-6 border-t border-white/5 flex items-center justify-between">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-[10px] text-slate-600 uppercase tracking-widest">Confidence</span>
|
||||||
|
<span class="text-cyan-400 font-bold text-sm">${s.confidence}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-24 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-gradient-to-r from-blue-600 to-cyan-400" style="width: ${s.confidence}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
const tbody = document.getElementById('history-body');
|
||||||
|
tbody.innerHTML = HISTORY_DATA.map(t => `
|
||||||
|
<tr class="group hover:bg-white/5 transition-colors cursor-default">
|
||||||
|
<td class="py-4 px-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-yellow-500/10 text-yellow-500 flex items-center justify-center text-[10px] font-bold border border-yellow-500/20">G</div>
|
||||||
|
<span class="text-slate-200 font-semibold">${t.asset}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold ${t.type === 'BUY' ? 'bg-emerald-400/10 text-emerald-400 border-emerald-400/20' : 'bg-rose-400/10 text-rose-400 border-rose-400/20'} border">
|
||||||
|
${t.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6 font-mono text-slate-400">${t.entry}</td>
|
||||||
|
<td class="py-4 px-6 font-mono text-slate-400">${t.exit}</td>
|
||||||
|
<td class="py-4 px-6 text-right font-mono font-medium ${t.color}">${t.yield}</td>
|
||||||
|
<td class="py-4 px-6 text-right text-slate-500 text-xs font-light">${t.time}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initParticleSphere();
|
||||||
|
renderSignals();
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
|
const initBtn = document.getElementById('init-btn');
|
||||||
|
const signalsSection = document.getElementById('signals-section');
|
||||||
|
|
||||||
|
initBtn.addEventListener('click', () => {
|
||||||
|
signalsSection.classList.remove('hidden-section');
|
||||||
|
signalsSection.classList.add('visible-section');
|
||||||
|
setTimeout(() => {
|
||||||
|
signalsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
2
public/assets/js/jquery.min.js
vendored
Normal file
2
public/assets/js/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
public/assets/js/sw.js
Normal file
5
public/assets/js/sw.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** An empty service worker! */
|
||||||
|
self.addEventListener ('fetch', function(event)
|
||||||
|
{
|
||||||
|
/** An empty fetch handler! */
|
||||||
|
});
|
||||||
168
public/controllers/controllers.md
Normal file
168
public/controllers/controllers.md
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Controllers — `public/controllers/`
|
||||||
|
|
||||||
|
UI page controllers. Each file maps to a URL segment via auto-routing.
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
- `PascalCase.php` → e.g. `ClientReports.php`
|
||||||
|
- URL `/client-reports` auto-routes to `ClientReports::index()`
|
||||||
|
|
||||||
|
## Base Class
|
||||||
|
|
||||||
|
All UI controllers extend `AppController` (not `Controller` directly):
|
||||||
|
|
||||||
|
```
|
||||||
|
core/Controller.php ← framework core, never modify
|
||||||
|
↑
|
||||||
|
app/Controllers/AppController.php ← app-wide concerns (injects $UserProfile)
|
||||||
|
↑
|
||||||
|
public/controllers/YourController.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard Controller Structure
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
class Clients extends AppController
|
||||||
|
{
|
||||||
|
public function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
Auth::handleLogin(); // redirect to /login if not authenticated
|
||||||
|
|
||||||
|
$this->view->Footer = $this->view->PartialView('admin_footer');
|
||||||
|
$this->view->SideBar = $this->view->PartialView('admin_sidebar');
|
||||||
|
$this->view->Header = $this->view->PartialView('admin_header');
|
||||||
|
$this->view->ToolBar = $this->view->PartialView('admin_toolbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
function index() {
|
||||||
|
$clients = Db::select("SELECT * FROM sp_clients WHERE active = 1");
|
||||||
|
$this->view->Clients = $clients;
|
||||||
|
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show($id) {
|
||||||
|
$client = Db::getRow("SELECT * FROM sp_clients WHERE id = :id", [':id' => $id]);
|
||||||
|
if (!$client) { http_response_code(404); die('Not found'); }
|
||||||
|
|
||||||
|
$this->view->Client = $client;
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Render Conventions
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Standard admin page (default wrapper)
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__);
|
||||||
|
|
||||||
|
// Login / minimal wrapper
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__, 'login');
|
||||||
|
|
||||||
|
// Welcome screen (config flag)
|
||||||
|
if (WELCOME) { $this->view->render(__CLASS__ . '/welcome', '', false); }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Injecting Page-Specific Assets
|
||||||
|
|
||||||
|
```php
|
||||||
|
function index() {
|
||||||
|
// CSS
|
||||||
|
$this->Styles[] = '/public/assets/plugins/datatables/datatables.bundle.css';
|
||||||
|
$this->view->Styles = $this->Styles;
|
||||||
|
|
||||||
|
// JS
|
||||||
|
$this->JavaScript[] = '/public/assets/plugins/datatables/datatables.bundle.js';
|
||||||
|
$this->JavaScript[] = '/public/assets/js/page-script.js';
|
||||||
|
$this->view->JavaScript = $this->JavaScript;
|
||||||
|
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
```php
|
||||||
|
Auth::handleLogin(); // redirects to /login if not authenticated
|
||||||
|
Auth::can('users.create') // returns bool
|
||||||
|
Auth::requirePermission('users.delete'); // halts with 403 JSON if denied
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passing Data to Views
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->view->Users = Db::select("SELECT * FROM sp_users");
|
||||||
|
$this->view->PageTitle = 'User List';
|
||||||
|
// accessed in view as $Users, $PageTitle
|
||||||
|
```
|
||||||
|
|
||||||
|
## CRUD Pattern
|
||||||
|
|
||||||
|
```php
|
||||||
|
function store() {
|
||||||
|
try {
|
||||||
|
if (!Validator::required($_POST['name'])) {
|
||||||
|
throw new Exception('Name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = Db::insert('sp_clients', [
|
||||||
|
'name' => $_POST['name'],
|
||||||
|
'created_at' => date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
|
||||||
|
redirect('/clients/show/' . $id);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if (DEBUG) { echo $e->getMessage(); }
|
||||||
|
else {
|
||||||
|
$this->view->Error = 'Could not save. Please try again.';
|
||||||
|
$this->view->FormData = $_POST;
|
||||||
|
$this->view->render(__CLASS__ . '/create');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update($id) {
|
||||||
|
Db::update('sp_clients',
|
||||||
|
['name' => $_POST['name'], 'updated_at' => date('Y-m-d H:i:s')],
|
||||||
|
'id = :id',
|
||||||
|
[':id' => $id]
|
||||||
|
);
|
||||||
|
redirect('/clients/show/' . $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function delete($id) {
|
||||||
|
Db::delete('sp_clients', 'id = :id', [':id' => $id]);
|
||||||
|
redirect('/clients');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```php
|
||||||
|
function create() {
|
||||||
|
try {
|
||||||
|
Db::beginTransaction();
|
||||||
|
// ... operations ...
|
||||||
|
Db::commit();
|
||||||
|
redirect('/clients');
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
if (DEBUG) {
|
||||||
|
echo "<pre>Error: {$e->getMessage()}\nFile: {$e->getFile()}:{$e->getLine()}</pre>";
|
||||||
|
} else {
|
||||||
|
error_log("Client creation failed: " . $e->getMessage());
|
||||||
|
$this->view->Error = 'An error occurred. Please try again.';
|
||||||
|
$this->view->render(__CLASS__ . '/create');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helper / Utility Code Rule
|
||||||
|
|
||||||
|
Reusable, stateless functions → `public static` method in `app/Helpers/`, never as a private controller method.
|
||||||
|
Call via `HelperClass::methodName(...)`.
|
||||||
29
public/controllers/docs.php
Normal file
29
public/controllers/docs.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Docs extends Controller {
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
// echo $ this->view->PartialView('menu');
|
||||||
|
|
||||||
|
|
||||||
|
$this->Styles['commenta'] = '<!-- Css files -->';
|
||||||
|
$this->view->Styles = $this->Styles;
|
||||||
|
|
||||||
|
$this->JavaScript[] = ASSETS . "js/bootstrap.bundle.min.js";
|
||||||
|
$this->JavaScript[] = ASSETS . "js/jquery.min.js";
|
||||||
|
$this->JavaScript[] = ASSETS . "js/custom.js";
|
||||||
|
$this->view->JavaScript = $this->JavaScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function index() {
|
||||||
|
$this->Styles['commenta'] = '<!-- Bots Page -->';
|
||||||
|
$this->view->Styles = $this->Styles;
|
||||||
|
|
||||||
|
$this->view->render('docs/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
24
public/controllers/error.php
Normal file
24
public/controllers/error.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class _Error extends Controller {
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
function index(){
|
||||||
|
$this->Styles['commenta'] = '<!-- Css files -->';
|
||||||
|
$this->view->Styles = $this->Styles;
|
||||||
|
|
||||||
|
$this->JavaScript[] = ASSETS . "/js/pages/crypto-dashboard.init.js";
|
||||||
|
$this->view->JavaScript = $this->JavaScript;
|
||||||
|
|
||||||
|
$this->view->title = COMPANY;
|
||||||
|
$this->view->render(__CLASS__ .'/'. __FUNCTION__);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} // end class
|
||||||
28
public/controllers/index.php
Normal file
28
public/controllers/index.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class Index extends Controller {
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
// echo $ this->view->PartialView('menu');
|
||||||
|
|
||||||
|
$this->Styles['commenta'] = '<!-- Css files -->';
|
||||||
|
$this->view->Styles = $this->Styles;
|
||||||
|
|
||||||
|
$this->JavaScript[] = ASSETS . "js/bootstrap.bundle.min.js";
|
||||||
|
$this->JavaScript[] = ASSETS . "js/jquery.min.js";
|
||||||
|
$this->JavaScript[] = ASSETS . "js/custom.js";
|
||||||
|
$this->view->JavaScript = $this->JavaScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function index() {
|
||||||
|
$this->Styles['commenta'] = '<!-- Bots Page -->';
|
||||||
|
$this->view->Styles = $this->Styles;
|
||||||
|
|
||||||
|
$this->view->render('index/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
11
public/index.php
Normal file
11
public/index.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
@session_start();
|
||||||
|
require $_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php';
|
||||||
|
require $_SERVER['DOCUMENT_ROOT'] . '/config.php';
|
||||||
|
|
||||||
|
define('VIEWS_PATH', __DIR__);
|
||||||
|
|
||||||
|
|
||||||
|
$bootstrap = new Bootstrap();
|
||||||
|
$bootstrap->init();
|
||||||
|
|
||||||
17
public/models/games_model.php
Normal file
17
public/models/games_model.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
class Games_Model extends Model
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function getGames()
|
||||||
|
{
|
||||||
|
//$Return = $this->db->select("select * from creditorinfo");
|
||||||
|
//return json_encode($Return);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} // End Class
|
||||||
11
public/models/index_model.php
Normal file
11
public/models/index_model.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
class Index_Model extends Model
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
} // End Class
|
||||||
74
public/models/login_model.php
Normal file
74
public/models/login_model.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
class Login_Model extends Model
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
This is basic authentication level for log in. However we need to enhance this a bit.
|
||||||
|
If User Logged then redirect to User screen
|
||||||
|
if Admin Logged then redirect to Admin Screen
|
||||||
|
If Agency Logged then redirect to Agency screen.
|
||||||
|
*/
|
||||||
|
public function run()
|
||||||
|
{
|
||||||
|
$sth = $this->db->prepare("SELECT user_id, username, role_id FROM users WHERE
|
||||||
|
username = :username AND password = :password");
|
||||||
|
$sth->execute(array(
|
||||||
|
':username' => $_POST['username'],
|
||||||
|
':password' => Hash::create('sha256', $_POST['password'], HASH_PASSWORD_KEY)
|
||||||
|
));
|
||||||
|
|
||||||
|
$data = $sth->fetch();
|
||||||
|
|
||||||
|
|
||||||
|
$count = $sth->rowCount();
|
||||||
|
if ($count > 0) {
|
||||||
|
// login
|
||||||
|
Session::init();
|
||||||
|
Session::pSet('UserProfile', 'roleId', $data['role_id']);
|
||||||
|
Session::pSet('UserProfile', 'loggedIn', true);
|
||||||
|
Session::pSet('UserProfile', 'userId', $data['user_id']);
|
||||||
|
|
||||||
|
$sql = "SELECT t2.perm_controller,t2.perm_action FROM role_perm as t1
|
||||||
|
JOIN permissions as t2 ON t1.perm_id = t2.perm_id
|
||||||
|
WHERE t1.role_id = :role_id";
|
||||||
|
|
||||||
|
$permission = array();
|
||||||
|
$permission = $this->db->select($sql, array(':role_id' => $data['role_id']));
|
||||||
|
|
||||||
|
Session::set('permission', $permission);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This is a bit annoying to have it hard coded but its good enough for now.
|
||||||
|
// This will set the specific redirects for the access levels Business, Client & User there's only 3 screens with sub levels of permissions.
|
||||||
|
switch($data['role_id']) {
|
||||||
|
# Business
|
||||||
|
case '1' : // Administrator
|
||||||
|
case '2' : // Marketing
|
||||||
|
header('location: /admin');
|
||||||
|
break;
|
||||||
|
|
||||||
|
# Client
|
||||||
|
case '4' : // Agency
|
||||||
|
header('location: /dashboard');
|
||||||
|
break;
|
||||||
|
|
||||||
|
#End User
|
||||||
|
case '6' : // Users
|
||||||
|
header('location: /dashboard');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
header('location: ../login');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
15
public/views/_error/index.php
Normal file
15
public/views/_error/index.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
|
||||||
|
<div class="middle-box text-center animated fadeInDown">
|
||||||
|
<h1>404</h1>
|
||||||
|
<h3 class="font-bold">Page Not Found</h3>
|
||||||
|
|
||||||
|
<div class="error-desc">
|
||||||
|
Sorry, but the page you are looking for has note been found. Try checking the URL for error, then hit the refresh button on your browser or try found something else in our app.
|
||||||
|
<form class="form-inline m-t justify-content-center" role="form">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-control" placeholder="Search for page">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
55
public/views/aitest/index.php
Normal file
55
public/views/aitest/index.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?
|
||||||
|
$Response = $this->OpenAiResponse;
|
||||||
|
?>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2>Testing OpenAI / ChatGPT API</h2>
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="exampleFormControlInput1">Ask Your Question: </label>
|
||||||
|
<input type="text" class="form-control" name="question" id="exampleFormControlInput1" placeholder="How is a Rainbow Made?">
|
||||||
|
</div>
|
||||||
|
<!-- <div class="form-group">-->
|
||||||
|
<!-- <label for="exampleFormControlSelect1">Example select</label>-->
|
||||||
|
<!-- <select class="form-control" id="exampleFormControlSelect1">-->
|
||||||
|
<!-- <option>1</option>-->
|
||||||
|
<!-- <option>2</option>-->
|
||||||
|
<!-- <option>3</option>-->
|
||||||
|
<!-- <option>4</option>-->
|
||||||
|
<!-- <option>5</option>-->
|
||||||
|
<!-- </select>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div class="form-group">-->
|
||||||
|
<!-- <label for="exampleFormControlTextarea1">Example textarea</label>-->
|
||||||
|
<!-- <textarea class="form-control" id="exampleFormControlTextarea1" rows="3"></textarea>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 mt-3">
|
||||||
|
|
||||||
|
<input type="submit" value="Submit Question" class="btn btn-primary">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
<hr class="mt-5">
|
||||||
|
<h4 class="mb-3"><strong>Your Question: </strong> <?= $_POST['question']; ?></h4>
|
||||||
|
|
||||||
|
<h4 class="mb-4"><strong>Your Answer:</strong> </h4>
|
||||||
|
<pre class="alert alert-light" style="white-space: pre-wrap;">
|
||||||
|
<?= $Response; ?>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="text-muted py-5">
|
||||||
|
<div class="container">
|
||||||
|
<p class="float-end mb-1">
|
||||||
|
<a href="#">Powered by SeedProject</a>
|
||||||
|
</p>
|
||||||
|
<p class="mb-1">My Application © 2022 </p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
40
public/views/aitest/translate.php
Normal file
40
public/views/aitest/translate.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
A Viva Air le quedarían muy pocas horas de vuelo
|
||||||
|
Por la crisis económica, la Junta Directiva habría tomado la decisión de cerrar la operación. Se verían afectadas un millón de personas que compraron tiquetes.
|
||||||
|
En cuestión de horas la aerolínea Viva pondría punto final a toda su operación. La Junta Directiva de la compañía aérea de bajo costo habría dado luz verde para dejar en tierra la totalidad de sus aeronaves, así como la de liquidar la actividad de la compañía.
|
||||||
|
|
||||||
|
EL COLOMBIANO pudo establecer que el órgano directivo de la empresa, en un reciente cónclave extraordinario, habría tomado la decisión de no darle más largas al asunto, y tiene lista la orden para la clausura total de la compañía aérea ante su crisis financiera, que a la fecha ya le ha representado dejar aviones sin volar.
|
||||||
|
|
||||||
|
Trascendió que para los miembros de la Junta Directiva las finanzas de la aerolínea no le permitirían continuar con la operación, incluso señalaron que aún amparada la compañía en el Decreto 560 (norma de Recuperación Económica) y pagado el 100% de los compromisos, la operación les daría para máximo dos semanas.
|
||||||
|
La situación es tan crítica que en una carta reciente que el anterior presidente de la compañía, Félix Antelo, envió al ministro de Transporte, Guillermo Reyes, advirtió que al 31 de enero de 2023, los pasivos de la empresa aérea de bajo costo llegaban a los $4,06 billones. “Como bien sabe la compañía no tiene la capacidad por sí misma para responder con sus obligaciones”, resaltó quien hasta el jueves pasado fuera el presidente de Viva Air.
|
||||||
|
|
||||||
|
Integración con Avianca
|
||||||
|
|
||||||
|
Se conoció además que, para los miembros de la Junta Directiva de la aerolínea será un error grande, que le causará una tragedia al turismo y a la economía de Colombia, la decisión de la Aeronáutica Civil (Aerocivil) de no hacer nada, ni decir nada, sobre la integración de Viva y Avianca.
|
||||||
|
|
||||||
|
Así mismo, la aerolínea de bajo costo espera que en las próximas horas, bien sea el director de la Aerocivil, Sergio París, o en su efecto el ministro Reyes, entren en contacto con los directivos de la compañía, y con el nuevo presidente de la misma, Francisco Lalinde, para lograr la tan anhelada integración con Avianca.
|
||||||
|
|
||||||
|
De no lograrse el aval por parte de la Aerocivil a más tardar el martes, como lo habría dictado la Junta Directiva, la aerolínea cerraría la operación con lo que resultarán damnificadas más de un millón de personas que a la fecha ya tiene comprados tiquetes y a las que no se les reembolsaría el dinero en el corto plazo.
|
||||||
|
|
||||||
|
No hay una decisión}
|
||||||
|
|
||||||
|
EL COLOMBIANO se comunicó con voceros de la compañía aérea para confirmar las versiones de su posible cierre de operaciones mañana martes. Al respecto, de manera oficial la aerolínea de bajo costo señaló: “Hasta la fecha no hay una definición de cierre de la compañía y Viva sigue operando y a la espera de una urgente definición por parte de la Aeronáutica Civil”.
|
||||||
|
|
||||||
|
Sin embargo, este diario pudo establecer que a la cascada de renuncias del personal del servicio en aire y en tierra, se viene ajustando la operación con los recursos y aeronaves que quedan.
|
||||||
|
|
||||||
|
Cabe recordar que Viva anunció hace una semana y en un comunicado interno que temporalmente tendrá que prescindir del uso de cinco aviones. “Se han presentado importantes negociaciones con los dueños de los aviones buscando llegar a acuerdos de cómo continuar nuestra operación. A pesar de esto, hemos sido notificados por uno de ellos que debemos dejar en tierra, por el momento, cinco de sus aviones hasta nueva orden en los Estados Unidos”, aseguró la compañía en el documento.
|
||||||
|
|
||||||
|
En menos de un mes la administración de la aerolínea no solo ha cancelado varias frecuencias entre la costa Caribe y Cali, además los 14 vuelos que operaban hacia San Andrés, lo que representa el 90% de la economía de la isla.
|
||||||
|
|
||||||
|
“A días de perder Viva”
|
||||||
|
|
||||||
|
En una inquietante carta, los trabajadores de la aerolínea se pronunciaron frente a la crítica situación financiera que vive.
|
||||||
|
|
||||||
|
En la misiva advierten que cerca de 5.000 personas que dependen económicamente de esta empresa se encuentran en incertidumbre ante la falta de claridad de las autoridades sobre si habrá o no rescate (integración con Avianca).
|
||||||
|
|
||||||
|
“Necesitamos una respuesta con urgencia”, dicen los trabajadores, quienes advierten que aunque han enviado cartas y han sido prudentes en su comunicación, no han recibido atención de la Aerocivil o del gobierno. El jueves de la semana pasada muchos de ellos protestaron en el aeropuerto José María Córdova.
|
||||||
|
|
||||||
|
Con la clausura de la operación de Viva en el negocio aéreo del país, quedaría un vacío del 21%, que es la participación de la aerolínea de bajo en el mercado, y teniendo en cuenta que Avianca posee el 40%.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1
public/views/docs/index.php
Normal file
1
public/views/docs/index.php
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
No Documentation Here
|
||||||
52
public/views/index/index.php
Normal file
52
public/views/index/index.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<body class="text-slate-300 antialiased selection:bg-cyan-500/30 selection:text-cyan-200">
|
||||||
|
<div class="min-h-screen flex flex-col relative overflow-hidden">
|
||||||
|
<!-- Background Ambient Glows -->
|
||||||
|
<div class="absolute top-0 left-1/4 -translate-x-1/2 w-[500px] h-[500px] bg-cyan-600/20 blur-[120px] rounded-full -z-10"></div>
|
||||||
|
<div class="absolute bottom-0 right-0 translate-y-1/4 w-[600px] h-[600px] bg-violet-600/20 blur-[120px] rounded-full -z-10"></div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-grow pt-32 pb-12 flex flex-col items-center relative z-10 px-4">
|
||||||
|
<div class="text-center max-w-3xl mx-auto mb-12">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-cyan-500/20 bg-cyan-500/10 text-cyan-400 text-xs font-medium tracking-wide mb-8">
|
||||||
|
<span class="relative flex h-2 w-2">
|
||||||
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
|
||||||
|
</span>
|
||||||
|
Light-Weight LLM Friendly PHP Framework
|
||||||
|
</div>
|
||||||
|
<h1 class="text-5xl md:text-7xl font-semibold text-white tracking-tighter mb-6 leading-[1.1]">
|
||||||
|
SeedProject <span class="bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-blue-500"> </br>Framework</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-slate-400 font-light text-base md:text-lg max-w-xl mx-auto leading-relaxed">
|
||||||
|
SeedProject Framework is revamped to work with LLM likes Claude Code, Gemini CLI to help you build more structured projects, harness the power of AI without messy confusing back-end code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Particle Sphere Container -->
|
||||||
|
<div class="relative w-full max-w-[21rem] aspect-square flex items-center justify-center mb-16" id="canvas-container">
|
||||||
|
<canvas id="particle-canvas" class="absolute inset-0 z-10 w-full h-full"></canvas>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center z-0">
|
||||||
|
<div class="w-24 h-24 rounded-full border border-cyan-500/20 bg-cyan-900/5 backdrop-blur-sm pulse-ring"></div>
|
||||||
|
<div class="absolute w-36 h-36 rounded-full border border-white/5 opacity-30"></div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-1/4 left-0 glass px-4 py-2.5 rounded-xl border border-white/10 transform -translate-x-6 animate-bounce" style="animation-duration: 4s;">
|
||||||
|
<div class="text-[10px] text-slate-500 uppercase tracking-widest mb-1">Light-Weight</div>
|
||||||
|
<div class="text-sm text-white font-mono font-medium">4.9MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-1/4 right-0 glass px-4 py-2.5 rounded-xl border border-white/10 transform translate-x-6 animate-bounce" style="animation-duration: 5s;">
|
||||||
|
<div class="text-[10px] text-slate-500 uppercase tracking-widest mb-1">Lines of Code</div>
|
||||||
|
<div class="text-sm text-emerald-400 font-mono font-medium">1k</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<div class="relative z-20 mb-32 group">
|
||||||
|
<div class="absolute -inset-1 bg-gradient-to-r from-cyan-600 to-blue-600 rounded-xl blur opacity-30 group-hover:opacity-60 transition duration-500"></div>
|
||||||
|
<button id="init-btn" class="relative px-10 py-5 bg-slate-900 rounded-xl leading-none flex items-center gap-4 border border-slate-800 hover:border-slate-700 transition-all duration-300">
|
||||||
|
<span class="text-slate-200 font-semibold tracking-tight text-lg">Review Documentation</span>
|
||||||
|
<svg class="text-cyan-400" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
0
public/views/partial/menu.php
Normal file
0
public/views/partial/menu.php
Normal file
170
public/views/views.md
Normal file
170
public/views/views.md
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
# Views — `public/views/`
|
||||||
|
|
||||||
|
PHP view templates, organized by controller name.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
public/views/
|
||||||
|
├── <controller_name>/ # snake_case folder per controller
|
||||||
|
│ ├── index.php
|
||||||
|
│ ├── show.php
|
||||||
|
│ └── edit.php
|
||||||
|
├── modals/ # Modal fragment views (AJAX loaded)
|
||||||
|
│ └── <modal_name>.php
|
||||||
|
├── partial/ # Reusable partial views
|
||||||
|
│ ├── admin_header.php
|
||||||
|
│ ├── admin_sidebar.php
|
||||||
|
│ ├── admin_toolbar.php
|
||||||
|
│ └── admin_footer.php
|
||||||
|
└── wrapper/ # Layout wrappers
|
||||||
|
├── admin/
|
||||||
|
│ ├── header.php
|
||||||
|
│ └── footer.php
|
||||||
|
└── site/
|
||||||
|
├── header.php
|
||||||
|
└── footer.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
- Folder: `snake_case` matching the controller name (lowercased)
|
||||||
|
- Files: `snake_case.php` matching the method name
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
Called from the controller:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Standard — uses __CLASS__/__FUNCTION__ convention
|
||||||
|
$this->view->render(__CLASS__ . '/' . __FUNCTION__);
|
||||||
|
|
||||||
|
// With explicit wrapper
|
||||||
|
$this->view->render('clients/index', true, 'site');
|
||||||
|
$this->view->render('admin/dashboard', true, 'admin');
|
||||||
|
|
||||||
|
// Login / minimal wrapper
|
||||||
|
$this->view->render('auth/login', 'login');
|
||||||
|
|
||||||
|
// No wrapper
|
||||||
|
$this->view->render('auth/login', false);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing View Data
|
||||||
|
|
||||||
|
Variables set on the controller via `$this->view->VarName = $value` are accessed in the view as **`$this->VarName`**.
|
||||||
|
|
||||||
|
`View::render()` calls `extract((array) $this)` before including the template, so all properties set on the view object are available as bare `$VarName` locals.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Controller
|
||||||
|
$this->view->Users = Db::select("SELECT * FROM sp_users");
|
||||||
|
$this->view->PageTitle = 'Users';
|
||||||
|
|
||||||
|
// View — use bare $VarName
|
||||||
|
<h1><?= $PageTitle ?></h1>
|
||||||
|
<?php foreach ($Users as $user): ?>
|
||||||
|
<p><?= htmlspecialchars($user['name']) ?></p>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wrappers
|
||||||
|
|
||||||
|
Wrappers live in `public/views/wrapper/`. The wrapper auto-loads:
|
||||||
|
- `public/assets/css/<controllername>.css` if it exists
|
||||||
|
- `public/assets/js/<controllername>.js` if it exists
|
||||||
|
|
||||||
|
## Partial Views
|
||||||
|
|
||||||
|
Partials are reusable snippets loaded from the controller constructor:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In controller __construct()
|
||||||
|
$this->view->Header = $this->view->PartialView('admin_header');
|
||||||
|
$this->view->SideBar = $this->view->PartialView('admin_sidebar');
|
||||||
|
$this->view->ToolBar = $this->view->PartialView('admin_toolbar');
|
||||||
|
$this->view->Footer = $this->view->PartialView('admin_footer');
|
||||||
|
```
|
||||||
|
|
||||||
|
Then echoed in the view:
|
||||||
|
```php
|
||||||
|
<?= $Header ?>
|
||||||
|
<?= $SideBar ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modal System
|
||||||
|
|
||||||
|
Modals are loaded dynamically via AJAX. Trigger a modal with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="RegularModal btn btn-primary"
|
||||||
|
data-url="/modals/load/<modal_name>"
|
||||||
|
data-size="mw-650px">
|
||||||
|
Open Modal
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
`data-size` accepts any Bootstrap modal width class or a custom `mw-*` value:
|
||||||
|
|
||||||
|
| Value | Width |
|
||||||
|
|-------|-------|
|
||||||
|
| *(omit)* | Default (~500px) |
|
||||||
|
| `mw-500px` | 500px max-width |
|
||||||
|
| `mw-650px` | 650px max-width |
|
||||||
|
| `mw-750px` | 750px max-width |
|
||||||
|
| `modal-lg` | ~800px (Bootstrap) |
|
||||||
|
| `modal-xl` | ~1140px (Bootstrap) — use for wizards / multi-step forms |
|
||||||
|
|
||||||
|
Modal views live at `public/views/modals/<modal_name>.php` and render **only** the `.modal-content` fragment (no full page wrapper):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!--begin::Modal content-->
|
||||||
|
<div class="modal-content">
|
||||||
|
<!--begin::Modal header-->
|
||||||
|
<div class="modal-header" id="kt_modal_add_client_header">
|
||||||
|
<h2>Add Client</h2>
|
||||||
|
<div class="btn btn-sm btn-icon btn-active-color-primary" data-bs-dismiss="modal">
|
||||||
|
<i class="ki-duotone ki-cross fs-1">
|
||||||
|
<span class="path1"></span>
|
||||||
|
<span class="path2"></span>
|
||||||
|
</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--end::Modal header-->
|
||||||
|
|
||||||
|
<!--begin::Modal body-->
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- form or content here -->
|
||||||
|
</div>
|
||||||
|
<!--end::Modal body-->
|
||||||
|
</div>
|
||||||
|
<!--end::Modal content-->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Assets Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
public/assets/
|
||||||
|
├── css/
|
||||||
|
│ ├── custom.css # Global overrides
|
||||||
|
│ └── <controller>.css # Page-specific (auto-loaded by wrapper)
|
||||||
|
├── js/
|
||||||
|
│ └── <controller>.js # Page-specific (auto-loaded by wrapper)
|
||||||
|
├── plugins/
|
||||||
|
│ └── datatables/
|
||||||
|
│ ├── datatables.bundle.css
|
||||||
|
│ └── datatables.bundle.js
|
||||||
|
└── media/
|
||||||
|
└── svg/
|
||||||
|
└── brand-logos/ # Integration / vendor logos
|
||||||
|
```
|
||||||
|
|
||||||
|
Inject additional assets from the controller method:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->Styles[] = '/public/assets/plugins/datatables/datatables.bundle.css';
|
||||||
|
$this->view->Styles = $this->Styles;
|
||||||
|
|
||||||
|
$this->JavaScript[] = '/public/assets/plugins/datatables/datatables.bundle.js';
|
||||||
|
$this->view->JavaScript = $this->JavaScript;
|
||||||
|
```
|
||||||
19
public/views/wrapper/site/footer.php
Normal file
19
public/views/wrapper/site/footer.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
|
||||||
|
<?php
|
||||||
|
if($this->JavaScript) {
|
||||||
|
foreach ($this->JavaScript as $kj => $JavaScript) :
|
||||||
|
if(!is_numeric($kj)) {
|
||||||
|
echo PHP_EOL;
|
||||||
|
echo $JavaScript . PHP_EOL;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo '<script src="'. $JavaScript .'"></script>' . PHP_EOL;
|
||||||
|
endforeach;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<script type="module" src="/public/assets/js/index.js"></script>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
public/views/wrapper/site/header.php
Normal file
52
public/views/wrapper/site/header.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CreditPullEngine</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background-color: #030712; /* Slate 950 */
|
||||||
|
overflow-x: hidden;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: #0f172a; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.glass-panel {
|
||||||
|
background: linear-gradient(180deg, rgba(30, 41, 59, 0.4) 0%, rgba(15, 23, 42, 0.6) 100%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% { transform: scale(0.8); opacity: 0.5; }
|
||||||
|
100% { transform: scale(1.2); opacity: 0; }
|
||||||
|
}
|
||||||
|
.pulse-ring { animation: pulse-ring 3s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; }
|
||||||
|
#signals-section { transition: opacity 1s ease, transform 1s ease; }
|
||||||
|
.hidden-section { opacity: 0; transform: translateY(50px); pointer-events: none; height: 0; overflow: hidden; }
|
||||||
|
.visible-section { opacity: 1; transform: translateY(0); pointer-events: auto; height: auto; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
if($this->Styles) {
|
||||||
|
foreach ($this->Styles as $ks => $Styles) :
|
||||||
|
if(!is_numeric($ks)) {
|
||||||
|
echo PHP_EOL;
|
||||||
|
echo $Styles . PHP_EOL;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo '<link type="text/css" rel="stylesheet" href="'. $Styles .'">' .PHP_EOL;
|
||||||
|
|
||||||
|
endforeach;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</head>
|
||||||
19
sw.js
Normal file
19
sw.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/** An empty service worker! */
|
||||||
|
self.addEventListener ('install', e => {
|
||||||
|
console.log("Installed!");
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open("static").then(cache => {
|
||||||
|
return cache.addAll(["/", "/assets/css/pwa.css", "/assets/icons/ComiidaIcon-192.png"]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
self.addEventListener("fetch", e => {
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(e.request).then(response => {
|
||||||
|
return response || fetch(e.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
170
system/ErrorHandler.php
Normal file
170
system/ErrorHandler.php
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
class ErrorHandler {
|
||||||
|
|
||||||
|
private static array $errorTypeMap = [
|
||||||
|
E_ERROR => 'E_ERROR',
|
||||||
|
E_WARNING => 'E_WARNING',
|
||||||
|
E_PARSE => 'E_PARSE',
|
||||||
|
E_NOTICE => 'E_NOTICE',
|
||||||
|
E_CORE_ERROR => 'E_CORE_ERROR',
|
||||||
|
E_CORE_WARNING => 'E_CORE_WARNING',
|
||||||
|
E_COMPILE_ERROR => 'E_COMPILE_ERROR',
|
||||||
|
E_COMPILE_WARNING => 'E_COMPILE_WARNING',
|
||||||
|
E_USER_ERROR => 'E_USER_ERROR',
|
||||||
|
E_USER_WARNING => 'E_USER_WARNING',
|
||||||
|
E_USER_NOTICE => 'E_USER_NOTICE',
|
||||||
|
E_STRICT => 'E_STRICT',
|
||||||
|
E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR',
|
||||||
|
E_DEPRECATED => 'E_DEPRECATED',
|
||||||
|
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static array $errorTypeLabels = [
|
||||||
|
E_ERROR => 'Fatal Error',
|
||||||
|
E_WARNING => 'Warning',
|
||||||
|
E_PARSE => 'Parse Error',
|
||||||
|
E_NOTICE => 'Notice',
|
||||||
|
E_CORE_ERROR => 'Core Error',
|
||||||
|
E_CORE_WARNING => 'Core Warning',
|
||||||
|
E_COMPILE_ERROR => 'Compile Error',
|
||||||
|
E_COMPILE_WARNING => 'Compile Warning',
|
||||||
|
E_USER_ERROR => 'User Error',
|
||||||
|
E_USER_WARNING => 'User Warning',
|
||||||
|
E_USER_NOTICE => 'User Notice',
|
||||||
|
E_STRICT => 'Strict Standards',
|
||||||
|
E_RECOVERABLE_ERROR => 'Recoverable Error',
|
||||||
|
E_DEPRECATED => 'Deprecated',
|
||||||
|
E_USER_DEPRECATED => 'User Deprecated',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static array $fatalTypes = [
|
||||||
|
E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR,
|
||||||
|
E_USER_ERROR, E_RECOVERABLE_ERROR,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function handleError($errno, $errstr, $errfile, $errline) {
|
||||||
|
if (!(error_reporting() & $errno)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only persist fatal/critical errors — skip warnings, notices, deprecated, strict
|
||||||
|
if (in_array($errno, self::$fatalTypes)) {
|
||||||
|
self::persistError($errno, $errstr, $errfile, $errline);
|
||||||
|
self::logError($errno, $errstr, $errfile, $errline);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::displayError($errno, $errstr, $errfile, $errline);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handleException($exception) {
|
||||||
|
$trace = $exception->getTraceAsString();
|
||||||
|
self::persistError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine(), $trace, 'Exception');
|
||||||
|
self::logError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine());
|
||||||
|
self::displayError(E_ERROR, $exception->getMessage(), $exception->getFile(), $exception->getLine(), $trace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handleShutdown() {
|
||||||
|
$error = error_get_last();
|
||||||
|
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
|
||||||
|
self::persistError($error['type'], $error['message'], $error['file'], $error['line']);
|
||||||
|
self::logError($error['type'], $error['message'], $error['file'], $error['line']);
|
||||||
|
self::displayError($error['type'], $error['message'], $error['file'], $error['line']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JSON flat-file persistence ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function persistError($errno, $errstr, $errfile, $errline, $trace = null, $forcedType = null) {
|
||||||
|
try {
|
||||||
|
$errorType = $forcedType ?? (self::$errorTypeMap[$errno] ?? 'UNKNOWN');
|
||||||
|
|
||||||
|
$userId = null;
|
||||||
|
$orgId = null;
|
||||||
|
if (isset($_SESSION['login'])) {
|
||||||
|
$userId = $_SESSION['login']['userid'] ?? null;
|
||||||
|
$orgId = $_SESSION['login']['org_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http')
|
||||||
|
. '://' . ($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['REQUEST_URI'] ?? '');
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? null;
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||||
|
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
|
||||||
|
$referer = $_SERVER['HTTP_REFERER'] ?? null;
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'id' => uniqid('err_', true),
|
||||||
|
'userid' => $userId,
|
||||||
|
'org_id' => $orgId,
|
||||||
|
'errortype' => $errorType,
|
||||||
|
'errorcode' => (string)$errno,
|
||||||
|
'url' => substr($url, 0, 512),
|
||||||
|
'file' => substr($errfile, 0, 512),
|
||||||
|
'line' => (int)$errline,
|
||||||
|
'fullerror' => $errstr,
|
||||||
|
'trace' => $trace,
|
||||||
|
'method' => $method ? substr($method, 0, 10) : null,
|
||||||
|
'ip_address' => $ipAddress ? substr($ipAddress, 0, 45) : null,
|
||||||
|
'user_agent' => $userAgent ? substr($userAgent, 0, 512) : null,
|
||||||
|
'referer' => $referer ? substr($referer, 0, 512) : null,
|
||||||
|
'status' => 'unresolved',
|
||||||
|
'createdate' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$logPath = __DIR__ . '/errors.json';
|
||||||
|
$entries = [];
|
||||||
|
if (file_exists($logPath)) {
|
||||||
|
$raw = file_get_contents($logPath);
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$entries = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
array_unshift($entries, $entry);
|
||||||
|
if (count($entries) > 1000) {
|
||||||
|
$entries = array_slice($entries, 0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($logPath, json_encode($entries, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently fail — never let error logging crash the app
|
||||||
|
error_log('ErrorHandler::persistError failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── File logging ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function logError($errno, $errstr, $errfile, $errline) {
|
||||||
|
$message = date('[Y-m-d H:i:s]') . " Error: [$errno] $errstr in $errfile on line $errline\n";
|
||||||
|
error_log($message, 3, __DIR__ . '/app_errors.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Display (debug mode only) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function displayError($errno, $errstr, $errfile, $errline, $trace = null) {
|
||||||
|
$errorType = self::$errorTypeLabels[$errno] ?? 'Unknown Error';
|
||||||
|
|
||||||
|
if (defined('DEBUG') && DEBUG) {
|
||||||
|
echo "<div style='background-color:#f8d7da;color:#721c24;padding:10px;margin:10px;border:1px solid #f5c6cb;border-radius:5px;'>";
|
||||||
|
echo "<h1 style='color:#721c24;'>" . htmlspecialchars($errorType) . " Occurred</h1>";
|
||||||
|
echo "<p><strong>Message:</strong> " . htmlspecialchars($errstr) . "</p>";
|
||||||
|
echo "<p><strong>File:</strong> " . htmlspecialchars($errfile) . "</p>";
|
||||||
|
echo "<p><strong>Line:</strong> " . htmlspecialchars((string)$errline) . "</p>";
|
||||||
|
if ($trace) {
|
||||||
|
echo "<h2>Stack Trace:</h2>";
|
||||||
|
echo "<pre>" . htmlspecialchars($trace) . "</pre>";
|
||||||
|
}
|
||||||
|
echo "<h2>Request Details:</h2><pre>";
|
||||||
|
echo "URL: " . htmlspecialchars($_SERVER['REQUEST_URI'] ?? '') . "\n";
|
||||||
|
echo "Method: " . htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? '') . "\n";
|
||||||
|
echo "Time: " . date('Y-m-d H:i:s') . "\n";
|
||||||
|
echo "IP: " . htmlspecialchars($_SERVER['REMOTE_ADDR'] ?? '') . "\n";
|
||||||
|
echo "</pre></div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
362
system/errors.json
Normal file
362
system/errors.json
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "err_69ee699b38a214.70118470",
|
||||||
|
"userid": 2565,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
|
||||||
|
"line": 317,
|
||||||
|
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
|
||||||
|
"method": "POST",
|
||||||
|
"ip_address": "193.36.238.59",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-26 15:38:03"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ee698c669d29.22210069",
|
||||||
|
"userid": 2565,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
|
||||||
|
"line": 317,
|
||||||
|
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
|
||||||
|
"method": "POST",
|
||||||
|
"ip_address": "193.36.238.59",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-26 15:37:48"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ee6950756db9.31923142",
|
||||||
|
"userid": 2565,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
|
||||||
|
"line": 317,
|
||||||
|
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
|
||||||
|
"method": "POST",
|
||||||
|
"ip_address": "193.36.238.59",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-26 15:36:48"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ee6934c9c873.23488128",
|
||||||
|
"userid": 2565,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
|
||||||
|
"line": 317,
|
||||||
|
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
|
||||||
|
"method": "POST",
|
||||||
|
"ip_address": "193.36.238.59",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-26 15:36:20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ee69273633b8.88201084",
|
||||||
|
"userid": 2565,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/appi\/pulls\/pullReport",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxCodeMap.php",
|
||||||
|
"line": 317,
|
||||||
|
"fullerror": "Call to undefined method App\\Services\\Credit\\EquifaxCodeMap::getScoreFactors()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(159): App\\Services\\Credit\\EquifaxCodeMap::getScoreFactor()\n#1 \/www\/wwwroot\/cpeSecure\/app\/Services\/Credit\/EquifaxAdapter.php(43): App\\Services\\Credit\\EquifaxAdapter->mapScoreFactors()\n#2 \/www\/wwwroot\/cpeSecure\/appi\/controllers\/pulls.php(134): App\\Services\\Credit\\EquifaxAdapter->transform()\n#3 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(227): pulls->pullReport()\n#4 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#5 \/www\/wwwroot\/cpeSecure\/appi\/index.php(11): Bootstrap->init()\n#6 {main}",
|
||||||
|
"method": "POST",
|
||||||
|
"ip_address": "193.36.238.59",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/secure.creditpullengine.com\/pulls",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-26 15:36:07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed670a033b66.19048308",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/public\/",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php",
|
||||||
|
"line": 131,
|
||||||
|
"fullerror": "Failed opening required 'public\/controllers\/index.php' (include_path='.:')",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#1 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#2 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "89.244.95.100",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/143.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/secure.creditpullengine.com\/login",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 21:14:50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5d01f08370.23851037",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"Carbon\\Carbon\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "140.82.26.192",
|
||||||
|
"user_agent": "curl\/7.76.1",
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:32:01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5d01e8bb28.09924309",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"Carbon\\Carbon\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "207.246.75.27",
|
||||||
|
"user_agent": null,
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:32:01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5cf5670d39.78662554",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"Carbon\\Carbon\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "206.62.143.66",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:31:49"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5cdaeef019.29989883",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"Carbon\\Carbon\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "206.62.143.66",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:31:22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5cc5a99338.48739755",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"Carbon\\Carbon\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "207.246.75.27",
|
||||||
|
"user_agent": null,
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:31:01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5cc5a5d377.51767422",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"Carbon\\Carbon\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "140.82.26.192",
|
||||||
|
"user_agent": "curl\/7.76.1",
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:31:01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5c896b0de3.37548309",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"App\\Core\\StripeService\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "207.246.75.27",
|
||||||
|
"user_agent": null,
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:30:01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69ed5c895c99d9.73562260",
|
||||||
|
"userid": null,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "https:\/\/secure.creditpullengine.com\/cron\/jsonReports",
|
||||||
|
"file": "\/www\/wwwroot\/cpeSecure\/public\/controllers\/cron.php",
|
||||||
|
"line": 14,
|
||||||
|
"fullerror": "Class \"App\\Core\\StripeService\" not found",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(148): Cron->__construct()\n#1 \/www\/wwwroot\/cpeSecure\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#2 \/www\/wwwroot\/cpeSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/cpeSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "140.82.26.192",
|
||||||
|
"user_agent": "curl\/7.76.1",
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-25 20:30:01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69e773f2990218.94993625",
|
||||||
|
"userid": 2548,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "http:\/\/nsecure.creditpullengine.com\/",
|
||||||
|
"file": "\/www\/wwwroot\/nSecure\/core\/View.php",
|
||||||
|
"line": 19,
|
||||||
|
"fullerror": "Failed opening required '\/www\/wwwroot\/nSecure\/public\/views\/index\/orgadmin.php' (include_path='.:')",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/nSecure\/public\/controllers\/index.php(25): View->render()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(133): Index->index()\n#2 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#3 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#4 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#5 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "172.70.55.166",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/nsecure.creditpullengine.com\/company\/consumer-shield-llc.ZhYbs5uFyhX2G7JfdVIGsmxCGaDRq2A91vcUedk5_yU",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-21 08:56:18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69e77133a9dbc2.26211292",
|
||||||
|
"userid": 3,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "http:\/\/nsecure.creditpullengine.com\/pulls\/view\/1427.f5CHVieZZZGJISi3HECOj9z-oRbIbCuYysAbz22Y9oI",
|
||||||
|
"file": "\/www\/wwwroot\/nSecure\/public\/controllers\/pulls.php",
|
||||||
|
"line": 104,
|
||||||
|
"fullerror": "Call to undefined function redirect()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(222): pulls->view()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "172.70.55.166",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": null,
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-21 08:44:35"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69e77014a27279.45734769",
|
||||||
|
"userid": 2566,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "http:\/\/nsecure.creditpullengine.com\/",
|
||||||
|
"file": "\/www\/wwwroot\/nSecure\/core\/View.php",
|
||||||
|
"line": 19,
|
||||||
|
"fullerror": "Failed opening required '\/www\/wwwroot\/nSecure\/public\/views\/index\/orgadmin.php' (include_path='.:')",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/nSecure\/public\/controllers\/index.php(25): View->render()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(133): Index->index()\n#2 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#3 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#4 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#5 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "104.23.237.20",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/nsecure.creditpullengine.com\/admin\/users",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-21 08:39:48"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69e76e93d1cc43.29318732",
|
||||||
|
"userid": 3,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "http:\/\/nsecure.creditpullengine.com\/pulls\/view\/1427.f5CHVieZZZGJISi3HECOj9z-oRbIbCuYysAbz22Y9oI",
|
||||||
|
"file": "\/www\/wwwroot\/nSecure\/public\/controllers\/pulls.php",
|
||||||
|
"line": 104,
|
||||||
|
"fullerror": "Call to undefined function redirect()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(222): pulls->view()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "104.23.237.20",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/nsecure.creditpullengine.com\/reports\/pulls\/consumer-shield-llc.YAj8lpRtFakNZbbopBFBpJGCDUF7bXC5B3HzJg0I3Bw",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-21 08:33:23"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69e76bb0efcf58.05946519",
|
||||||
|
"userid": 3,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "http:\/\/nsecure.creditpullengine.com\/pulls\/view\/1459.jDV7q5dcrBikg_9FMFi4k6ZMtZokNkCl9Uk6JMYOIZg",
|
||||||
|
"file": "\/www\/wwwroot\/nSecure\/public\/controllers\/pulls.php",
|
||||||
|
"line": 104,
|
||||||
|
"fullerror": "Call to undefined function redirect()",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(222): pulls->view()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#3 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#4 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "198.41.231.17",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/nsecure.creditpullengine.com\/reports\/pulls\/limpia-deudas-llc._i0s98IqNv38bmtnqrE7eluqQNSKLtkOdaJu2qOk3-I",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-21 08:21:04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "err_69e76b1f397f19.04102361",
|
||||||
|
"userid": 2565,
|
||||||
|
"org_id": null,
|
||||||
|
"errortype": "Exception",
|
||||||
|
"errorcode": "1",
|
||||||
|
"url": "http:\/\/nsecure.creditpullengine.com\/",
|
||||||
|
"file": "\/www\/wwwroot\/nSecure\/core\/View.php",
|
||||||
|
"line": 19,
|
||||||
|
"fullerror": "Failed opening required '\/www\/wwwroot\/nSecure\/public\/views\/index\/orgadmin.php' (include_path='.:')",
|
||||||
|
"trace": "#0 \/www\/wwwroot\/nSecure\/public\/controllers\/index.php(25): View->render()\n#1 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(133): Index->index()\n#2 \/www\/wwwroot\/nSecure\/core\/Bootstrap.php(40): Bootstrap->_loadDefaultController()\n#3 \/www\/wwwroot\/nSecure\/public\/index.php(11): Bootstrap->init()\n#4 \/www\/wwwroot\/nSecure\/index.php(2): require_once('...')\n#5 {main}",
|
||||||
|
"method": "GET",
|
||||||
|
"ip_address": "172.68.7.18",
|
||||||
|
"user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/147.0.0.0 Safari\/537.36",
|
||||||
|
"referer": "https:\/\/nsecure.creditpullengine.com\/admin\/users",
|
||||||
|
"status": "unresolved",
|
||||||
|
"createdate": "2026-04-21 08:18:39"
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Reference in a new issue