diff --git a/config/seed_data.sql b/config/seed_data.sql index 6ab2ad6a..80f5efd1 100644 --- a/config/seed_data.sql +++ b/config/seed_data.sql @@ -36,11 +36,11 @@ INSERT OR IGNORE INTO list_members (list_id, user_id) VALUES (2, 5), (3, 1); -INSERT OR IGNORE INTO events (id, title, slug, description_html, description_updated_at, start, end, capacity, unlisted) VALUES - (1, 'An upcoming person will Present Sounds', 'upcoming-present-sounds', 'An upcoming person will present sounds.', '2024-01-01 00:00:00', '2026-07-31 23:00:00', '2026-08-01 03:00:00', 2, 0), - (4, 'Another upcoming person will Present Sounds', 'upcoming-present-sounds-2', 'An upcoming person will present sounds.', '2024-01-01 00:00:00', '2026-08-31 23:00:00', '2026-09-01 03:00:00', 2, 0), - (3, 'A past person will Present Sounds', 'past-present-sounds', 'A past person will present sounds.', '2024-01-01 00:00:00', '2024-08-14 23:00:00', '2024-08-15 03:00:00', 2, 0), - (2, 'Another past person will Present Sounds', 'past-present-sounds-2', 'A past person will present sounds.', '2024-01-01 00:00:00', '2024-07-14 23:00:00', '2024-07-15 03:00:00', 2, 0); +INSERT OR IGNORE INTO events (id, token, kind, artist_share, title, slug, description_html, description_updated_at, start, end, capacity, unlisted) VALUES + (1, '00000000000000e1', 'internal', 70, 'An upcoming person will Present Sounds', 'upcoming-present-sounds', 'An upcoming person will present sounds.', '2024-01-01 00:00:00', '2027-07-31 23:00:00', '2027-08-01 03:00:00', 2, 0), + (4, '00000000000000e4', 'internal', 70, 'Another upcoming person will Present Sounds', 'upcoming-present-sounds-2', 'An upcoming person will present sounds.', '2024-01-01 00:00:00', '2027-08-31 23:00:00', '2027-09-01 03:00:00', 2, 0), + (3, '00000000000000e3', 'internal', 70, 'A past person will Present Sounds', 'past-present-sounds', 'A past person will present sounds.', '2024-01-01 00:00:00', '2024-08-14 23:00:00', '2024-08-15 03:00:00', 2, 0), + (2, '00000000000000e2', 'internal', 70, 'Another past person will Present Sounds', 'past-present-sounds-2', 'A past person will present sounds.', '2024-01-01 00:00:00', '2024-07-14 23:00:00', '2024-07-15 03:00:00', 2, 0); INSERT OR IGNORE INTO spots (id, name, description, qty_total, qty_per_person, kind, sort, required_contribution, min_contribution, max_contribution, suggested_contribution, required_notice_hours) VALUES (1, 'Free!', 'Brand new cherry red ferrarri!', 1, 1, 'free', 0, NULL, NULL, NULL, NULL, NULL), (2, 'Accessibility Contribution', 'When I pay less, I know I am letting my community hold me and support me.', 2, 1, 'fixed', 1, 20, NULL, NULL, NULL, NULL), diff --git a/frontend/styles/events/rsvp_manage.css b/frontend/styles/events/rsvp_manage.css index 96cc2148..b2cab3a4 100644 --- a/frontend/styles/events/rsvp_manage.css +++ b/frontend/styles/events/rsvp_manage.css @@ -37,6 +37,10 @@ p { @apply text-lsd-gray text-sm leading-tight; } + .when { + @apply text-lsd-white mt-3 text-sm uppercase; + letter-spacing: 0.2px; + } } .buttons { @@ -58,5 +62,35 @@ @apply border-lsd-red/30 bg-lsd-red/20; } } + + .calendar { + summary { + @apply bg-lsd-charcoal cursor-pointer rounded-lg py-4 text-center; + list-style: none; + &::-webkit-details-marker { + display: none; + } + &::after { + @apply text-lsd-gray ml-2 inline-block; + content: "\25be"; + } + } + &[open] summary { + @apply rounded-b-none; + &::after { + transform: rotate(180deg); + } + } + + .options { + @apply border-lsd-charcoal flex flex-col rounded-b-lg border border-t-0; + a { + @apply text-lsd-gray hover:text-lsd-white rounded-none py-3 text-sm; + } + a + a { + @apply border-lsd-charcoal border-t; + } + } + } } } diff --git a/frontend/templates/emails/event_confirmation.html b/frontend/templates/emails/event_confirmation.html index 6ab4c98c..ae174003 100644 --- a/frontend/templates/emails/event_confirmation.html +++ b/frontend/templates/emails/event_confirmation.html @@ -42,6 +42,26 @@

>View your confirmation + + + + + {% endblock %} @@ -71,5 +91,28 @@

margin-top: 32px; background-color: #1e1b19; } + .calendar { + width: 100%; + margin-top: 24px; + } + .calendar .label { + color: #878787; + font-size: 12px; + letter-spacing: 0.2px; + text-transform: uppercase; + margin: 0 0 8px; + } + .calendar .links { + margin: 0; + font-size: 14px; + line-height: 24px; + } + .calendar .links span { + color: #878787; + padding: 0 4px; + } + .calendar .links a { + white-space: nowrap; + } {% endblock %} diff --git a/frontend/templates/events/rsvp_manage.html b/frontend/templates/events/rsvp_manage.html index bbc45e1e..5131f1e6 100644 --- a/frontend/templates/events/rsvp_manage.html +++ b/frontend/templates/events/rsvp_manage.html @@ -45,6 +45,15 @@

Thank you.

Your contribution has been received. A confirmation email will be sent to you{% if rsvps.len() > 1 %}and your guests{% endif %} shortly.

+

+ + · + + {% if let Some(end) = event.end %} + – + + {% endif %} +

@@ -93,6 +102,17 @@

Thank you.

{% endif %} +
+ Add to calendar + +
AppRouter { .public_routes(|r| { r.route("/e/{slug}", get(read::view_page)) .route("/e/{slug}/flyer", get(read::flyer_by_slug)) + .route("/e/{slug}/event.ics", get(read::calendar_ics)) .route("/e/{slug}/stats", get(read::stats_page)) .route("/e/{slug}/rsvp", get(rsvp::rsvp_form)) .route("/e/{slug}/rsvp/guestlist", get(rsvp::guestlist_page).post(rsvp::guestlist_form)) @@ -54,6 +57,65 @@ pub fn add_routes(router: AppRouter) -> AppRouter { }) } +/// Send the confirmation email for `event` to `user`, with the event's `.ics` attached. +pub async fn send_confirmation_email( + state: &SharedAppState, event: &Event, user: &User, session_token: &str, ctx: &str, +) -> Result<()> { + #[derive(Template, WebTemplate)] + #[template(path = "emails/event_confirmation.html")] + struct ConfirmationEmailHtml { + email_token: String, + event: Event, + token: String, + flyer: Option, + } + + let email = Email::create_confirmation(&state.db, event.id, user.id).await?; + let flyer = EventFlyer::lookup(&state.db, event.id).await?; + + let from = &state.config.email.from; + let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); + let subject = event + .confirmation_subject + .clone() + .unwrap_or_else(|| format!("Confirmation for {}", event.title)); + let html = ConfirmationEmailHtml { + email_token: email.token, + event: event.clone(), + token: session_token.to_string(), + flyer, + } + .render()?; + + let message = state + .mailer + .builder() + .to(user.email.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(html) + .unwrap(); + + match state.mailer.send(&message).await { + Ok(_) => { + Email::mark_sent(&state.db, email.id).await?; + tracing::info!("Confirmation for event_id={} sent to email={:?}{ctx}", event.id, user.email); + } + Err(e) => { + let e = e.message(); + Email::mark_error(&state.db, email.id, e).await?; + alert!( + "Error sending confirmation for event_id={} to email={:?}{ctx}: {e}", + event.id, + user.email + ); + } + } + + Ok(()) +} + // View and list events. mod read { use super::*; @@ -126,6 +188,25 @@ mod read { EventFlyer::serve(&state.db, &slug, params.get("size")).await } + /// Serve an event as an iCalendar file, for Apple Calendar, Outlook desktop, and anything else + /// without a deep link scheme. + pub async fn calendar_ics(State(state): State, Path(slug): Path) -> HtmlResult { + let Some(event) = Event::lookup_by_slug(&state.db, &slug).await? else { + bail_not_found!(); + }; + + let ics = calendar::CalendarEvent::from_event(&event).ics(); + Ok(( + [ + (header::CONTENT_TYPE, "text/calendar; charset=utf-8".to_string()), + (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{slug}.ics\"")), + (header::CACHE_CONTROL, "no-store".to_string()), + ], + ics, + ) + .into_response()) + } + #[derive(serde::Deserialize)] pub struct StatsQuery { secret: String, @@ -1751,57 +1832,7 @@ mod rsvp { let user = User::lookup_by_id(&state.db, user_id).await?.ok_or_else(invalid)?; if !Email::have_sent_confirmation(&state.db, event.id, user_id).await? { - let email = Email::create_confirmation(&state.db, event.id, user_id).await?; - let flyer = EventFlyer::lookup(&state.db, event.id).await?; - - #[derive(Template, WebTemplate)] - #[template(path = "emails/event_confirmation.html")] - struct ConfirmationEmailHtml { - email_token: String, - event: Event, - token: String, - flyer: Option, - } - - let from = &state.config.email.from; - let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); - let subject = event - .confirmation_subject - .clone() - .unwrap_or_else(|| format!("Confirmation for {}", event.title)); - let message = state - .mailer - .builder() - .to(user.email.parse().unwrap()) - .reply_to(reply_to.clone()) - .subject(subject) - .header(lettre::message::header::ContentType::TEXT_HTML) - .body( - ConfirmationEmailHtml { - email_token: email.token, - event: event.clone(), - token: session.token.clone(), - flyer, - } - .render()?, - ) - .unwrap(); - - match state.mailer.send(&message).await { - Ok(_) => { - Email::mark_sent(&state.db, email.id).await?; - tracing::info!("Confirmation for event_id={} sent to email={:?}", event.id, user.email); - } - Err(e) => { - let e = e.message(); - Email::mark_error(&state.db, email.id, e).await?; - alert!( - "Error sending confirmation for event_id={} to email={:?}: {e}", - event.id, - user.email - ); - } - }; + send_confirmation_email(&state, &event, &user, &session.token, "").await?; } Ok(Redirect::to(&format!("/e/{slug}/rsvp/manage?reservation={}", session.token)).into_response()) @@ -2037,65 +2068,12 @@ mod rsvp { let flyer = EventFlyer::lookup(&state.db, event.id).await?; if !Email::have_sent_confirmation(&state.db, session.event_id, user_id).await? { - let email = Email::create_confirmation(&state.db, session.event_id, user_id).await?; - let flyer = EventFlyer::lookup(&state.db, event.id).await?; - - #[derive(Template, WebTemplate)] - #[template(path = "emails/event_confirmation.html")] - struct ConfirmationEmailHtml { - email_token: String, - event: Event, - token: String, - flyer: Option, - } - - let from = &state.config.email.from; - let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); - let message = state - .mailer - .builder() - .to(session_user.email.parse().unwrap()) - .reply_to(reply_to.clone()) - .subject( - event - .confirmation_subject - .clone() - .unwrap_or_else(|| format!("Confirmation for {}", event.title)), - ) - .header(lettre::message::header::ContentType::TEXT_HTML) - .body( - ConfirmationEmailHtml { - email_token: email.token, - event: event.clone(), - token: session.token.clone(), - flyer, - } - .render()?, - ) - .unwrap(); - - match state.mailer.send(&message).await { - Ok(_) => { - Email::mark_sent(&state.db, email.id).await?; - tracing::info!( - "Confirmation for event_id={} sent to email={:?}", - event.id, - session_user.email - ); - } - Err(e) => { - let e = e.message(); - Email::mark_error(&state.db, email.id, e).await?; - alert!( - "Error sending confirmation for event_id={} to email={:?}: {e}", - event.id, - session_user.email - ); - } - }; + send_confirmation_email(&state, &event, &session_user, &session.token, "").await?; // If dayof email has been sent out, also send it to this new RSVP if event.dayof_sent_at.is_some() { + let from = &state.config.email.from; + let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); let dayof_email = Email::create_send_dayof_single(&state.db, event.id, user_id).await?; let dayof_flyer = EventFlyer::lookup(&state.db, event.id).await?; @@ -2135,7 +2113,7 @@ mod rsvp { } Err(e) => { let e = e.message(); - Email::mark_error(&state.db, email.id, e).await?; + Email::mark_error(&state.db, dayof_email.id, e).await?; alert!( "Error sending day-of for event_id={} to email={:?}: {e}", event.id, @@ -2182,9 +2160,8 @@ mod rsvp { discount: Option, can_add_guests: bool, } - let mut response = - ManageHtml { user, session, event, flyer, rsvps, price, discount, can_add_guests } - .into_response(); + let mut response = ManageHtml { user, session, event, flyer, rsvps, price, discount, can_add_guests } + .into_response(); // Always clear stale child cookie on manage page let clear = Cookie::build(("rsvp_child_session", "")) diff --git a/src/app/webhooks.rs b/src/app/webhooks.rs index f6dab274..98ff61c5 100644 --- a/src/app/webhooks.rs +++ b/src/app/webhooks.rs @@ -12,7 +12,6 @@ pub mod stripe { use super::*; use crate::db::event::Event; - use crate::db::event_flyer::EventFlyer; use crate::db::rsvp_session::RsvpSession; type HmacSha256 = Hmac; @@ -140,61 +139,14 @@ pub mod stripe { session.set_payment_intent_id(&state.db, &payload.payment_intent).await?; if !Email::have_sent_confirmation(&state.db, session.event_id, user_id).await? { - let email = Email::create_confirmation(&state.db, session.event_id, user_id).await?; - let flyer = EventFlyer::lookup(&state.db, event.id).await?; - - #[derive(Template, WebTemplate)] - #[template(path = "emails/event_confirmation.html")] - struct ConfirmationEmailHtml { - email_token: String, - event: Event, - token: String, - flyer: Option, - } - - let from = &state.config.email.from; - let reply_to = state.config.email.contact_to.as_ref().unwrap_or(from); - let subject = event - .confirmation_subject - .clone() - .unwrap_or_else(|| format!("Confirmation for {}", event.title)); - let message = state - .mailer - .builder() - .to(user.email.parse().unwrap()) - .reply_to(reply_to.clone()) - .subject(subject) - .header(lettre::message::header::ContentType::TEXT_HTML) - .body( - ConfirmationEmailHtml { - email_token: email.token, - event: event.clone(), - token: session.token, - flyer, - } - .render()?, - ) - .unwrap(); - - match state.mailer.send(&message).await { - Ok(_) => { - Email::mark_sent(&state.db, email.id).await?; - tracing::info!( - "Confirmation for event_id={} sent to email={:?} from webhook", - event.id, - user.email - ); - } - Err(e) => { - let e = e.message(); - Email::mark_error(&state.db, email.id, e).await?; - alert!( - "Error sending confirmation for event_id={} to email={:?} from webhook: {e}", - event.id, - user.email - ); - } - }; + crate::app::events::send_confirmation_email( + &state, + &event, + &user, + &session.token, + " from webhook", + ) + .await?; } } status => { diff --git a/src/utils/calendar.rs b/src/utils/calendar.rs new file mode 100644 index 00000000..c3fd12a2 --- /dev/null +++ b/src/utils/calendar.rs @@ -0,0 +1,183 @@ +use chrono::{TimeDelta, Utc}; +use url::Url; + +use crate::db::event::Event; +use crate::prelude::*; + +/// Venue name used for every event. +const LOCATION: &str = "KG's"; +/// Assumed length of an event which has no explicit end time. +const DEFAULT_DURATION_HOURS: i64 = 3; + +/// `YYYYMMDDTHHMMSSZ`, used by ICS, Google, and Yahoo +const COMPACT_TIME: &str = "%Y%m%dT%H%M%SZ"; +/// `YYYY-MM-DDTHH:MM:SSZ`, used by Outlook and Microsoft 365 +const ISO_TIME: &str = "%Y-%m-%dT%H:%M:%SZ"; + +/// Apple Calendar and Outlook desktop have no deep link scheme, so they use `.ics` files +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Provider { + Google, + Outlook, + Office, + Yahoo, +} + +impl Provider { + pub fn parse(name: &str) -> Option { + Some(match name { + "google" => Self::Google, + "outlook" => Self::Outlook, + "office" => Self::Office, + "yahoo" => Self::Yahoo, + _ => return None, + }) + } +} + +pub struct CalendarEvent { + pub uid: String, + pub title: String, + pub description: String, + pub location: String, + pub url: String, + pub start: NaiveDateTime, + pub end: NaiveDateTime, +} + +impl CalendarEvent { + pub fn from_event(event: &Event) -> Self { + let cfg = config(); + let url = format!("{}/e/{}", cfg.app.url, event.slug); + Self { + // Stable per event, so re-adding updates the entry instead of duplicating it. + uid: format!("{}@{}", event.token, cfg.app.domain), + title: event.title.clone(), + description: url.clone(), + location: LOCATION.into(), + url, + start: event.start, + end: event.end.unwrap_or(event.start + TimeDelta::hours(DEFAULT_DURATION_HOURS)), + } + } + + pub fn ics(&self) -> String { + let dtstamp = Utc::now().naive_utc().format(COMPACT_TIME); + let start = self.start.format(COMPACT_TIME); + let end = self.end.format(COMPACT_TIME); + + let mut out = String::new(); + for line in [ + "BEGIN:VCALENDAR".into(), + "VERSION:2.0".into(), + "PRODID:-//Light and Sound Design//lsd//EN".into(), + "CALSCALE:GREGORIAN".into(), + "METHOD:PUBLISH".into(), + "BEGIN:VEVENT".into(), + format!("UID:{}", escape(&self.uid)), + format!("DTSTAMP:{dtstamp}"), + format!("DTSTART:{start}"), + format!("DTEND:{end}"), + format!("SUMMARY:{}", escape(&self.title)), + format!("DESCRIPTION:{}", escape(&self.description)), + format!("LOCATION:{}", escape(&self.location)), + format!("URL:{}", self.url), + "STATUS:CONFIRMED".into(), + "END:VEVENT".into(), + "END:VCALENDAR".into(), + ] { + fold(&line, &mut out); + out.push_str("\r\n"); + } + out + } + + pub fn provider_url(&self, provider: Provider) -> String { + let compact_start = self.start.format(COMPACT_TIME).to_string(); + let compact_end = self.end.format(COMPACT_TIME).to_string(); + let range = format!("{compact_start}/{compact_end}"); + let start = self.start.format(ISO_TIME).to_string(); + let end = self.end.format(ISO_TIME).to_string(); + + let (base, params): (_, Vec<(&str, &str)>) = match provider { + Provider::Google => ( + "https://calendar.google.com/calendar/render", + vec![ + ("action", "TEMPLATE"), + ("text", &self.title), + ("dates", &range), + ("details", &self.description), + ("location", &self.location), + ], + ), + Provider::Outlook => ( + "https://outlook.live.com/calendar/0/addfromweb/", + vec![ + ("subject", &self.title), + ("startdt", &start), + ("enddt", &end), + ("body", &self.description), + ("location", &self.location), + ("allday", "false"), + ], + ), + Provider::Office => ( + "https://outlook.office.com/calendar/0/action/compose", + vec![ + ("rru", "addevent"), + ("subject", &self.title), + ("startdt", &start), + ("enddt", &end), + ("body", &self.description), + ("location", &self.location), + ("allday", "false"), + ], + ), + Provider::Yahoo => ( + "https://calendar.yahoo.com/", + vec![ + ("v", "60"), + ("title", &self.title), + ("st", &compact_start), + ("et", &compact_end), + ("desc", &self.description), + ("in_loc", &self.location), + ], + ), + }; + + Url::parse_with_params(base, params).expect("invalid calendar url").into() + } +} + +/// Escape RFC 5545 +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + ';' => out.push_str("\\;"), + ',' => out.push_str("\\,"), + '\n' => out.push_str("\\n"), + '\r' => {} + c => out.push(c), + } + } + out +} + +/// Append `line` to `out` per RFC 5545 +fn fold(line: &str, out: &mut String) { + const LIMIT: usize = 75; + + let mut used = 0; + for c in line.chars() { + let len = c.len_utf8(); + if used + len > LIMIT { + out.push_str("\r\n "); + used = 1; + } + out.push(c); + used += len; + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 7fe95cf3..e08a972c 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod alerts; +pub mod calendar; pub mod cloudflare; pub mod config; pub mod editor; diff --git a/src/utils/templates.rs b/src/utils/templates.rs index b92566df..91d44fc3 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -1,6 +1,8 @@ use chrono::NaiveDateTime; +use crate::db::event::Event; use crate::prelude::*; +use crate::utils::calendar; /// Askama implicitly looks for a `filters` module to be in the same scope as /// the `#[derive(Template)]` to provide extra functions to templates. @@ -49,6 +51,13 @@ pub mod filters { }) } + pub fn calendar_url(event: &Event, provider: &str) -> Result { + let Some(provider) = calendar::Provider::parse(provider) else { + return Err(askama::Error::Custom(format!("unknown calendar provider {provider:?}").into())); + }; + Ok(calendar::CalendarEvent::from_event(event).provider_url(provider)) + } + /// Returns the site domain pub fn domain(_dummy: &str) -> Result { Ok(config().app.domain.clone())