Phase 1: API + Frontend Skelett

This commit is contained in:
Hermes Agent
2026-05-18 23:08:54 +02:00
parent aa9580c061
commit 4f0ab5c518
29 changed files with 3436 additions and 1 deletions

View File

@@ -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');
}
}

View File

@@ -0,0 +1,317 @@
<?php
/**
* Auth Controller
*/
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Support\Database;
use App\Support\Response;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
class AuthController
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
/**
* POST /api/auth/login
*/
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
if (empty($email) || empty($password)) {
return Response::validationError($response, [
'email' => 'Email is required',
'password' => 'Password is required',
]);
}
// Check if it's a platform admin login
$admin = Database::fetch(
'SELECT * FROM platform_admins WHERE email = $1',
[$email]
);
if ($admin && password_verify($password, $admin['password_hash'])) {
return $this->createTokenResponse($response, $admin, 'platform_admin');
}
// Check if it's a tenant user login
$user = Database::fetch(
'SELECT u.*, t.status as tenant_status
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE u.email = $1',
[$email]
);
if ($user && password_verify($password, $user['password_hash'])) {
if ($user['tenant_status'] !== 'active') {
return Response::error($response, 'Account is not active', 403);
}
if ($user['status'] !== 'active') {
return Response::error($response, 'User is inactive', 403);
}
// Update last login
Database::update('users', ['last_login_at' => 'NOW()'], 'id = $1', [$user['id']]);
return $this->createTokenResponse($response, $user, 'user');
}
return Response::error($response, 'Invalid credentials', 401);
}
/**
* POST /api/auth/register
*/
public function register(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$name = trim($body['name'] ?? '');
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
$phone = trim($body['phone'] ?? '');
$address = trim($body['address'] ?? '');
// Validation
$errors = [];
if (empty($name)) $errors['name'] = 'Name is required';
if (empty($email)) $errors['email'] = 'Email is required';
elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors['email'] = 'Invalid email format';
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 already exists
$exists = Database::fetch(
'SELECT id FROM tenants WHERE email = $1',
[$email]
);
if ($exists) {
return Response::error($response, 'Email already registered', 409);
}
// Generate slug from name
$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++;
}
// Create tenant
$tenantId = Database::insert('tenants', [
'name' => $name,
'slug' => $slug,
'email' => $email,
'phone' => $phone,
'address' => $address,
'status' => 'pending_approval',
]);
// Create first user (admin of the tenant)
$userId = Database::insert('users', [
'tenant_id' => $tenantId,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'first_name' => $name,
'last_name' => '',
'user_type' => 'admin',
'status' => 'pending', // Needs email verification first
]);
// Enable default modules (office, calendar, messenger)
$defaultModules = Database::fetchAll('SELECT id FROM modules WHERE key IN (\'office\', \'calendar\', \'messenger\')');
foreach ($defaultModules as $module) {
Database::insert('tenant_modules', [
'tenant_id' => $tenantId,
'module_id' => $module['id'],
'status' => 'enabled',
]);
}
return Response::success($response, [
'tenant_id' => $tenantId,
'user_id' => $userId,
'message' => 'Registration successful. Pending admin approval.',
], 'Registration successful', 201);
}
/**
* POST /api/auth/verify-email
*/
public function verifyEmail(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$token = $body['token'] ?? '';
if (empty($token)) {
return Response::validationError($response, ['token' => 'Token is required']);
}
// TODO: Verify token and activate user
return Response::success($response, null, 'Email verified successfully');
}
/**
* GET /api/auth/me
*/
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$auth = $request->getAttribute('auth');
if ($auth['type'] === 'platform_admin') {
$data = Database::fetch(
'SELECT id, email, name, role, created_at, last_login_at FROM platform_admins WHERE id = $1',
[$auth['id']]
);
$data['type'] = 'platform_admin';
} else {
$data = Database::fetch(
'SELECT u.id, u.email, u.first_name, u.last_name, u.user_type, u.status, u.avatar_url, u.last_login_at,
t.id as tenant_id, t.name as tenant_name, t.slug as tenant_slug, t.status as tenant_status
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE u.id = $1',
[$auth['id']]
);
$data['type'] = 'user';
}
return Response::success($response, $data);
}
/**
* POST /api/auth/refresh
*/
public function refresh(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$refreshToken = $body['refresh_token'] ?? '';
if (empty($refreshToken)) {
return Response::validationError($response, ['refresh_token' => 'Refresh token is required']);
}
try {
$decoded = JWT::decode($refreshToken, new Key($this->config['jwt']['secret'], 'HS256'));
if ($decoded->type !== 'refresh') {
return Response::error($response, 'Invalid token type', 401);
}
// Get user/admin
if ($decoded->user_type === 'platform_admin') {
$user = Database::fetch('SELECT * FROM platform_admins WHERE id = $1', [$decoded->sub]);
} else {
$user = Database::fetch('SELECT * FROM users WHERE id = $1', [$decoded->sub]);
}
if (!$user) {
return Response::error($response, 'User not found', 401);
}
return $this->createTokenResponse($response, $user, $decoded->user_type);
} catch (\Exception $e) {
return Response::error($response, 'Invalid refresh token', 401);
}
}
/**
* POST /api/auth/logout
*/
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
// JWT is stateless, so logout is handled client-side
// Here we could blacklist the token if needed
return Response::success($response, null, 'Logged out successfully');
}
/**
* POST /api/auth/forgot-password
*/
public function forgotPassword(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$email = trim($body['email'] ?? '');
if (empty($email)) {
return Response::validationError($response, ['email' => 'Email is required']);
}
// Check if email exists
$admin = Database::fetch('SELECT id FROM platform_admins WHERE email = $1', [$email]);
$user = Database::fetch('SELECT id FROM users WHERE email = $1', [$email]);
// Always return success to prevent email enumeration
if (!$admin && !$user) {
return Response::success($response, null, 'If the email exists, a reset link has been sent');
}
// TODO: Generate reset token, send email
return Response::success($response, null, 'If the email exists, a reset link has been sent');
}
/**
* Create JWT token response
*/
private function createTokenResponse(ResponseInterface $response, array $user, string $type): ResponseInterface
{
$now = time();
$expiry = $this->config['jwt']['expiry'];
$refreshExpiry = $this->config['jwt']['refresh_expiry'];
$payload = [
'iss' => 'fahrschuldesk',
'sub' => $user['id'],
'iat' => $now,
'exp' => $now + $expiry,
'type' => 'access',
'user_type' => $type,
];
$refreshPayload = [
'iss' => 'fahrschuldesk',
'sub' => $user['id'],
'iat' => $now,
'exp' => $now + $refreshExpiry,
'type' => 'refresh',
'user_type' => $type,
];
$accessToken = JWT::encode($payload, $this->config['jwt']['secret'], 'HS256');
$refreshToken = JWT::encode($refreshPayload, $this->config['jwt']['secret'], 'HS256');
return Response::json($response, [
'success' => true,
'data' => [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => $expiry,
],
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* JWT Authentication Middleware
*/
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Support\Database;
use App\Support\Response;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;
class AuthMiddleware
{
private string $jwtSecret;
public function __construct(string $jwtSecret)
{
$this->jwtSecret = $jwtSecret;
}
/**
* Validate JWT token and attach user to request
*/
public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$authHeader = $request->getHeaderLine('Authorization');
if (empty($authHeader) || !str_starts_with($authHeader, 'Bearer ')) {
return Response::unauthorized($response ?? $handler->handle($request), 'No token provided');
}
$token = substr($authHeader, 7);
try {
$decoded = JWT::decode($token, new Key($this->jwtSecret, 'HS256'));
if ($decoded->type !== 'access') {
return Response::unauthorized($handler->handle($request), 'Invalid token type');
}
// Attach auth info to request
$request = $request->withAttribute('auth', [
'id' => $decoded->sub,
'type' => $decoded->user_type,
'token' => $decoded,
]);
return $handler->handle($request);
} catch (\Firebase\JWT\ExpiredException $e) {
return Response::unauthorized($handler->handle($request), 'Token expired');
} catch (\Exception $e) {
return Response::unauthorized($handler->handle($request), 'Invalid token');
}
}
/**
* Validate that the user is a platform admin
*/
public static function platformAdmin(ServerRequestInterface $request): ?ResponseInterface
{
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'platform_admin') {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'Platform admin access required');
}
return null; // OK
}
/**
* Validate that the user is a tenant user (any type)
*/
public static function tenantUser(ServerRequestInterface $request): ?ResponseInterface
{
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'user') {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'Tenant user access required');
}
return null; // OK
}
/**
* Validate that the user has a specific user type
*/
public static function requireUserType(ServerRequestInterface $request, array $types): ?ResponseInterface
{
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'user') {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'User access required');
}
$user = Database::fetch('SELECT user_type FROM users WHERE id = $1', [$auth['id']]);
if (!$user || !in_array($user['user_type'], $types)) {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'Insufficient permissions');
}
return null; // OK
}
}

View File

@@ -0,0 +1,104 @@
<?php
/**
* Database Support Class
*/
declare(strict_types=1);
namespace App\Support;
use PDO;
use PDOStatement;
class Database
{
private static ?PDO $connection = null;
public static function setConnection(PDO $pdo): void
{
self::$connection = $pdo;
}
public static function getConnection(): PDO
{
if (self::$connection === null) {
throw new \RuntimeException('Database connection not initialized');
}
return self::$connection;
}
public static function fetch(string $sql, array $params = []): ?array
{
$stmt = self::getConnection()->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetch();
return $result ?: null;
}
public static function fetchAll(string $sql, array $params = []): array
{
$stmt = self::getConnection()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public static function insert(string $table, array $data): string
{
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders}) RETURNING id";
$stmt = self::getConnection()->prepare($sql);
$stmt->execute(array_values($data));
$result = $stmt->fetch();
return $result['id'] ?? '';
}
public static function update(string $table, array $data, string $where, array $whereParams = []): int
{
$set = implode(' = ?, ', array_keys($data)) . ' = ?';
$sql = "UPDATE {$table} SET {$set} WHERE {$where}";
$stmt = self::getConnection()->prepare($sql);
$stmt->execute([...array_values($data), ...$whereParams]);
return $stmt->rowCount();
}
public static function delete(string $table, string $where, array $whereParams = []): int
{
$sql = "DELETE FROM {$table} WHERE {$where}";
$stmt = self::getConnection()->prepare($sql);
$stmt->execute($whereParams);
return $stmt->rowCount();
}
public static function query(string $sql, array $params = []): PDOStatement
{
$stmt = self::getConnection()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public static function beginTransaction(): bool
{
return self::getConnection()->beginTransaction();
}
public static function commit(): bool
{
return self::getConnection()->commit();
}
public static function rollback(): bool
{
return self::getConnection()->rollBack();
}
public static function lastInsertId(): string
{
return self::getConnection()->lastInsertId();
}
}

View File

@@ -0,0 +1,77 @@
<?php
/**
* JSON Response Helper
*/
declare(strict_types=1);
namespace App\Support;
use Psr\Http\Message\ResponseInterface;
class Response
{
public static function json(
ResponseInterface $response,
mixed $data,
int $status = 200
): ResponseInterface {
$response->getBody()->write(json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
return $response
->withStatus($status)
->withHeader('Content-Type', 'application/json');
}
public static function success(
ResponseInterface $response,
mixed $data = null,
string $message = 'OK',
int $status = 200
): ResponseInterface {
return self::json($response, [
'success' => true,
'message' => $message,
'data' => $data,
], $status);
}
public static function error(
ResponseInterface $response,
string $message,
int $status = 400,
array $errors = []
): ResponseInterface {
$body = [
'success' => false,
'message' => $message,
];
if (!empty($errors)) {
$body['errors'] = $errors;
}
return self::json($response, $body, $status);
}
public static function notFound(ResponseInterface $response, string $message = 'Not found'): ResponseInterface
{
return self::error($response, $message, 404);
}
public static function unauthorized(ResponseInterface $response, string $message = 'Unauthorized'): ResponseInterface
{
return self::error($response, $message, 401);
}
public static function forbidden(ResponseInterface $response, string $message = 'Forbidden'): ResponseInterface
{
return self::error($response, $message, 403);
}
public static function validationError(
ResponseInterface $response,
array $errors
): ResponseInterface {
return self::error($response, 'Validation failed', 422, $errors);
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* Migration Runner
*
* Run: php bin/migrate.php
*/
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
// Load config
$config = require __DIR__ . '/../config/app.php';
echo "fahrschuldesk Migration Runner\n";
echo "==============================\n\n";
// Database connection
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$config['db']['host'],
$config['db']['port'],
$config['db']['database']
);
try {
$pdo = new PDO($dsn, $config['db']['username'], $config['db']['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
echo "✓ Connected to database\n\n";
} catch (PDOException $e) {
echo "✗ Database connection failed: " . $e->getMessage() . "\n";
exit(1);
}
// Get all migration files
$migrationsPath = __DIR__ . '/../database/migrations';
$files = glob($migrationsPath . '/*.sql');
if (empty($files)) {
echo "No migration files found.\n";
exit(0);
}
sort($files);
echo "Found " . count($files) . " migration(s)\n\n";
// Create migrations table if not exists
$pdo->exec("
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
");
// Get executed migrations
$executed = $pdo->query("SELECT name FROM migrations")->fetchAll(PDO::FETCH_COLUMN);
// Run pending migrations
foreach ($files as $file) {
$filename = basename($file);
if (in_array($filename, $executed)) {
echo "{$filename} (already executed, skipping)\n";
continue;
}
echo "→ Running: {$filename}\n";
try {
$sql = file_get_contents($file);
$pdo->exec($sql);
$stmt = $pdo->prepare("INSERT INTO migrations (name) VALUES (:name)");
$stmt->execute(['name' => $filename]);
echo "{$filename} completed\n";
} catch (PDOException $e) {
echo "{$filename} failed: " . $e->getMessage() . "\n";
exit(1);
}
}
echo "\n==============================\n";
echo "All migrations completed!\n";

View File

@@ -0,0 +1,52 @@
<?php
/**
* fahrschuldesk API Bootstrap
*/
declare(strict_types=1);
// Error reporting
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Base paths
define('ROOT_PATH', dirname(__DIR__));
define('STORAGE_PATH', ROOT_PATH . '/storage');
define('CONFIG_PATH', ROOT_PATH . '/config');
// Load Composer autoloader
require ROOT_PATH . '/vendor/autoload.php';
// Load configuration
$config = require CONFIG_PATH . '/app.php';
// Create DI container
$container = new \DI\Container();
// Database connection
$container->set('db', function () use ($config) {
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$config['db']['host'],
$config['db']['port'],
$config['db']['database']
);
try {
$pdo = new PDO($dsn, $config['db']['username'], $config['db']['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
} catch (PDOException $e) {
throw new \RuntimeException('Database connection failed: ' . $e->getMessage());
}
});
// JWT Secret
$container->set('jwt_secret', $config['jwt']['secret']);
// Return container
return $container;

View File

@@ -0,0 +1,22 @@
{
"name": "fahrschuldesk/api",
"description": "fahrschuldesk REST API",
"type": "project",
"require": {
"php": "^8.2",
"slim/slim": "^4.12",
"slim/psr7": "^1.6",
"php-di/php-di": "^7.0",
"firebase/php-jwt": "^6.10",
"passwordlib/passwordlib": "^1.0",
"ramsey/uuid": "^4.7"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"scripts": {
"migrate": "php bin/migrate.php"
}
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* fahrschuldesk API Configuration
*/
declare(strict_types=1);
return [
'app' => [
'name' => 'fahrschuldesk API',
'env' => getenv('APP_ENV') ?: 'production',
'debug' => getenv('APP_DEBUG') === 'true',
'timezone' => 'Europe/Berlin',
],
'db' => [
'host' => getenv('DB_HOST') ?: '10.255.30.11',
'port' => (int)(getenv('DB_PORT') ?: 5433),
'database' => getenv('DB_DATABASE') ?: 'fahrschuldesk',
'username' => getenv('DB_USERNAME') ?: 'fahrschuldesk_api',
'password' => getenv('DB_PASSWORD') ?: '',
],
'jwt' => [
'secret' => getenv('JWT_SECRET') ?: 'change-this-in-production',
'expiry' => 3600 * 24, // 24 hours
'refresh_expiry' => 3600 * 24 * 30, // 30 days
],
'storage' => [
'path' => getenv('STORAGE_PATH') ?: '/srv/web/fahrschuldesk/storage',
],
'mail' => [
'driver' => getenv('MAIL_DRIVER') ?: 'log', // log, smtp
'from' => [
'address' => 'noreply@fahrschuldesk.de',
'name' => 'fahrschuldesk',
],
],
'cors' => [
'allowed_origins' => explode(',', getenv('CORS_ORIGINS') ?: 'https://fahrschuldesk.de,https://www.fahrschuldesk.de'),
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'allow_credentials' => true,
],
];

View File

@@ -0,0 +1,370 @@
-- Migration: 001_initial_schema.sql
-- Created: 2024
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- PLATFORM ADMINS (internal team)
-- ============================================
CREATE TABLE platform_admins (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'support' CHECK (role IN ('super_admin', 'support', 'sales')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_platform_admins_email ON platform_admins(email);
-- ============================================
-- MODULES (global module definitions)
-- ============================================
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
monthly_price INTEGER NOT NULL DEFAULT 0, -- cent
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Insert default modules
INSERT INTO modules (key, name, description, monthly_price) VALUES
('office', 'Office', 'Kundenverwaltung, Fahrzeugverwaltung, Berichte', 0),
('calendar', 'Kalender', 'Terminplanung (Integration fahrschultermin.de)', 0),
('messenger', 'Messenger', 'WhatsApp-ähnlicher Messenger', 0),
('learning_blank', 'Lernsoftware (Blanko)', 'Eigene Inhalte erstellen', 0),
('learning_premium', 'Lernsoftware (Premium)', 'Inkl. vorgefertigter Lerninhalte', 0);
-- ============================================
-- PACKAGES (pricing bundles)
-- ============================================
CREATE TABLE packages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
monthly_price INTEGER NOT NULL DEFAULT 0, -- cent
modules_included JSONB DEFAULT '[]',
max_users INTEGER DEFAULT 5,
max_branches INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Insert default packages
INSERT INTO packages (key, name, description, monthly_price, modules_included, max_users, max_branches) VALUES
('starter', 'Starter', 'Basic-Paket für kleine Fahrschulen', 4900, '["office", "calendar", "messenger"]', 5, 1),
('professional', 'Professional', 'Für mittlere Fahrschulen', 9900, '["office", "calendar", "messenger", "learning_blank"]', 20, 3),
('premium', 'Premium', 'Alle Funktionen inkl. Premium-Lerninhalte', 14900, '["office", "calendar", "messenger", "learning_premium"]', 100, 10);
-- ============================================
-- TENANTS (fahrschulen / customers)
-- ============================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
phone VARCHAR(100),
address TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'pending_approval' CHECK (status IN ('pending_approval', 'active', 'suspended', 'trial')),
trial_ends_at TIMESTAMP WITH TIME ZONE,
is_self_hosted BOOLEAN DEFAULT FALSE,
self_hosted_license_key VARCHAR(255),
-- Branding
logo_url VARCHAR(500),
primary_color VARCHAR(7) DEFAULT '#2563eb',
secondary_color VARCHAR(7) DEFAULT '#64748b',
custom_css TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_email ON tenants(email);
-- ============================================
-- BRANCHES (filialen)
-- ============================================
CREATE TABLE branches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
address TEXT,
phone VARCHAR(100),
email VARCHAR(255),
opening_hours JSONB DEFAULT '{}',
is_headquarters BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_branches_tenant ON branches(tenant_id);
-- ============================================
-- USERS (tenant users)
-- ============================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
branch_id UUID REFERENCES branches(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
user_type VARCHAR(50) NOT NULL DEFAULT 'student' CHECK (user_type IN ('admin', 'office', 'instructor', 'lecturer', 'student')),
status VARCHAR(50) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'pending')),
avatar_url VARCHAR(500),
phone VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE,
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_users_branch ON users(branch_id);
CREATE INDEX idx_users_email ON users(email);
-- ============================================
-- TENANT MODULES (module activation per tenant)
-- ============================================
CREATE TABLE tenant_modules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE RESTRICT,
status VARCHAR(50) NOT NULL DEFAULT 'enabled' CHECK (status IN ('enabled', 'disabled')),
enabled_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
trial_ends_at TIMESTAMP WITH TIME ZONE,
UNIQUE(tenant_id, module_id)
);
CREATE INDEX idx_tenant_modules_tenant ON tenant_modules(tenant_id);
-- ============================================
-- TENANT PACKAGES (active package per tenant)
-- ============================================
CREATE TABLE tenant_packages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
package_id UUID NOT NULL REFERENCES packages(id) ON DELETE RESTRICT,
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
UNIQUE(tenant_id)
);
CREATE INDEX idx_tenant_packages_tenant ON tenant_packages(tenant_id);
-- ============================================
-- LEARNING CONTENTS
-- ============================================
CREATE TABLE learning_contents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = platform global
category VARCHAR(255) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
content_type VARCHAR(50) NOT NULL CHECK (content_type IN ('video', 'document', 'quiz', 'exercise')),
content_url VARCHAR(500),
thumbnail_url VARCHAR(500),
duration_minutes INTEGER,
is_premium BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_learning_contents_tenant ON learning_contents(tenant_id);
CREATE INDEX idx_learning_contents_category ON learning_contents(category);
-- ============================================
-- STUDENT PROGRESS
-- ============================================
CREATE TABLE student_progress (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content_id UUID NOT NULL REFERENCES learning_contents(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed')),
completed_at TIMESTAMP WITH TIME ZONE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, content_id)
);
CREATE INDEX idx_student_progress_user ON student_progress(user_id);
CREATE INDEX idx_student_progress_content ON student_progress(content_id);
-- ============================================
-- AUDIT LOGS
-- ============================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = platform admin action
user_id UUID, -- can be NULL if system action
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id UUID,
old_values JSONB,
new_values JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_logs_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
-- ============================================
-- INVOICES
-- ============================================
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
invoice_number VARCHAR(100) NOT NULL UNIQUE,
amount INTEGER NOT NULL, -- cent
vat_amount INTEGER NOT NULL DEFAULT 0,
total_amount INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR',
status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('draft', 'pending', 'paid', 'overdue', 'cancelled')),
billing_period_start DATE,
billing_period_end DATE,
due_date DATE,
paid_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_invoices_tenant ON invoices(tenant_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_due_date ON invoices(due_date);
CREATE TABLE invoice_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
unit_price INTEGER NOT NULL, -- cent
total_price INTEGER NOT NULL
);
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
-- ============================================
-- NOTIFICATIONS
-- ============================================
CREATE TABLE notification_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key VARCHAR(100) NOT NULL UNIQUE,
subject VARCHAR(500) NOT NULL,
body_html TEXT,
body_text TEXT,
variables JSONB DEFAULT '[]',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
template_key VARCHAR(100),
subject VARCHAR(500) NOT NULL,
body TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
sent_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_notifications_user ON notifications(user_id);
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id);
-- ============================================
-- FILES (DKS)
-- ============================================
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id UUID,
file_type VARCHAR(100) NOT NULL,
original_filename VARCHAR(500) NOT NULL,
storage_path VARCHAR(1000) NOT NULL,
mime_type VARCHAR(255),
size_bytes BIGINT,
expires_at TIMESTAMP WITH TIME ZONE, -- for temp files
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_files_tenant ON files(tenant_id);
CREATE INDEX idx_files_entity ON files(entity_type, entity_id);
CREATE INDEX idx_files_user ON files(user_id);
-- ============================================
-- MESSENGER
-- ============================================
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL DEFAULT 'direct' CHECK (type IN ('direct', 'group')),
name VARCHAR(255), -- only for groups
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_conversations_tenant ON conversations(tenant_id);
CREATE TABLE conversation_participants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_read_at TIMESTAMP WITH TIME ZONE,
UNIQUE(conversation_id, user_id)
);
CREATE INDEX idx_conversation_participants_conversation ON conversation_participants(conversation_id);
CREATE INDEX idx_conversation_participants_user ON conversation_participants(user_id);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
is_edited BOOLEAN DEFAULT FALSE,
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_messages_conversation ON messages(conversation_id);
CREATE INDEX idx_messages_created ON messages(created_at);
-- ============================================
-- UPDATE TIMESTAMP TRIGGER
-- ============================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_platform_admins_updated_at BEFORE UPDATE ON platform_admins FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_tenants_updated_at BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_branches_updated_at BEFORE UPDATE ON branches FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_learning_contents_updated_at BEFORE UPDATE ON learning_contents FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_student_progress_updated_at BEFORE UPDATE ON student_progress FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_invoices_updated_at BEFORE UPDATE ON invoices FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_conversations_updated_at BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_messages_updated_at BEFORE UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_updated_at();

View File

@@ -1,2 +1,75 @@
<?php
echo 'api.fahrschuldesk.de - ' . date('Y-m-d H:i:s');
/**
* fahrschuldesk API - Entry Point
*/
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Support\Database;
use App\Support\Response;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Factory\AppFactory;
use Slim\Routing\RouteCollectorProxy;
// Load config
$config = require __DIR__ . '/../config/app.php';
// Set timezone
date_default_timezone_set($config['app']['timezone']);
// Create app
$app = AppFactory::create();
// Get container
$container = $app->getContainer();
// Database connection for SSH tunnel
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$config['db']['host'],
$config['db']['port'],
$config['db']['database']
);
try {
$pdo = new PDO($dsn, $config['db']['username'], $config['db']['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
Database::setConnection($pdo);
} catch (PDOException $e) {
die('Database connection failed: ' . $e->getMessage());
}
// CORS Middleware
$app->add(function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($config) {
$origin = $request->getHeaderLine('Origin');
$allowed = $config['cors']['allowed_origins'];
if (in_array('*', $allowed) || in_array($origin, $allowed)) {
return $handler->handle($request)
->withHeader('Access-Control-Allow-Origin', $origin ?: '*')
->withHeader('Access-Control-Allow-Methods', implode(', ', $config['cors']['allowed_methods']))
->withHeader('Access-Control-Allow-Headers', implode(', ', $config['cors']['allowed_headers']))
->withHeader('Access-Control-Allow-Credentials', 'true');
}
return $handler->handle($request);
});
// Error middleware
$displayErrors = $config['app']['debug'];
$app->addErrorMiddleware($displayErrors, true, true);
// Import routes
require __DIR__ . '/../routes/api.php';
// Run
$app->run();

View File

@@ -0,0 +1,208 @@
<?php
/**
* fahrschuldesk API Routes
*/
declare(strict_types=1);
use App\Http\Controllers\AuthController;
use App\Http\Controllers\AdminController;
use App\Http\Middleware\AuthMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Ramsey\Uuid\Uuid;
// Load config
$config = require __DIR__ . '/../config/app.php';
// Create controllers
$authController = new AuthController($config);
$adminController = new AdminController($config);
// Create middleware
$authMiddleware = new AuthMiddleware($config['jwt']['secret']);
// Helper to create error response
$unauthorized = function (string $message = 'Unauthorized'): ResponseInterface {
$response = new \Slim\Psr7\Response();
return (new \App\Support\Response())->unauthorized($response, $message);
};
// Helper to create admin-only error response
$adminOnly = function (ServerRequestInterface $request) use ($unauthorized): ?ResponseInterface {
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'platform_admin') {
return $unauthorized('Platform admin access required');
}
return null;
};
// Helper to create tenant-only error response
$tenantOnly = function (ServerRequestInterface $request) use ($unauthorized): ?ResponseInterface {
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'user') {
return $unauthorized('Tenant user access required');
}
return null;
};
// ==========================================
// PUBLIC ROUTES (no auth required)
// ==========================================
// Health check
$app->get('/api/health', function (ServerRequestInterface $request, ResponseInterface $response) {
return \App\Support\Response::json($response, ['status' => 'ok', 'timestamp' => date('c')]);
});
// Auth routes
$app->post('/api/auth/login', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) {
return $authController->login($request, $response);
});
$app->post('/api/auth/register', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) {
return $authController->register($request, $response);
});
$app->post('/api/auth/verify-email', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) {
return $authController->verifyEmail($request, $response);
});
$app->post('/api/auth/forgot-password', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) {
return $authController->forgotPassword($request, $response);
});
// ==========================================
// AUTHENTICATED ROUTES
// ==========================================
$app->add(function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($authMiddleware) {
return $authMiddleware($request, $handler);
});
// Auth (requires auth)
$app->post('/api/auth/logout', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) {
return $authController->logout($request, $response);
});
$app->post('/api/auth/refresh', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) {
return $authController->refresh($request, $response);
});
$app->get('/api/auth/me', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) {
return $authController->me($request, $response);
});
// ==========================================
// PLATFORM ADMIN ROUTES
// ==========================================
$app->group('/api/admin', function (\Slim\Routing\RouteCollectorProxy $group) use ($adminController, $adminOnly) {
// Tenants
$group->get('/tenants', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->listTenants($request, $response);
});
$group->post('/tenants', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->createTenant($request, $response);
});
$group->get('/tenants/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->getTenant($request, $response, $args);
});
$group->put('/tenants/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->updateTenant($request, $response, $args);
});
$group->delete('/tenants/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->deleteTenant($request, $response, $args);
});
$group->post('/tenants/{id}/approve', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->approveTenant($request, $response, $args);
});
$group->post('/tenants/{id}/suspend', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->suspendTenant($request, $response, $args);
});
// Tenant Modules
$group->get('/tenants/{id}/modules', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->getTenantModules($request, $response, $args);
});
$group->post('/tenants/{id}/modules', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->enableTenantModule($request, $response, $args);
});
$group->delete('/tenants/{id}/modules/{moduleId}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->disableTenantModule($request, $response, $args);
});
// Platform Modules
$group->get('/platform-modules', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->listPlatformModules($request, $response);
});
// Audit Logs
$group->get('/audit-logs', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->listAuditLogs($request, $response);
});
// Invoices
$group->get('/invoices', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->listInvoices($request, $response);
});
$group->put('/invoices/{id}/mark-paid', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->markInvoicePaid($request, $response, $args);
});
// Platform Admins
$group->get('/platform-admins', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->listPlatformAdmins($request, $response);
});
$group->post('/platform-admins', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->createPlatformAdmin($request, $response);
});
$group->put('/platform-admins/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->updatePlatformAdmin($request, $response, $args);
});
$group->delete('/platform-admins/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) {
if ($err = $adminOnly($request)) return $err;
return $adminController->deletePlatformAdmin($request, $response, $args);
});
});
// ==========================================
// TENANT ROUTES (authenticated tenant users)
// ==========================================
$app->group('/api', function (\Slim\Routing\RouteCollectorProxy $group) use ($tenantOnly) {
// These routes need tenant user auth - placeholder for now
// Will be implemented in tenant routes file
});
// Catch-all for API (404)
$app->any('/api/{path:.*}', function (ServerRequestInterface $request, ResponseInterface $response) {
return \App\Support\Response::notFound($response, 'API endpoint not found');
});