diff --git a/.sqlx/query-cda387b02687b974762bb8f9bbf4f71c09d93beb491192969e543cd0fa8a8c00.json b/.sqlx/query-2e4338535090b9a9ee75ed43780fd0c7fd2b1a16eb78c6fb6ab0215eec1ce39c.json similarity index 65% rename from .sqlx/query-cda387b02687b974762bb8f9bbf4f71c09d93beb491192969e543cd0fa8a8c00.json rename to .sqlx/query-2e4338535090b9a9ee75ed43780fd0c7fd2b1a16eb78c6fb6ab0215eec1ce39c.json index 75eccba..9196270 100644 --- a/.sqlx/query-cda387b02687b974762bb8f9bbf4f71c09d93beb491192969e543cd0fa8a8c00.json +++ b/.sqlx/query-2e4338535090b9a9ee75ed43780fd0c7fd2b1a16eb78c6fb6ab0215eec1ce39c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT\n e.id, e.title, e.slug, e.start, e.guest_list_id,\n COALESCE(\n (SELECT COUNT(*)\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = e.id\n AND rs.status IN ('payment_pending', 'payment_confirmed')),\n 0\n ) as \"rsvp_count!: i64\"\n FROM events e", + "query": "SELECT\n e.id, e.title, e.slug, e.start, e.guest_list_id,\n COALESCE(\n (SELECT COUNT(*)\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = e.id\n AND rs.status IN ('payment_pending', 'payment_confirmed')),\n 0\n ) as \"rsvp_count!: i64\",\n COALESCE(\n (SELECT SUM(r.contribution)\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n WHERE rs.event_id = e.id\n AND rs.status IN ('payment_pending', 'payment_confirmed')),\n 0\n ) as \"total_contributions!: i64\"\n FROM events e", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "name": "rsvp_count!: i64", "ordinal": 5, "type_info": "Integer" + }, + { + "name": "total_contributions!: i64", + "ordinal": 6, + "type_info": "Integer" } ], "parameters": { @@ -43,8 +48,9 @@ false, false, true, + false, false ] }, - "hash": "cda387b02687b974762bb8f9bbf4f71c09d93beb491192969e543cd0fa8a8c00" + "hash": "2e4338535090b9a9ee75ed43780fd0c7fd2b1a16eb78c6fb6ab0215eec1ce39c" } diff --git a/.sqlx/query-53fe179776690a17b40541aaca5d1f1c2ce7ba0df5dc6e76b3b9a4ac470c8d40.json b/.sqlx/query-dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047.json similarity index 95% rename from .sqlx/query-53fe179776690a17b40541aaca5d1f1c2ce7ba0df5dc6e76b3b9a4ac470c8d40.json rename to .sqlx/query-dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047.json index 15d90df..e2e03b2 100644 --- a/.sqlx/query-53fe179776690a17b40541aaca5d1f1c2ce7ba0df5dc6e76b3b9a4ac470c8d40.json +++ b/.sqlx/query-dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n u.id AS user_id,\n u.first_name as \"first_name!\",\n u.last_name as \"last_name!\",\n u.email,\n CASE\n WHEN rs.user_id IS NOT NULL AND rs.user_id != r.user_id\n THEN hu.first_name || ' ' || hu.last_name\n ELSE NULL\n END AS guest_of,\n\n sp.name AS spot_name,\n r.contribution,\n\n FALSE AS \"is_manual!: bool\",\n r.created_at,\n r.checkin_at\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n JOIN spots sp ON sp.id = r.spot_id\n JOIN users u ON u.id = r.user_id\n JOIN users hu ON hu.id = rs.user_id\n WHERE rs.event_id = ?\n AND rs.status IN ('payment_pending', 'payment_confirmed')\n\n UNION ALL\n\n SELECT\n u.id AS user_id,\n u.first_name as \"first_name!\",\n u.last_name as \"last_name!\",\n u.email,\n cu.first_name || ' ' || cu.last_name AS guest_of,\n\n NULL AS spot_name,\n 0 AS contribution,\n\n TRUE AS \"is_manual!: bool\",\n mr.created_at,\n mr.checkin_at\n FROM manual_rsvps mr\n JOIN users u ON u.id = mr.user_id\n JOIN users cu ON cu.id = mr.creator_user_id\n WHERE mr.event_id = ?\n\n ORDER BY 3;\n ", + "query": "\n SELECT\n u.id AS user_id,\n u.first_name as \"first_name!\",\n u.last_name as \"last_name!\",\n u.email,\n CASE\n WHEN rs.user_id IS NOT NULL AND rs.user_id != r.user_id\n THEN hu.first_name || ' ' || hu.last_name\n ELSE NULL\n END AS guest_of,\n\n sp.name AS spot_name,\n r.contribution,\n\n FALSE AS \"is_manual!: bool\",\n r.created_at,\n r.checkin_at\n FROM rsvps r\n JOIN rsvp_sessions rs ON rs.id = r.session_id\n JOIN spots sp ON sp.id = r.spot_id\n JOIN users u ON u.id = r.user_id\n JOIN users hu ON hu.id = rs.user_id\n WHERE rs.event_id = ?\n AND rs.status IN ('payment_pending', 'payment_confirmed')\n\n UNION ALL\n\n SELECT\n u.id AS user_id,\n u.first_name as \"first_name!\",\n u.last_name as \"last_name!\",\n u.email,\n cu.first_name || ' ' || cu.last_name AS guest_of,\n\n NULL AS spot_name,\n 0 AS contribution,\n\n TRUE AS \"is_manual!: bool\",\n mr.created_at,\n mr.checkin_at\n FROM manual_rsvps mr\n JOIN users u ON u.id = mr.user_id\n JOIN users cu ON cu.id = mr.creator_user_id\n WHERE mr.event_id = ?\n\n ORDER BY 9;\n ", "describe": { "columns": [ { @@ -70,5 +70,5 @@ true ] }, - "hash": "53fe179776690a17b40541aaca5d1f1c2ce7ba0df5dc6e76b3b9a4ac470c8d40" + "hash": "dd2f2b72dfb17f591fc00c87d75c5994d885440678689efcd6103a70ac22e047" } diff --git a/frontend/styles/events/attendees.css b/frontend/styles/events/attendees.css index c3a5438..22f8839 100644 --- a/frontend/styles/events/attendees.css +++ b/frontend/styles/events/attendees.css @@ -3,6 +3,13 @@ @apply flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between; } + .info { + @apply flex flex-col; + time, .stats { + @apply text-lsd-gray text-xs; + } + } + .table-wrapper { @apply overflow-x-scroll; } diff --git a/frontend/styles/events/list.css b/frontend/styles/events/list.css index 5bbd220..9a8e8bd 100644 --- a/frontend/styles/events/list.css +++ b/frontend/styles/events/list.css @@ -12,6 +12,9 @@ time { @apply text-lsd-gray mb-0.5 text-xs; } + .stats { + @apply text-lsd-gray mb-1 text-xs; + } .actions { @apply flex flex-wrap gap-2 text-xs; diff --git a/frontend/templates/events/attendees.html b/frontend/templates/events/attendees.html index abbdced..28a89e0 100644 --- a/frontend/templates/events/attendees.html +++ b/frontend/templates/events/attendees.html @@ -4,7 +4,13 @@ {% block content %}
-

{{ event.title }}

+
+

{{ event.title }}

+ + {{ rsvp_count }} RSVPs • ${{ total_contributions }} in contributions +
@@ -349,6 +351,12 @@

Available Spots

return; } + const slug = $("slug").value; + if (!slug || !/^[a-zA-Z0-9\-]+$/.test(slug)) { + alert("Slug can only contain letters, numbers, and dashes."); + return; + } + // Retrieve the value of an input, with adjustments for HTML input type weirdness let value = (name, root = ui.form) => { const el = root.querySelector(`[name="${name}"]`); diff --git a/frontend/templates/events/list.html b/frontend/templates/events/list.html index 9debfb0..98ac5b1 100644 --- a/frontend/templates/events/list.html +++ b/frontend/templates/events/list.html @@ -16,6 +16,7 @@

+ {{ event.rsvp_count }} RSVPs • ${{ event.total_contributions }} in contributions
RSVP now + Confirm RSVP
diff --git a/src/app/events.rs b/src/app/events.rs index 84334f3..50f6329 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -1,6 +1,6 @@ use image::DynamicImage; -use crate::db::event::{Event, EventLimits, EventWithRsvpCount, UpdateEvent}; +use crate::db::event::{Event, EventLimits, EventWithStats, UpdateEvent}; use crate::db::event_flyer::*; use crate::db::rsvp_session::*; use crate::db::spot::*; @@ -73,7 +73,7 @@ mod read { #[template(path = "events/list.html")] struct ListHtml { user: Option, - events: Vec, + events: Vec, } pub async fn list_page(user: Option, State(state): State) -> HtmlResult { @@ -241,6 +241,14 @@ mod edit { let form = form.ok_or_else(invalid)?; + // Validate slug: must be non-empty and only contain alphanumeric characters and dashes + if form.event.slug.is_empty() + || !form.event.slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Ok((StatusCode::BAD_REQUEST, "Slug can only contain letters, numbers, and dashes.") + .into_response()); + } + match form.id { Some(id) => { Event::update(&state.db, id, &form.event, &flyer).await?; @@ -834,10 +842,14 @@ mod edit { struct Html { pub user: Option, event: Event, + rsvp_count: usize, + total_contributions: i64, rsvps: Vec, } - Ok(Html { user: Some(user), event, rsvps }.into_response()) + let rsvp_count = rsvps.len(); + let total_contributions = rsvps.iter().map(|r| r.contribution).sum(); + Ok(Html { user: Some(user), event, rsvp_count, total_contributions, rsvps }.into_response()) } /// Handle delete submission. @@ -1057,6 +1069,13 @@ mod rsvp { match List::has_user_id(&state.db, guest_list_id, user.id).await? { true => { + tracing::info!( + "Guestlist check passed with event_id={} user_id={} user_email={:?}", + event.id, + user.id, + user.email + ); + // Check for conflicts let other_users = Rsvp::list_reserved_users_for_event(&state.db, &event, session.as_ref()).await?; @@ -1064,6 +1083,10 @@ mod rsvp { if let Some(Conflict::Guest { email, status } | Conflict::Primary { email, status }) = validate::no_conflicts(&other_users, &primary_user, &[]) { + tracing::info!( + "RSVP conflict detected with event_id={} conflict_email={email:?} status={status:?}", + event.id + ); return goto::error_conflict(&email, &status); } diff --git a/src/db/event.rs b/src/db/event.rs index a661b79..960d128 100644 --- a/src/db/event.rs +++ b/src/db/event.rs @@ -56,19 +56,20 @@ pub struct UpdateEvent { /// Event with RSVP count for the admin list page. #[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] -pub struct EventWithRsvpCount { +pub struct EventWithStats { pub id: i64, pub title: String, pub slug: String, pub start: NaiveDateTime, pub guest_list_id: Option, pub rsvp_count: i64, + pub total_contributions: i64, } impl Event { - pub async fn list(db: &Db) -> Result> { + pub async fn list(db: &Db) -> Result> { let events = sqlx::query_as!( - EventWithRsvpCount, + EventWithStats, r#"SELECT e.id, e.title, e.slug, e.start, e.guest_list_id, COALESCE( @@ -78,7 +79,15 @@ impl Event { WHERE rs.event_id = e.id AND rs.status IN ('payment_pending', 'payment_confirmed')), 0 - ) as "rsvp_count!: i64" + ) as "rsvp_count!: i64", + COALESCE( + (SELECT SUM(r.contribution) + FROM rsvps r + JOIN rsvp_sessions rs ON rs.id = r.session_id + WHERE rs.event_id = e.id + AND rs.status IN ('payment_pending', 'payment_confirmed')), + 0 + ) as "total_contributions!: i64" FROM events e"# ) .fetch_all(db) diff --git a/src/db/rsvp.rs b/src/db/rsvp.rs index 0f6697f..a7ae645 100644 --- a/src/db/rsvp.rs +++ b/src/db/rsvp.rs @@ -125,7 +125,7 @@ impl Rsvp { JOIN users cu ON cu.id = mr.creator_user_id WHERE mr.event_id = ? - ORDER BY 3; + ORDER BY 9; "#, event_id, event_id diff --git a/src/db/rsvp_session.rs b/src/db/rsvp_session.rs index 72883b4..a15fd18 100644 --- a/src/db/rsvp_session.rs +++ b/src/db/rsvp_session.rs @@ -96,6 +96,7 @@ impl RsvpSession { let token = format!("{:08x}", OsRng.r#gen::()); let user = user.as_ref(); let user_id = user.map(|u| u.id); + let user_email = user.map(|u| u.email.as_str()); let user_version = user.map(|u| u.version); let session = sqlx::query_as!( @@ -113,10 +114,20 @@ impl RsvpSession { .fetch_one(db) .await?; + tracing::info!( + "Created RSVP session with session_id={} event_id={event_id} user_id={user_id:?} user_email={user_email:?}", + session.id + ); Ok(session) } pub async fn delete(&self, db: &Db) -> Result<()> { + tracing::info!( + "Deleting RSVP session with session_id={} event_id={} status={:?}", + self.id, + self.event_id, + self.status + ); sqlx::query!("DELETE FROM rsvps WHERE session_id = ?", self.id) .execute(db) .await?; @@ -127,6 +138,11 @@ impl RsvpSession { } pub async fn takeover_for_event(&self, db: &Db, event: &Event, email: &str) -> Result<()> { + tracing::info!( + "RSVP session takeover with session_id={} event_id={} taking_over_email={email:?}", + self.id, + event.id, + ); sqlx::query!( "DELETE FROM rsvp_sessions WHERE user_id IN ( @@ -147,6 +163,13 @@ impl RsvpSession { /// Update pub async fn set_user(&mut self, db: &Db, user: &User) -> Result<()> { + tracing::info!( + "Setting user on RSVP session with session_id={} event_id={} user_id={} user_email={:?}", + self.id, + self.event_id, + user.id, + user.email + ); sqlx::query!( "UPDATE rsvp_sessions SET user_id = ?, @@ -165,6 +188,13 @@ impl RsvpSession { } pub async fn set_status(&self, db: &Db, status: &str) -> Result<()> { + tracing::info!( + "RSVP status transition with session_id={} event_id={} user_id={:?} status={:?} -> {status:?}", + self.id, + self.event_id, + self.user_id, + self.status, + ); sqlx::query!( "UPDATE rsvp_sessions SET status = ?, diff --git a/src/utils/tracing.rs b/src/utils/tracing.rs index 5dcfece..bd2c5fa 100644 --- a/src/utils/tracing.rs +++ b/src/utils/tracing.rs @@ -2,7 +2,8 @@ use axum::http::Request; use tower::ServiceBuilder; use tower_http::ServiceBuilderExt as _; use tower_http::request_id::{MakeRequestId, RequestId}; -use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; +use tower_http::trace::{DefaultOnResponse, MakeSpan, TraceLayer}; +use tracing::Span; use uuid::Uuid; use crate::prelude::*; @@ -17,6 +18,17 @@ impl MakeRequestId for MakeRequestUuidV7 { } } +#[derive(Clone, Copy)] +pub struct LoggingMakeSpan; +impl MakeSpan for LoggingMakeSpan { + fn make_span(&mut self, request: &Request) -> Span { + let method = request.method(); + let path = request.uri().path(); + tracing::info!("{method} {path:?}"); + tracing::span!(tracing::Level::DEBUG, "request", %method, %path) + } +} + /// Register tracing-related middleware into the router. pub fn add_middleware(router: AxumRouter) -> AxumRouter { router.layer( @@ -24,7 +36,7 @@ pub fn add_middleware(router: AxumRouter) -> AxumRouter { .set_x_request_id(MakeRequestUuidV7) .layer( TraceLayer::new_for_http() - .make_span_with(DefaultMakeSpan::new()) + .make_span_with(LoggingMakeSpan) .on_response(DefaultOnResponse::new()), ) .propagate_x_request_id(),