Files

535 lines
21 KiB
PHP

<?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';
}
// Resolutions map: mobile_number => chosen group index (0-based)
// Sent on the second submission after the user resolves duplicates in the UI.
$resolutions = [];
if (!empty($_POST['resolutions'])) {
$dec = json_decode((string) $_POST['resolutions'], true);
if (is_array($dec)) {
$resolutions = $dec;
}
}
// ─── Phase 1: Parse all valid rows into memory ────────────────────────────
$validRows = [];
$skipped = 0;
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
$rejected = [];
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
$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']] ?? '')) : '';
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;
}
// Parse workshop columns up-front (needed for duplicate display and import)
$wsName = $hasWorkshops ? trim((string) ($row[$col['workshop_name']] ?? '')) : '';
$wsTime = ($hasWorkshops && $col['workshop_time'] !== null)
? trim((string) ($row[$col['workshop_time']] ?? ''))
: '';
$dt = null;
if ($hasWorkshops && $wsName !== '') {
if ($wsTime === '') {
$wsTime = extract_time_from_string($wsName) ?? '';
$wsName = trim_time_from_string($wsName);
}
if ($wsTime !== '') {
$parsed = strtotime($wsTime);
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
}
}
$validRows[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'mobile_number' => $mobile,
'workshop_name' => $wsName,
'workshop_time' => $dt,
];
}
fclose($handle);
// ─── Phase 2: Detect intra-CSV duplicate mobile numbers ───────────────────
$byMobile = [];
foreach ($validRows as $i => $vr) {
$byMobile[$vr['mobile_number']][] = $i;
}
$duplicateGroups = [];
foreach ($byMobile as $mobile => $indices) {
if (count($indices) > 1) {
$duplicateGroups[] = [
'mobile' => $mobile,
'rows' => array_values(array_map(fn($i) => $validRows[$i], $indices)),
];
}
}
// If duplicates exist and the user hasn't resolved them yet, pause and ask.
if (!empty($duplicateGroups) && empty($resolutions)) {
json_out(['status' => 'needs_resolution', 'duplicates' => $duplicateGroups]);
}
// Apply resolutions — keep only the chosen row for each duplicate mobile.
if (!empty($resolutions)) {
$indicesToRemove = [];
foreach ($byMobile as $mobile => $indices) {
if (!isset($resolutions[$mobile]) || count($indices) < 2) {
continue;
}
$chosenGroupPos = (int) $resolutions[$mobile];
foreach ($indices as $groupPos => $rowIdx) {
if ($groupPos !== $chosenGroupPos) {
$indicesToRemove[$rowIdx] = true;
}
}
}
$validRows = array_values(
array_filter($validRows, fn($_, $i) => !isset($indicesToRemove[$i]), ARRAY_FILTER_USE_BOTH)
);
}
// ─── Phase 3: Import ──────────────────────────────────────────────────────
$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 mode)
$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)
ON DUPLICATE KEY UPDATE
first_name = VALUES(first_name),
last_name = VALUES(last_name),
email = VALUES(email),
id = LAST_INSERT_ID(id)'
);
$findByEmailStmt = $db->prepare(
"SELECT id FROM attendees WHERE email = :email AND 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;
foreach ($validRows as $vr) {
$firstName = $vr['first_name'];
$lastName = $vr['last_name'];
$email = $vr['email'];
$mobile = $vr['mobile_number'];
if ($mode === 'clear') {
$insertStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
]);
$attendeeId = (int) $db->lastInsertId();
// rowCount: 1 = new insert, 2 = ON DUPLICATE KEY UPDATE fired
if ($insertStmt->rowCount() >= 2) { $updated++; } else { $imported++; }
} else {
$matches = [];
if ($email !== '') {
$findByEmailStmt->execute([':email' => $email]);
$matches = $findByEmailStmt->fetchAll();
}
if (count($matches) === 0) {
$insertStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
]);
$attendeeId = (int) $db->lastInsertId();
$imported++;
} else {
$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++;
}
}
if ($hasWorkshops && $vr['workshop_name'] !== '') {
$cacheKey = $vr['workshop_name'] . '|' . ($vr['workshop_time'] ?? '');
if (!isset($workshopCache[$cacheKey])) {
$workshopStmt->execute([':name' => $vr['workshop_name'], ':time' => $vr['workshop_time']]);
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
}
$regStmt->execute([':attendee_id' => $attendeeId, ':ws_id' => $workshopCache[$cacheKey]]);
}
}
$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);
}