chore: initial commit
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user