chore: initial commit

This commit is contained in:
2026-06-06 00:03:51 +01:00
commit 99d7f7d3a4
29 changed files with 5183 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/admin.php ───────────────────────────────────────────────────────
function handle_get(): never
{
$db = db(); // migrations run inside db()
$sandbox_mode = (bool) (int) ($db->query(
"SELECT setting_value FROM `system` WHERE setting_name = 'sandbox_mode'"
)->fetchColumn());
$counts = $db->query(
"SELECT
COUNT(*) AS total,
SUM(is_sandbox = 1) AS sandbox_count,
SUM(is_sandbox = 0 AND status IN ('sent','delivered')) AS real_sent,
SUM(is_sandbox = 0 AND status = 'failed') AS real_failed
FROM delivery_log"
)->fetch();
json_out([
'sandbox_mode' => $sandbox_mode,
'delivery_log_total' => (int) ($counts['total'] ?? 0),
'delivery_log_sandbox' => (int) ($counts['sandbox_count'] ?? 0),
'delivery_log_real_sent' => (int) ($counts['real_sent'] ?? 0),
'delivery_log_real_failed' => (int) ($counts['real_failed'] ?? 0),
'attendees' => (int) $db->query('SELECT COUNT(*) FROM attendees')->fetchColumn(),
'templates' => (int) $db->query('SELECT COUNT(*) FROM sms_templates')->fetchColumn(),
'schedules' => (int) $db->query('SELECT COUNT(*) FROM scheduled_rules')->fetchColumn(),
]);
}
// ─── POST /api/admin.php ──────────────────────────────────────────────────────
function handle_post(): never
{
$body = json_body();
$action = (string) ($body['action'] ?? '');
match ($action) {
'toggle_sandbox' => action_toggle_sandbox(),
'mark_all_sandbox' => action_mark_all_sandbox(),
'purge' => action_purge($body),
default => error_out('Unknown action'),
};
}
function action_toggle_sandbox(): never
{
$db = db();
$current = (int) $db->query(
"SELECT setting_value FROM `system` WHERE setting_name = 'sandbox_mode'"
)->fetchColumn();
$new = $current ? '0' : '1';
$db->prepare(
"UPDATE `system` SET setting_value = :v WHERE setting_name = 'sandbox_mode'"
)->execute([':v' => $new]);
json_out(['sandbox_mode' => (bool) (int) $new]);
}
function action_mark_all_sandbox(): never
{
db()->exec('UPDATE delivery_log SET is_sandbox = 1');
json_out(['updated' => true]);
}
function action_purge(array $body): never
{
$target = (string) ($body['target'] ?? '');
$db = db();
match ($target) {
'delivery_log' => purge_delivery_log($db, $body),
'attendees' => purge_attendees($db),
'templates' => purge_templates($db),
'schedules' => purge_schedules($db),
default => error_out('Unknown purge target'),
};
}
function purge_delivery_log(PDO $db, array $body): never
{
$before = isset($body['before']) ? trim((string) $body['before']) : '';
if ($before !== '') {
if (strtotime($before) === false) {
error_out('Invalid date');
}
$stmt = $db->prepare('DELETE FROM delivery_log WHERE sent_at < :before');
$stmt->execute([':before' => $before]);
json_out(['purged' => $stmt->rowCount()]);
}
$db->exec('DELETE FROM delivery_log');
json_out(['purged' => true]);
}
function purge_attendees(PDO $db): never
{
$db->exec('DELETE FROM registrations');
$db->exec('DELETE FROM attendees');
$db->exec('DELETE FROM workshop_sessions');
json_out(['purged' => true]);
}
function purge_templates(PDO $db): never
{
$db->exec('DELETE FROM sms_templates');
json_out(['purged' => true]);
}
function purge_schedules(PDO $db): never
{
$db->exec('DELETE FROM scheduled_rules');
json_out(['purged' => true]);
}
+476
View File
@@ -0,0 +1,476 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
'PUT' => handle_put(),
'DELETE' => handle_delete(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/attendees.php ───────────────────────────────────────────────────
// Returns all attendees with their registered workshops.
function handle_get(): never
{
$rows = db()->query(
"SELECT a.id, a.first_name, a.last_name, a.email, a.mobile_number,
GROUP_CONCAT(ws.workshop_name ORDER BY ws.workshop_time SEPARATOR ', ') AS workshops,
GROUP_CONCAT(ws.workshop_time ORDER BY ws.workshop_time SEPARATOR ',') AS workshop_times,
MIN(ws.workshop_time) AS workshop_time,
MIN(r.workshop_session_id) AS workshop_session_id
FROM attendees a
LEFT JOIN registrations r ON r.attendee_id = a.id
LEFT JOIN workshop_sessions ws ON ws.id = r.workshop_session_id
GROUP BY a.id
ORDER BY a.last_name, a.first_name"
)->fetchAll();
json_out(['attendees' => $rows, 'total' => count($rows)]);
}
// ─── POST /api/attendees.php ──────────────────────────────────────────────────
// Accepts a CSV file upload (field name: "file").
// Replaces all existing attendees with the CSV contents.
// Required columns: first_name, last_name, mobile_number
// Optional columns: email
function handle_post(): never
{
if (empty($_FILES['file'])) {
error_out('No file uploaded');
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
error_out('Upload error (code ' . $file['error'] . ')');
}
$ext = strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION));
if ($ext !== 'csv') {
error_out('File must be a .csv');
}
$handle = fopen($file['tmp_name'], 'r');
if ($handle === false) {
error_out('Could not read uploaded file');
}
// Auto-detect delimiter from the first line (tab, semicolon, or comma)
// Strip UTF-8 BOM if present so it doesn't interfere with delimiter counting or fgetcsv
$rawFirstLine = (string) fgets($handle);
$hasBom = str_starts_with($rawFirstLine, "\xEF\xBB\xBF");
$firstLine = $hasBom ? substr($rawFirstLine, 3) : $rawFirstLine;
rewind($handle);
if ($hasBom) {
fseek($handle, 3); // seek past BOM so fgetcsv sees the opening quote of the first field
}
$tabCount = substr_count($firstLine, "\t");
$semiCount = substr_count($firstLine, ';');
$commaCount = substr_count($firstLine, ',');
if ($tabCount >= $semiCount && $tabCount >= $commaCount) {
$delim = "\t";
} elseif ($semiCount >= $commaCount) {
$delim = ';';
} else {
$delim = ',';
}
$headers = fgetcsv($handle, 0, $delim);
if ($headers === false) {
error_out('CSV file is empty');
}
$headers = array_map(fn($h) => strtolower(trim((string) $h, " \t\n\r\0\x0B\"")), $headers);
$col = [
'first_name' => find_col($headers, ['first name', 'first_name', 'firstname', 'first']),
'last_name' => find_col($headers, ['last name', 'last_name', 'lastname', 'last', 'surname']),
'email' => find_col($headers, ['email', 'email address', 'email_address']),
'mobile_number' => find_col($headers, ['custom: mobile number', 'mobile_number', 'mobile number', 'mobile', 'phone', 'telephone', 'cell']),
'status' => find_col($headers, ['status', 'rsvp status', 'rsvp_status']),
// Workshop columns — both are optional; time may also be embedded in the name
'workshop_name' => find_col($headers, [
'custom: please pick one workshop you\'d like to book into.',
'custom: please pick one workshop you\'d like to book into',
'workshop_name', 'workshop name', 'workshop', 'session', 'activity',
]),
'workshop_time' => find_col($headers, ['workshop_time', 'workshop time', 'session_time', 'time', 'start_time']),
];
if ($col['first_name'] === null || $col['last_name'] === null || $col['mobile_number'] === null) {
fclose($handle);
$missing = [];
if ($col['first_name'] === null) $missing[] = 'first_name (e.g. "First Name")';
if ($col['last_name'] === null) $missing[] = 'last_name (e.g. "Last Name")';
if ($col['mobile_number'] === null) $missing[] = 'mobile_number (e.g. "Custom: Mobile number")';
error_out('Missing required columns: ' . implode(', ', $missing) . '. Headers detected: ' . implode(' | ', $headers));
}
$hasWorkshops = $col['workshop_name'] !== null;
$mode = trim((string) ($_POST['mode'] ?? 'clear'));
if (!in_array($mode, ['clear', 'overwrite'], true)) {
$mode = 'clear';
}
$db = db();
$db->beginTransaction();
if ($mode === 'clear') {
$db->exec('DELETE FROM registrations');
$db->exec('DELETE FROM attendees');
if ($hasWorkshops) {
$db->exec('DELETE FROM workshop_sessions');
}
}
// Pre-populate workshop cache from existing sessions (for overwrite/append)
$workshopCache = [];
if ($hasWorkshops && $mode !== 'clear') {
foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) {
$workshopCache[$ws['workshop_name'] . '|' . ($ws['workshop_time'] ?? '')] = (int) $ws['id'];
}
}
$workshopStmt = $db->prepare(
'INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)'
);
$regStmt = $db->prepare(
'INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:attendee_id, :ws_id)'
);
$insertStmt = $db->prepare(
'INSERT INTO attendees (first_name, last_name, email, mobile_number)
VALUES (:first_name, :last_name, :email, :mobile_number)'
);
// Match by email — most stable identifier
$findByEmailStmt = $db->prepare(
"SELECT id FROM attendees WHERE email = :email AND email != ''"
);
// Update all fields including mobile (e.g. number may have changed, matched by email)
$updateStmt = $db->prepare(
'UPDATE attendees SET first_name=:first_name, last_name=:last_name, email=:email, mobile_number=:mobile_number WHERE id=:id'
);
$deleteRegsStmt = $db->prepare(
'DELETE FROM registrations WHERE attendee_id = :attendee_id'
);
$imported = 0;
$updated = 0;
$skipped = 0;
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
$rejected = [];
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
// Strip leading/trailing double-quotes that some spreadsheet exports add around field values
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row);
$firstName = trim((string) ($row[$col['first_name']] ?? ''));
$lastName = trim((string) ($row[$col['last_name']] ?? ''));
$email = trim((string) ($row[$col['email']] ?? ''));
$rawMobile = trim((string) ($row[$col['mobile_number']] ?? ''), " \t\n\r\0\x0B\"'");
$rawStatus = $col['status'] !== null ? trim((string) ($row[$col['status']] ?? '')) : '';
// Skip non-attending rows when a status column is present
if ($col['status'] !== null) {
$status = strtolower($rawStatus);
if ($status !== '' && $status !== 'attending') {
$skipped++;
$skipReasons['not_attending']++;
$rejected[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'raw_mobile' => $rawMobile,
'status' => $rawStatus,
'reason' => 'not_attending',
];
continue;
}
}
$mobile = sanitise_mobile($rawMobile);
if ($mobile === '') {
$skipped++;
$skipReasons['no_mobile']++;
$rejected[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'raw_mobile' => $rawMobile,
'status' => $rawStatus,
'reason' => classify_mobile_rejection($rawMobile),
];
continue;
}
if ($mode === 'clear') {
$insertStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
]);
$attendeeId = (int) $db->lastInsertId();
$imported++;
} else {
// Match by email only
$matches = [];
if ($email !== '') {
$findByEmailStmt->execute([':email' => $email]);
$matches = $findByEmailStmt->fetchAll();
}
if (count($matches) === 0) {
// Not found — insert
$insertStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
]);
$attendeeId = (int) $db->lastInsertId();
$imported++;
} else {
// overwrite: update all fields (incl. mobile), collapse any duplicates
$attendeeId = (int) $matches[0]['id'];
$updateStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
':id' => $attendeeId,
]);
$deleteRegsStmt->execute([':attendee_id' => $attendeeId]);
if (count($matches) > 1) {
$extraIds = array_column(array_slice($matches, 1), 'id');
$placeholders = implode(',', array_fill(0, count($extraIds), '?'));
$db->prepare("DELETE FROM attendees WHERE id IN ($placeholders)")
->execute($extraIds);
}
$updated++;
}
}
// Assign to a workshop session (runs for all modes)
if ($hasWorkshops) {
$wsName = trim((string) ($row[$col['workshop_name']] ?? ''));
if ($wsName !== '') {
// Try separate time column first; otherwise extract time from the name
$wsTime = $col['workshop_time'] !== null
? trim((string) ($row[$col['workshop_time']] ?? ''))
: '';
if ($wsTime === '') {
$wsTime = extract_time_from_string($wsName) ?? '';
// Strip the extracted time token from the workshop name
$wsName = trim_time_from_string($wsName);
}
$dt = null;
if ($wsTime !== '') {
$parsed = strtotime($wsTime);
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
}
$cacheKey = $wsName . '|' . ($dt ?? '');
if (!isset($workshopCache[$cacheKey])) {
$workshopStmt->execute([':name' => $wsName, ':time' => $dt]);
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
}
$regStmt->execute([
':attendee_id' => $attendeeId,
':ws_id' => $workshopCache[$cacheKey],
]);
}
}
}
fclose($handle);
$db->commit();
json_out(['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'skip_reasons' => $skipReasons, 'workshops_imported' => $hasWorkshops, 'rejected' => $rejected]);
}
// ─── PUT /api/attendees.php ───────────────────────────────────────────────────
// Body: { id, first_name, last_name, email, mobile_number,
// workshop_name, workshop_time (ISO datetime or empty) }
// Updates a single attendee and replaces their workshop registration.
function handle_put(): never
{
$body = json_decode((string) file_get_contents('php://input'), true);
$id = (int) ($body['id'] ?? 0);
$firstName = trim((string) ($body['first_name'] ?? ''));
$lastName = trim((string) ($body['last_name'] ?? ''));
$email = trim((string) ($body['email'] ?? ''));
$rawMobile = trim((string) ($body['mobile_number'] ?? ''));
$workshopName = trim((string) ($body['workshop_name'] ?? ''));
$workshopTime = trim((string) ($body['workshop_time'] ?? ''));
if (!$firstName) error_out('first_name is required');
if (!$lastName) error_out('last_name is required');
$mobile = sanitise_mobile($rawMobile);
if ($mobile === '') error_out('A valid mobile_number is required');
$db = db();
$db->beginTransaction();
if ($id <= 0) {
// Insert new attendee (used when manually adding a previously-rejected row)
$db->prepare(
'INSERT INTO attendees (first_name, last_name, email, mobile_number) VALUES (:fn, :ln, :email, :mobile)'
)->execute([':fn' => $firstName, ':ln' => $lastName, ':email' => $email, ':mobile' => $mobile]);
$id = (int) $db->lastInsertId();
} else {
$check = $db->prepare('SELECT id FROM attendees WHERE id = :id');
$check->execute([':id' => $id]);
if (!$check->fetch()) { $db->rollBack(); error_out('Attendee not found', 404); }
$db->prepare(
'UPDATE attendees SET first_name=:fn, last_name=:ln, email=:email, mobile_number=:mobile WHERE id=:id'
)->execute([':fn' => $firstName, ':ln' => $lastName, ':email' => $email, ':mobile' => $mobile, ':id' => $id]);
$db->prepare('DELETE FROM registrations WHERE attendee_id = :id')->execute([':id' => $id]);
}
$workshopSessionId = isset($body['workshop_session_id']) ? (int) $body['workshop_session_id'] : null;
if ($workshopSessionId !== null && $workshopSessionId > 0) {
$verify = $db->prepare('SELECT id FROM workshop_sessions WHERE id = :id');
$verify->execute([':id' => $workshopSessionId]);
if (!$verify->fetch()) error_out('Workshop session not found', 404);
$db->prepare('INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:aid, :wsid)')
->execute([':aid' => $id, ':wsid' => $workshopSessionId]);
} elseif ($workshopName !== '') {
$dt = null;
if ($workshopTime !== '') {
$parsed = strtotime($workshopTime);
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
}
$find = $db->prepare(
'SELECT id FROM workshop_sessions
WHERE workshop_name = :name
AND ((:time IS NULL AND workshop_time IS NULL) OR workshop_time = :time)'
);
$find->execute([':name' => $workshopName, ':time' => $dt]);
$ws = $find->fetch();
if ($ws) {
$wsId = (int) $ws['id'];
} else {
$db->prepare('INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)')
->execute([':name' => $workshopName, ':time' => $dt]);
$wsId = (int) $db->lastInsertId();
}
$db->prepare('INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:aid, :wsid)')
->execute([':aid' => $id, ':wsid' => $wsId]);
}
$db->commit();
json_out(['updated' => true]);
}
// ─── DELETE /api/attendees.php ────────────────────────────────────────────────
// Removes all attendees (and their registrations via CASCADE).
function handle_delete(): never
{
$db = db();
$db->exec('DELETE FROM registrations');
$db->exec('DELETE FROM attendees');
$db->exec('DELETE FROM workshop_sessions');
json_out(['deleted' => true]);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function find_col(array $headers, array $candidates): ?int
{
foreach ($candidates as $candidate) {
$idx = array_search($candidate, $headers, true);
if ($idx !== false) {
return (int) $idx;
}
}
return null;
}
function sanitise_mobile(string $number): string
{
$stripped = trim($number, " \t\n\r\0\x0B\"'");
$prefix = str_starts_with($stripped, '+') ? '+' : '';
$digits = (string) preg_replace('/\D/', '', $stripped);
$normalised = $prefix . $digits;
// Accept UK mobile numbers only: +447XXXXXXXXX, 07XXXXXXXXX, or 7XXXXXXXXX (9 digits after the prefix)
if (!preg_match('/^(\+447|07|7)\d{9}$/', $normalised)) {
return '';
}
// Normalise all variants to international format: +447XXXXXXXXX
if (str_starts_with($normalised, '07')) {
return '+44' . substr($normalised, 1);
}
if (str_starts_with($normalised, '7')) {
return '+447' . substr($normalised, 1);
}
return $normalised; // already +447…
}
/**
* Returns a rejection reason string for a mobile number that failed sanitise_mobile.
* Reasons: 'no_mobile' | 'foreign_country_code' | 'invalid_number'
*/
function classify_mobile_rejection(string $number): string
{
$stripped = trim($number, " \t\n\r\0\x0B\"'");
$prefix = str_starts_with($stripped, '+') ? '+' : '';
$digits = (string) preg_replace('/\D/', '', $stripped);
$normalised = $prefix . $digits;
if ($normalised === '' || strlen($digits) < 3) {
return 'no_mobile';
}
if (str_starts_with($normalised, '+') && !str_starts_with($normalised, '+44')) {
return 'foreign_country_code';
}
return 'invalid_number';
}
/**
* Extracts the first recognisable time token from a string.
* e.g. "Magic / Illusion Workshop 1pm" → "1pm"
* "Axe Throwing 13:30" → "13:30"
*/
function extract_time_from_string(string $str): ?string
{
if (preg_match('/\b(\d{1,2}:\d{2}\s*(?:am|pm)?|\d{1,2}\s*(?:am|pm))\b/i', $str, $m)) {
return trim($m[1]);
}
return null;
}
/**
* Removes the first recognisable time token (and any surrounding separators/spaces)
* from a string, returning the cleaned name.
* e.g. "Magic / Illusion Workshop 1pm" → "Magic / Illusion Workshop"
*/
function trim_time_from_string(string $str): string
{
$cleaned = preg_replace('/[\s\-–—_,]+\b\d{1,2}:\d{2}\s*(?:am|pm)?\b/i', '', $str);
$cleaned = preg_replace('/[\s\-–—_,]+\b\d{1,2}\s*(?:am|pm)\b/i', '', (string) $cleaned);
return trim((string) $cleaned);
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config.php';
/**
* Returns a shared PDO connection for the current request.
*/
function db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$pdo = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
// Idempotent migrations — safe to run on every boot
$pdo->exec('ALTER TABLE delivery_log ADD COLUMN IF NOT EXISTS is_sandbox TINYINT(1) NOT NULL DEFAULT 0');
$pdo->exec("INSERT IGNORE INTO `system` (setting_name, setting_value) VALUES ('sandbox_mode', '0')");
return $pdo;
}
/**
* Sends a JSON response and exits.
*/
function json_out(mixed $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
/**
* Decodes the request body as JSON and returns an array.
*/
function json_body(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return [];
}
return json_decode($raw, true) ?? [];
}
/**
* Sends a JSON error response and exits.
*/
function error_out(string $message, int $status = 400): never
{
json_out(['error' => $message], $status);
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
error_out('Method not allowed', 405);
}
// ─── GET /api/delivery-log.php ────────────────────────────────────────────────
// Optional query params:
// ?page=N (default 1, 50 rows per page)
try {
$db = db();
// Aggregate stats — sandbox sends (is_sandbox=1) are excluded so they don't
// inflate cost calculations or success rate on the dashboard.
$stats = $db->query(
"SELECT
SUM(status IN ('sent','delivered') AND is_sandbox = 0) AS total_sent,
SUM(status = 'failed' AND is_sandbox = 0) AS total_failed
FROM delivery_log"
)->fetch();
$total_attempts = (int) $stats['total_sent'] + (int) $stats['total_failed'];
$stats['success_rate'] = $total_attempts > 0
? round(((int) $stats['total_sent'] / $total_attempts) * 100, 1)
: 0.0;
// Paginated log entries — pass ?all=1 to skip pagination (used by DataTables)
$fetchAll = isset($_GET['all']) && $_GET['all'] === '1';
$perPage = 50;
$page = max(1, (int) ($_GET['page'] ?? 1));
$offset = ($page - 1) * $perPage;
$limitClause = $fetchAll ? '' : "LIMIT $perPage OFFSET $offset";
// $perPage and $offset are cast ints — no injection risk from interpolation
$entries = $db->query(
"SELECT dl.id, dl.mobile_number, dl.status, dl.sent_at, dl.delivered_at, dl.error_message,
CONCAT(a.first_name, ' ', a.last_name) AS attendee_name,
COALESCE(t.name, 'Custom message') AS template_name,
COALESCE(sj.custom_body, t.body) AS message_body,
sj.id AS job_id
FROM delivery_log dl
LEFT JOIN attendees a ON a.id = dl.attendee_id
LEFT JOIN send_jobs sj ON sj.id = dl.send_job_id
LEFT JOIN sms_templates t ON t.id = sj.template_id
ORDER BY dl.sent_at DESC
$limitClause"
)->fetchAll();
$total = (int) $db->query('SELECT COUNT(*) FROM delivery_log')->fetchColumn();
json_out([
'stats' => $stats,
'entries' => $entries,
'total' => $total,
'page' => $fetchAll ? 1 : $page,
'per_page' => $fetchAll ? $total : $perPage,
'pages' => $fetchAll ? 1 : (int) ceil($total / $perPage),
]);
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
'PUT' => handle_put(),
'DELETE' => handle_delete(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/schedule.php ────────────────────────────────────────────────────
function handle_get(): never
{
$rows = db()->query(
'SELECT sr.id, sr.mode, sr.offset_value, sr.offset_unit,
sr.specific_datetime, sr.created_at,
t.id AS template_id, t.name AS template_name
FROM scheduled_rules sr
JOIN sms_templates t ON t.id = sr.template_id
ORDER BY sr.created_at DESC'
)->fetchAll();
json_out(['rules' => $rows]);
}
// ─── POST /api/schedule.php ───────────────────────────────────────────────────
// Body: {
// "template_id": 1,
// "mode": "relative", -- or "specific"
// "offset_value": 30, -- required for relative
// "offset_unit": "minutes", -- required for relative (minutes|hours|days)
// "specific_datetime": "..." -- required for specific (ISO 8601 or MySQL datetime)
// }
function handle_post(): never
{
$body = json_body();
$templateId = (int) ($body['template_id'] ?? 0);
$mode = (string) ($body['mode'] ?? '');
if ($templateId <= 0) {
error_out('template_id is required');
}
if (!in_array($mode, ['relative', 'specific'], true)) {
error_out('mode must be "relative" or "specific"');
}
$offsetValue = null;
$offsetUnit = null;
$specificDt = null;
if ($mode === 'relative') {
$offsetValue = (int) ($body['offset_value'] ?? 0);
$offsetUnit = (string) ($body['offset_unit'] ?? '');
if ($offsetValue <= 0) {
error_out('offset_value must be a positive integer');
}
if (!in_array($offsetUnit, ['minutes', 'hours', 'days'], true)) {
error_out('offset_unit must be "minutes", "hours", or "days"');
}
} else {
$specificDt = (string) ($body['specific_datetime'] ?? '');
if ($specificDt === '') {
error_out('specific_datetime is required when mode is "specific"');
}
if (strtotime($specificDt) === false) {
error_out('specific_datetime is not a valid date/time');
}
}
// Verify template exists
$check = db()->prepare('SELECT id FROM sms_templates WHERE id = :id');
$check->execute([':id' => $templateId]);
if (!$check->fetch()) {
error_out('Template not found', 404);
}
$stmt = db()->prepare(
'INSERT INTO scheduled_rules (template_id, mode, offset_value, offset_unit, specific_datetime)
VALUES (:template_id, :mode, :offset_value, :offset_unit, :specific_datetime)'
);
$stmt->execute([
':template_id' => $templateId,
':mode' => $mode,
':offset_value' => $offsetValue,
':offset_unit' => $offsetUnit,
':specific_datetime' => $specificDt,
]);
json_out(['id' => (int) db()->lastInsertId()], 201);
}
// ─── PUT /api/schedule.php?id=N ─────────────────────────────────────────────
// Body: same fields as POST
function handle_put(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$body = json_body();
$templateId = (int) ($body['template_id'] ?? 0);
$mode = (string) ($body['mode'] ?? '');
if ($templateId <= 0) {
error_out('template_id is required');
}
if (!in_array($mode, ['relative', 'specific'], true)) {
error_out('mode must be "relative" or "specific"');
}
$offsetValue = null;
$offsetUnit = null;
$specificDt = null;
if ($mode === 'relative') {
$offsetValue = (int) ($body['offset_value'] ?? 0);
$offsetUnit = (string) ($body['offset_unit'] ?? '');
if ($offsetValue <= 0) {
error_out('offset_value must be a positive integer');
}
if (!in_array($offsetUnit, ['minutes', 'hours', 'days'], true)) {
error_out('offset_unit must be "minutes", "hours", or "days"');
}
} else {
$specificDt = (string) ($body['specific_datetime'] ?? '');
if ($specificDt === '') {
error_out('specific_datetime is required when mode is "specific"');
}
if (strtotime($specificDt) === false) {
error_out('specific_datetime is not a valid date/time');
}
}
$db = db();
$check = $db->prepare('SELECT id FROM sms_templates WHERE id = :id');
$check->execute([':id' => $templateId]);
if (!$check->fetch()) {
error_out('Template not found', 404);
}
$stmt = $db->prepare(
'UPDATE scheduled_rules
SET template_id = :template_id, mode = :mode,
offset_value = :offset_value, offset_unit = :offset_unit,
specific_datetime = :specific_datetime
WHERE id = :id'
);
$stmt->execute([
':template_id' => $templateId,
':mode' => $mode,
':offset_value' => $offsetValue,
':offset_unit' => $offsetUnit,
':specific_datetime' => $specificDt,
':id' => $id,
]);
if ($stmt->rowCount() === 0) {
error_out('Rule not found', 404);
}
// Detach previous send_jobs so the updated rule fires fresh on the next
// scheduler tick (delivery_log history is preserved via the send_jobs FK).
$db->prepare('UPDATE send_jobs SET scheduled_rule_id = NULL WHERE scheduled_rule_id = :id')
->execute([':id' => $id]);
json_out(['updated' => true]);
}
// ─── DELETE /api/schedule.php?id=N ───────────────────────────────────────────
function handle_delete(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$stmt = db()->prepare('DELETE FROM scheduled_rules WHERE id = :id');
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() === 0) {
error_out('Rule not found', 404);
}
json_out(['deleted' => true]);
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
error_out('Method not allowed', 405);
}
// ─── POST /api/send-now.php ───────────────────────────────────────────────────
// Body: {
// "message": "...", -- required if template_id is omitted
// "template_id": 1, -- optional, overrides message
// "recipient_scope": "all", -- "all" (default) or "workshop"
// "workshop_session_id": 2 -- required when recipient_scope is "workshop"
// }
try {
$body = json_body();
$customBody = trim((string) ($body['message'] ?? ''));
$templateId = isset($body['template_id']) ? (int) $body['template_id'] : null;
$scope = (string) ($body['recipient_scope'] ?? 'all');
$workshopId = isset($body['workshop_session_id']) ? (int) $body['workshop_session_id'] : null;
if ($customBody === '' && $templateId === null) {
error_out('Either message or template_id is required');
}
if (!in_array($scope, ['all', 'workshop'], true)) {
error_out('recipient_scope must be "all" or "workshop"');
}
if ($scope === 'workshop' && ($workshopId === null || $workshopId <= 0)) {
error_out('workshop_session_id is required when recipient_scope is "workshop"');
}
if ($customBody !== '' && mb_strlen($customBody) > 160) {
error_out('message must be 160 characters or fewer');
}
$db = db();
// Resolve message text from template if provided
$messageBody = $customBody;
if ($templateId !== null) {
$t = $db->prepare('SELECT body FROM sms_templates WHERE id = :id');
$t->execute([':id' => $templateId]);
$tpl = $t->fetch();
if (!$tpl) {
error_out('Template not found', 404);
}
$messageBody = $tpl['body'];
}
// Fetch recipients
if ($scope === 'all') {
$recipients = $db->query('SELECT id, mobile_number FROM attendees')->fetchAll();
} else {
$stmt = $db->prepare(
'SELECT a.id, a.mobile_number
FROM attendees a
JOIN registrations r ON r.attendee_id = a.id
WHERE r.workshop_session_id = :ws_id'
);
$stmt->execute([':ws_id' => $workshopId]);
$recipients = $stmt->fetchAll();
}
if (empty($recipients)) {
error_out('No recipients found for the selected scope');
}
$db->beginTransaction();
// Create the send job record (worker will update status once it has sent)
$jobStmt = $db->prepare(
'INSERT INTO send_jobs (template_id, custom_body, recipient_scope, workshop_session_id, status)
VALUES (:template_id, :custom_body, :scope, :ws_id, "pending")'
);
$jobStmt->execute([
':template_id' => $templateId,
':custom_body' => $customBody !== '' ? $customBody : null,
':scope' => $scope,
':ws_id' => $workshopId,
]);
$jobId = (int) $db->lastInsertId();
$db->commit();
// Dispatch to Python worker (synchronous — waits for all messages to send)
$ch = curl_init(WORKER_URL . '/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['job_id' => $jobId]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError || $httpCode !== 200) {
$db->prepare("UPDATE send_jobs SET status='failed' WHERE id = :id")
->execute([':id' => $jobId]);
error_out('SMS worker unavailable: ' . ($curlError ?: "HTTP $httpCode"), 503);
}
$result = json_decode((string) $response, true) ?? [];
json_out([
'job_id' => $jobId,
'sent' => (int) ($result['sent'] ?? 0),
'failed' => (int) ($result['failed'] ?? 0),
'recipients' => count($recipients),
]);
} catch (PDOException $e) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_out('Database error: ' . $e->getMessage(), 500);
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/settings.php?key=<name> ────────────────────────────────────────
// Returns whether the setting is set; never exposes the full value.
function handle_get(): never
{
$key = trim((string) ($_GET['key'] ?? ''));
if ($key === '') {
error_out('Missing key parameter');
}
$stmt = db()->prepare('SELECT setting_value FROM `system` WHERE setting_name = :key');
$stmt->execute([':key' => $key]);
$row = $stmt->fetch();
$value = ($row !== false) ? $row['setting_value'] : null;
$isSet = $value !== null && $value !== '';
// For non-secret settings (no "key" in the name) return the value directly
$isSensitive = str_contains($key, '_key');
json_out([
'set' => $isSet,
'hint' => $isSet && $isSensitive ? '••••••••' . substr($value, -4) : null,
'value' => $isSensitive ? null : $value,
]);
}
// ─── POST /api/settings.php ───────────────────────────────────────────────────
// Body: { "key": "clickatell_api_key", "value": "..." }
// Upserts the setting value.
function handle_post(): never
{
$body = json_decode((string) file_get_contents('php://input'), true);
$key = trim((string) ($body['key'] ?? ''));
$value = trim((string) ($body['value'] ?? ''));
if ($key === '') {
error_out('Missing key');
}
// Only allow known setting names to prevent arbitrary writes
$allowed = ['clickatell_api_key', 'clickatell_cost_per_sms', 'default_sender_id'];
if (!in_array($key, $allowed, true)) {
error_out('Unknown setting key', 400);
}
$stmt = db()->prepare(
'INSERT INTO `system` (setting_name, setting_value) VALUES (:key, :value)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
);
$stmt->execute([':key' => $key, ':value' => $value !== '' ? $value : null]);
json_out(['saved' => true]);
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
'PUT' => handle_put(),
'DELETE' => handle_delete(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/templates.php ───────────────────────────────────────────────────
function handle_get(): never
{
$rows = db()->query(
'SELECT t.id, t.name, t.body, t.created_at, t.updated_at,
COUNT(sr.id) AS scheduled_count
FROM sms_templates t
LEFT JOIN scheduled_rules sr ON sr.template_id = t.id
GROUP BY t.id
ORDER BY t.name'
)->fetchAll();
json_out(['templates' => $rows]);
}
// ─── POST /api/templates.php ──────────────────────────────────────────────────
// Body: { "name": "...", "body": "..." }
function handle_post(): never
{
$body = json_body();
$name = trim((string) ($body['name'] ?? ''));
$text = trim((string) ($body['body'] ?? ''));
if ($name === '') {
error_out('name is required');
}
if ($text === '') {
error_out('body is required');
}
if (mb_strlen($text) > 160) {
error_out('body must be 160 characters or fewer');
}
$stmt = db()->prepare('INSERT INTO sms_templates (name, body) VALUES (:name, :body)');
$stmt->execute([':name' => $name, ':body' => $text]);
$id = (int) db()->lastInsertId();
json_out(['id' => $id, 'name' => $name, 'body' => $text], 201);
}
// ─── PUT /api/templates.php?id=N ─────────────────────────────────────────────
// Body: { "name": "...", "body": "..." }
function handle_put(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$body = json_body();
$name = trim((string) ($body['name'] ?? ''));
$text = trim((string) ($body['body'] ?? ''));
if ($name === '') {
error_out('name is required');
}
if ($text === '') {
error_out('body is required');
}
if (mb_strlen($text) > 160) {
error_out('body must be 160 characters or fewer');
}
$stmt = db()->prepare('UPDATE sms_templates SET name = :name, body = :body WHERE id = :id');
$stmt->execute([':name' => $name, ':body' => $text, ':id' => $id]);
if ($stmt->rowCount() === 0) {
error_out('Template not found', 404);
}
json_out(['id' => $id, 'name' => $name, 'body' => $text]);
}
// ─── DELETE /api/templates.php?id=N ──────────────────────────────────────────
function handle_delete(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$stmt = db()->prepare('DELETE FROM sms_templates WHERE id = :id');
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() === 0) {
error_out('Template not found', 404);
}
json_out(['deleted' => true]);
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
error_out('Method not allowed', 405);
}
// ─── GET /api/workshops.php ───────────────────────────────────────────────────
// Returns all workshop sessions with their registered attendee count.
try {
$rows = db()->query(
'SELECT ws.id, ws.workshop_name, ws.workshop_time,
COUNT(r.id) AS attendee_count
FROM workshop_sessions ws
LEFT JOIN registrations r ON r.workshop_session_id = ws.id
GROUP BY ws.id
ORDER BY ws.workshop_time'
)->fetchAll();
json_out(['workshops' => $rows]);
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}