chore: initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
### Database Environment Variables
|
||||
MYSQL_HOST=db
|
||||
MYSQL_DATABASE=imf_sms
|
||||
MYSQL_USER=imf_user
|
||||
MYSQL_PASSWORD=password
|
||||
MYSQL_ROOT_PASSWORD=password123
|
||||
|
||||
TZ=Europe/London
|
||||
|
||||
### SMS Worker
|
||||
WORKER_URL=http://worker:5000
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM php:8.3-apache
|
||||
RUN docker-php-ext-install pdo pdo_mysql
|
||||
@@ -0,0 +1,18 @@
|
||||
<center><h1>IMFestival SMS Dashboard</h1></center>
|
||||
|
||||
### What is this repository?
|
||||
|
||||
This repository holds the SMS notification system for IMFestival, featuring a web dashboard along with containerization files (`Dockerfile` and `docker-compose.yml`) for easy deployment.
|
||||
|
||||
## How to use the dashboard
|
||||
The dashboard will allow the upload of a CSV file that contains the user's name, email, workshop name and times.
|
||||
|
||||
You can use the dashboard to schedule messages for delivery to selected users at specific times. Message content can be configured directly within the dashboard and supports the placeholders {name} for the recipient's name, {workshop} for the workshop name, and {workshop_time} for the workshop time.
|
||||
|
||||
## Considerations
|
||||
The character limit needs to check against the longest workshop name to ensure the limit isn't misrespresented.
|
||||
|
||||
### Python Dependencies
|
||||
- numpy
|
||||
- dateutil
|
||||
- pandas
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
-- IMFestival SMS Dashboard — database schema
|
||||
-- Runs automatically on first MariaDB container start.
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS imf_sms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE imf_sms;
|
||||
|
||||
-- ─── Core attendee data ───────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendees (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) DEFAULT NULL,
|
||||
mobile_number VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_mobile (mobile_number)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workshop_sessions (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
workshop_name VARCHAR(255) NOT NULL,
|
||||
workshop_time DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registrations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
attendee_id INT UNSIGNED NOT NULL,
|
||||
workshop_session_id INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workshop_session_id) REFERENCES workshop_sessions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── SMS templates ────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_templates (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
body VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── Scheduled send rules ─────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_rules (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
template_id INT UNSIGNED NOT NULL,
|
||||
mode ENUM('relative','specific') NOT NULL,
|
||||
offset_value INT DEFAULT NULL, -- used when mode=relative
|
||||
offset_unit ENUM('minutes','hours','days') DEFAULT NULL, -- used when mode=relative
|
||||
specific_datetime DATETIME DEFAULT NULL, -- used when mode=specific
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (template_id) REFERENCES sms_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── Send jobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS send_jobs (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
template_id INT UNSIGNED DEFAULT NULL,
|
||||
custom_body VARCHAR(160) DEFAULT NULL,
|
||||
recipient_scope ENUM('all','workshop') NOT NULL DEFAULT 'all',
|
||||
workshop_session_id INT UNSIGNED DEFAULT NULL,
|
||||
scheduled_rule_id INT UNSIGNED DEFAULT NULL,
|
||||
status ENUM('pending','sending','completed','failed') NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
sent_at TIMESTAMP DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (template_id) REFERENCES sms_templates(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (workshop_session_id) REFERENCES workshop_sessions(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (scheduled_rule_id) REFERENCES scheduled_rules(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── Delivery log ─────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS delivery_log (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
send_job_id INT UNSIGNED NOT NULL,
|
||||
attendee_id INT UNSIGNED DEFAULT NULL,
|
||||
mobile_number VARCHAR(30) NOT NULL,
|
||||
status ENUM('sent','delivered','failed') NOT NULL DEFAULT 'sent',
|
||||
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
delivered_at TIMESTAMP DEFAULT NULL,
|
||||
error_message TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (send_job_id) REFERENCES send_jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── System settings ──────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `system` (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
setting_name VARCHAR(100) NOT NULL,
|
||||
setting_value TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_setting_name (setting_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Seed default settings
|
||||
INSERT IGNORE INTO `system` (setting_name, setting_value) VALUES
|
||||
('clickatell_api_key', NULL),
|
||||
('clickatell_cost_per_sms', '0.04849'),
|
||||
('default_sender_id', 'IMFestival'),
|
||||
('sandbox_mode', '0');
|
||||
|
||||
-- ─── Schema migrations (idempotent) ─────────────────────────────────────────
|
||||
-- Safe to run on existing databases; ADD COLUMN IF NOT EXISTS is a no-op when
|
||||
-- the column already exists.
|
||||
ALTER TABLE delivery_log ADD COLUMN IF NOT EXISTS is_sandbox TINYINT(1) NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
db:
|
||||
image: mariadb:11
|
||||
volumes:
|
||||
- imf_db:/var/lib/mysql
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
ports:
|
||||
- "3306:3306"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: "${TZ:-Europe/London}"
|
||||
|
||||
web:
|
||||
build: .
|
||||
volumes:
|
||||
- ./web:/var/www/html
|
||||
ports:
|
||||
- "8000:80"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: "${TZ:-Europe/London}"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ./python
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: "${TZ:-Europe/London}"
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
imf_db:
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py .
|
||||
CMD ["python", "main.py"]
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IMFestival SMS Worker
|
||||
---------------------
|
||||
Runs as a Flask HTTP service on port 5000.
|
||||
|
||||
POST /send { "job_id": N }
|
||||
Sends all messages for an existing send_jobs row and writes delivery_log entries.
|
||||
Called by the PHP API immediately after a send_job is created.
|
||||
|
||||
GET /health
|
||||
Returns {"status": "ok"} — used by Docker health checks.
|
||||
|
||||
Background scheduler thread
|
||||
Wakes every 60 s, resolves due scheduled_rules, creates send_job rows, then
|
||||
calls the same send logic used by /send.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Thread
|
||||
|
||||
import requests as http_requests
|
||||
import mysql.connector
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
CLICKATELL_URL = "https://platform.clickatell.com/messages"
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.environ.get("MYSQL_HOST", "db"),
|
||||
"user": os.environ.get("MYSQL_USER", "imf_user"),
|
||||
"password": os.environ.get("MYSQL_PASSWORD", "password"),
|
||||
"database": os.environ.get("MYSQL_DATABASE", "imf_sms"),
|
||||
}
|
||||
|
||||
|
||||
# -- Database helpers ---------------------------------------------------------
|
||||
|
||||
def get_db():
|
||||
return mysql.connector.connect(**DB_CONFIG)
|
||||
|
||||
|
||||
def get_api_key(cursor) -> str | None:
|
||||
cursor.execute(
|
||||
"SELECT setting_value FROM `system` WHERE setting_name = 'clickatell_api_key'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return row["setting_value"] if row and row["setting_value"] else None
|
||||
|
||||
|
||||
# -- SMS character handling --------------------------------------------------
|
||||
|
||||
# Characters that ARE in the GSM-7 basic + extended character set.
|
||||
# Anything outside this set forces UCS-2 (Unicode) encoding → 70 chars/part.
|
||||
_GSM7 = set(
|
||||
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1b"
|
||||
"ÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
|
||||
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ"
|
||||
"¿abcdefghijklmnopqrstuvwxyzäöñüà"
|
||||
# Extended (each costs 2 GSM-7 chars)
|
||||
"€[]{}\\^|~"
|
||||
)
|
||||
|
||||
_NORMALISE_MAP = str.maketrans({
|
||||
"\u2018": "'", # left single quotation mark
|
||||
"\u2019": "'", # right single quotation mark / apostrophe
|
||||
"\u201c": '"', # left double quotation mark
|
||||
"\u201d": '"', # right double quotation mark
|
||||
"\u2013": "-", # en dash
|
||||
"\u2014": "-", # em dash
|
||||
"\u2026": "...", # horizontal ellipsis
|
||||
"\u00a0": " ", # non-breaking space
|
||||
"\u2022": "*", # bullet
|
||||
})
|
||||
|
||||
def normalise_gsm7(text: str) -> str:
|
||||
"""Replace common non-GSM-7 characters with safe ASCII equivalents."""
|
||||
return text.translate(_NORMALISE_MAP)
|
||||
|
||||
def sms_length(text: str) -> tuple[int, bool]:
|
||||
"""
|
||||
Returns (effective_length, is_unicode).
|
||||
Extended GSM-7 characters (€[]{}\\^|~) count as 2 chars each.
|
||||
If any character is outside GSM-7, the whole message is Unicode (UCS-2).
|
||||
"""
|
||||
if any(c not in _GSM7 for c in text):
|
||||
return len(text), True
|
||||
extended = set("€[]{}\\^|~")
|
||||
return sum(2 if c in extended else 1 for c in text), False
|
||||
|
||||
|
||||
# -- Clickatell dispatch ------------------------------------------------------
|
||||
|
||||
def clickatell_send(api_key: str, mobile: str, message: str) -> tuple[bool, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": api_key,
|
||||
}
|
||||
try:
|
||||
r = http_requests.post(
|
||||
CLICKATELL_URL,
|
||||
json={"content": message, "to": [mobile]},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
return r.status_code == 202, r.text
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
# -- Core send logic ----------------------------------------------------------
|
||||
|
||||
def execute_job(job_id: int) -> dict:
|
||||
"""
|
||||
Send messages for the given send_jobs row, write delivery_log entries, and
|
||||
update the job status. Returns {"sent": N, "failed": N} or {"error": "..."}.
|
||||
"""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
try:
|
||||
cursor.execute(
|
||||
"SELECT sj.id, sj.recipient_scope, sj.workshop_session_id, "
|
||||
" COALESCE(sj.custom_body, t.body) AS message_body "
|
||||
"FROM send_jobs sj "
|
||||
"LEFT JOIN sms_templates t ON t.id = sj.template_id "
|
||||
"WHERE sj.id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
job = cursor.fetchone()
|
||||
if not job:
|
||||
return {"error": f"Job {job_id} not found"}
|
||||
|
||||
api_key = get_api_key(cursor)
|
||||
if not api_key:
|
||||
cursor.execute(
|
||||
"UPDATE send_jobs SET status='failed' WHERE id = %s", (job_id,)
|
||||
)
|
||||
conn.commit()
|
||||
return {"error": "Clickatell API key not configured"}
|
||||
|
||||
cursor.execute(
|
||||
"SELECT setting_value FROM `system` WHERE setting_name = 'sandbox_mode'"
|
||||
)
|
||||
sandbox_row = cursor.fetchone()
|
||||
is_sandbox = 1 if sandbox_row and sandbox_row["setting_value"] == "1" else 0
|
||||
if is_sandbox:
|
||||
log.info("Job %d: sandbox mode ON — sends will be tagged as sandbox", job_id)
|
||||
|
||||
if job["recipient_scope"] == "all":
|
||||
cursor.execute(
|
||||
"SELECT a.id, a.first_name, a.mobile_number, "
|
||||
" (SELECT ws2.workshop_name FROM registrations r2 "
|
||||
" JOIN workshop_sessions ws2 ON ws2.id = r2.workshop_session_id "
|
||||
" WHERE r2.attendee_id = a.id ORDER BY ws2.workshop_time LIMIT 1) AS workshop_name, "
|
||||
" (SELECT ws2.workshop_time FROM registrations r2 "
|
||||
" JOIN workshop_sessions ws2 ON ws2.id = r2.workshop_session_id "
|
||||
" WHERE r2.attendee_id = a.id ORDER BY ws2.workshop_time LIMIT 1) AS workshop_time "
|
||||
"FROM attendees a"
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"SELECT a.id, a.first_name, a.mobile_number, "
|
||||
" ws.workshop_name, ws.workshop_time "
|
||||
"FROM attendees a "
|
||||
"JOIN registrations r ON r.attendee_id = a.id "
|
||||
"JOIN workshop_sessions ws ON ws.id = r.workshop_session_id "
|
||||
"WHERE r.workshop_session_id = %s",
|
||||
(job["workshop_session_id"],),
|
||||
)
|
||||
recipients = cursor.fetchall()
|
||||
|
||||
if not recipients:
|
||||
cursor.execute(
|
||||
"UPDATE send_jobs SET status='completed', sent_at=NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"sent": 0, "failed": 0}
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE send_jobs SET status='sending', sent_at=NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
sent = failed = 0
|
||||
message = job["message_body"] or ""
|
||||
|
||||
for recipient in recipients:
|
||||
ws_name = recipient.get("workshop_name") or ""
|
||||
ws_time_raw = recipient.get("workshop_time")
|
||||
if ws_time_raw and hasattr(ws_time_raw, "strftime"):
|
||||
ws_time = ws_time_raw.strftime("%H:%M")
|
||||
elif ws_time_raw:
|
||||
ws_time = str(ws_time_raw)[11:16] # extract HH:MM from datetime string
|
||||
else:
|
||||
ws_time = ""
|
||||
msg = (
|
||||
message
|
||||
.replace("{name}", recipient["first_name"] or "")
|
||||
.replace("{first name}", recipient["first_name"] or "")
|
||||
.replace("{first_name}", recipient["first_name"] or "")
|
||||
.replace("{workshop}", ws_name)
|
||||
.replace("{workshop_time}", ws_time)
|
||||
)
|
||||
msg = normalise_gsm7(msg)
|
||||
length, is_unicode = sms_length(msg)
|
||||
limit = 70 if is_unicode else 160
|
||||
if length > limit:
|
||||
log.warning(
|
||||
"Message to %s is %d chars (%s encoding, limit %d) — truncating",
|
||||
recipient["mobile_number"], length,
|
||||
"Unicode" if is_unicode else "GSM-7", limit,
|
||||
)
|
||||
msg = msg[:limit]
|
||||
success, response_text = clickatell_send(api_key, recipient["mobile_number"], msg)
|
||||
status = "sent" if success else "failed"
|
||||
error = None if success else response_text[:500]
|
||||
|
||||
cursor.execute(
|
||||
"INSERT INTO delivery_log "
|
||||
" (send_job_id, attendee_id, mobile_number, status, sent_at, error_message, is_sandbox) "
|
||||
"VALUES (%s, %s, %s, %s, NOW(), %s, %s)",
|
||||
(job_id, recipient["id"], recipient["mobile_number"], status, error, is_sandbox),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
if success:
|
||||
sent += 1
|
||||
log.info("Sent -> %s", recipient["mobile_number"])
|
||||
else:
|
||||
failed += 1
|
||||
log.warning("Failed -> %s : %s", recipient["mobile_number"], response_text[:120])
|
||||
|
||||
final_status = "completed" if sent > 0 else "failed"
|
||||
cursor.execute(
|
||||
"UPDATE send_jobs SET status=%s, sent_at=NOW() WHERE id = %s",
|
||||
(final_status, job_id),
|
||||
)
|
||||
conn.commit()
|
||||
log.info("Job %d complete -- sent=%d failed=%d", job_id, sent, failed)
|
||||
return {"sent": sent, "failed": failed}
|
||||
|
||||
except Exception as exc:
|
||||
log.exception("execute_job error for job %d", job_id)
|
||||
try:
|
||||
cursor.execute(
|
||||
"UPDATE send_jobs SET status='failed' WHERE id = %s", (job_id,)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return {"error": str(exc)}
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
# -- HTTP endpoints -----------------------------------------------------------
|
||||
|
||||
@app.route("/send", methods=["POST"])
|
||||
def send_endpoint():
|
||||
data = request.get_json(force=True) or {}
|
||||
job_id = data.get("job_id")
|
||||
if not job_id:
|
||||
return jsonify({"error": "job_id required"}), 400
|
||||
|
||||
result = execute_job(int(job_id))
|
||||
if "error" in result:
|
||||
return jsonify(result), 500
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# -- Scheduler ----------------------------------------------------------------
|
||||
|
||||
def run_scheduler():
|
||||
"""Check for due scheduled_rules and fire any that have not run yet."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT id, template_id, mode, offset_value, offset_unit, specific_datetime "
|
||||
"FROM scheduled_rules"
|
||||
)
|
||||
rules = cursor.fetchall()
|
||||
|
||||
for rule in rules:
|
||||
|
||||
if rule["mode"] == "specific":
|
||||
fire_time = rule["specific_datetime"]
|
||||
if isinstance(fire_time, str):
|
||||
fire_time = datetime.fromisoformat(fire_time)
|
||||
if now < fire_time:
|
||||
log.info("Rule %d (specific): not yet due — fires at %s", rule["id"], fire_time)
|
||||
continue
|
||||
|
||||
# Already executed?
|
||||
cursor.execute(
|
||||
"SELECT id FROM send_jobs "
|
||||
"WHERE scheduled_rule_id = %s AND workshop_session_id IS NULL LIMIT 1",
|
||||
(rule["id"],),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"INSERT INTO send_jobs "
|
||||
" (template_id, recipient_scope, scheduled_rule_id, status) "
|
||||
"VALUES (%s, 'all', %s, 'pending')",
|
||||
(rule["template_id"], rule["id"]),
|
||||
)
|
||||
job_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
log.info("Firing specific rule %d -> job %d", rule["id"], job_id)
|
||||
execute_job(job_id)
|
||||
|
||||
elif rule["mode"] == "relative":
|
||||
unit_map = {"minutes": "minutes", "hours": "hours", "days": "days"}
|
||||
offset = timedelta(
|
||||
**{unit_map.get(rule["offset_unit"], "minutes"): rule["offset_value"]}
|
||||
)
|
||||
# Apply rule to every workshop session individually
|
||||
cursor.execute("SELECT id, workshop_time FROM workshop_sessions")
|
||||
for session in cursor.fetchall():
|
||||
ws_time = session["workshop_time"]
|
||||
if isinstance(ws_time, str):
|
||||
ws_time = datetime.fromisoformat(ws_time)
|
||||
fire_time = ws_time - offset
|
||||
if now < fire_time:
|
||||
log.debug("Rule %d session %d: not yet due — fires at %s", rule["id"], session["id"], fire_time)
|
||||
continue
|
||||
|
||||
# Already executed for this (rule, session) pair?
|
||||
cursor.execute(
|
||||
"SELECT id FROM send_jobs "
|
||||
"WHERE scheduled_rule_id = %s AND workshop_session_id = %s LIMIT 1",
|
||||
(rule["id"], session["id"]),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"INSERT INTO send_jobs "
|
||||
" (template_id, recipient_scope, workshop_session_id, "
|
||||
" scheduled_rule_id, status) "
|
||||
"VALUES (%s, 'workshop', %s, %s, 'pending')",
|
||||
(rule["template_id"], session["id"], rule["id"]),
|
||||
)
|
||||
job_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
log.info(
|
||||
"Firing relative rule %d for session %d -> job %d",
|
||||
rule["id"], session["id"], job_id,
|
||||
)
|
||||
execute_job(job_id)
|
||||
|
||||
except Exception as exc:
|
||||
log.exception("Scheduler error: %s", exc)
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def scheduler_loop():
|
||||
log.info("Scheduler started -- polling every 60 s")
|
||||
# Brief startup delay to let MariaDB finish initialising
|
||||
time.sleep(5)
|
||||
while True:
|
||||
log.info("Scheduler tick")
|
||||
run_scheduler()
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
# -- Entry point --------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
Thread(target=scheduler_loop, daemon=True).start()
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
requests>=2.31
|
||||
mysql-connector-python>=8.0
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin — IMFestival SMS Dashboard</title>
|
||||
|
||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
||||
crossorigin="anonymous">
|
||||
<link href="assets/css/dashboard.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ─── Mobile top bar ─── -->
|
||||
<div class="topbar">
|
||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;font-size:0.95rem;">Admin</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
||||
<div class="offcanvas-header">
|
||||
<div class="brand"
|
||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<span>SMS Dashboard</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-3">
|
||||
<nav class="d-flex flex-column gap-1">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Desktop sidebar ─── -->
|
||||
<div class="sidebar">
|
||||
<div class="brand">
|
||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<nav class="d-flex flex-column gap-1 mt-2">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||
<div class="nav-link-custom" style="cursor:default;">
|
||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
||||
<span style="font-size:0.82rem;">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Main content ─── -->
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<div id="sandboxBanner" class="alert alert-warning"
|
||||
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded
|
||||
from
|
||||
cost calculations.
|
||||
</div>
|
||||
|
||||
<div class="page-header d-flex align-items-center gap-3">
|
||||
<a href="index.html" class="btn btn-sm btn-secondary">
|
||||
<i class="fa-solid fa-arrow-left me-1"></i> Back
|
||||
</a>
|
||||
<div>
|
||||
<h1>Admin</h1>
|
||||
<p>Sandbox mode and data management controls.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert area -->
|
||||
<div id="adminResult" class="alert mb-4" style="display:none;" role="alert"></div>
|
||||
|
||||
<!-- ── Sandbox Mode ─────────────────────────────────────────────────────── -->
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="d-flex align-items-start justify-content-between mb-3 gap-3">
|
||||
<div>
|
||||
<h5 class="mb-1">
|
||||
<i class="fa-solid fa-flask fa-fw me-1" style="color:var(--text-muted);"></i>
|
||||
Sandbox Mode
|
||||
</h5>
|
||||
<p class="text-muted mb-0" style="font-size:0.85rem;">
|
||||
When enabled, all new messages are tagged as sandbox. Sandbox messages are excluded from the
|
||||
success rate and cost calculations on the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-check form-switch flex-shrink-0 mt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="sandboxToggle"
|
||||
style="width:3rem;height:1.5rem;cursor:pointer;" aria-label="Toggle sandbox mode">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Real Sends</div>
|
||||
<div class="stat-value" id="stat-real" style="color:#4ade80;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Sandbox Sends</div>
|
||||
<div class="stat-value" id="stat-sandbox" style="color:var(--text-muted);">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Logged</div>
|
||||
<div class="stat-value" id="stat-total">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-secondary" onclick="markAllSandbox()">
|
||||
<i class="fa-solid fa-flask me-1"></i>
|
||||
Mark all existing sends as sandbox
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Data Management ───────────────────────────────────────────────────── -->
|
||||
<div class="card p-4">
|
||||
<h5 class="mb-4">
|
||||
<i class="fa-solid fa-database fa-fw me-1" style="color:var(--text-muted);"></i>
|
||||
Data Management
|
||||
</h5>
|
||||
|
||||
<!-- Delivery Log -->
|
||||
<div class="mb-4">
|
||||
<h6 class="mb-1">Delivery Log</h6>
|
||||
<p class="text-muted mb-2" style="font-size:0.82rem;">
|
||||
<span id="count-log">—</span> entries logged.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-danger" onclick="purge('delivery_log')">
|
||||
<i class="fa-solid fa-trash-can me-1"></i> Purge All
|
||||
</button>
|
||||
<span class="text-muted" style="font-size:0.82rem;">or purge before:</span>
|
||||
<input type="date" id="purgeBefore" class="form-control form-control-sm"
|
||||
style="width:auto;min-width:150px;">
|
||||
<button class="btn btn-sm btn-warning" onclick="purge('delivery_log', true)">
|
||||
<i class="fa-solid fa-calendar-xmark me-1"></i> Purge Before Date
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:var(--border);">
|
||||
|
||||
<!-- Attendees -->
|
||||
<div class="mb-4">
|
||||
<h6 class="mb-1">Attendees & Workshops</h6>
|
||||
<p class="text-muted mb-2" style="font-size:0.82rem;">
|
||||
<span id="count-attendees">—</span> attendees. Removes all attendees, registrations and
|
||||
workshop sessions.
|
||||
</p>
|
||||
<button class="btn btn-sm btn-danger" onclick="purge('attendees')">
|
||||
<i class="fa-solid fa-trash-can me-1"></i> Purge All Attendees
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:var(--border);">
|
||||
|
||||
<!-- Templates -->
|
||||
<div class="mb-4">
|
||||
<h6 class="mb-1">SMS Templates</h6>
|
||||
<p class="text-muted mb-2" style="font-size:0.82rem;">
|
||||
<span id="count-templates">—</span> templates.
|
||||
</p>
|
||||
<button class="btn btn-sm btn-danger" onclick="purge('templates')">
|
||||
<i class="fa-solid fa-trash-can me-1"></i> Purge All Templates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:var(--border);">
|
||||
|
||||
<!-- Schedules -->
|
||||
<div>
|
||||
<h6 class="mb-1">Scheduled Rules</h6>
|
||||
<p class="text-muted mb-2" style="font-size:0.82rem;">
|
||||
<span id="count-schedules">—</span> scheduled rules.
|
||||
</p>
|
||||
<button class="btn btn-sm btn-danger" onclick="purge('schedules')">
|
||||
<i class="fa-solid fa-trash-can me-1"></i> Purge All Schedules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.main-inner -->
|
||||
</div><!-- /.main -->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// ─── Load admin state ────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAdmin() {
|
||||
try {
|
||||
const data = await fetch('/api/admin.php').then(r => r.json());
|
||||
|
||||
document.getElementById('sandboxToggle').checked = data.sandbox_mode;
|
||||
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
|
||||
|
||||
document.getElementById('stat-real').textContent = data.delivery_log_real_sent ?? '—';
|
||||
document.getElementById('stat-sandbox').textContent = data.delivery_log_sandbox ?? '—';
|
||||
document.getElementById('stat-total').textContent = data.delivery_log_total ?? '—';
|
||||
|
||||
document.getElementById('count-log').textContent = data.delivery_log_total ?? '—';
|
||||
document.getElementById('count-attendees').textContent = data.attendees ?? '—';
|
||||
document.getElementById('count-templates').textContent = data.templates ?? '—';
|
||||
document.getElementById('count-schedules').textContent = data.schedules ?? '—';
|
||||
} catch (e) {
|
||||
showResult('Failed to load admin data: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sandbox toggle ──────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('sandboxToggle').addEventListener('change', async () => {
|
||||
try {
|
||||
const data = await fetch('/api/admin.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'toggle_sandbox' }),
|
||||
}).then(r => r.json());
|
||||
document.getElementById('sandboxToggle').checked = data.sandbox_mode;
|
||||
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
|
||||
showResult(`Sandbox mode ${data.sandbox_mode ? 'enabled' : 'disabled'}.`, 'success');
|
||||
} catch (e) {
|
||||
showResult('Failed to toggle sandbox mode: ' + e.message, 'danger');
|
||||
await loadAdmin(); // revert toggle visual state
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Mark all as sandbox ─────────────────────────────────────────────────────
|
||||
|
||||
async function markAllSandbox() {
|
||||
if (!confirm('Mark ALL existing delivery log entries as sandbox?\n\nThey will be excluded from cost calculations and success rate on the dashboard.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/admin.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'mark_all_sandbox' }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? 'Failed');
|
||||
showResult('All sends marked as sandbox.', 'success');
|
||||
loadAdmin();
|
||||
} catch (e) {
|
||||
showResult('Failed: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Purge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const PURGE_LABELS = {
|
||||
delivery_log: 'all delivery log entries',
|
||||
attendees: 'all attendees, registrations and workshops',
|
||||
templates: 'all SMS templates',
|
||||
schedules: 'all scheduled rules',
|
||||
};
|
||||
|
||||
async function purge(target, useDateFilter = false) {
|
||||
const body = { action: 'purge', target };
|
||||
|
||||
if (useDateFilter) {
|
||||
const before = document.getElementById('purgeBefore').value;
|
||||
if (!before) {
|
||||
alert('Please select a date first.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Purge all delivery log entries before ${before}?\n\nThis cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
body.before = before;
|
||||
} else {
|
||||
if (!confirm(`This will permanently delete ${PURGE_LABELS[target]}.\n\nThis cannot be undone. Are you sure?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Purge failed');
|
||||
const n = typeof data.purged === 'number' ? ` (${data.purged} rows)` : '';
|
||||
showResult(`Purge completed successfully${n}.`, 'success');
|
||||
loadAdmin();
|
||||
} catch (e) {
|
||||
showResult('Purge failed: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Toast/alert helper ──────────────────────────────────────────────────────
|
||||
|
||||
function showResult(msg, type) {
|
||||
const el = document.getElementById('adminResult');
|
||||
el.className = `alert alert-${type}`;
|
||||
el.textContent = msg;
|
||||
el.style.display = '';
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => { el.style.display = 'none'; }, 6000);
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadAdmin();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
section div {
|
||||
max-width: 255px;
|
||||
word-wrap: break-word;
|
||||
margin-bottom: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.from-them {
|
||||
position: relative;
|
||||
padding: 10px 20px;
|
||||
background: #E5E5EA;
|
||||
border-radius: 25px;
|
||||
color: black;
|
||||
}
|
||||
.from-them:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: 0px;
|
||||
left: -7px;
|
||||
height: 20px;
|
||||
border-left: 20px solid #E5E5EA;
|
||||
border-bottom-right-radius: 16px 14px;
|
||||
-webkit-transform: translate(0, -2px);
|
||||
}
|
||||
.from-them:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
bottom: 0px;
|
||||
left: 4px;
|
||||
width: 26px;
|
||||
height: 20px;
|
||||
background: #1c2230;
|
||||
border-bottom-right-radius: 10px;
|
||||
-webkit-transform: translate(-30px, -2px);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
:root {
|
||||
--bg-base: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-card: #1c2230;
|
||||
--border: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #4f8ef7;
|
||||
--accent-hover: #6aa3ff;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
|
||||
.sidebar {
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
width: 240px;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem 1rem;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1040;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar .brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar .brand img {
|
||||
width: 120px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar .brand span {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-link-custom:hover,
|
||||
.nav-link-custom.active {
|
||||
background: rgba(79, 142, 247, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-link-custom i {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-link-custom.active i,
|
||||
.nav-link-custom:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Top bar (mobile) ── */
|
||||
.topbar {
|
||||
display: none;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1030;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.main {
|
||||
margin-left: 240px;
|
||||
padding: 1.75rem 2rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Page header ── */
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.card h5 {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Stat cards ── */
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 1.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 142, 247, 0.18);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-control[type="file"] {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.table {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-striped-bg: rgba(255,255,255,0.03);
|
||||
--bs-table-border-color: var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.table td {
|
||||
border-color: var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover, .btn-primary:focus {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-secondary:hover, .btn-secondary:focus {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-color: transparent;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-danger:hover, .btn-danger:focus {
|
||||
background: rgba(220, 53, 69, 0.22);
|
||||
border-color: transparent;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background: rgba(79, 142, 247, 0.12);
|
||||
border-color: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-accent:hover, .btn-accent:focus {
|
||||
background: rgba(79, 142, 247, 0.22);
|
||||
border-color: transparent;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Hamburger toggle ── */
|
||||
.hamburger {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Offcanvas dark sidebar (mobile) ── */
|
||||
.offcanvas {
|
||||
background: var(--bg-surface) !important;
|
||||
border-right: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.offcanvas-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Badge ── */
|
||||
.badge-failed {
|
||||
background: rgba(220,53,69,0.18);
|
||||
color: #f87171;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2em 0.55em;
|
||||
}
|
||||
|
||||
.badge-delivered {
|
||||
background: rgba(34,197,94,0.15);
|
||||
color: #4ade80;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2em 0.55em;
|
||||
}
|
||||
|
||||
/* ── Dropzone ── */
|
||||
.dropzone {
|
||||
background: var(--bg-surface);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone.dz-drag-hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(79, 142, 247, 0.06);
|
||||
}
|
||||
|
||||
.dropzone .dz-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropzone .dz-message .dz-icon {
|
||||
font-size: 1.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropzone .dz-message span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dropzone .dz-preview .dz-filename span,
|
||||
.dropzone .dz-preview .dz-size {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropzone .dz-preview .dz-progress .dz-upload {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Section header ── */
|
||||
.section-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Chat bubble tail override when sitting on --bg-surface ── */
|
||||
.bubble-on-surface .from-them:after {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar { display: none; }
|
||||
.topbar { display: flex; }
|
||||
.main { margin-left: 0; padding: 1rem; }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -0,0 +1,652 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Attendees — IMFestival SMS Dashboard</title>
|
||||
|
||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
||||
crossorigin="anonymous">
|
||||
<link href="assets/css/dashboard.css" rel="stylesheet">
|
||||
<link href="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone.css" rel="stylesheet">
|
||||
<link href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ─── Mobile top bar ─── -->
|
||||
<div class="topbar">
|
||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;font-size:0.95rem;">Attendees</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
||||
<div class="offcanvas-header">
|
||||
<div class="brand"
|
||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<img src="assets/img/logo.png" alt="Logo" style="width:120px;height:auto;">
|
||||
<span>SMS Dashboard</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-3">
|
||||
<nav class="d-flex flex-column gap-1">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Desktop sidebar ─── -->
|
||||
<div class="sidebar">
|
||||
<div class="brand">
|
||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<nav class="d-flex flex-column gap-1 mt-2">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||
<div class="nav-link-custom" style="cursor:default;">
|
||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
||||
<span style="font-size:0.82rem;">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Main content ─── -->
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<div id="sandboxBanner" class="alert alert-warning"
|
||||
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded
|
||||
from
|
||||
cost calculations.
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Attendees</h1>
|
||||
<p>Upload a CSV file to import participants.</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mb-4">
|
||||
<h5 class="mb-3">Import CSV</h5>
|
||||
<div class="mb-3 d-flex flex-wrap gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="importMode" id="modeClear" value="clear"
|
||||
checked>
|
||||
<label class="form-check-label" for="modeClear">
|
||||
<strong>Clear & Import</strong>
|
||||
<span class="text-muted d-block" style="font-size:0.78rem;">Delete all existing attendees,
|
||||
then import</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="importMode" id="modeOverwrite"
|
||||
value="overwrite">
|
||||
<label class="form-check-label" for="modeOverwrite">
|
||||
<strong>Overwrite Existing</strong>
|
||||
<span class="text-muted d-block" style="font-size:0.78rem;">Update existing attendees, add
|
||||
new ones</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="csvDropzone" class="dropzone">
|
||||
<div class="dz-message">
|
||||
<i class="fa-solid fa-file-csv dz-icon"></i>
|
||||
<span>Drop a CSV here or <strong>click to browse</strong></span>
|
||||
<span class="text-muted" style="font-size:0.78rem;">Only .csv files accepted · Max 5 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="confirmUploadBar" class="d-none mt-2 d-flex align-items-center gap-2">
|
||||
<span id="confirmUploadFilename" class="text-muted" style="font-size:0.85rem;"></span>
|
||||
<button class="btn btn-sm btn-primary" onclick="confirmUpload()">
|
||||
<i class="fa-solid fa-upload fa-fw"></i> Confirm Upload
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="cancelUpload()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div id="importResult" class="mt-2" style="font-size:0.82rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Rejected records (shown after import if rows were skipped) ─── -->
|
||||
<div id="rejectedSection" class="card p-4 mb-4 d-none">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa-solid fa-circle-exclamation fa-fw me-1" style="color:#facc15;"></i>
|
||||
Rejected Records <span id="rejectedCount" class="badge ms-1"
|
||||
style="background:rgba(250,204,21,0.15);color:#facc15;font-size:0.75rem;"></span>
|
||||
</h5>
|
||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||
<div class="btn-group btn-group-sm" id="rejectedFilter" role="group"
|
||||
aria-label="Filter rejected records">
|
||||
<button type="button" class="btn btn-secondary" data-filter="all">All</button>
|
||||
<button type="button" class="btn btn-secondary" data-filter="not_attending">Not
|
||||
Attending</button>
|
||||
<button type="button" class="btn btn-secondary active" data-filter="no_mobile">Missing
|
||||
Mobile</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" onclick="dismissAllRejected()">
|
||||
<i class="fa-solid fa-xmark fa-fw"></i> Dismiss All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Mobile (raw)</th>
|
||||
<th>Status</th>
|
||||
<th>Reason</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rejectedBody">
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-center py-3">No rejected records.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0">Participants</h5>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted" id="attendeeCount" style="font-size:0.82rem;">—</span>
|
||||
<button class="btn btn-sm btn-danger" id="purgeBtn" onclick="purgeAttendees()">
|
||||
<i class="fa-solid fa-trash-can fa-fw"></i> Purge All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0" id="attendeesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Mobile</th>
|
||||
<th>Workshop</th>
|
||||
<th>Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attendeeBody">
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-center py-4">Loading…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone-min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
||||
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
||||
<script>
|
||||
Dropzone.autoDiscover = false;
|
||||
|
||||
const dz = new Dropzone("#csvDropzone", {
|
||||
url: "/api/attendees.php",
|
||||
acceptedFiles: ".csv",
|
||||
maxFiles: 1,
|
||||
maxFilesize: 5,
|
||||
autoProcessQueue: false,
|
||||
dictDefaultMessage: "",
|
||||
params: () => ({ mode: document.querySelector('input[name="importMode"]:checked').value }),
|
||||
});
|
||||
|
||||
dz.on("addedfile", (file) => {
|
||||
document.getElementById('confirmUploadFilename').textContent = file.name;
|
||||
document.getElementById('confirmUploadBar').classList.remove('d-none');
|
||||
document.getElementById('importResult').innerHTML = '';
|
||||
});
|
||||
|
||||
dz.on("removedfile", () => {
|
||||
document.getElementById('confirmUploadBar').classList.add('d-none');
|
||||
document.getElementById('confirmUploadFilename').textContent = '';
|
||||
});
|
||||
|
||||
function confirmUpload() {
|
||||
dz.processQueue();
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
dz.removeAllFiles();
|
||||
}
|
||||
|
||||
dz.on("success", (file, response) => {
|
||||
dz.removeFile(file);
|
||||
const r = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
const resultEl = document.getElementById('importResult');
|
||||
const parts = [];
|
||||
if (r.imported) parts.push(`<span class="text-success">${r.imported} added</span>`);
|
||||
if (r.updated) parts.push(`<span class="text-info">${r.updated} updated</span>`);
|
||||
if (r.skipped) {
|
||||
const reasons = [];
|
||||
if (r.skip_reasons?.not_attending) reasons.push(`${r.skip_reasons.not_attending} not attending`);
|
||||
if (r.skip_reasons?.no_mobile) reasons.push(`${r.skip_reasons.no_mobile} invalid/missing mobile`);
|
||||
const detail = reasons.length ? ` (${reasons.join(', ')})` : '';
|
||||
parts.push(`<span class="text-muted">${r.skipped} skipped${detail}</span>`);
|
||||
}
|
||||
resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';
|
||||
if (r.rejected && r.rejected.length > 0) {
|
||||
showRejectedRecords(r.rejected);
|
||||
} else {
|
||||
rejectedRows = [];
|
||||
renderRejectedTable();
|
||||
}
|
||||
loadAttendees();
|
||||
});
|
||||
|
||||
dz.on("error", (file, msg) => {
|
||||
const errText = typeof msg === 'object' ? (msg.error ?? JSON.stringify(msg)) : msg;
|
||||
document.getElementById('importResult').innerHTML = `<span class="text-danger">Import failed: ${esc(errText)}</span>`;
|
||||
dz.removeFile(file);
|
||||
});
|
||||
|
||||
let table = null;
|
||||
|
||||
async function loadAttendees() {
|
||||
const tbody = document.getElementById('attendeeBody');
|
||||
const countEl = document.getElementById('attendeeCount');
|
||||
|
||||
// Destroy existing DataTable instance before touching the DOM
|
||||
if (table) { table.destroy(); table = null; }
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Loading…</td></tr>';
|
||||
try {
|
||||
const data = await fetch('/api/attendees.php').then(r => r.json());
|
||||
const list = data.attendees ?? [];
|
||||
countEl.textContent = list.length + ' total';
|
||||
if (list.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">No attendees yet. Upload a CSV to get started.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = list.map(a => `
|
||||
<tr>
|
||||
<td>${esc(a.first_name + ' ' + a.last_name)}</td>
|
||||
<td style="color:var(--text-muted);">${esc(a.email ?? '')}</td>
|
||||
<td style="color:var(--text-muted);">${esc(a.mobile_number)}</td>
|
||||
<td>${esc(a.workshops ?? '—')}</td>
|
||||
<td style="color:var(--text-muted);">${formatWorkshopTimes(a.workshop_times)}</td>
|
||||
<td style="white-space:nowrap;">
|
||||
<button class="btn btn-sm btn-accent" onclick='openEditModal(${JSON.stringify(a)})'>
|
||||
<i class="fa-solid fa-pen fa-fw"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
table = new DataTable('#attendeesTable', { columnDefs: [{ orderable: false, targets: -1 }] });
|
||||
} catch {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Could not load attendees.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function formatWorkshopTimes(timesStr) {
|
||||
if (!timesStr) return '—';
|
||||
return esc(timesStr.split(',').map(t => {
|
||||
const d = new Date(t.trim().replace(' ', 'T'));
|
||||
return isNaN(d.getTime()) ? t.trim() : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}).join(', '));
|
||||
}
|
||||
|
||||
async function purgeAttendees() {
|
||||
if (!confirm('This will permanently delete all attendees and their registrations. Are you sure?')) return;
|
||||
const btn = document.getElementById('purgeBtn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/attendees.php', { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
loadAttendees();
|
||||
} catch (e) {
|
||||
alert('Purge failed: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openApiKeyModal() {
|
||||
document.getElementById('apiKeyInput').value = '';
|
||||
document.getElementById('apiKeyError').textContent = '';
|
||||
const statusEl = document.getElementById('apiKeyStatus');
|
||||
statusEl.textContent = 'Checking…';
|
||||
try {
|
||||
const [keyData, costData] = await Promise.all([
|
||||
fetch('/api/settings.php?key=clickatell_api_key').then(r => r.json()),
|
||||
fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),
|
||||
]);
|
||||
statusEl.innerHTML = keyData.set
|
||||
? `API key set: <code>${esc(keyData.hint)}</code> — enter a new key to replace it.`
|
||||
: '<span class="text-warning">No API key configured.</span> Enter one below to enable SMS sending.';
|
||||
document.getElementById('costPerSmsInput').value = costData.value ?? '0.04849';
|
||||
} catch {
|
||||
statusEl.textContent = 'Could not load current settings.';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiKey() {
|
||||
const key = document.getElementById('apiKeyInput').value.trim();
|
||||
const cost = document.getElementById('costPerSmsInput').value.trim();
|
||||
const errEl = document.getElementById('apiKeyError');
|
||||
const btn = document.getElementById('apiKeySaveBtn');
|
||||
errEl.textContent = '';
|
||||
if (!key && !cost) { errEl.textContent = 'Please enter at least one value.'; return; }
|
||||
const costNum = parseFloat(cost);
|
||||
if (cost && (isNaN(costNum) || costNum < 0)) { errEl.textContent = 'Cost must be a positive number.'; return; }
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const saves = [];
|
||||
if (key) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_api_key', value: key }) }));
|
||||
if (cost) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_cost_per_sms', value: cost }) }));
|
||||
const results = await Promise.all(saves);
|
||||
for (const res of results) { if (!res.ok) throw new Error((await res.json()).error ?? 'Save failed'); }
|
||||
bootstrap.Modal.getInstance(document.getElementById('apiKeyModal')).hide();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadAttendees();
|
||||
|
||||
// ─── Rejected records ────────────────────────────────────────────────────────
|
||||
|
||||
let rejectedRows = [];
|
||||
let rejectedFilter = 'no_mobile';
|
||||
|
||||
function showRejectedRecords(rejected) {
|
||||
rejectedRows = rejected.map(r => ({ ...r, dismissed: false }));
|
||||
rejectedFilter = 'no_mobile';
|
||||
document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('#rejectedFilter button[data-filter="no_mobile"]').classList.add('active');
|
||||
renderRejectedTable();
|
||||
}
|
||||
|
||||
function renderRejectedTable() {
|
||||
const section = document.getElementById('rejectedSection');
|
||||
const tbody = document.getElementById('rejectedBody');
|
||||
const countEl = document.getElementById('rejectedCount');
|
||||
|
||||
const active = rejectedRows.filter(r => !r.dismissed);
|
||||
if (active.length === 0) { section.classList.add('d-none'); return; }
|
||||
|
||||
const visible = rejectedFilter === 'all'
|
||||
? active
|
||||
: rejectedFilter === 'not_attending'
|
||||
? active.filter(r => r.reason === 'not_attending')
|
||||
: active.filter(r => r.reason !== 'not_attending');
|
||||
section.classList.remove('d-none');
|
||||
countEl.textContent = active.length;
|
||||
|
||||
if (visible.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-3">No records match this filter.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = visible.map(r => {
|
||||
const idx = rejectedRows.indexOf(r);
|
||||
const reasonBadges = {
|
||||
not_attending: '<span class="badge" style="background:rgba(250,204,21,0.15);color:#facc15;">Not Attending</span>',
|
||||
no_mobile: '<span class="badge" style="background:rgba(248,113,113,0.15);color:#f87171;">No Mobile</span>',
|
||||
foreign_country_code: '<span class="badge" style="background:rgba(251,146,60,0.15);color:#fb923c;">Foreign Number</span>',
|
||||
invalid_number: '<span class="badge" style="background:rgba(248,113,113,0.15);color:#f87171;">Invalid Number</span>',
|
||||
};
|
||||
const reasonBadge = reasonBadges[r.reason] ?? reasonBadges.invalid_number;
|
||||
const addBtn = r.reason !== 'not_attending'
|
||||
? `<button class="btn btn-sm btn-accent me-1" onclick="addRejectedManually(${idx})"><i class="fa-solid fa-user-plus fa-fw"></i> Add</button>`
|
||||
: '';
|
||||
return `
|
||||
<tr>
|
||||
<td>${esc(r.first_name + ' ' + r.last_name)}</td>
|
||||
<td style="color:var(--text-muted);">${esc(r.email ?? '')}</td>
|
||||
<td style="color:var(--text-muted);">${esc(r.raw_mobile || '—')}</td>
|
||||
<td style="color:var(--text-muted);">${esc(r.status || '—')}</td>
|
||||
<td>${reasonBadge}</td>
|
||||
<td style="white-space:nowrap;">
|
||||
${addBtn}
|
||||
<button class="btn btn-sm btn-secondary" onclick="dismissRejected(${idx})"><i class="fa-solid fa-xmark fa-fw"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function dismissRejected(idx) {
|
||||
rejectedRows[idx].dismissed = true;
|
||||
renderRejectedTable();
|
||||
}
|
||||
|
||||
function dismissAllRejected() {
|
||||
rejectedRows.forEach(r => { r.dismissed = true; });
|
||||
renderRejectedTable();
|
||||
}
|
||||
|
||||
function addRejectedManually(idx) {
|
||||
const r = rejectedRows[idx];
|
||||
openEditModal({
|
||||
id: 0,
|
||||
first_name: r.first_name,
|
||||
last_name: r.last_name,
|
||||
email: r.email ?? '',
|
||||
mobile_number: r.raw_mobile ?? '',
|
||||
workshop_session_id: null,
|
||||
_rejectedIdx: idx,
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#rejectedFilter button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
rejectedFilter = btn.dataset.filter;
|
||||
renderRejectedTable();
|
||||
});
|
||||
});
|
||||
|
||||
let editingAttendee = null;
|
||||
|
||||
function openEditModal(a) {
|
||||
editingAttendee = a;
|
||||
document.getElementById('editFirstName').value = a.first_name ?? '';
|
||||
document.getElementById('editLastName').value = a.last_name ?? '';
|
||||
document.getElementById('editEmail').value = a.email ?? '';
|
||||
document.getElementById('editMobile').value = a.mobile_number ?? '';
|
||||
document.getElementById('editError').textContent = '';
|
||||
|
||||
const sel = document.getElementById('editWorkshop');
|
||||
sel.innerHTML = '<option value="">— No workshop —</option>';
|
||||
fetch('/api/workshops.php').then(r => r.json()).then(data => {
|
||||
const workshops = data.workshops ?? [];
|
||||
// Group sessions by workshop name (case-insensitive) so capitalisation
|
||||
// variants (e.g. "Hot Sauce tasting" vs "Hot Sauce Tasting") are merged.
|
||||
const groups = {};
|
||||
workshops.forEach(ws => {
|
||||
const key = ws.workshop_name.toLowerCase();
|
||||
if (!groups[key]) groups[key] = { label: ws.workshop_name, sessions: [] };
|
||||
groups[key].sessions.push(ws);
|
||||
});
|
||||
Object.values(groups).forEach(({ label, sessions }) => {
|
||||
const fmtTime = ws => ws.workshop_time
|
||||
? new Date(ws.workshop_time.replace(' ', 'T')).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: null;
|
||||
if (sessions.length === 1) {
|
||||
const ws = sessions[0];
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ws.id;
|
||||
const time = fmtTime(ws);
|
||||
opt.textContent = label + (time ? ' (' + time + ')' : '');
|
||||
if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
} else {
|
||||
const grp = document.createElement('optgroup');
|
||||
grp.label = label;
|
||||
sessions.forEach(ws => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ws.id;
|
||||
opt.textContent = fmtTime(ws) ?? '(no time)';
|
||||
if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;
|
||||
grp.appendChild(opt);
|
||||
});
|
||||
sel.appendChild(grp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editModal')).show();
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const errEl = document.getElementById('editError');
|
||||
const btn = document.getElementById('editSaveBtn');
|
||||
errEl.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const wsVal = document.getElementById('editWorkshop').value;
|
||||
const res = await fetch('/api/attendees.php', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingAttendee.id,
|
||||
first_name: document.getElementById('editFirstName').value.trim(),
|
||||
last_name: document.getElementById('editLastName').value.trim(),
|
||||
email: document.getElementById('editEmail').value.trim(),
|
||||
mobile_number: document.getElementById('editMobile').value.trim(),
|
||||
workshop_session_id: wsVal ? parseInt(wsVal, 10) : 0,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Save failed');
|
||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
||||
// If this was a manual-add from the rejected table, dismiss that row
|
||||
if (editingAttendee._rejectedIdx !== undefined) {
|
||||
rejectedRows[editingAttendee._rejectedIdx].dismissed = true;
|
||||
renderRejectedTable();
|
||||
}
|
||||
loadAttendees();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- ─── Edit attendee modal ─── -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editModalLabel">
|
||||
<i class="fa-solid fa-pen fa-fw me-2"></i>Edit Attendee
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">First Name</label>
|
||||
<input type="text" class="form-control" id="editFirstName">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Last Name</label>
|
||||
<input type="text" class="form-control" id="editLastName">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="editEmail">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Mobile Number</label>
|
||||
<input type="text" class="form-control" id="editMobile">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Workshop</label>
|
||||
<select class="form-select" id="editWorkshop">
|
||||
<option value="">— No workshop —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editError" class="text-danger mt-3" style="font-size:0.82rem;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="editSaveBtn" onclick="saveEdit()">
|
||||
<i class="fa-solid fa-floppy-disk fa-fw"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(async function () {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
||||
} catch { }
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Loads .env from the project root and defines DB_* constants.
|
||||
*
|
||||
* Running via PHP dev server (php -S localhost:8000 from web/):
|
||||
* The DB container port is exposed on localhost:3306, so DB_HOST resolves
|
||||
* to 'localhost' automatically when not inside Docker.
|
||||
*
|
||||
* Running via Docker Compose (docker compose up):
|
||||
* DB_HOST stays as 'db' (the service name) from the .env file.
|
||||
*/
|
||||
|
||||
// Parse .env from project root (one level above web/)
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
if (is_readable($envFile)) {
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key, $val] = explode('=', $line, 2);
|
||||
$_ENV[trim($key)] = trim($val);
|
||||
}
|
||||
}
|
||||
|
||||
// When running the PHP built-in dev server on the host, 'db' won't resolve —
|
||||
// fall back to localhost (the container port is exposed via docker-compose).
|
||||
$host = $_ENV['MYSQL_HOST'] ?? 'localhost';
|
||||
if ($host === 'db' && !file_exists('/.dockerenv')) {
|
||||
$host = 'localhost';
|
||||
}
|
||||
|
||||
define('DB_HOST', $host);
|
||||
define('DB_NAME', $_ENV['MYSQL_DATABASE'] ?? 'imf_sms');
|
||||
define('DB_USER', $_ENV['MYSQL_USER'] ?? 'imf_user');
|
||||
define('DB_PASS', $_ENV['MYSQL_PASSWORD'] ?? '');
|
||||
|
||||
// SMS worker URL — falls back to localhost when running outside Docker.
|
||||
$workerUrl = $_ENV['WORKER_URL'] ?? 'http://worker:5000';
|
||||
if (str_contains($workerUrl, '//worker') && !file_exists('/.dockerenv')) {
|
||||
$workerUrl = 'http://localhost:5000';
|
||||
}
|
||||
define('WORKER_URL', $workerUrl);
|
||||
@@ -0,0 +1,257 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Delivery Log — IMFestival SMS Dashboard</title>
|
||||
|
||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
||||
crossorigin="anonymous">
|
||||
<link href="assets/css/dashboard.css" rel="stylesheet">
|
||||
<link href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ─── Mobile top bar ─── -->
|
||||
<div class="topbar">
|
||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;font-size:0.95rem;">Delivery Log</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
||||
<div class="offcanvas-header">
|
||||
<div class="brand"
|
||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<span>IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-3">
|
||||
<nav class="d-flex flex-column gap-1">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Desktop sidebar ─── -->
|
||||
<div class="sidebar">
|
||||
<div class="brand">
|
||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<nav class="d-flex flex-column gap-1 mt-2">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||
<div class="nav-link-custom" style="cursor:default;">
|
||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
||||
<span style="font-size:0.82rem;">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Main content ─── -->
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<div id="sandboxBanner" class="alert alert-warning"
|
||||
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
||||
cost calculations.
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Delivery Log</h1>
|
||||
<p>A record of all messages sent and failed.</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-sm-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Sent</div>
|
||||
<div class="stat-value" id="stat-sent" style="color:#4ade80;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Failed</div>
|
||||
<div class="stat-value" id="stat-failed" style="color:#f87171;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Success Rate</div>
|
||||
<div class="stat-value" id="stat-rate">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0" id="logTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recipient</th>
|
||||
<th>Template</th>
|
||||
<th>Mobile</th>
|
||||
<th>Status</th>
|
||||
<th>Sent at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logBody">
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-center py-4">Loading…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
||||
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
||||
<script>
|
||||
let logTable = null;
|
||||
|
||||
function esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function errorDesc(msg) {
|
||||
if (!msg) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
// Top-level error (older Clickatell responses)
|
||||
if (parsed.error) return parsed.error;
|
||||
// Clickatell v2: { messages: [{ error, errorDescription }] }
|
||||
const m = Array.isArray(parsed.messages) ? parsed.messages[0] : null;
|
||||
if (m?.error) return m.error;
|
||||
if (m?.errorDescription) return m.errorDescription;
|
||||
return parsed.message || msg;
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(str) {
|
||||
if (!str) return '—';
|
||||
return new Date(str).toLocaleString('en-GB', {
|
||||
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLog() {
|
||||
const tbody = document.getElementById('logBody');
|
||||
|
||||
try {
|
||||
const data = await fetch('/api/delivery-log.php?all=1').then(r => r.json());
|
||||
const stats = data.stats ?? {};
|
||||
const entries = data.entries ?? [];
|
||||
|
||||
document.getElementById('stat-sent').textContent = stats.total_sent ?? '—';
|
||||
document.getElementById('stat-failed').textContent = stats.total_failed ?? '—';
|
||||
document.getElementById('stat-rate').textContent = stats.success_rate != null ? stats.success_rate + '%' : '—';
|
||||
|
||||
// Destroy existing DataTable before rewriting DOM
|
||||
if (logTable) { logTable.destroy(); logTable = null; }
|
||||
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-muted text-center py-4">No messages sent yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = entries.map(e => {
|
||||
const statusBadge = e.status === 'failed'
|
||||
? `<span class="badge-failed" style="cursor:help;"${e.error_message ? ` data-bs-toggle="tooltip" data-bs-placement="left" data-bs-title="${esc(errorDesc(e.error_message))}"` : ''}>Failed</span>`
|
||||
: `<span class="badge-delivered">Sent</span>`;
|
||||
return `
|
||||
<tr>
|
||||
<td>${esc(e.attendee_name ?? '—')}</td>
|
||||
<td style="color:var(--text-muted);">${e.message_body
|
||||
? `<span style="cursor:help;border-bottom:1px dashed var(--text-muted);" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="${esc(e.message_body)}">${esc(e.template_name)}</span>`
|
||||
: esc(e.template_name)}</td>
|
||||
<td style="color:var(--text-muted);">${esc(e.mobile_number)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td style="color:var(--text-muted);font-size:0.82rem;" data-order="${e.sent_at ?? ''}">${fmtDate(e.sent_at)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
logTable = new DataTable('#logTable', {
|
||||
order: [[4, 'desc']],
|
||||
columnDefs: [{ orderable: false, targets: 3 }],
|
||||
pageLength: 25,
|
||||
drawCallback: () => {
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
bootstrap.Tooltip.getOrCreateInstance(el);
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-danger text-center py-4">Could not load delivery log.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
loadLog();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
||||
} catch { }
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Home — IMFestival SMS Dashboard</title>
|
||||
|
||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
||||
crossorigin="anonymous">
|
||||
<link href="assets/css/dashboard.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ─── Mobile top bar ─── -->
|
||||
<div class="topbar">
|
||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;font-size:0.95rem;">SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
||||
<div class="offcanvas-header">
|
||||
<div class="brand"
|
||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<span>IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-3">
|
||||
<nav class="d-flex flex-column gap-1">
|
||||
<a href="index.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Desktop sidebar ─── -->
|
||||
<div class="sidebar">
|
||||
<div class="brand">
|
||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<nav class="d-flex flex-column gap-1 mt-2">
|
||||
<a href="index.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||
<div class="nav-link-custom" style="cursor:default;">
|
||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
||||
<span style="font-size:0.82rem;">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Main content ─── -->
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<div id="sandboxBanner" class="alert alert-warning"
|
||||
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
||||
cost calculations.
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Overview</h1>
|
||||
<p>Welcome back. Here's what's happening today.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-sm-3">
|
||||
<div class="stat-card border-0">
|
||||
<div class="stat-label">Attendees</div>
|
||||
<div class="stat-value" id="stat-attendees">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-3">
|
||||
<div class="stat-card border-0">
|
||||
<div class="stat-label">Total Sent</div>
|
||||
<div class="stat-value" id="stat-sent">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-3">
|
||||
<div class="stat-card border-0">
|
||||
<div class="stat-label">Success Rate</div>
|
||||
<div class="stat-value" id="stat-rate">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-3">
|
||||
<div class="stat-card border-0">
|
||||
<div class="stat-label">Estimated Cost</div>
|
||||
<div class="stat-value" id="stat-cost">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Send SMS Now — primary action card, full width -->
|
||||
<div class="col-12">
|
||||
<a href="send-now.html" class="card p-4 text-decoration-none d-flex flex-row align-items-center gap-3"
|
||||
style="border-color:var(--accent);background:linear-gradient(135deg,#1a2540 0%,var(--bg-card) 100%);">
|
||||
<i class="fa-solid fa-paper-plane fa-lg" style="color:var(--accent);"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary);">Send SMS Now</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);">Compose and send a one-off message instantly</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-sm-6">
|
||||
<a href="attendees.html"
|
||||
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
|
||||
<i class="fa-solid fa-users fa-lg" style="color:var(--accent);"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary);">Attendees</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);">Upload and manage participant CSV</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<a href="templates.html"
|
||||
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
|
||||
<i class="fa-solid fa-comment-sms fa-lg" style="color:var(--accent);"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary);">SMS Templates</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);">Create and preview message templates</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<a href="schedule.html"
|
||||
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
|
||||
<i class="fa-solid fa-calendar-days fa-lg" style="color:var(--accent);"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary);">Schedule</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);">Set send rules and timing</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<a href="delivery-log.html"
|
||||
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
|
||||
<i class="fa-solid fa-list-check fa-lg" style="color:var(--accent);"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary);">Delivery Log</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);">Track sent, delivered and failed messages</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Start Admin Settings -->
|
||||
<hr class="mt-4 mb-3">
|
||||
<div class="page-header mt-0 mb-0">
|
||||
<p><em>You're seeing these options because you're an admin. Proceed with caution!</em></p>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<a href="admin.html" class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
|
||||
<i class="fa-solid fa-user-shield fa-lg" style="color:var(--accent);"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary);">Admin</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);">Sandbox mode and data management controls</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<button type="button" onclick="openApiKeyModal()" data-bs-toggle="modal" data-bs-target="#apiKeyModal"
|
||||
class="card p-4 text-decoration-none d-flex flex-row align-items-center gap-3 w-100 border-0 text-start"
|
||||
style="cursor:pointer;">
|
||||
<i class="fa-solid fa-cog fa-lg" style="color:var(--accent);"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary);">Clickatell Settings</div>
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);">Change Clickatell API key and cost per SMS</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Clickatell API Key modal ─── -->
|
||||
<div class="modal fade" id="apiKeyModal" tabindex="-1" aria-labelledby="apiKeyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="apiKeyModalLabel">
|
||||
<i class="fa-solid fa-gear fa-fw me-2"></i>Clickatell Settings
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="apiKeyStatus" class="text-muted mb-3" style="font-size:0.85rem;">Checking…</p>
|
||||
<div class="mb-3">
|
||||
<label for="apiKeyInput" class="form-label">API Key</label>
|
||||
<input type="password" class="form-control" id="apiKeyInput" placeholder="Enter new API key"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label for="costPerSmsInput" class="form-label">Cost per SMS (£)</label>
|
||||
<input type="number" class="form-control" id="costPerSmsInput" placeholder="e.g. 0.04849" step="0.00001"
|
||||
min="0">
|
||||
</div>
|
||||
<div id="apiKeyError" class="text-danger mt-2" style="font-size:0.82rem;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="apiKeySaveBtn" onclick="saveApiKey()">
|
||||
<i class="fa-solid fa-floppy-disk fa-fw"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
function esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
async function openApiKeyModal() {
|
||||
document.getElementById('apiKeyInput').value = '';
|
||||
document.getElementById('apiKeyError').textContent = '';
|
||||
const statusEl = document.getElementById('apiKeyStatus');
|
||||
statusEl.textContent = 'Checking…';
|
||||
try {
|
||||
const [keyData, costData] = await Promise.all([
|
||||
fetch('/api/settings.php?key=clickatell_api_key').then(r => r.json()),
|
||||
fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),
|
||||
]);
|
||||
statusEl.innerHTML = keyData.set
|
||||
? `API key set: <code>${esc(keyData.hint)}</code> — enter a new key to replace it.`
|
||||
: '<span class="text-warning">No API key configured.</span> Enter one below to enable SMS sending.';
|
||||
document.getElementById('costPerSmsInput').value = costData.value ?? '0.04849';
|
||||
} catch {
|
||||
statusEl.textContent = 'Could not load current settings.';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiKey() {
|
||||
const key = document.getElementById('apiKeyInput').value.trim();
|
||||
const cost = document.getElementById('costPerSmsInput').value.trim();
|
||||
const errEl = document.getElementById('apiKeyError');
|
||||
const btn = document.getElementById('apiKeySaveBtn');
|
||||
errEl.textContent = '';
|
||||
if (!key && !cost) { errEl.textContent = 'Please enter at least one value.'; return; }
|
||||
const costNum = parseFloat(cost);
|
||||
if (cost && (isNaN(costNum) || costNum < 0)) { errEl.textContent = 'Cost must be a positive number.'; return; }
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const saves = [];
|
||||
if (key) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_api_key', value: key }) }));
|
||||
if (cost) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_cost_per_sms', value: cost }) }));
|
||||
const results = await Promise.all(saves);
|
||||
for (const res of results) { if (!res.ok) throw new Error((await res.json()).error ?? 'Save failed'); }
|
||||
bootstrap.Modal.getInstance(document.getElementById('apiKeyModal')).hide();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const [att, log] = await Promise.all([
|
||||
fetch('/api/attendees.php').then(r => r.json()),
|
||||
fetch('/api/delivery-log.php').then(r => r.json()),
|
||||
]);
|
||||
document.getElementById('stat-attendees').textContent = att.total ?? '—';
|
||||
document.getElementById('stat-sent').textContent = log.stats?.total_sent ?? '—';
|
||||
const rate = log.stats?.success_rate;
|
||||
document.getElementById('stat-rate').textContent = rate != null ? rate + '%' : '—';
|
||||
const sent = log.stats?.total_sent;
|
||||
const costPerSms = parseFloat((await fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json())).value ?? '0.04849');
|
||||
document.getElementById('stat-cost').textContent = sent != null
|
||||
? '£' + (sent * costPerSms).toFixed(2)
|
||||
: '—';
|
||||
} catch { /* DB unavailable — stats stay as dashes */ }
|
||||
}
|
||||
loadStats();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
||||
} catch { }
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,513 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Schedule — IMFestival SMS Dashboard</title>
|
||||
|
||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
||||
crossorigin="anonymous">
|
||||
<link href="assets/css/dashboard.css" rel="stylesheet">
|
||||
<style>
|
||||
.mode-btn {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.mode-btn.active-mode {
|
||||
background: rgba(79, 142, 247, 0.14);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ─── Mobile top bar ─── -->
|
||||
<div class="topbar">
|
||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;font-size:0.95rem;">Schedule</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
||||
<div class="offcanvas-header">
|
||||
<div class="brand"
|
||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<span>IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-3">
|
||||
<nav class="d-flex flex-column gap-1">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Desktop sidebar ─── -->
|
||||
<div class="sidebar">
|
||||
<div class="brand">
|
||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<nav class="d-flex flex-column gap-1 mt-2">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||
<div class="nav-link-custom" style="cursor:default;">
|
||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
||||
<span style="font-size:0.82rem;">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Main content ─── -->
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<div id="sandboxBanner" class="alert alert-warning"
|
||||
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
||||
cost calculations.
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Schedule</h1>
|
||||
<p>Configure when messages are automatically sent to attendees.</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<h5>Schedule an SMS message</h5>
|
||||
|
||||
<!-- Timing mode toggle -->
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Send timing</label>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button type="button" id="modeSpecific" class="btn btn-sm mode-btn active-mode" onclick="setMode('specific')">
|
||||
<i class="fa-solid fa-calendar-days me-1"></i> Specific date & time
|
||||
</button>
|
||||
<button type="button" id="modeRelative" class="btn btn-sm mode-btn" onclick="setMode('relative')">
|
||||
<i class="fa-solid fa-clock me-1"></i> Relative to workshop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Specific date/time options -->
|
||||
<div id="specificFields">
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Send at</label>
|
||||
<input type="datetime-local" class="form-control mb-3" id="specificDatetime">
|
||||
</div>
|
||||
|
||||
<!-- Relative options -->
|
||||
<div id="relativeFields" style="display:none;">
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Offset</label>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<input type="number" class="form-control" id="offsetValue" value="1" min="1" style="width:90px;">
|
||||
<select class="form-select" id="offsetUnit">
|
||||
<option value="minutes">Minute(s) before</option>
|
||||
<option value="hours" selected>Hour(s) before</option>
|
||||
<option value="days">Day(s) before</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Template to use</label>
|
||||
<select class="form-select mb-3" id="templateSelect">
|
||||
<option value="" disabled selected>Loading templates…</option>
|
||||
</select>
|
||||
|
||||
<button class="btn btn-primary" onclick="addSchedule()">
|
||||
<i class="fa-solid fa-plus me-1"></i> Add to Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled sends -->
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0">Sending soon</h5>
|
||||
<span class="text-muted" id="scheduleCount" style="font-size:0.82rem;">3 scheduled</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-3" id="scheduleList">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Previously scheduled -->
|
||||
<div class="card p-4 mt-4" id="pastCard" style="display:none;">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0" style="color:var(--text-muted);">Previously Scheduled</h5>
|
||||
<span class="text-muted" id="pastCount" style="font-size:0.82rem;"></span>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-3" id="pastList">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Edit Rule Modal ─── -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editModalLabel">Edit Scheduled Rule</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="editId">
|
||||
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Send timing</label>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button type="button" id="editModeSpecific" class="btn btn-sm mode-btn active-mode"
|
||||
onclick="setEditMode('specific')">
|
||||
<i class="fa-solid fa-calendar-days me-1"></i> Specific date & time
|
||||
</button>
|
||||
<button type="button" id="editModeRelative" class="btn btn-sm mode-btn" onclick="setEditMode('relative')">
|
||||
<i class="fa-solid fa-clock me-1"></i> Relative to workshop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="editSpecificFields">
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Send at</label>
|
||||
<input type="datetime-local" class="form-control mb-3" id="editSpecificDatetime">
|
||||
</div>
|
||||
|
||||
<div id="editRelativeFields" style="display:none;">
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Offset</label>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<input type="number" class="form-control" id="editOffsetValue" value="1" min="1" style="width:90px;">
|
||||
<select class="form-select" id="editOffsetUnit">
|
||||
<option value="minutes">Minute(s) before</option>
|
||||
<option value="hours">Hour(s) before</option>
|
||||
<option value="days">Day(s) before</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Template to use</label>
|
||||
<select class="form-select mb-1" id="editTemplateSelect"></select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEdit()">
|
||||
<i class="fa-solid fa-floppy-disk me-1"></i> Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
// ── Mode toggle ──────────────────────────────────────────
|
||||
let currentMode = 'specific';
|
||||
|
||||
function setMode(mode) {
|
||||
currentMode = mode;
|
||||
document.getElementById('relativeFields').style.display = mode === 'relative' ? '' : 'none';
|
||||
document.getElementById('specificFields').style.display = mode === 'specific' ? '' : 'none';
|
||||
document.getElementById('modeRelative').classList.toggle('active-mode', mode === 'relative');
|
||||
document.getElementById('modeSpecific').classList.toggle('active-mode', mode === 'specific');
|
||||
}
|
||||
|
||||
// ── Load templates into select ───────────────────────────
|
||||
async function loadTemplateOptions() {
|
||||
const sel = document.getElementById('templateSelect');
|
||||
try {
|
||||
const data = await fetch('/api/templates.php').then(r => r.json());
|
||||
const templates = data.templates ?? [];
|
||||
if (templates.length === 0) {
|
||||
sel.innerHTML = '<option value="" disabled selected>No templates — create one first</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = templates.map(t =>
|
||||
`<option value="${t.id}">${esc(t.name)}</option>`
|
||||
).join('');
|
||||
} catch {
|
||||
sel.innerHTML = '<option value="" disabled selected>Could not load templates</option>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add a rule ───────────────────────────────────────────
|
||||
async function addSchedule() {
|
||||
const templateId = document.getElementById('templateSelect').value;
|
||||
if (!templateId) { alert('Please select a template.'); return; }
|
||||
|
||||
const payload = { template_id: parseInt(templateId), mode: currentMode };
|
||||
|
||||
if (currentMode === 'relative') {
|
||||
payload.offset_value = parseInt(document.getElementById('offsetValue').value) || 1;
|
||||
payload.offset_unit = document.getElementById('offsetUnit').value;
|
||||
} else {
|
||||
const dt = document.getElementById('specificDatetime').value;
|
||||
if (!dt) { alert('Please pick a date and time.'); return; }
|
||||
payload.specific_datetime = dt;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/schedule.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert('Error: ' + (err.error ?? 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
// ── Edit modal ───────────────────────────────────────────
|
||||
let editMode = 'specific';
|
||||
|
||||
function setEditMode(mode) {
|
||||
editMode = mode;
|
||||
document.getElementById('editRelativeFields').style.display = mode === 'relative' ? '' : 'none';
|
||||
document.getElementById('editSpecificFields').style.display = mode === 'specific' ? '' : 'none';
|
||||
document.getElementById('editModeRelative').classList.toggle('active-mode', mode === 'relative');
|
||||
document.getElementById('editModeSpecific').classList.toggle('active-mode', mode === 'specific');
|
||||
}
|
||||
|
||||
async function openEditModal(id) {
|
||||
const rule = window._scheduleRules?.[id];
|
||||
if (!rule) return;
|
||||
document.getElementById('editId').value = rule.id;
|
||||
setEditMode(rule.mode);
|
||||
|
||||
if (rule.mode === 'specific') {
|
||||
// Convert stored datetime to datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
const dt = new Date(rule.specific_datetime.replace(' ', 'T'));
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
document.getElementById('editSpecificDatetime').value =
|
||||
`${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
} else {
|
||||
document.getElementById('editOffsetValue').value = rule.offset_value;
|
||||
document.getElementById('editOffsetUnit').value = rule.offset_unit;
|
||||
}
|
||||
|
||||
// Populate template select, mirroring the main select
|
||||
const mainSel = document.getElementById('templateSelect');
|
||||
const editSel = document.getElementById('editTemplateSelect');
|
||||
editSel.innerHTML = mainSel.innerHTML;
|
||||
editSel.value = rule.template_id;
|
||||
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('editModal')).show();
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const id = parseInt(document.getElementById('editId').value);
|
||||
const templateId = parseInt(document.getElementById('editTemplateSelect').value);
|
||||
if (!templateId) { alert('Please select a template.'); return; }
|
||||
|
||||
const payload = { template_id: templateId, mode: editMode };
|
||||
|
||||
if (editMode === 'relative') {
|
||||
payload.offset_value = parseInt(document.getElementById('editOffsetValue').value) || 1;
|
||||
payload.offset_unit = document.getElementById('editOffsetUnit').value;
|
||||
} else {
|
||||
const dt = document.getElementById('editSpecificDatetime').value;
|
||||
if (!dt) { alert('Please pick a date and time.'); return; }
|
||||
payload.specific_datetime = dt;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/schedule.php?id=${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert('Error: ' + (err.error ?? 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
// ── Remove a rule ────────────────────────────────────────
|
||||
async function removeSchedule(id) {
|
||||
if (!confirm('Remove this scheduled rule?')) return;
|
||||
const res = await fetch(`/api/schedule.php?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) { alert('Could not remove rule.'); return; }
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────
|
||||
function relativeTime(date) {
|
||||
const diffMs = date - Date.now();
|
||||
const diffMin = Math.round(diffMs / 60000);
|
||||
if (diffMin < 1) return 'now';
|
||||
if (diffMin < 60) return `in ${diffMin} minute${diffMin !== 1 ? 's' : ''}`;
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
if (diffHr < 24) return `in ${diffHr} hour${diffHr !== 1 ? 's' : ''}`;
|
||||
const diffDay = Math.round(diffHr / 24);
|
||||
return `in ${diffDay} day${diffDay !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
return date.toLocaleString('en-GB', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function ruleLabel(rule) {
|
||||
if (rule.mode === 'specific') {
|
||||
return 'Specific time: ' + formatDateTime(new Date(rule.specific_datetime));
|
||||
}
|
||||
const unit = { minutes: 'minute', hours: 'hour', days: 'day' }[rule.offset_unit] ?? rule.offset_unit;
|
||||
const val = rule.offset_value;
|
||||
return `${val} ${unit}${val !== 1 ? 's' : ''} before workshop`;
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
const list = document.getElementById('scheduleList');
|
||||
const countEl = document.getElementById('scheduleCount');
|
||||
const pastList = document.getElementById('pastList');
|
||||
const pastCard = document.getElementById('pastCard');
|
||||
const pastCountEl = document.getElementById('pastCount');
|
||||
try {
|
||||
const data = await fetch('/api/schedule.php').then(r => r.json());
|
||||
const rules = data.rules ?? [];
|
||||
|
||||
// Store rules by id so edit button can look them up safely
|
||||
window._scheduleRules = {};
|
||||
rules.forEach(r => { window._scheduleRules[r.id] = r; });
|
||||
|
||||
const now = Date.now();
|
||||
const upcoming = [];
|
||||
const past = [];
|
||||
rules.forEach(rule => {
|
||||
if (rule.mode === 'specific') {
|
||||
const t = new Date(rule.specific_datetime.replace(' ', 'T')).getTime();
|
||||
(t > now ? upcoming : past).push(rule);
|
||||
} else {
|
||||
upcoming.push(rule);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort upcoming: specific rules soonest first, relative rules after
|
||||
upcoming.sort((a, b) => {
|
||||
const aTime = a.mode === 'specific' ? new Date(a.specific_datetime.replace(' ', 'T')).getTime() : Infinity;
|
||||
const bTime = b.mode === 'specific' ? new Date(b.specific_datetime.replace(' ', 'T')).getTime() : Infinity;
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
countEl.textContent = `${upcoming.length} scheduled`;
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
list.innerHTML = '<p class="text-muted" style="font-size:0.85rem;">No upcoming rules. Add one above.</p>';
|
||||
} else {
|
||||
list.innerHTML = upcoming.map(rule => renderRuleCard(rule, false)).join('');
|
||||
}
|
||||
|
||||
if (past.length > 0) {
|
||||
pastCard.style.display = '';
|
||||
pastCountEl.textContent = `${past.length} past`;
|
||||
pastList.innerHTML = past.map(rule => renderRuleCard(rule, true)).join('');
|
||||
} else {
|
||||
pastCard.style.display = 'none';
|
||||
}
|
||||
} catch {
|
||||
list.innerHTML = '<p class="text-danger" style="font-size:0.85rem;">Could not load schedule.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRuleCard(rule, isPast) {
|
||||
const label = ruleLabel(rule);
|
||||
const sendAt = rule.mode === 'specific' ? new Date(rule.specific_datetime.replace(' ', 'T')) : null;
|
||||
const timeTag = sendAt
|
||||
? isPast
|
||||
? `<span style="font-size:0.75rem;background:rgba(139,148,158,0.12);color:var(--text-muted);border-radius:6px;padding:0.15em 0.6em;"><i class="fa-solid fa-circle-check me-1"></i>${formatDateTime(sendAt)}</span>`
|
||||
: `<span style="font-size:0.75rem;background:rgba(79,142,247,0.12);color:var(--accent);border-radius:6px;padding:0.15em 0.6em;">${relativeTime(sendAt)}</span>`
|
||||
: '';
|
||||
const opacity = isPast ? 'opacity:0.6;' : '';
|
||||
return `
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;padding:1rem;${opacity}">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-1">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.9rem;">${esc(rule.template_name)}</div>
|
||||
<div class="text-muted" style="font-size:0.78rem;">${esc(label)}</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
${!isPast ? `<button class="btn btn-sm btn-secondary" onclick="openEditModal(${rule.id})"><i class="fa-solid fa-pencil fa-fw"></i></button>` : `<button class="btn btn-sm btn-secondary" onclick="openEditModal(${rule.id})" title="Reschedule"><i class="fa-solid fa-rotate-right fa-fw"></i></button>`}
|
||||
<button class="btn btn-sm btn-danger" onclick="removeSchedule(${rule.id})"><i class="fa-solid fa-trash fa-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
${timeTag}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
loadTemplateOptions();
|
||||
loadSchedule();
|
||||
setInterval(loadSchedule, 60000);
|
||||
|
||||
// Set default datetime-local to now
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const now = new Date();
|
||||
document.getElementById('specificDatetime').value =
|
||||
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
||||
} catch { }
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,381 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Send SMS Now — IMFestival SMS Dashboard</title>
|
||||
|
||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
||||
crossorigin="anonymous">
|
||||
<link href="assets/css/dashboard.css" rel="stylesheet">
|
||||
<link href="assets/css/chat.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ─── Mobile top bar ─── -->
|
||||
<div class="topbar">
|
||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;font-size:0.95rem;">SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
||||
<div class="offcanvas-header">
|
||||
<div class="brand"
|
||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<span>IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-3">
|
||||
<nav class="d-flex flex-column gap-1">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Desktop sidebar ─── -->
|
||||
<div class="sidebar">
|
||||
<div class="brand">
|
||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<nav class="d-flex flex-column gap-1 mt-2">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||
<div class="nav-link-custom" style="cursor:default;">
|
||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
||||
<span style="font-size:0.82rem;">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Main content ─── -->
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<div id="sandboxBanner" class="alert alert-warning"
|
||||
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
||||
cost calculations.
|
||||
</div>
|
||||
|
||||
<div class="page-header d-flex align-items-center gap-3">
|
||||
<a href="index.html" class="btn btn-sm btn-secondary">
|
||||
<i class="fa-solid fa-arrow-left me-1"></i> Back
|
||||
</a>
|
||||
<div>
|
||||
<h1>Send SMS Now</h1>
|
||||
<p>Compose and send a one-off message to your attendees instantly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Compose card -->
|
||||
<div class="col-12 col-md-7">
|
||||
<div class="card p-4">
|
||||
<h2 class="section-title mb-4">Compose</h2>
|
||||
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Recipients</label>
|
||||
<select class="form-select mb-3" id="recipients">
|
||||
<option value="all" data-count="0">Loading attendees…</option>
|
||||
</select>
|
||||
|
||||
<label class="form-label text-muted" style="font-size:0.82rem;">Message</label>
|
||||
<select class="form-select mb-2" id="templatePicker">
|
||||
<option value="">— Custom message —</option>
|
||||
</select>
|
||||
<textarea id="message" class="form-control mb-1" rows="5" maxlength="160"
|
||||
placeholder="Type your message…"></textarea>
|
||||
<small id="charCount" class="text-muted d-block mb-2">0/160 characters</small>
|
||||
<div class="d-flex flex-wrap gap-1 mb-4">
|
||||
<span class="text-muted" style="font-size:0.75rem;align-self:center;">Insert:</span>
|
||||
<button type="button" class="btn btn-sm"
|
||||
style="font-size:0.75rem;padding:0.15em 0.6em;background:rgba(79,142,247,0.12);color:var(--accent);border:1px solid rgba(79,142,247,0.3);border-radius:6px;"
|
||||
onclick="insertPlaceholder('{name}')">{name}</button>
|
||||
<button type="button" class="btn btn-sm"
|
||||
style="font-size:0.75rem;padding:0.15em 0.6em;background:rgba(79,142,247,0.12);color:var(--accent);border:1px solid rgba(79,142,247,0.3);border-radius:6px;"
|
||||
onclick="insertPlaceholder('{workshop}')">{workshop}</button>
|
||||
<button type="button" class="btn btn-sm"
|
||||
style="font-size:0.75rem;padding:0.15em 0.6em;background:rgba(79,142,247,0.12);color:var(--accent);border:1px solid rgba(79,142,247,0.3);border-radius:6px;"
|
||||
onclick="insertPlaceholder('{workshop_time}')">{workshop_time}</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary w-100" id="sendBtn" disabled>
|
||||
<i class="fa-solid fa-paper-plane me-2"></i>
|
||||
Send to <span id="recipientCount">0</span> attendees
|
||||
</button>
|
||||
<div id="sendResult" class="mt-3" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview card -->
|
||||
<div class="col-12 col-md-5">
|
||||
<div class="card p-4">
|
||||
<h2 class="section-title mb-4">Preview</h2>
|
||||
<section id="previewSection" style="visibility:hidden;">
|
||||
<div class="from-them" id="preview"></div>
|
||||
</section>
|
||||
<p id="previewPlaceholder" class="text-muted" style="font-size:0.85rem;">Your message preview will appear
|
||||
here as you type.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
const SEND_MAX = 160;
|
||||
|
||||
// GSM-7 basic + extended character set
|
||||
const GSM7 = new Set('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà€[]{}\\^|~');
|
||||
const GSM7_EXTENDED = new Set('€[]{}\\^|~');
|
||||
|
||||
function smsEncoding(text) {
|
||||
const isUnicode = [...text].some(c => !GSM7.has(c));
|
||||
const limit = isUnicode ? 70 : 160;
|
||||
const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);
|
||||
return { length, limit, isUnicode };
|
||||
}
|
||||
|
||||
const msgEl = document.getElementById('message');
|
||||
const countEl = document.getElementById('charCount');
|
||||
const previewEl = document.getElementById('preview');
|
||||
const sectionEl = document.getElementById('previewSection');
|
||||
const placeholderEl = document.getElementById('previewPlaceholder');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const countSpan = document.getElementById('recipientCount');
|
||||
const recipientsSel = document.getElementById('recipients');
|
||||
const resultEl = document.getElementById('sendResult');
|
||||
|
||||
// ── Populate recipients from API ───────────────────────────
|
||||
async function loadRecipients() {
|
||||
try {
|
||||
const [attData, wsData] = await Promise.all([
|
||||
fetch('/api/attendees.php').then(r => r.json()),
|
||||
fetch('/api/workshops.php').then(r => r.json()),
|
||||
]);
|
||||
const total = attData.total ?? 0;
|
||||
const workshops = wsData.workshops ?? [];
|
||||
|
||||
const opts = [`<option value="all" data-count="${total}">All attendees (${total})</option>`];
|
||||
// Group sessions by workshop name (case-insensitive)
|
||||
const groups = {};
|
||||
workshops.forEach(ws => {
|
||||
const key = ws.workshop_name.toLowerCase();
|
||||
if (!groups[key]) groups[key] = { label: ws.workshop_name, sessions: [] };
|
||||
groups[key].sessions.push(ws);
|
||||
});
|
||||
const fmtTime = ws => ws.workshop_time
|
||||
? new Date(ws.workshop_time.replace(' ', 'T')).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
: '(no time)';
|
||||
Object.values(groups).forEach(({ label, sessions }) => {
|
||||
if (sessions.length === 1) {
|
||||
const ws = sessions[0];
|
||||
opts.push(`<option value="workshop:${ws.id}" data-count="${ws.attendee_count}">${esc(label)} — ${fmtTime(ws)} (${ws.attendee_count})</option>`);
|
||||
} else {
|
||||
opts.push(`<optgroup label="${esc(label)}">`);
|
||||
sessions.forEach(ws => {
|
||||
opts.push(`<option value="workshop:${ws.id}" data-count="${ws.attendee_count}">${fmtTime(ws)} (${ws.attendee_count})</option>`);
|
||||
});
|
||||
opts.push(`</optgroup>`);
|
||||
}
|
||||
});
|
||||
|
||||
recipientsSel.innerHTML = opts.join('');
|
||||
updateCount();
|
||||
} catch {
|
||||
recipientsSel.innerHTML = '<option value="all" data-count="0">Could not load recipients</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const selected = recipientsSel.options[recipientsSel.selectedIndex];
|
||||
countSpan.textContent = selected?.dataset.count ?? '0';
|
||||
}
|
||||
|
||||
recipientsSel.addEventListener('change', updateCount);
|
||||
|
||||
// ── Message compose ───────────────────────────────────────
|
||||
function previewText(raw) {
|
||||
return raw
|
||||
.replace(/\{name\}/gi, 'Jordan')
|
||||
.replace(/\{first[_ ]name\}/gi, 'Jordan')
|
||||
.replace(/\{workshop\}/gi, 'Axe Throwing')
|
||||
.replace(/\{workshop_time\}/gi, '13:30');
|
||||
}
|
||||
|
||||
function insertPlaceholder(text) {
|
||||
const start = msgEl.selectionStart;
|
||||
const end = msgEl.selectionEnd;
|
||||
const val = msgEl.value;
|
||||
msgEl.value = val.slice(0, start) + text + val.slice(end);
|
||||
msgEl.selectionStart = msgEl.selectionEnd = start + text.length;
|
||||
msgEl.focus();
|
||||
msgEl.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
msgEl.addEventListener('input', function () {
|
||||
const val = this.value;
|
||||
const { length, limit, isUnicode } = smsEncoding(val);
|
||||
const remaining = limit - length;
|
||||
|
||||
countEl.innerHTML = `${length}/${limit} characters${isUnicode ? ' <span style="color:#f87171;font-size:0.8em;"><i class="fa-solid fa-triangle-exclamation"></i> Unicode encoding — limit reduced to 70</span>' : ''}`;
|
||||
countEl.className = `d-block mb-2 ${remaining <= 10 ? (remaining <= 0 ? 'text-danger' : 'text-warning') : 'text-muted'}`;
|
||||
|
||||
const empty = val.trim() === '';
|
||||
sectionEl.style.visibility = empty ? 'hidden' : 'visible';
|
||||
placeholderEl.style.display = empty ? '' : 'none';
|
||||
previewEl.textContent = empty ? '' : previewText(val);
|
||||
sendBtn.disabled = empty;
|
||||
resultEl.style.display = 'none';
|
||||
});
|
||||
|
||||
// ── Send ───────────────────────────────────────────────────
|
||||
function esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
const message = msgEl.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
const selVal = recipientsSel.value;
|
||||
const isAll = selVal === 'all';
|
||||
const wsId = isAll ? null : parseInt(selVal.split(':')[1]);
|
||||
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin me-2"></i>Sending…';
|
||||
resultEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const selectedTemplateId = templatePicker.value ? parseInt(templatePicker.value) : null;
|
||||
const payload = {
|
||||
recipient_scope: isAll ? 'all' : 'workshop',
|
||||
};
|
||||
if (selectedTemplateId) {
|
||||
payload.template_id = selectedTemplateId;
|
||||
} else {
|
||||
payload.message = message;
|
||||
}
|
||||
if (!isAll) payload.workshop_session_id = wsId;
|
||||
|
||||
const res = await fetch('/api/send-now.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.error ?? 'Unknown error');
|
||||
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);border-radius:8px;padding:0.75rem 1rem;color:#4ade80;font-size:0.9rem;">
|
||||
<i class="fa-solid fa-circle-check me-2"></i>
|
||||
Sent to ${data.sent} recipient${data.sent !== 1 ? 's' : ''}.
|
||||
</div>`;
|
||||
msgEl.value = '';
|
||||
msgEl.dispatchEvent(new Event('input'));
|
||||
} catch (err) {
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:rgba(220,53,69,0.1);border:1px solid rgba(220,53,69,0.3);border-radius:8px;padding:0.75rem 1rem;color:#f87171;font-size:0.9rem;">
|
||||
<i class="fa-solid fa-circle-xmark me-2"></i>${esc(err.message)}
|
||||
</div>`;
|
||||
} finally {
|
||||
sendBtn.disabled = msgEl.value.trim() === '';
|
||||
sendBtn.innerHTML = '<i class="fa-solid fa-paper-plane me-2"></i>Send to <span id="recipientCount">' + countSpan.textContent + '</span> attendees';
|
||||
}
|
||||
});
|
||||
|
||||
loadRecipients();
|
||||
|
||||
// ── Template picker ───────────────────────────────────────
|
||||
const templatePicker = document.getElementById('templatePicker');
|
||||
let templateBodies = {}; // id → body
|
||||
|
||||
async function loadTemplatePicker() {
|
||||
try {
|
||||
const data = await fetch('/api/templates.php').then(r => r.json());
|
||||
const templates = data.templates ?? [];
|
||||
templateBodies = {};
|
||||
templates.forEach(t => { templateBodies[t.id] = t.body; });
|
||||
templatePicker.innerHTML = '<option value="">— Custom message —</option>'
|
||||
+ templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
|
||||
} catch {
|
||||
// leave the default option in place
|
||||
}
|
||||
}
|
||||
|
||||
templatePicker.addEventListener('change', () => {
|
||||
const body = templateBodies[templatePicker.value];
|
||||
if (body !== undefined) {
|
||||
msgEl.value = body;
|
||||
msgEl.dispatchEvent(new Event('input'));
|
||||
msgEl.focus();
|
||||
}
|
||||
});
|
||||
|
||||
loadTemplatePicker();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
||||
} catch { }
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SMS Templates — IMFestival SMS Dashboard</title>
|
||||
|
||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
|
||||
crossorigin="anonymous">
|
||||
<link href="assets/css/dashboard.css" rel="stylesheet">
|
||||
<link href="assets/css/chat.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- ─── Mobile top bar ─── -->
|
||||
<div class="topbar">
|
||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<span style="font-weight:600;font-size:0.95rem;">SMS Templates</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
||||
<div class="offcanvas-header">
|
||||
<div class="brand"
|
||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||
<span>SMS Dashboard</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-3">
|
||||
<nav class="d-flex flex-column gap-1">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Desktop sidebar ─── -->
|
||||
<div class="sidebar">
|
||||
<div class="brand">
|
||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||
</div>
|
||||
|
||||
<nav class="d-flex flex-column gap-1 mt-2">
|
||||
<a href="index.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-house fa-fw"></i>
|
||||
Home
|
||||
</a>
|
||||
<a href="attendees.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-users fa-fw"></i>
|
||||
Attendees
|
||||
</a>
|
||||
<a href="templates.html" class="nav-link-custom active">
|
||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
||||
SMS Templates
|
||||
</a>
|
||||
<a href="schedule.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
||||
Schedule
|
||||
</a>
|
||||
<a href="delivery-log.html" class="nav-link-custom">
|
||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
||||
Delivery Log
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3" style="border-top:1px solid var(--border);">
|
||||
<div class="nav-link-custom" style="cursor:default;">
|
||||
<i class="fa-solid fa-circle-user fa-fw"></i>
|
||||
<span style="font-size:0.82rem;">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Main content ─── -->
|
||||
<div class="main">
|
||||
<div class="main-inner">
|
||||
<div id="sandboxBanner" class="alert alert-warning"
|
||||
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
||||
cost calculations.
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>SMS Templates</h1>
|
||||
<p>Create message templates using <code>{name}</code>, <code>{workshop}</code> and <code>{workshop_time}</code>
|
||||
placeholders.</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<h5 id="formTitle">New Template</h5>
|
||||
<input id="templateName" class="form-control mb-2" placeholder="Template name">
|
||||
<input type="hidden" id="editId" value="">
|
||||
<textarea id="template" class="form-control mb-1" rows="5" maxlength="160"></textarea>
|
||||
<small id="charCount" class="d-block mb-3"></small>
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<button class="btn btn-primary" id="saveBtn" onclick="saveTemplate()">
|
||||
<i class="fa-solid fa-floppy-disk me-1"></i> <span id="saveBtnLabel">Save Template</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary d-none" id="cancelEditBtn" onclick="cancelEdit()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h5>Preview</h5>
|
||||
<section>
|
||||
<div class="from-them" id="preview"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Saved templates -->
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0">Saved Templates</h5>
|
||||
<span class="text-muted" id="templateCount" style="font-size:0.82rem;">—</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-3" id="templateList">
|
||||
<p class="text-muted" style="font-size:0.85rem;">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
const MAX_CHARS = 160;
|
||||
|
||||
// GSM-7 basic + extended character set
|
||||
const GSM7 = new Set('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà€[]{}\\^|~');
|
||||
const GSM7_EXTENDED = new Set('€[]{}\\^|~');
|
||||
|
||||
function smsEncoding(text) {
|
||||
const isUnicode = [...text].some(c => !GSM7.has(c));
|
||||
const limit = isUnicode ? 70 : 160;
|
||||
const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);
|
||||
return { length, limit, isUnicode };
|
||||
}
|
||||
|
||||
function updateCharCount(text) {
|
||||
const { length, limit, isUnicode } = smsEncoding(text);
|
||||
const remaining = limit - length;
|
||||
const el = document.getElementById('charCount');
|
||||
el.innerHTML = `${length}/${limit} characters${isUnicode ? ' <span style="color:#f87171;font-size:0.8em;"><i class="fa-solid fa-triangle-exclamation"></i> Unicode encoding — limit reduced to 70</span>' : ''}`;
|
||||
el.className = `d-block mb-3 ${remaining <= 10 ? (remaining <= 0 ? 'text-danger' : 'text-warning') : 'text-muted'}`;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const template = document.getElementById('template').value;
|
||||
const previewEl = document.getElementById('preview');
|
||||
const previewSection = previewEl.closest('section');
|
||||
if (template.trim() === '') {
|
||||
previewSection.style.visibility = 'hidden';
|
||||
previewEl.textContent = '';
|
||||
} else {
|
||||
previewSection.style.visibility = 'visible';
|
||||
previewEl.textContent = template
|
||||
.replaceAll('{name}', 'Jordan')
|
||||
.replaceAll('{workshop}', 'Axe Throwing')
|
||||
.replaceAll('{workshop_time}', '13:30');
|
||||
}
|
||||
updateCharCount(template);
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const name = document.getElementById('templateName').value.trim();
|
||||
const body = document.getElementById('template').value.trim();
|
||||
const editId = document.getElementById('editId').value;
|
||||
if (!name || !body) { alert('Please enter a template name and message.'); return; }
|
||||
|
||||
const isEdit = editId !== '';
|
||||
const url = isEdit ? `/api/templates.php?id=${editId}` : '/api/templates.php';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, body }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert('Error: ' + (err.error ?? 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
cancelEdit();
|
||||
loadTemplates();
|
||||
}
|
||||
|
||||
function editTemplate(id, name, body) {
|
||||
document.getElementById('editId').value = id;
|
||||
document.getElementById('templateName').value = name;
|
||||
document.getElementById('template').value = body;
|
||||
document.getElementById('formTitle').textContent = 'Edit Template';
|
||||
document.getElementById('saveBtnLabel').textContent = 'Update Template';
|
||||
document.getElementById('cancelEditBtn').classList.remove('d-none');
|
||||
document.getElementById('template').focus();
|
||||
updatePreview();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
document.getElementById('editId').value = '';
|
||||
document.getElementById('templateName').value = '';
|
||||
document.getElementById('template').value = '';
|
||||
document.getElementById('formTitle').textContent = 'New Template';
|
||||
document.getElementById('saveBtnLabel').textContent = 'Save Template';
|
||||
document.getElementById('cancelEditBtn').classList.add('d-none');
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
async function deleteTemplate(id) {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
const res = await fetch(`/api/templates.php?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) { alert('Could not delete template.'); return; }
|
||||
loadTemplates();
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
const list = document.getElementById('templateList');
|
||||
const countEl = document.getElementById('templateCount');
|
||||
list.innerHTML = '<p class="text-muted" style="font-size:0.85rem;">Loading…</p>';
|
||||
try {
|
||||
const data = await fetch('/api/templates.php').then(r => r.json());
|
||||
const templates = data.templates ?? [];
|
||||
countEl.textContent = templates.length + ' template' + (templates.length !== 1 ? 's' : '');
|
||||
if (templates.length === 0) {
|
||||
list.innerHTML = '<p class="text-muted" style="font-size:0.85rem;">No templates saved yet. Create one above.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = templates.map(t => {
|
||||
const chars = t.body.length;
|
||||
const date = new Date(t.updated_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
return `
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;padding:1rem;">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.9rem;">${esc(t.name)}</div>
|
||||
<div class="text-muted" style="font-size:0.78rem;">Last edited ${date}</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-accent"
|
||||
onclick='editTemplate(${t.id}, ${JSON.stringify(t.name)}, ${JSON.stringify(t.body)})'>
|
||||
<i class="fa-solid fa-pen fa-fw"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="deleteTemplate(${t.id})">
|
||||
<i class="fa-solid fa-trash fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="bubble-on-surface">
|
||||
<div class="from-them">${esc(t.body.replaceAll('{name}', 'Jordan').replaceAll('{workshop}', 'Axe Throwing').replaceAll('{workshop_time}', '13:30'))}</div>
|
||||
</section>
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<span style="font-size:0.75rem;background:rgba(79,142,247,0.12);color:var(--accent);border-radius:6px;padding:0.15em 0.55em;">${chars}/160 chars</span>
|
||||
${parseInt(t.scheduled_count) > 0 ? `<span style="font-size:0.75rem;background:rgba(74,222,128,0.12);color:#4ade80;border-radius:6px;padding:0.15em 0.55em;"><i class="fa-solid fa-clock me-1"></i>Scheduled</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch {
|
||||
list.innerHTML = '<p class="text-danger" style="font-size:0.85rem;">Could not load templates.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('template').addEventListener('input', updatePreview);
|
||||
updatePreview();
|
||||
loadTemplates();
|
||||
</script>
|
||||
<script>
|
||||
(async function () {
|
||||
try {
|
||||
const d = await fetch('/api/admin.php').then(r => r.json());
|
||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
||||
} catch { }
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user