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); }