···11+-- Create private_event_content table for conditional event content
22+CREATE TABLE private_event_content (
33+ aturi TEXT PRIMARY KEY,
44+ display_criteria JSONB NOT NULL DEFAULT '[]',
55+ content TEXT NOT NULL DEFAULT '',
66+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
77+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
88+);
99+1010+-- Create index for faster lookups
1111+CREATE INDEX idx_private_event_content_aturi ON private_event_content(aturi);
+1-1
src/bin/crypto.rs
···44use rand::RngCore;
5566fn main() {
77- let mut rng = rand::thread_rng();
77+ let mut rng = rand::rng();
8899 env::args().for_each(|arg| {
1010 if arg.as_str() == "key" {
+3-1
src/http/errors/create_rsvp_errors.rs
···1717 ///
1818 /// This error occurs when a user attempts to RSVP to an event that requires
1919 /// confirmed email addresses, but the user has not confirmed their email address.
2020- #[error("error-smokesignal-create-rsvp-2 Email confirmation required: This event requires a confirmed email address to RSVP. Please confirm your email address in your settings.")]
2020+ #[error(
2121+ "error-smokesignal-create-rsvp-2 Email confirmation required: This event requires a confirmed email address to RSVP. Please confirm your email address in your settings."
2222+ )]
2123 EmailConfirmationRequired,
2224}
···118118 if !found_errors {
119119 // Check if the event requires confirmed email
120120 let event_aturi = build_rsvp_form.subject_aturi.as_ref().unwrap();
121121- let event = crate::storage::event::event_get(&web_context.pool, event_aturi).await?;
121121+ let event =
122122+ crate::storage::event::event_get(&web_context.pool, event_aturi).await?;
122123123124 if event.require_confirmed_email {
124125 // Check if user has confirmed email
+76-1
src/http/handle_edit_event.rs
···198198 build_event_form.description = Some(description.clone());
199199 build_event_form.require_confirmed_email = Some(event.require_confirmed_email);
200200201201+ // Load private event content if it exists
202202+ if let Ok(Some(private_content)) =
203203+ crate::storage::private_event_content::private_event_content_get(
204204+ &ctx.web_context.pool,
205205+ &lookup_aturi,
206206+ )
207207+ .await
208208+ {
209209+ build_event_form.private_content = Some(private_content.content);
210210+211211+ // Set checkboxes based on criteria
212212+ for criterion in private_content.display_criteria.0.iter() {
213213+ match criterion.as_str() {
214214+ "going_confirmed" => {
215215+ build_event_form.private_content_criteria_going_confirmed = Some(true)
216216+ }
217217+ "going" => build_event_form.private_content_criteria_going = Some(true),
218218+ "interested" => {
219219+ build_event_form.private_content_criteria_interested = Some(true)
220220+ }
221221+ _ => {}
222222+ }
223223+ }
224224+ }
225225+201226 // If we have a single address location, populate the form fields with its data
202227 if let LocationEditStatus::Editable(Address {
203228 country,
···650675 error_template,
651676 default_context,
652677 err,
653653- StatusCode::OK
678678+ StatusCode::INTERNAL_SERVER_ERROR
654679 );
680680+ }
681681+682682+ // Save private event content if provided
683683+ let private_content = build_event_form.private_content.as_deref().unwrap_or("");
684684+685685+ // Build display criteria array from checkboxes
686686+ let mut display_criteria = Vec::new();
687687+ if build_event_form
688688+ .private_content_criteria_going_confirmed
689689+ .unwrap_or(false)
690690+ {
691691+ display_criteria.push("going_confirmed".to_string());
692692+ }
693693+ if build_event_form
694694+ .private_content_criteria_going
695695+ .unwrap_or(false)
696696+ {
697697+ display_criteria.push("going".to_string());
698698+ }
699699+ if build_event_form
700700+ .private_content_criteria_interested
701701+ .unwrap_or(false)
702702+ {
703703+ display_criteria.push("interested".to_string());
704704+ }
705705+706706+ // Only save if there's content or criteria, otherwise delete
707707+ if !private_content.is_empty() || !display_criteria.is_empty() {
708708+ if let Err(err) =
709709+ crate::storage::private_event_content::private_event_content_upsert(
710710+ &ctx.web_context.pool,
711711+ &lookup_aturi,
712712+ &display_criteria,
713713+ private_content,
714714+ )
715715+ .await
716716+ {
717717+ tracing::error!("Failed to save private event content: {:?}", err);
718718+ }
719719+ } else {
720720+ // Delete private content if both content and criteria are empty
721721+ if let Err(err) =
722722+ crate::storage::private_event_content::private_event_content_delete(
723723+ &ctx.web_context.pool,
724724+ &lookup_aturi,
725725+ )
726726+ .await
727727+ {
728728+ tracing::error!("Failed to delete private event content: {:?}", err);
729729+ }
655730 }
656731657732 // Send email notifications to RSVP holders if checkbox is checked and emailer is enabled
+6-4
src/http/handle_oauth_aip_login.rs
···1212use axum_template::RenderHtml;
1313use http::StatusCode;
1414use minijinja::context as template_context;
1515-use rand::{Rng, distributions::Alphanumeric};
1515+use rand::{Rng, distr::Alphanumeric};
1616use serde::Deserialize;
17171818use crate::{
···86868787 if let Some(subject) = validated_subject {
8888 // Generate OAuth parameters
8989- let state: String = rand::thread_rng()
8989+ let state: String = rand::rng()
9090 .sample_iter(&Alphanumeric)
9191 .take(30)
9292 .map(char::from)
9393 .collect();
9494- let nonce: String = rand::thread_rng()
9494+ let nonce: String = rand::rng()
9595 .sample_iter(&Alphanumeric)
9696 .take(30)
9797 .map(char::from)
···218218 stringify(oauth_args)
219219 );
220220221221- if hx_request && let Ok(hx_redirect) = HxRedirect::try_from(destination.as_str()) {
221221+ if hx_request {
222222+ let hx_redirect = HxRedirect::try_from(destination.as_str())
223223+ .expect("HxRedirect construction should not fail");
222224 return Ok((StatusCode::OK, hx_redirect, "").into_response());
223225 }
224226
···2828use crate::storage::event::event_exists;
2929use crate::storage::event::event_get;
3030use crate::storage::event::get_event_rsvps_with_validation;
3131-use crate::storage::event::get_user_rsvp_with_email_shared;
3131+use crate::storage::event::get_user_rsvp_status_and_validation;
3232use crate::storage::identity_profile::handle_for_did;
3333use crate::storage::identity_profile::handle_for_handle;
3434use crate::storage::identity_profile::handles_by_did;
···285285 .is_some_and(|current_entity| current_entity.did == profile.did);
286286287287 // Get user's RSVP status if logged in
288288- let user_rsvp_status = if let Some(current_entity) = &ctx.current_handle {
289289- match get_user_rsvp_with_email_shared(&ctx.web_context.pool, &aturi, ¤t_entity.did)
288288+ let (user_rsvp_status, user_rsvp_is_validated) =
289289+ if let Some(current_entity) = &ctx.current_handle {
290290+ match get_user_rsvp_status_and_validation(
291291+ &ctx.web_context.pool,
292292+ &aturi,
293293+ ¤t_entity.did,
294294+ )
290295 .await
291291- {
292292- Ok(Some((status, _email_shared))) => Some(status),
293293- Ok(None) => None,
294294- Err(err) => {
295295- tracing::error!("Error getting user RSVP status: {:?}", err);
296296- None
296296+ {
297297+ Ok(Some((status, is_validated))) => (Some(status), is_validated),
298298+ Ok(None) => (None, false),
299299+ Err(err) => {
300300+ tracing::error!("Error getting user RSVP status: {:?}", err);
301301+ (None, false)
302302+ }
297303 }
298298- }
299299- } else {
300300- None
301301- };
304304+ } else {
305305+ (None, false)
306306+ };
302307303308 // Check if there's a pending acceptance ticket for the current user's RSVP
304309 let pending_acceptance = if let Some(current_entity) = &ctx.current_handle {
···398403 let interested_tab_url = format!("?tab=interested&collection={}", encoded_collection);
399404 let notgoing_tab_url = format!("?tab=notgoing&collection={}", encoded_collection);
400405406406+ // Check if private content should be displayed
407407+ let private_content = if ctx.current_handle.is_some() {
408408+ // Load private event content
409409+ if let Ok(Some(private_event_content)) =
410410+ crate::storage::private_event_content::private_event_content_get(
411411+ &ctx.web_context.pool,
412412+ &aturi,
413413+ )
414414+ .await
415415+ {
416416+ // Check if viewer meets any of the display criteria
417417+ let mut meets_criteria = false;
418418+419419+ // If display_criteria is empty, show to anyone who has RSVP'd
420420+ if private_event_content.display_criteria.0.is_empty() {
421421+ meets_criteria = user_rsvp_status.is_some();
422422+ } else {
423423+ // Check specific criteria
424424+ for criterion in private_event_content.display_criteria.0.iter() {
425425+ match criterion.as_str() {
426426+ "going_confirmed" => {
427427+ // Check if user has "going" status AND has a validated RSVP
428428+ if user_rsvp_status.as_deref() == Some("going")
429429+ && user_rsvp_is_validated
430430+ {
431431+ meets_criteria = true;
432432+ break;
433433+ }
434434+ }
435435+ "going" => {
436436+ // Check if user has "going" status
437437+ if user_rsvp_status.as_deref() == Some("going") {
438438+ meets_criteria = true;
439439+ break;
440440+ }
441441+ }
442442+ "interested" => {
443443+ // Check if user has "interested" status
444444+ if user_rsvp_status.as_deref() == Some("interested") {
445445+ meets_criteria = true;
446446+ break;
447447+ }
448448+ }
449449+ _ => {}
450450+ }
451451+ }
452452+ }
453453+454454+ if meets_criteria {
455455+ Some(private_event_content.content)
456456+ } else {
457457+ None
458458+ }
459459+ } else {
460460+ None
461461+ }
462462+ } else {
463463+ None
464464+ };
465465+401466 Ok((
402467 StatusCode::OK,
403468 RenderHtml(
···424489 notgoing_tab_url,
425490 // Email confirmation status
426491 user_has_confirmed_email,
492492+ // Private event content
493493+ private_content,
427494 },
428495 ),
429496 )
+3-2
src/storage/content.rs
···319319 bloom_filter_capacity: usize,
320320 bloom_filter_error_rate: f64,
321321 ) -> Self {
322322- let bloom_filter = Bloom::new_for_fp_rate(bloom_filter_capacity, bloom_filter_error_rate);
322322+ let bloom_filter = Bloom::new_for_fp_rate(bloom_filter_capacity, bloom_filter_error_rate)
323323+ .expect("TODO fix this");
323324324325 Self {
325326 underlying_storage,
···381382 // This is a limitation of bloom filters - in production you might want
382383 // to use a counting bloom filter or periodically reset the filter
383384 let mut filter = self.not_found_filter.write().await;
384384- *filter = Bloom::new_for_fp_rate(10000, 0.01);
385385+ *filter = Bloom::new_for_fp_rate(10000, 0.01).expect("TODO fix this");
385386 }
386387}
387388
+2-2
src/storage/event.rs
···589589 Ok(status)
590590}
591591592592-pub async fn get_user_rsvp_with_email_shared(
592592+pub async fn get_user_rsvp_status_and_validation(
593593 pool: &StoragePool,
594594 event_aturi: &str,
595595 did: &str,
···614614 .map_err(StorageError::CannotBeginDatabaseTransaction)?;
615615616616 let result = sqlx::query_as::<_, (String, bool)>(
617617- "SELECT status, email_shared FROM rsvps WHERE event_aturi = $1 AND did = $2",
617617+ "SELECT status, validated_at IS NOT NULL FROM rsvps WHERE event_aturi = $1 AND did = $2",
618618 )
619619 .bind(event_aturi)
620620 .bind(did)
+1
src/storage/mod.rs
···99pub mod identity_profile;
1010pub mod notification;
1111pub mod oauth;
1212+pub mod private_event_content;
1213pub mod profile;
1314pub mod types;
1415pub mod webhook;
+119
src/storage/private_event_content.rs
···11+use chrono::Utc;
22+use serde::{Deserialize, Serialize};
33+use serde_json::json;
44+use sqlx::FromRow;
55+66+use crate::storage::StoragePool;
77+use crate::storage::errors::StorageError;
88+99+#[derive(Clone, FromRow, Deserialize, Serialize, Debug)]
1010+pub struct PrivateEventContent {
1111+ pub aturi: String,
1212+ pub display_criteria: sqlx::types::Json<Vec<String>>,
1313+ pub content: String,
1414+ pub created_at: chrono::DateTime<Utc>,
1515+ pub updated_at: chrono::DateTime<Utc>,
1616+}
1717+1818+/// Upsert private event content for an event
1919+pub async fn private_event_content_upsert(
2020+ pool: &StoragePool,
2121+ aturi: &str,
2222+ display_criteria: &[String],
2323+ content: &str,
2424+) -> Result<(), StorageError> {
2525+ // Validate aturi is not empty
2626+ if aturi.trim().is_empty() {
2727+ return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
2828+ "Event URI cannot be empty".into(),
2929+ )));
3030+ }
3131+3232+ let mut tx = pool
3333+ .begin()
3434+ .await
3535+ .map_err(StorageError::CannotBeginDatabaseTransaction)?;
3636+3737+ let now = Utc::now();
3838+3939+ sqlx::query(
4040+ "INSERT INTO private_event_content (aturi, display_criteria, content, created_at, updated_at)
4141+ VALUES ($1, $2, $3, $4, $5)
4242+ ON CONFLICT (aturi) DO UPDATE SET
4343+ display_criteria = $2,
4444+ content = $3,
4545+ updated_at = $5",
4646+ )
4747+ .bind(aturi)
4848+ .bind(json!(display_criteria))
4949+ .bind(content)
5050+ .bind(now)
5151+ .bind(now)
5252+ .execute(tx.as_mut())
5353+ .await
5454+ .map_err(StorageError::UnableToExecuteQuery)?;
5555+5656+ tx.commit()
5757+ .await
5858+ .map_err(StorageError::CannotCommitDatabaseTransaction)
5959+}
6060+6161+/// Get private event content for an event by AT-URI
6262+pub async fn private_event_content_get(
6363+ pool: &StoragePool,
6464+ aturi: &str,
6565+) -> Result<Option<PrivateEventContent>, StorageError> {
6666+ // Validate aturi is not empty
6767+ if aturi.trim().is_empty() {
6868+ return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
6969+ "Event URI cannot be empty".into(),
7070+ )));
7171+ }
7272+7373+ let mut tx = pool
7474+ .begin()
7575+ .await
7676+ .map_err(StorageError::CannotBeginDatabaseTransaction)?;
7777+7878+ let content = sqlx::query_as::<_, PrivateEventContent>(
7979+ "SELECT * FROM private_event_content WHERE aturi = $1",
8080+ )
8181+ .bind(aturi)
8282+ .fetch_optional(tx.as_mut())
8383+ .await
8484+ .map_err(StorageError::UnableToExecuteQuery)?;
8585+8686+ tx.commit()
8787+ .await
8888+ .map_err(StorageError::CannotCommitDatabaseTransaction)?;
8989+9090+ Ok(content)
9191+}
9292+9393+/// Delete private event content for an event
9494+pub async fn private_event_content_delete(
9595+ pool: &StoragePool,
9696+ aturi: &str,
9797+) -> Result<(), StorageError> {
9898+ // Validate aturi is not empty
9999+ if aturi.trim().is_empty() {
100100+ return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol(
101101+ "Event URI cannot be empty".into(),
102102+ )));
103103+ }
104104+105105+ let mut tx = pool
106106+ .begin()
107107+ .await
108108+ .map_err(StorageError::CannotBeginDatabaseTransaction)?;
109109+110110+ sqlx::query("DELETE FROM private_event_content WHERE aturi = $1")
111111+ .bind(aturi)
112112+ .execute(tx.as_mut())
113113+ .await
114114+ .map_err(StorageError::UnableToExecuteQuery)?;
115115+116116+ tx.commit()
117117+ .await
118118+ .map_err(StorageError::CannotCommitDatabaseTransaction)
119119+}
+2-2
src/task_webhooks.rs
···22use atproto_identity::traits::DidDocumentStorage;
33use atproto_oauth::jwt::{Claims, Header, mint};
44use chrono::Utc;
55-use rand::distributions::{Alphanumeric, DistString};
55+use rand::distr::{Alphanumeric, SampleString};
66use serde::{Deserialize, Serialize};
77use serde_json::json;
88use sqlx::PgPool;
···197197 let header: Header = self.service_key.0.clone().try_into()?;
198198199199 let now = Utc::now();
200200- let jti = Alphanumeric.sample_string(&mut rand::thread_rng(), 30);
200200+ let jti = Alphanumeric.sample_string(&mut rand::rng(), 30);
201201202202 // Create base claims
203203 let mut claims = Claims::default();
+74
templates/en-us/edit_event.common.html
···6868 </a>
6969 </div>
70707171+ <div class="box has-background-link-light">
7272+ <h2 class="title is-4">Private Event Content</h2>
7373+ <p class="mb-4">Add content that will only be visible to attendees who meet specific criteria. This content is stored off-protocol and not published to your PDS.</p>
7474+7575+ <form hx-post="/{{ handle_slug }}/{{ event_rkey }}/edit" hx-target="body" hx-swap="outerHTML">
7676+ <!-- Include all other form fields as hidden to preserve state -->
7777+ <input type="hidden" name="build_state" value="Selected">
7878+ {% if build_event_form.name %}
7979+ <input type="hidden" name="name" value="{{ build_event_form.name }}">
8080+ {% endif %}
8181+ {% if build_event_form.description %}
8282+ <input type="hidden" name="description" value="{{ build_event_form.description }}">
8383+ {% endif %}
8484+ {% if build_event_form.status %}
8585+ <input type="hidden" name="status" value="{{ build_event_form.status }}">
8686+ {% endif %}
8787+ {% if build_event_form.mode %}
8888+ <input type="hidden" name="mode" value="{{ build_event_form.mode }}">
8989+ {% endif %}
9090+ {% if build_event_form.starts_at %}
9191+ <input type="hidden" name="starts_at" value="{{ build_event_form.starts_at }}">
9292+ {% endif %}
9393+ {% if build_event_form.ends_at %}
9494+ <input type="hidden" name="ends_at" value="{{ build_event_form.ends_at }}">
9595+ {% endif %}
9696+ {% if build_event_form.require_confirmed_email %}
9797+ <input type="hidden" name="require_confirmed_email" value="true">
9898+ {% endif %}
9999+100100+ <div class="field">
101101+ <label class="label" for="privateContentTextarea">Private Content</label>
102102+ <div class="control">
103103+ <textarea class="textarea" id="privateContentTextarea" name="private_content"
104104+ maxlength="5000" rows="6"
105105+ placeholder="Information that only specific attendees can see...">{% if build_event_form.private_content %}{{ build_event_form.private_content }}{% endif %}</textarea>
106106+ </div>
107107+ <p class="help">This content will only be shown to attendees who meet the selected criteria below.</p>
108108+ </div>
109109+110110+ <div class="field">
111111+ <label class="label">Show to:</label>
112112+ <div class="control">
113113+ <label class="checkbox is-block mb-2">
114114+ <input type="checkbox" name="private_content_criteria_going_confirmed" value="true"
115115+ {% if build_event_form.private_content_criteria_going_confirmed %}checked{% endif %}>
116116+ Attendees with "Going" status who have accepted their RSVP ticket
117117+ </label>
118118+ <label class="checkbox is-block mb-2">
119119+ <input type="checkbox" name="private_content_criteria_going" value="true"
120120+ {% if build_event_form.private_content_criteria_going %}checked{% endif %}>
121121+ All attendees with "Going" status
122122+ </label>
123123+ <label class="checkbox is-block">
124124+ <input type="checkbox" name="private_content_criteria_interested" value="true"
125125+ {% if build_event_form.private_content_criteria_interested %}checked{% endif %}>
126126+ Attendees with "Interested" status
127127+ </label>
128128+ </div>
129129+ <p class="help mt-2">Select one or more criteria. Content will be shown if the viewer matches any selected criterion.</p>
130130+ </div>
131131+132132+ <div class="field">
133133+ <div class="control">
134134+ <button type="submit" class="button is-link">
135135+ <span class="icon">
136136+ <i class="fas fa-save"></i>
137137+ </span>
138138+ <span>Save Private Content</span>
139139+ </button>
140140+ </div>
141141+ </div>
142142+ </form>
143143+ </div>
144144+71145 {% if delete_event_url %}
72146 {% include 'en-us/delete_event.partial.html' %}
73147 {% endif %}