39760-vm/app.php
2026-04-21 09:45:25 +00:00

552 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/db/config.php';
function app_env(string $key, string $default = ''): string
{
$serverValue = $_SERVER[$key] ?? null;
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
if (is_string($envValue) && $envValue !== '') {
return $envValue;
}
return $default;
}
function project_name(): string
{
return app_env('PROJECT_NAME', 'Fast LAMP Desk');
}
function project_description(): string
{
return app_env('PROJECT_DESCRIPTION', 'Fast intake workflow with a simple admin dashboard for incoming project requests.');
}
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function app_strlen(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
}
function app_substr(string $value, int $start, int $length): string
{
return function_exists('mb_substr') ? mb_substr($value, $start, $length) : substr($value, $start, $length);
}
function ensure_schema(): void
{
static $done = false;
if ($done) {
return;
}
$sqlPath = __DIR__ . '/db/migrations/001_project_requests.sql';
$sql = file_get_contents($sqlPath);
if ($sql === false) {
throw new RuntimeException('Unable to load schema migration.');
}
db()->exec($sql);
$done = true;
}
function admin_email(): string
{
return app_env('ADMIN_EMAIL', 'admin@local.test');
}
function admin_password(): string
{
return app_env('ADMIN_PASSWORD', 'admin12345');
}
function using_default_admin_credentials(): bool
{
return app_env('ADMIN_EMAIL', '') === '' || app_env('ADMIN_PASSWORD', '') === '';
}
function attempt_admin_login(string $email, string $password): bool
{
if (!hash_equals(strtolower(admin_email()), strtolower(trim($email)))) {
return false;
}
if (!hash_equals(admin_password(), $password)) {
return false;
}
$_SESSION['admin_logged_in'] = true;
$_SESSION['admin_email'] = admin_email();
return true;
}
function logout_admin(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
}
function is_admin_logged_in(): bool
{
return !empty($_SESSION['admin_logged_in']);
}
function require_admin(): void
{
if (!is_admin_logged_in()) {
set_flash('warning', 'Please sign in to access the dashboard.');
header('Location: /login.php');
exit;
}
}
function set_flash(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function get_flash(): ?array
{
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
function request_status_options(): array
{
return [
'new' => 'New',
'reviewed' => 'Reviewed',
'qualified' => 'Qualified',
'closed' => 'Closed',
];
}
function request_type_options(): array
{
return [
'Internal tool' => 'Internal tool',
'Admin dashboard' => 'Admin dashboard',
'Client portal' => 'Client portal',
'Marketing site' => 'Marketing site',
'Other MVP' => 'Other MVP',
];
}
function timeline_options(): array
{
return [
'ASAP' => 'ASAP',
'2-4 weeks' => '24 weeks',
'1-2 months' => '12 months',
'Flexible' => 'Flexible',
];
}
function budget_options(): array
{
return [
'<5k' => 'Under $5k',
'5k-15k' => '$5k$15k',
'15k-50k' => '$15k$50k',
'50k+' => '$50k+',
'Unknown' => 'Still defining',
];
}
function validate_request_input(array $input): array
{
$errors = [];
$name = trim((string) ($input['name'] ?? ''));
$email = trim((string) ($input['email'] ?? ''));
$company = trim((string) ($input['company'] ?? ''));
$projectType = trim((string) ($input['project_type'] ?? ''));
$timeline = trim((string) ($input['timeline'] ?? ''));
$budget = trim((string) ($input['budget'] ?? ''));
$details = trim((string) ($input['details'] ?? ''));
if ($name === '' || app_strlen($name) < 2) {
$errors['name'] = 'Enter a contact name.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Enter a valid work email.';
}
if ($projectType === '' || !array_key_exists($projectType, request_type_options())) {
$errors['project_type'] = 'Choose the type of MVP you need.';
}
if ($timeline === '' || !array_key_exists($timeline, timeline_options())) {
$errors['timeline'] = 'Choose a timeline.';
}
if ($budget === '' || !array_key_exists($budget, budget_options())) {
$errors['budget'] = 'Choose a budget range.';
}
if ($details === '' || app_strlen($details) < 20) {
$errors['details'] = 'Share at least a short brief (20+ characters).';
}
return [
'errors' => $errors,
'data' => [
'name' => app_substr($name, 0, 120),
'email' => app_substr($email, 0, 190),
'company' => app_substr($company, 0, 160),
'project_type' => $projectType,
'timeline' => $timeline,
'budget' => $budget,
'details' => app_substr($details, 0, 4000),
],
];
}
function create_request(array $input): int
{
ensure_schema();
$validated = validate_request_input($input);
if ($validated['errors']) {
throw new InvalidArgumentException('Request data is invalid.');
}
$data = $validated['data'];
$stmt = db()->prepare('INSERT INTO project_requests (name, email, company, project_type, timeline, budget, details, status, created_at) VALUES (:name, :email, :company, :project_type, :timeline, :budget, :details, :status, UTC_TIMESTAMP())');
$stmt->bindValue(':name', $data['name']);
$stmt->bindValue(':email', $data['email']);
$stmt->bindValue(':company', $data['company'] === '' ? null : $data['company'], $data['company'] === '' ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->bindValue(':project_type', $data['project_type']);
$stmt->bindValue(':timeline', $data['timeline']);
$stmt->bindValue(':budget', $data['budget']);
$stmt->bindValue(':details', $data['details']);
$stmt->bindValue(':status', 'new');
$stmt->execute();
return (int) db()->lastInsertId();
}
function normalize_request_status(?string $status): ?string
{
$status = trim((string) $status);
if ($status === '' || !array_key_exists($status, request_status_options())) {
return null;
}
return $status;
}
function normalize_request_search(string $search): string
{
$search = preg_replace('/\s+/', ' ', trim($search));
return app_substr((string) $search, 0, 120);
}
function request_search_reference_id(string $search): ?int
{
$search = strtoupper(trim($search));
if ($search === '') {
return null;
}
if (preg_match('/^REQ-(\d{1,10})$/', $search, $matches) === 1) {
return (int) $matches[1];
}
if (ctype_digit($search)) {
return (int) $search;
}
return null;
}
function build_request_filters(?string $status = null, string $search = ''): array
{
$where = [];
$bindings = [];
$status = normalize_request_status($status);
if ($status !== null) {
$where[] = 'status = :status';
$bindings[':status'] = ['value' => $status, 'type' => PDO::PARAM_STR];
}
$search = normalize_request_search($search);
if ($search !== '') {
$searchConditions = [
'name LIKE :search',
'email LIKE :search',
'company LIKE :search',
'project_type LIKE :search',
'details LIKE :search',
];
$bindings[':search'] = ['value' => '%' . $search . '%', 'type' => PDO::PARAM_STR];
$referenceId = request_search_reference_id($search);
if ($referenceId !== null) {
$searchConditions[] = 'id = :search_id';
$bindings[':search_id'] = ['value' => $referenceId, 'type' => PDO::PARAM_INT];
}
$where[] = '(' . implode(' OR ', $searchConditions) . ')';
}
return [
'where_sql' => $where ? ' WHERE ' . implode(' AND ', $where) : '',
'bindings' => $bindings,
];
}
function bind_request_query_values(PDOStatement $stmt, array $bindings): void
{
foreach ($bindings as $placeholder => $binding) {
$stmt->bindValue($placeholder, $binding['value'], $binding['type']);
}
}
function fetch_requests(?string $status = null, string $search = '', int $limit = 0, int $offset = 0): array
{
ensure_schema();
$filters = build_request_filters($status, $search);
$sql = 'SELECT * FROM project_requests' . $filters['where_sql'] . ' ORDER BY created_at DESC, id DESC';
if ($limit > 0) {
$sql .= ' LIMIT :limit OFFSET :offset';
}
$stmt = db()->prepare($sql);
bind_request_query_values($stmt, $filters['bindings']);
if ($limit > 0) {
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', max(0, $offset), PDO::PARAM_INT);
}
$stmt->execute();
return $stmt->fetchAll();
}
function count_requests(?string $status = null, string $search = ''): int
{
ensure_schema();
$filters = build_request_filters($status, $search);
$stmt = db()->prepare('SELECT COUNT(*) FROM project_requests' . $filters['where_sql']);
bind_request_query_values($stmt, $filters['bindings']);
$stmt->execute();
return (int) $stmt->fetchColumn();
}
function fetch_request(int $id): ?array
{
ensure_schema();
$stmt = db()->prepare('SELECT * FROM project_requests WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$request = $stmt->fetch();
return $request ?: null;
}
function update_request_status(int $id, string $status): bool
{
ensure_schema();
if (!array_key_exists($status, request_status_options())) {
return false;
}
$stmt = db()->prepare('UPDATE project_requests SET status = :status WHERE id = :id');
$stmt->bindValue(':status', $status);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
return $stmt->execute();
}
function dashboard_metrics(): array
{
ensure_schema();
$rows = db()->query('SELECT status, COUNT(*) AS count_rows FROM project_requests GROUP BY status')->fetchAll();
$metrics = [
'total' => 0,
'new' => 0,
'reviewed' => 0,
'qualified' => 0,
'closed' => 0,
];
foreach ($rows as $row) {
$status = (string) $row['status'];
$count = (int) $row['count_rows'];
$metrics['total'] += $count;
if (array_key_exists($status, $metrics)) {
$metrics[$status] = $count;
}
}
return $metrics;
}
function request_reference(int $id): string
{
return 'REQ-' . str_pad((string) $id, 4, '0', STR_PAD_LEFT);
}
function format_datetime(string $value): string
{
try {
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
return $date->format('M j, Y \a\t H:i') . ' UTC';
} catch (Throwable $e) {
return $value;
}
}
function status_badge_class(string $status): string
{
return match ($status) {
'new' => 'text-bg-primary',
'reviewed' => 'text-bg-secondary',
'qualified' => 'text-bg-success',
'closed' => 'text-bg-dark',
default => 'text-bg-light',
};
}
function active_nav(string $active, string $value): string
{
return $active === $value ? 'active' : '';
}
function render_head(string $title, string $pageDescription = '', bool $noindex = false): void
{
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? getenv('PROJECT_IMAGE_URL') ?: '';
$description = $pageDescription !== '' ? $pageDescription : ($projectDescription ?: project_description());
$assetVersion = (string) (filemtime(__DIR__ . '/assets/css/custom.css') ?: time());
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= e($title) ?></title>
<meta name="description" content="<?= e($description) ?>" />
<?php if ($projectDescription): ?>
<meta property="og:description" content="<?= e($projectDescription) ?>" />
<meta property="twitter:description" content="<?= e($projectDescription) ?>" />
<?php else: ?>
<meta property="og:description" content="<?= e($description) ?>" />
<meta property="twitter:description" content="<?= e($description) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>" />
<?php endif; ?>
<?php if ($noindex): ?>
<meta name="robots" content="noindex, nofollow" />
<?php endif; ?>
<meta name="theme-color" content="#111827" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= e($assetVersion) ?>">
</head>
<body>
<?php
}
function render_navbar(string $active = 'home'): void
{
?>
<nav class="navbar navbar-expand-lg border-bottom bg-white sticky-top app-navbar">
<div class="container">
<a class="navbar-brand fw-semibold" href="/">
<span class="brand-mark">FL</span>
<?= e(project_name()) ?>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#primaryNav" aria-controls="primaryNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="primaryNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'home') ?>" href="/">Home</a></li>
<?php if (is_admin_logged_in()): ?>
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'dashboard') ?>" href="/dashboard.php">Dashboard</a></li>
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'detail') ?>" href="/dashboard.php#requests">Requests</a></li>
<li class="nav-item ms-lg-2"><a class="btn btn-dark btn-sm px-3" href="/logout.php">Log out</a></li>
<?php else: ?>
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'login') ?>" href="/login.php">Admin login</a></li>
<li class="nav-item ms-lg-2"><a class="btn btn-dark btn-sm px-3" href="/#request-form">New request</a></li>
<?php endif; ?>
</ul>
</div>
</div>
</nav>
<?php
}
function render_flash_toast(): void
{
$flash = get_flash();
if (!$flash) {
return;
}
$type = $flash['type'] ?? 'info';
$map = [
'success' => 'text-bg-success',
'danger' => 'text-bg-danger',
'warning' => 'text-bg-warning',
'info' => 'text-bg-primary',
];
$class = $map[$type] ?? 'text-bg-primary';
?>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div class="toast show align-items-center border-0 <?= e($class) ?>" role="status" aria-live="polite" aria-atomic="true" data-autohide="true">
<div class="d-flex">
<div class="toast-body"><?= e((string) ($flash['message'] ?? 'Done.')) ?></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<?php
}
function render_footer(string $active = ''): void
{
$assetVersion = (string) (filemtime(__DIR__ . '/assets/js/main.js') ?: time());
?>
<footer class="border-top bg-white py-4 mt-5">
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-2 small text-secondary">
<div><?= e(project_name()) ?> · fast intake workflow</div>
<div class="d-flex gap-3">
<a href="/" class="link-secondary text-decoration-none">Home</a>
<?php if (is_admin_logged_in()): ?>
<a href="/dashboard.php" class="link-secondary text-decoration-none">Dashboard</a>
<?php else: ?>
<a href="/login.php" class="link-secondary text-decoration-none">Admin login</a>
<?php endif; ?>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="/assets/js/main.js?v=<?= e($assetVersion) ?>" defer></script>
</body>
</html>
<?php
}