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())