39401-vm/includes/bootstrap.php
2026-03-30 17:47:50 +00:00

715 lines
21 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_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../db/config.php';
date_default_timezone_set('UTC');
function app_env(string $key, string $default = ''): string
{
$serverValue = $_SERVER[$key] ?? null;
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
return is_string($envValue) && $envValue !== '' ? $envValue : $default;
}
function app_base_path(): string
{
static $basePath = null;
if ($basePath !== null) {
return $basePath;
}
$scriptName = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
$dir = str_replace('\\', '/', dirname($scriptName));
if ($dir === '/' || $dir === '.' || $dir === '\\') {
$basePath = '';
return $basePath;
}
$basePath = rtrim($dir, '/');
return $basePath;
}
function app_url(string $path = ''): string
{
$path = trim($path);
if ($path !== '' && (preg_match('#^[a-z][a-z0-9+.-]*://#i', $path) === 1 || str_starts_with($path, '//'))) {
return $path;
}
$basePath = app_base_path();
if ($path === '' || $path === '/') {
return $basePath !== '' ? $basePath . '/' : '/';
}
return ($basePath !== '' ? $basePath : '') . '/' . ltrim($path, '/');
}
function asset_url(string $path): string
{
return app_url($path);
}
function public_asset_url(string $path): string
{
$path = trim($path);
if ($path === '') {
return '';
}
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $path) === 1 || str_starts_with($path, '//') || str_starts_with($path, 'data:')) {
return $path;
}
return app_url(ltrim($path, '/'));
}
function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function schema_column_exists(string $table, string $column): bool
{
$stmt = db()->prepare(
'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name'
);
$stmt->execute([
':schema' => DB_NAME,
':table_name' => $table,
':column_name' => $column,
]);
return (int) $stmt->fetchColumn() > 0;
}
function schema_index_exists(string $table, string $index): bool
{
$stmt = db()->prepare(
'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table_name AND INDEX_NAME = :index_name'
);
$stmt->execute([
':schema' => DB_NAME,
':table_name' => $table,
':index_name' => $index,
]);
return (int) $stmt->fetchColumn() > 0;
}
function ensure_column_exists(string $table, string $column, string $definition): void
{
if (!schema_column_exists($table, $column)) {
db()->exec(sprintf('ALTER TABLE %s ADD COLUMN %s %s', $table, $column, $definition));
}
}
function ensure_index_exists(string $table, string $index, string $definition): void
{
if (!schema_index_exists($table, $index)) {
db()->exec(sprintf('ALTER TABLE %s ADD INDEX %s %s', $table, $index, $definition));
}
}
function ensure_app_schema(): void
{
static $done = false;
if ($done) {
return;
}
$usersSql = <<<'SQL'
CREATE TABLE IF NOT EXISTS app_users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR(190) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_user_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL;
$itemsSql = <<<'SQL'
CREATE TABLE IF NOT EXISTS service_tracker_items (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
item_type ENUM('service','setting') NOT NULL DEFAULT 'service',
user_id INT UNSIGNED DEFAULT NULL,
slug_key VARCHAR(120) DEFAULT NULL,
vehicle_name VARCHAR(120) DEFAULT NULL,
vehicle_category VARCHAR(20) DEFAULT NULL,
plate_number VARCHAR(40) DEFAULT NULL,
service_name VARCHAR(80) DEFAULT NULL,
last_service_date DATE DEFAULT NULL,
reminder_interval_days INT DEFAULT NULL,
next_due_date DATE DEFAULT NULL,
odometer_km INT DEFAULT NULL,
notes TEXT DEFAULT NULL,
content_longtext LONGTEXT DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_item_slug (item_type, slug_key),
KEY idx_service_due (item_type, next_due_date),
KEY idx_vehicle_name (vehicle_name),
KEY idx_user_service_due (user_id, item_type, next_due_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL;
db()->exec($usersSql);
db()->exec($itemsSql);
ensure_column_exists('service_tracker_items', 'user_id', 'INT UNSIGNED DEFAULT NULL AFTER item_type');
ensure_index_exists('service_tracker_items', 'idx_user_service_due', '(user_id, item_type, next_due_date)');
$done = true;
}
ensure_app_schema();
function service_catalog(): array
{
return [
'Ganti oli mesin' => 90,
'Filter udara' => 120,
'Bersihin CVT' => 90,
'Vanbelt' => 180,
'Ganti oli gardan' => 180,
'Busi' => 180,
'Tune up ringan' => 120,
'Cek rem & kampas' => 90,
];
}
function normalize_email(string $email): string
{
return strtolower(trim($email));
}
function current_user(): ?array
{
$user = $_SESSION['user'] ?? null;
return is_array($user) ? $user : null;
}
function current_user_id(): int
{
return (int) ((current_user()['id'] ?? 0));
}
function current_user_name(): string
{
$user = current_user();
if (!$user) {
return '';
}
$name = trim((string) ($user['name'] ?? ''));
if ($name !== '') {
return $name;
}
return (string) ($user['email'] ?? '');
}
function is_user_logged_in(): bool
{
return current_user_id() > 0;
}
function login_user(array $user): void
{
$_SESSION['user'] = [
'id' => (int) ($user['id'] ?? 0),
'name' => (string) ($user['name'] ?? ''),
'email' => (string) ($user['email'] ?? ''),
];
session_regenerate_id(true);
}
function logout_user(): void
{
unset($_SESSION['user']);
session_regenerate_id(true);
}
function auth_redirect_target(string $default = ''): string
{
$fallback = $default !== '' ? $default : app_url('dashboard.php');
if (!str_starts_with($fallback, '/') && preg_match('#^[a-z][a-z0-9+.-]*://#i', $fallback) !== 1) {
$fallback = app_url($fallback);
}
$target = trim((string) ($_POST['redirect'] ?? $_GET['redirect'] ?? ''));
if ($target === '') {
return $fallback;
}
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $target) === 1 || str_starts_with($target, '//')) {
return $fallback;
}
return str_starts_with($target, '/') ? $target : app_url($target);
}
function require_user_login(): void
{
if (is_user_logged_in()) {
return;
}
set_flash('warning', 'Silakan login dulu supaya catatan servis hanya terlihat oleh akun kamu sendiri.');
$target = (string) ($_SERVER['REQUEST_URI'] ?? app_url('dashboard.php'));
header('Location: ' . app_url('login.php') . '?redirect=' . urlencode($target));
exit;
}
function find_user_by_email(string $email): ?array
{
$stmt = db()->prepare('SELECT * FROM app_users WHERE email = :email LIMIT 1');
$stmt->execute([
':email' => normalize_email($email),
]);
$row = $stmt->fetch();
return $row ?: null;
}
function create_user_account(string $name, string $email, string $password): int
{
$stmt = db()->prepare(
'INSERT INTO app_users (name, email, password_hash) VALUES (:name, :email, :password_hash)'
);
$stmt->execute([
':name' => trim($name),
':email' => normalize_email($email),
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
]);
return (int) db()->lastInsertId();
}
function verify_user_login(string $email, string $password): ?array
{
$user = find_user_by_email($email);
if (!$user) {
return null;
}
return password_verify($password, (string) $user['password_hash']) ? $user : null;
}
function app_setting(string $key, string $default = ''): string
{
$stmt = db()->prepare('SELECT content_longtext FROM service_tracker_items WHERE item_type = :type AND slug_key = :slug LIMIT 1');
$stmt->execute([
':type' => 'setting',
':slug' => $key,
]);
$value = $stmt->fetchColumn();
return is_string($value) ? $value : $default;
}
function save_setting(string $key, string $value): void
{
$stmt = db()->prepare(
'INSERT INTO service_tracker_items (item_type, slug_key, content_longtext) VALUES (:type, :slug, :content)
ON DUPLICATE KEY UPDATE content_longtext = VALUES(content_longtext), updated_at = CURRENT_TIMESTAMP'
);
$stmt->execute([
':type' => 'setting',
':slug' => $key,
':content' => $value,
]);
}
function head_ad_code(): string
{
return app_setting('ads_head');
}
function body_ad_code(): string
{
return app_setting('ads_body');
}
function app_project_name(): string
{
return app_env('PROJECT_NAME', 'ServisIngat');
}
function app_brand_initial(): string
{
$projectName = trim(app_project_name());
if ($projectName === '') {
return 'S';
}
if (function_exists('mb_substr')) {
return mb_strtoupper((string) mb_substr($projectName, 0, 1, 'UTF-8'), 'UTF-8');
}
return strtoupper(substr($projectName, 0, 1));
}
function app_logo_url(): string
{
return app_setting('brand_logo_url');
}
function app_favicon_url(): string
{
return app_setting('brand_favicon_url');
}
function set_flash(string $type, string $message): void
{
$_SESSION['flash'] = [
'type' => $type,
'message' => $message,
];
}
function consume_flash(): ?array
{
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
function empty_dashboard_summary(): array
{
return [
'total_services' => 0,
'overdue_count' => 0,
'due_soon_count' => 0,
'last_update' => null,
];
}
function dashboard_summary(): array
{
$stmt = db()->query(
"SELECT
SUM(CASE WHEN item_type = 'service' THEN 1 ELSE 0 END) AS total_services,
SUM(CASE WHEN item_type = 'service' AND next_due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_count,
SUM(CASE WHEN item_type = 'service' AND next_due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY) THEN 1 ELSE 0 END) AS due_soon_count,
MAX(CASE WHEN item_type = 'service' THEN updated_at ELSE NULL END) AS last_update
FROM service_tracker_items"
);
$row = $stmt->fetch() ?: [];
return [
'total_services' => (int) ($row['total_services'] ?? 0),
'overdue_count' => (int) ($row['overdue_count'] ?? 0),
'due_soon_count' => (int) ($row['due_soon_count'] ?? 0),
'last_update' => $row['last_update'] ?? null,
];
}
function dashboard_summary_for_user(int $userId): array
{
if ($userId <= 0) {
return empty_dashboard_summary();
}
$stmt = db()->prepare(
"SELECT
COUNT(*) AS total_services,
SUM(CASE WHEN next_due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_count,
SUM(CASE WHEN next_due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY) THEN 1 ELSE 0 END) AS due_soon_count,
MAX(updated_at) AS last_update
FROM service_tracker_items
WHERE item_type = 'service' AND user_id = :user_id"
);
$stmt->execute([
':user_id' => $userId,
]);
$row = $stmt->fetch() ?: [];
return [
'total_services' => (int) ($row['total_services'] ?? 0),
'overdue_count' => (int) ($row['overdue_count'] ?? 0),
'due_soon_count' => (int) ($row['due_soon_count'] ?? 0),
'last_update' => $row['last_update'] ?? null,
];
}
function fetch_services(int $limit = 100): array
{
$stmt = db()->prepare(
'SELECT * FROM service_tracker_items WHERE item_type = :type ORDER BY next_due_date IS NULL, next_due_date ASC, updated_at DESC LIMIT :limit'
);
$stmt->bindValue(':type', 'service');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function fetch_services_for_user(int $userId, int $limit = 100): array
{
if ($userId <= 0) {
return [];
}
$stmt = db()->prepare(
'SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id ORDER BY next_due_date IS NULL, next_due_date ASC, updated_at DESC LIMIT :limit'
);
$stmt->bindValue(':type', 'service');
$stmt->bindValue(':user_id', $userId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function fetch_recent_services(int $limit = 5): array
{
$stmt = db()->prepare(
'SELECT * FROM service_tracker_items WHERE item_type = :type ORDER BY created_at DESC LIMIT :limit'
);
$stmt->bindValue(':type', 'service');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function fetch_recent_services_for_user(int $userId, int $limit = 5): array
{
if ($userId <= 0) {
return [];
}
$stmt = db()->prepare(
'SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id ORDER BY created_at DESC LIMIT :limit'
);
$stmt->bindValue(':type', 'service');
$stmt->bindValue(':user_id', $userId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function fetch_service_by_id(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM service_tracker_items WHERE item_type = :type AND id = :id LIMIT 1');
$stmt->execute([
':type' => 'service',
':id' => $id,
]);
$row = $stmt->fetch();
return $row ?: null;
}
function fetch_service_by_id_for_user(int $userId, int $id): ?array
{
if ($userId <= 0 || $id <= 0) {
return null;
}
$stmt = db()->prepare('SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id AND id = :id LIMIT 1');
$stmt->execute([
':type' => 'service',
':user_id' => $userId,
':id' => $id,
]);
$row = $stmt->fetch();
return $row ?: null;
}
function due_state(?string $dateValue): array
{
if (!$dateValue) {
return [
'label' => 'Belum dijadwalkan',
'class' => 'status-neutral',
'tone' => 'secondary',
'description' => 'Atur tanggal servis berikutnya untuk mulai dipantau.',
];
}
$today = new DateTimeImmutable('today');
$dueDate = new DateTimeImmutable($dateValue);
$diff = (int) $today->diff($dueDate)->format('%r%a');
if ($diff < 0) {
$days = abs($diff);
return [
'label' => 'Terlambat ' . $days . ' hari',
'class' => 'status-overdue',
'tone' => 'danger',
'description' => 'Sudah melewati jadwal, sebaiknya segera ditindaklanjuti.',
];
}
if ($diff <= 14) {
return [
'label' => 'Jatuh tempo ' . $diff . ' hari lagi',
'class' => 'status-soon',
'tone' => 'warning',
'description' => 'Sudah dekat dengan jadwal berikutnya, cocok masuk prioritas.',
];
}
return [
'label' => 'Aman ' . $diff . ' hari lagi',
'class' => 'status-ok',
'tone' => 'success',
'description' => 'Masih aman, tapi sudah tercatat untuk pengingat berikutnya.',
];
}
function validate_service_payload(array $data): array
{
$catalog = service_catalog();
$vehicleName = trim((string) ($data['vehicle_name'] ?? ''));
$vehicleCategory = trim((string) ($data['vehicle_category'] ?? ''));
$plateNumber = strtoupper(trim((string) ($data['plate_number'] ?? '')));
$serviceName = trim((string) ($data['service_name'] ?? ''));
$lastServiceDate = trim((string) ($data['last_service_date'] ?? ''));
$intervalDays = (int) ($data['reminder_interval_days'] ?? 0);
$odometerKm = trim((string) ($data['odometer_km'] ?? ''));
$notes = trim((string) ($data['notes'] ?? ''));
$errors = [];
if ($vehicleName === '' || strlen($vehicleName) < 3) {
$errors['vehicle_name'] = 'Nama kendaraan minimal 3 karakter.';
}
if (!in_array($vehicleCategory, ['Motor', 'Mobil'], true)) {
$errors['vehicle_category'] = 'Pilih jenis kendaraan.';
}
if ($serviceName === '') {
$errors['service_name'] = 'Pilih item servis utama.';
}
if ($lastServiceDate === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $lastServiceDate)) {
$errors['last_service_date'] = 'Tanggal servis terakhir wajib diisi.';
}
if ($intervalDays < 14 || $intervalDays > 365) {
$errors['reminder_interval_days'] = 'Interval pengingat harus 14365 hari.';
}
if ($odometerKm !== '' && (!ctype_digit($odometerKm) || (int) $odometerKm < 0)) {
$errors['odometer_km'] = 'Kilometer harus berupa angka positif.';
}
$nextDueDate = null;
if (!isset($errors['last_service_date']) && !isset($errors['reminder_interval_days'])) {
try {
$nextDueDate = (new DateTimeImmutable($lastServiceDate))
->modify('+' . $intervalDays . ' days')
->format('Y-m-d');
} catch (Throwable $exception) {
$errors['last_service_date'] = 'Tanggal servis terakhir tidak valid.';
}
}
return [
'errors' => $errors,
'clean' => [
'vehicle_name' => $vehicleName,
'vehicle_category' => $vehicleCategory,
'plate_number' => $plateNumber,
'service_name' => $serviceName,
'last_service_date' => $lastServiceDate,
'reminder_interval_days' => $intervalDays,
'next_due_date' => $nextDueDate,
'odometer_km' => $odometerKm === '' ? null : (int) $odometerKm,
'notes' => $notes,
'suggested_days' => $catalog[$serviceName] ?? null,
],
];
}
function create_service(array $payload): int
{
$userId = current_user_id();
if ($userId <= 0) {
throw new RuntimeException('User must be logged in before creating a service record.');
}
$stmt = db()->prepare(
'INSERT INTO service_tracker_items (
item_type, user_id, vehicle_name, vehicle_category, plate_number, service_name,
last_service_date, reminder_interval_days, next_due_date, odometer_km, notes
) VALUES (
:item_type, :user_id, :vehicle_name, :vehicle_category, :plate_number, :service_name,
:last_service_date, :reminder_interval_days, :next_due_date, :odometer_km, :notes
)'
);
$stmt->execute([
':item_type' => 'service',
':user_id' => $userId,
':vehicle_name' => $payload['vehicle_name'],
':vehicle_category' => $payload['vehicle_category'],
':plate_number' => $payload['plate_number'],
':service_name' => $payload['service_name'],
':last_service_date' => $payload['last_service_date'],
':reminder_interval_days' => $payload['reminder_interval_days'],
':next_due_date' => $payload['next_due_date'],
':odometer_km' => $payload['odometer_km'],
':notes' => $payload['notes'],
]);
return (int) db()->lastInsertId();
}
function is_admin_logged_in(): bool
{
return !empty($_SESSION['is_admin_logged_in']);
}
function admin_username(): string
{
return app_env('ADMIN_PORTAL_USER', 'admin');
}
function admin_default_password_hint(): string
{
return app_env('ADMIN_PORTAL_PASSWORD_HINT', 'servis123!');
}
function admin_password_hash(): string
{
$storedHash = app_setting('admin_password_hash');
if ($storedHash !== '') {
return $storedHash;
}
return app_env('ADMIN_PORTAL_PASSWORD_HASH', '$2y$10$riyTXC1R9fEPRr2T18rxUuycZVTjpQVvCDOmRQD4ID1EVWw9fyDHC');
}
function admin_has_custom_password(): bool
{
return app_setting('admin_password_hash') !== '';
}
function save_admin_password(string $password): void
{
save_setting('admin_password_hash', password_hash($password, PASSWORD_DEFAULT));
}
function verify_admin_login(string $username, string $password): bool
{
$expectedUser = admin_username();
$normalizedUsername = trim($username);
return hash_equals($expectedUser, $normalizedUsername) && password_verify($password, admin_password_hash());
}