Phase 1: API + Frontend Skelett
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin Controller (Platform Admin only)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\Database;
|
||||
use App\Support\Response;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class AdminController
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/tenants
|
||||
*/
|
||||
public function listTenants(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$page = (int)($request->getQueryParams()['page'] ?? 1);
|
||||
$limit = min((int)($request->getQueryParams()['limit'] ?? 20), 100);
|
||||
$offset = ($page - 1) * $limit;
|
||||
$status = $request->getQueryParams()['status'] ?? null;
|
||||
|
||||
$where = '';
|
||||
$params = [];
|
||||
|
||||
if ($status) {
|
||||
$where = 'WHERE status = $1';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$total = Database::fetch("SELECT COUNT(*) as count FROM tenants {$where}", $params)['count'];
|
||||
|
||||
$tenants = Database::fetchAll(
|
||||
"SELECT id, name, slug, email, status, trial_ends_at, is_self_hosted, created_at
|
||||
FROM tenants {$where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT {$limit} OFFSET {$offset}",
|
||||
$params
|
||||
);
|
||||
|
||||
// Get module counts
|
||||
foreach ($tenants as &$tenant) {
|
||||
$modules = Database::fetchAll(
|
||||
'SELECT m.key FROM modules m JOIN tenant_modules tm ON tm.module_id = m.id WHERE tm.tenant_id = $1 AND tm.status = $2',
|
||||
[$tenant['id'], 'enabled']
|
||||
);
|
||||
$tenant['modules'] = array_column($modules, 'key');
|
||||
$tenant['module_count'] = count($modules);
|
||||
}
|
||||
|
||||
return Response::success($response, [
|
||||
'tenants' => $tenants,
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total' => (int)$total,
|
||||
'pages' => ceil($total / $limit),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/tenants
|
||||
*/
|
||||
public function createTenant(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$body = $request->getParsedBody();
|
||||
|
||||
$name = trim($body['name'] ?? '');
|
||||
$email = trim($body['email'] ?? '');
|
||||
$phone = trim($body['phone'] ?? '');
|
||||
$address = trim($body['address'] ?? '');
|
||||
|
||||
$errors = [];
|
||||
if (empty($name)) $errors['name'] = 'Name is required';
|
||||
if (empty($email)) $errors['email'] = 'Email is required';
|
||||
|
||||
if (!empty($errors)) {
|
||||
return Response::validationError($response, $errors);
|
||||
}
|
||||
|
||||
$slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', $name));
|
||||
$originalSlug = $slug;
|
||||
$counter = 1;
|
||||
while (Database::fetch('SELECT id FROM tenants WHERE slug = $1', [$slug])) {
|
||||
$slug = $originalSlug . '-' . $counter++;
|
||||
}
|
||||
|
||||
$tenantId = Database::insert('tenants', [
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'email' => $email,
|
||||
'phone' => $phone,
|
||||
'address' => $address,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$tenantId]);
|
||||
|
||||
return Response::success($response, $tenant, 'Tenant created', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/tenants/:id
|
||||
*/
|
||||
public function getTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
|
||||
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
|
||||
|
||||
if (!$tenant) {
|
||||
return Response::notFound($response, 'Tenant not found');
|
||||
}
|
||||
|
||||
// Get branches
|
||||
$tenant['branches'] = Database::fetchAll(
|
||||
'SELECT * FROM branches WHERE tenant_id = $1 ORDER BY is_headquarters DESC, name ASC',
|
||||
[$id]
|
||||
);
|
||||
|
||||
// Get users count
|
||||
$tenant['user_count'] = Database::fetch(
|
||||
'SELECT COUNT(*) as count FROM users WHERE tenant_id = $1',
|
||||
[$id]
|
||||
)['count'];
|
||||
|
||||
// Get modules
|
||||
$modules = Database::fetchAll(
|
||||
'SELECT m.*, tm.status, tm.enabled_at FROM modules m JOIN tenant_modules tm ON tm.module_id = m.id WHERE tm.tenant_id = $1',
|
||||
[$id]
|
||||
);
|
||||
$tenant['modules'] = $modules;
|
||||
|
||||
return Response::success($response, $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/tenants/:id
|
||||
*/
|
||||
public function updateTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
$body = $request->getParsedBody();
|
||||
|
||||
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
|
||||
if (!$tenant) {
|
||||
return Response::notFound($response, 'Tenant not found');
|
||||
}
|
||||
|
||||
$allowed = ['name', 'email', 'phone', 'address', 'status', 'primary_color', 'secondary_color', 'logo_url', 'custom_css'];
|
||||
$data = array_intersect_key($body, array_flip($allowed));
|
||||
|
||||
if (!empty($data)) {
|
||||
Database::update('tenants', $data, 'id = $1', [$id]);
|
||||
}
|
||||
|
||||
$updated = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
|
||||
|
||||
return Response::success($response, $updated, 'Tenant updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/tenants/:id
|
||||
*/
|
||||
public function deleteTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
|
||||
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
|
||||
if (!$tenant) {
|
||||
return Response::notFound($response, 'Tenant not found');
|
||||
}
|
||||
|
||||
// Check if tenant has users
|
||||
$userCount = Database::fetch('SELECT COUNT(*) as count FROM users WHERE tenant_id = $1', [$id])['count'];
|
||||
if ($userCount > 0) {
|
||||
return Response::error($response, 'Cannot delete tenant with users. Delete users first.', 400);
|
||||
}
|
||||
|
||||
Database::delete('tenants', 'id = $1', [$id]);
|
||||
|
||||
return Response::success($response, null, 'Tenant deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/tenants/:id/approve
|
||||
*/
|
||||
public function approveTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
|
||||
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
|
||||
if (!$tenant) {
|
||||
return Response::notFound($response, 'Tenant not found');
|
||||
}
|
||||
|
||||
if ($tenant['status'] !== 'pending_approval') {
|
||||
return Response::error($response, 'Tenant is not pending approval', 400);
|
||||
}
|
||||
|
||||
// Set to trial for 14 days
|
||||
$trialEnds = date('Y-m-d H:i:s', strtotime('+14 days'));
|
||||
|
||||
Database::update('tenants', [
|
||||
'status' => 'trial',
|
||||
'trial_ends_at' => $trialEnds,
|
||||
], 'id = $1', [$id]);
|
||||
|
||||
$updated = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
|
||||
|
||||
return Response::success($response, $updated, 'Tenant approved, trial started');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/tenants/:id/suspend
|
||||
*/
|
||||
public function suspendTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
|
||||
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
|
||||
if (!$tenant) {
|
||||
return Response::notFound($response, 'Tenant not found');
|
||||
}
|
||||
|
||||
Database::update('tenants', ['status' => 'suspended'], 'id = $1', [$id]);
|
||||
|
||||
return Response::success($response, null, 'Tenant suspended');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/tenants/:id/modules
|
||||
*/
|
||||
public function getTenantModules(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
|
||||
$modules = Database::fetchAll(
|
||||
'SELECT m.*, tm.status, tm.enabled_at, tm.expires_at, tm.trial_ends_at
|
||||
FROM modules m
|
||||
LEFT JOIN tenant_modules tm ON tm.module_id = m.id AND tm.tenant_id = $1
|
||||
ORDER BY m.name',
|
||||
[$id]
|
||||
);
|
||||
|
||||
return Response::success($response, $modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/tenants/:id/modules
|
||||
*/
|
||||
public function enableTenantModule(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
$body = $request->getParsedBody();
|
||||
|
||||
$moduleId = $body['module_id'] ?? '';
|
||||
$expiresAt = $body['expires_at'] ?? null;
|
||||
$trialEndsAt = $body['trial_ends_at'] ?? null;
|
||||
|
||||
if (empty($moduleId)) {
|
||||
return Response::validationError($response, ['module_id' => 'Module ID is required']);
|
||||
}
|
||||
|
||||
// Check if module exists
|
||||
$module = Database::fetch('SELECT * FROM modules WHERE id = $1', [$moduleId]);
|
||||
if (!$module) {
|
||||
return Response::notFound($response, 'Module not found');
|
||||
}
|
||||
|
||||
// Check if already enabled
|
||||
$existing = Database::fetch(
|
||||
'SELECT * FROM tenant_modules WHERE tenant_id = $1 AND module_id = $2',
|
||||
[$id, $moduleId]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
return Response::error($response, 'Module already enabled for this tenant', 409);
|
||||
}
|
||||
|
||||
Database::insert('tenant_modules', [
|
||||
'tenant_id' => $id,
|
||||
'module_id' => $moduleId,
|
||||
'status' => 'enabled',
|
||||
'expires_at' => $expiresAt,
|
||||
'trial_ends_at' => $trialEndsAt,
|
||||
]);
|
||||
|
||||
return Response::success($response, null, 'Module enabled', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/tenants/:id/modules/:moduleId
|
||||
*/
|
||||
public function disableTenantModule(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$tenantId = $args['id'];
|
||||
$moduleId = $args['moduleId'];
|
||||
|
||||
Database::delete('tenant_modules', 'tenant_id = $1 AND module_id = $2', [$tenantId, $moduleId]);
|
||||
|
||||
return Response::success($response, null, 'Module disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/platform-modules
|
||||
*/
|
||||
public function listPlatformModules(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$modules = Database::fetchAll('SELECT * FROM modules ORDER BY name');
|
||||
|
||||
return Response::success($response, $modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/audit-logs
|
||||
*/
|
||||
public function listAuditLogs(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$page = (int)($request->getQueryParams()['page'] ?? 1);
|
||||
$limit = min((int)($request->getQueryParams()['limit'] ?? 50), 100);
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$tenantId = $request->getQueryParams()['tenant_id'] ?? null;
|
||||
$action = $request->getQueryParams()['action'] ?? null;
|
||||
$entityType = $request->getQueryParams()['entity_type'] ?? null;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
$paramIndex = 1;
|
||||
|
||||
if ($tenantId) {
|
||||
$where[] = "tenant_id = \${$paramIndex}";
|
||||
$params[] = $tenantId;
|
||||
$paramIndex++;
|
||||
}
|
||||
|
||||
if ($action) {
|
||||
$where[] = "action = \${$paramIndex}";
|
||||
$params[] = $action;
|
||||
$paramIndex++;
|
||||
}
|
||||
|
||||
if ($entityType) {
|
||||
$where[] = "entity_type = \${$paramIndex}";
|
||||
$params[] = $entityType;
|
||||
$paramIndex++;
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$total = Database::fetch("SELECT COUNT(*) as count FROM audit_logs {$whereClause}", $params)['count'];
|
||||
|
||||
$logs = Database::fetchAll(
|
||||
"SELECT al.*,
|
||||
pa.name as admin_name, pa.email as admin_email,
|
||||
u.first_name, u.last_name, u.email as user_email,
|
||||
t.name as tenant_name
|
||||
FROM audit_logs al
|
||||
LEFT JOIN platform_admins pa ON al.user_id = al.tenant_id IS NULL AND pa.id = al.user_id
|
||||
LEFT JOIN users u ON al.tenant_id IS NOT NULL AND u.id = al.user_id
|
||||
LEFT JOIN tenants t ON t.id = al.tenant_id
|
||||
{$whereClause}
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT {$limit} OFFSET {$offset}",
|
||||
$params
|
||||
);
|
||||
|
||||
return Response::success($response, [
|
||||
'logs' => $logs,
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total' => (int)$total,
|
||||
'pages' => ceil($total / $limit),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/invoices
|
||||
*/
|
||||
public function listInvoices(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$page = (int)($request->getQueryParams()['page'] ?? 1);
|
||||
$limit = min((int)($request->getQueryParams()['limit'] ?? 20), 100);
|
||||
$offset = ($page - 1) * $limit;
|
||||
$status = $request->getQueryParams()['status'] ?? null;
|
||||
|
||||
$tenantId = $request->getQueryParams()['tenant_id'] ?? null;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
$paramIndex = 1;
|
||||
|
||||
if ($status) {
|
||||
$where[] = "i.status = \${$paramIndex}";
|
||||
$params[] = $status;
|
||||
$paramIndex++;
|
||||
}
|
||||
|
||||
if ($tenantId) {
|
||||
$where[] = "i.tenant_id = \${$paramIndex}";
|
||||
$params[] = $tenantId;
|
||||
$paramIndex++;
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$total = Database::fetch("SELECT COUNT(*) as count FROM invoices i {$whereClause}", $params)['count'];
|
||||
|
||||
$invoices = Database::fetchAll(
|
||||
"SELECT i.*, t.name as tenant_name, t.slug as tenant_slug
|
||||
FROM invoices i
|
||||
JOIN tenants t ON t.id = i.tenant_id
|
||||
{$whereClause}
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT {$limit} OFFSET {$offset}",
|
||||
$params
|
||||
);
|
||||
|
||||
return Response::success($response, [
|
||||
'invoices' => $invoices,
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total' => (int)$total,
|
||||
'pages' => ceil($total / $limit),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/invoices/:id/mark-paid
|
||||
*/
|
||||
public function markInvoicePaid(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
|
||||
$invoice = Database::fetch('SELECT * FROM invoices WHERE id = $1', [$id]);
|
||||
if (!$invoice) {
|
||||
return Response::notFound($response, 'Invoice not found');
|
||||
}
|
||||
|
||||
Database::update('invoices', [
|
||||
'status' => 'paid',
|
||||
'paid_at' => date('Y-m-d H:i:s'),
|
||||
], 'id = $1', [$id]);
|
||||
|
||||
$updated = Database::fetch('SELECT * FROM invoices WHERE id = $1', [$id]);
|
||||
|
||||
return Response::success($response, $updated, 'Invoice marked as paid');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/platform-admins
|
||||
*/
|
||||
public function listPlatformAdmins(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$admins = Database::fetchAll(
|
||||
'SELECT id, email, name, role, created_at, last_login_at FROM platform_admins ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
return Response::success($response, $admins);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/platform-admins
|
||||
*/
|
||||
public function createPlatformAdmin(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$body = $request->getParsedBody();
|
||||
|
||||
$email = trim($body['email'] ?? '');
|
||||
$name = trim($body['name'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
$role = $body['role'] ?? 'support';
|
||||
|
||||
$errors = [];
|
||||
if (empty($email)) $errors['email'] = 'Email is required';
|
||||
if (empty($name)) $errors['name'] = 'Name is required';
|
||||
if (empty($password)) $errors['password'] = 'Password is required';
|
||||
elseif (strlen($password) < 8) $errors['password'] = 'Password must be at least 8 characters';
|
||||
|
||||
if (!empty($errors)) {
|
||||
return Response::validationError($response, $errors);
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
if (Database::fetch('SELECT id FROM platform_admins WHERE email = $1', [$email])) {
|
||||
return Response::error($response, 'Email already exists', 409);
|
||||
}
|
||||
|
||||
$id = Database::insert('platform_admins', [
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
$admin = Database::fetch(
|
||||
'SELECT id, email, name, role, created_at FROM platform_admins WHERE id = $1',
|
||||
[$id]
|
||||
);
|
||||
|
||||
return Response::success($response, $admin, 'Platform admin created', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/platform-admins/:id
|
||||
*/
|
||||
public function updatePlatformAdmin(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
$body = $request->getParsedBody();
|
||||
|
||||
$allowed = ['name', 'role'];
|
||||
if (!empty($body['password'])) {
|
||||
$allowed[] = 'password_hash';
|
||||
$body['password_hash'] = password_hash($body['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$data = array_intersect_key($body, array_flip($allowed));
|
||||
|
||||
if (!empty($data)) {
|
||||
Database::update('platform_admins', $data, 'id = $1', [$id]);
|
||||
}
|
||||
|
||||
$admin = Database::fetch(
|
||||
'SELECT id, email, name, role, created_at, last_login_at FROM platform_admins WHERE id = $1',
|
||||
[$id]
|
||||
);
|
||||
|
||||
return Response::success($response, $admin, 'Platform admin updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/platform-admins/:id
|
||||
*/
|
||||
public function deletePlatformAdmin(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$id = $args['id'];
|
||||
|
||||
$auth = $request->getAttribute('auth');
|
||||
if ($auth['id'] === $id) {
|
||||
return Response::error($response, 'Cannot delete yourself', 400);
|
||||
}
|
||||
|
||||
Database::delete('platform_admins', 'id = $1', [$id]);
|
||||
|
||||
return Response::success($response, null, 'Platform admin deleted');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user