diff --git a/src/facets.rs b/src/facets.rs index 358575b..80e5d73 100644 --- a/src/facets.rs +++ b/src/facets.rs @@ -160,12 +160,15 @@ pub fn parse_tags(text: &str) -> Vec { // Extract just the tag text (without the hash symbol) // Normalize to lowercase for case-insensitive tag matching - let tag = std::str::from_utf8(tag_match.as_bytes()) - .unwrap_or_default(); + let tag = std::str::from_utf8(tag_match.as_bytes()).unwrap_or_default(); // Only include tags that are not purely numeric if !tag.chars().all(|c| c.is_ascii_digit()) { - spans.push(TagSpan { start, end, tag: tag.to_string() }); + spans.push(TagSpan { + start, + end, + tag: tag.to_string(), + }); } } } diff --git a/src/http/context.rs b/src/http/context.rs index cea2d2f..6ff2338 100644 --- a/src/http/context.rs +++ b/src/http/context.rs @@ -252,11 +252,13 @@ where pub(crate) fn admin_template_context( ctx: &AdminRequestContext, canonical_url: &str, + active_section: &str, ) -> minijinja::value::Value { template_context! { language => ctx.language.to_string(), current_handle => ctx.admin_handle.clone(), canonical_url => canonical_url, + active_section => active_section, } } diff --git a/src/http/event_view.rs b/src/http/event_view.rs index 4ab7011..20ce84f 100644 --- a/src/http/event_view.rs +++ b/src/http/event_view.rs @@ -58,8 +58,8 @@ pub struct EventView { pub status: Option, pub address_display: Option, pub links: Vec<(String, Option)>, // (uri, name) - pub description_links: Vec, // facet links from description - pub description_tags: Vec, // facet tags from description + pub description_links: Vec, // facet links from description + pub description_tags: Vec, // facet tags from description pub header: Option<(String, String)>, // (cid, alt text) @@ -177,7 +177,9 @@ impl }); // Extract facets from the event record and render HTML description - let (description_html, description_links, description_tags) = if let Some(desc_text) = &description { + let (description_html, description_links, description_tags) = if let Some(desc_text) = + &description + { // Try to extract facets from the event record's extra fields let facets = event .record @@ -254,18 +256,12 @@ impl .map(|value| value.with_timezone(&tz).to_string()); // Separate date and time for the redesigned template - let starts_at_date = starts_at.as_ref().map(|value| { - value - .with_timezone(&tz) - .format("%B %e, %Y") - .to_string() - }); - let starts_at_time = starts_at.as_ref().map(|value| { - value - .with_timezone(&tz) - .format("%I:%M %p %Z") - .to_string() - }); + let starts_at_date = starts_at + .as_ref() + .map(|value| value.with_timezone(&tz).format("%B %e, %Y").to_string()); + let starts_at_time = starts_at + .as_ref() + .map(|value| value.with_timezone(&tz).format("%I:%M %p %Z").to_string()); // Calculate hours until event starts (for countdown) let starts_in_hours = starts_at.as_ref().and_then(|value| { @@ -296,18 +292,12 @@ impl let ends_at_machine = ends_at .as_ref() .map(|value| value.with_timezone(&tz).to_string()); - let ends_at_date = ends_at.as_ref().map(|value| { - value - .with_timezone(&tz) - .format("%B %e, %Y") - .to_string() - }); - let ends_at_time = ends_at.as_ref().map(|value| { - value - .with_timezone(&tz) - .format("%I:%M %p %Z") - .to_string() - }); + let ends_at_date = ends_at + .as_ref() + .map(|value| value.with_timezone(&tz).format("%B %e, %Y").to_string()); + let ends_at_time = ends_at + .as_ref() + .map(|value| value.with_timezone(&tz).format("%I:%M %p %Z").to_string()); let site_url = if event.lexicon == LexiconCommunityEventNSID { format!("/{}/{}", repository, rkey) diff --git a/src/http/handle_admin_content_list.rs b/src/http/handle_admin_content_list.rs new file mode 100644 index 0000000..e07fbd2 --- /dev/null +++ b/src/http/handle_admin_content_list.rs @@ -0,0 +1,89 @@ +use anyhow::Result; +use axum::{extract::Query, response::IntoResponse}; +use axum_template::RenderHtml; +use minijinja::context as template_context; +use serde::Serialize; + +use crate::{ + contextual_error, + http::{ + context::{AdminRequestContext, admin_template_context}, + errors::WebError, + pagination::{Pagination, PaginationView}, + }, + select_template, + storage::private_event_content::private_event_content_list, +}; + +#[derive(Serialize)] +struct ContentListItem { + aturi: String, + criteria_count: usize, + content_preview: String, + created_at: String, +} + +pub(crate) async fn handle_admin_content_list( + admin_ctx: AdminRequestContext, + pagination: Query, +) -> Result { + let canonical_url = format!( + "https://{}/admin/content", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "content"); + + let language = admin_ctx.language; + let web_context = admin_ctx.web_context; + + let render_template = select_template!("admin_content_list", false, false, language); + let error_template = select_template!(false, false, language); + + let (page, page_size) = pagination.admin_clamped(); + + let content = private_event_content_list(&web_context.pool, page, page_size).await; + if let Err(err) = content { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + err + ); + } + let (total_count, mut content) = content.unwrap(); + + let params: Vec<(&str, &str)> = vec![]; + + let pagination_view = PaginationView::new(page_size, content.len() as i64, page, params); + + if content.len() > page_size as usize { + content.truncate(page_size as usize); + } + + // Transform content to a simpler structure for the template + let content_items: Vec = content + .iter() + .map(|item| ContentListItem { + aturi: item.aturi.clone(), + criteria_count: item.display_criteria.0.len(), + content_preview: if item.content.len() > 50 { + format!("{}...", &item.content[..50]) + } else { + item.content.clone() + }, + created_at: item.created_at.to_rfc3339(), + }) + .collect(); + + Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { ..default_context, ..template_context! { + content => content_items, + total_count => total_count, + pagination => pagination_view, + }}, + ) + .into_response()) +} diff --git a/src/http/handle_admin_content_view.rs b/src/http/handle_admin_content_view.rs new file mode 100644 index 0000000..60788bc --- /dev/null +++ b/src/http/handle_admin_content_view.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use axum::{ + Form, + extract::Query, + response::{IntoResponse, Redirect}, +}; +use axum_htmx::{HxRedirect, HxRequest}; +use axum_template::RenderHtml; +use http::StatusCode; +use minijinja::context as template_context; +use serde::Deserialize; +use std::borrow::Cow; + +use crate::{ + contextual_error, + http::{ + context::{AdminRequestContext, admin_template_context}, + errors::WebError, + }, + select_template, + storage::{ + denylist::denylist_add_or_update, + private_event_content::{private_event_content_delete, private_event_content_get}, + }, +}; + +#[derive(Deserialize)] +pub(crate) struct ContentRecordQuery { + pub(crate) aturi: String, +} + +pub(crate) async fn handle_admin_content_view( + admin_ctx: AdminRequestContext, + Query(query): Query, +) -> Result { + let canonical_url = format!( + "https://{}/admin/content/view", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "content"); + + let language = admin_ctx.language; + let web_context = admin_ctx.web_context; + + let render_template = select_template!("admin_content_view", false, false, language); + let error_template = select_template!(false, false, language); + + // Get the content record by AT-URI + let content = private_event_content_get(&web_context.pool, &query.aturi).await; + if let Err(err) = content { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + err + ); + } + let content = content.unwrap(); + + if content.is_none() { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + "Content not found" + ); + } + let content = content.unwrap(); + + // Convert to JSON for display + let content_json = serde_json::to_string_pretty(&content) + .unwrap_or_else(|_| "Error formatting JSON".to_string()); + + Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { ..default_context, ..template_context! { + aturi => query.aturi.clone(), + content => content, + content_json => content_json, + }}, + ) + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct NukeContentForm { + aturi: String, +} + +pub(crate) async fn handle_admin_content_nuke( + admin_ctx: AdminRequestContext, + HxRequest(hx_request): HxRequest, + Form(form): Form, +) -> Result { + let error_template = select_template!(false, false, admin_ctx.language); + + // Delete the content record + if let Err(err) = private_event_content_delete(&admin_ctx.web_context.pool, &form.aturi).await { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + // Add content AT-URI to denylist + let reason = format!( + "Content nuked by admin {}", + admin_ctx.admin_handle.did.replace('\'', "") + ); + + if let Err(err) = denylist_add_or_update( + &admin_ctx.web_context.pool, + Cow::Borrowed(&form.aturi), + Cow::Owned(reason), + ) + .await + { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + if hx_request { + let hx_redirect = HxRedirect::from("/admin/content"); + Ok((StatusCode::OK, hx_redirect, "").into_response()) + } else { + Ok(Redirect::to("/admin/content").into_response()) + } +} diff --git a/src/http/handle_admin_denylist.rs b/src/http/handle_admin_denylist.rs index bc6fa39..a486cd5 100644 --- a/src/http/handle_admin_denylist.rs +++ b/src/http/handle_admin_denylist.rs @@ -39,7 +39,7 @@ pub(crate) async fn handle_admin_denylist( "https://{}/admin/denylist", admin_ctx.web_context.config.external_base ); - let default_context = admin_template_context(&admin_ctx, &canonical_url); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "denylist"); let render_template = select_template!("admin_denylist", false, false, admin_ctx.language); let error_template = select_template!(false, false, admin_ctx.language); diff --git a/src/http/handle_admin_event.rs b/src/http/handle_admin_event.rs index 0dcc8ae..efb8c8f 100644 --- a/src/http/handle_admin_event.rs +++ b/src/http/handle_admin_event.rs @@ -1,14 +1,28 @@ use anyhow::Result; -use axum::{extract::Query, response::IntoResponse}; +use axum::{ + Form, + extract::Query, + response::{IntoResponse, Redirect}, +}; +use axum_htmx::{HxRedirect, HxRequest}; use axum_template::RenderHtml; +use http::StatusCode; use minijinja::context as template_context; use serde::Deserialize; +use std::borrow::Cow; use crate::{ contextual_error, - http::{context::AdminRequestContext, errors::WebError}, + http::{ + context::{AdminRequestContext, admin_template_context}, + errors::WebError, + }, select_template, - storage::event::event_get, + storage::{ + denylist::denylist_add_or_update, + event::{event_delete, event_get}, + private_event_content::private_event_content_delete, + }, }; #[derive(Deserialize)] @@ -20,19 +34,15 @@ pub(crate) async fn handle_admin_event( admin_ctx: AdminRequestContext, Query(query): Query, ) -> Result { + let canonical_url = format!( + "https://{}/admin/event", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "events"); + let language = admin_ctx.language; let web_context = admin_ctx.web_context; - let canonical_url = format!("https://{}/admin/event", web_context.config.external_base); - - // Create context with query parameters - let context_with_aturi = template_context! { - language => language.to_string(), - current_handle => admin_ctx.admin_handle.clone(), - canonical_url => canonical_url, - aturi => query.aturi.clone() - }; - let render_template = select_template!("admin_event", false, false, language); let error_template = select_template!(false, false, language); @@ -43,7 +53,7 @@ pub(crate) async fn handle_admin_event( web_context, language.0, error_template, - context_with_aturi, + default_context, err ); } @@ -56,14 +66,75 @@ pub(crate) async fn handle_admin_event( Ok(RenderHtml( &render_template, web_context.engine.clone(), - template_context! { - language => language.to_string(), - current_handle => admin_ctx.admin_handle.clone(), - canonical_url => canonical_url, + template_context! { ..default_context, ..template_context! { aturi => query.aturi.clone(), event => event, event_json => event_json, - }, + }}, ) .into_response()) } + +#[derive(Deserialize)] +pub(crate) struct NukeEventForm { + aturi: String, +} + +pub(crate) async fn handle_admin_event_nuke( + admin_ctx: AdminRequestContext, + HxRequest(hx_request): HxRequest, + Form(form): Form, +) -> Result { + let error_template = select_template!(false, false, admin_ctx.language); + + // Delete any associated private content first + if let Err(err) = private_event_content_delete(&admin_ctx.web_context.pool, &form.aturi).await { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + // Delete the event record + if let Err(err) = event_delete(&admin_ctx.web_context.pool, &form.aturi).await { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + // Add event AT-URI to denylist + let reason = format!( + "Event nuked by admin {}", + admin_ctx.admin_handle.did.replace('\'', "") + ); + + if let Err(err) = denylist_add_or_update( + &admin_ctx.web_context.pool, + Cow::Borrowed(&form.aturi), + Cow::Owned(reason), + ) + .await + { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + if hx_request { + let hx_redirect = HxRedirect::from("/admin/events"); + Ok((StatusCode::OK, hx_redirect, "").into_response()) + } else { + Ok(Redirect::to("/admin/events").into_response()) + } +} diff --git a/src/http/handle_admin_events.rs b/src/http/handle_admin_events.rs index 4f87750..48f70a1 100644 --- a/src/http/handle_admin_events.rs +++ b/src/http/handle_admin_events.rs @@ -6,7 +6,7 @@ use minijinja::context as template_context; use crate::{ contextual_error, http::{ - context::AdminRequestContext, + context::{AdminRequestContext, admin_template_context}, errors::WebError, pagination::{Pagination, PaginationView}, }, @@ -18,16 +18,15 @@ pub(crate) async fn handle_admin_events( admin_ctx: AdminRequestContext, pagination: Query, ) -> Result { + let canonical_url = format!( + "https://{}/admin/events", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "events"); + let language = admin_ctx.language; let web_context = admin_ctx.web_context; - let canonical_url = format!("https://{}/admin/events", web_context.config.external_base); - let default_context = template_context! { - language => language.to_string(), - current_handle => admin_ctx.admin_handle.clone(), - canonical_url => canonical_url, - }; - let render_template = select_template!("admin_events", false, false, language); let error_template = select_template!(false, false, language); @@ -56,14 +55,11 @@ pub(crate) async fn handle_admin_events( Ok(RenderHtml( &render_template, web_context.engine.clone(), - template_context! { - language => language.to_string(), - current_handle => admin_ctx.admin_handle.clone(), - canonical_url => canonical_url, + template_context! { ..default_context, ..template_context! { events => events, total_count => total_count, pagination => pagination_view, - }, + }}, ) .into_response()) } diff --git a/src/http/handle_admin_handles.rs b/src/http/handle_admin_handles.rs index 12fd1d7..30a67c4 100644 --- a/src/http/handle_admin_handles.rs +++ b/src/http/handle_admin_handles.rs @@ -27,7 +27,7 @@ pub(crate) async fn handle_admin_handles( "https://{}/admin/handles", admin_ctx.web_context.config.external_base ); - let default_context = admin_template_context(&admin_ctx, &canonical_url); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "identity_profiles"); let render_template = select_template!("admin_handles", false, false, admin_ctx.language); let error_template = select_template!(false, false, admin_ctx.language); diff --git a/src/http/handle_admin_identity_profile.rs b/src/http/handle_admin_identity_profile.rs new file mode 100644 index 0000000..80eff90 --- /dev/null +++ b/src/http/handle_admin_identity_profile.rs @@ -0,0 +1,243 @@ +use anyhow::Result; +use axum::{ + Form, + extract::Query, + response::{IntoResponse, Redirect}, +}; +use axum_htmx::{HxRedirect, HxRequest}; +use axum_template::RenderHtml; +use http::StatusCode; +use minijinja::context as template_context; +use serde::Deserialize; +use std::borrow::Cow; + +use crate::{ + contextual_error, + http::{ + context::{AdminRequestContext, admin_template_context}, + errors::WebError, + }, + select_template, + storage::{ + denylist::denylist_add_or_update, + identity_profile::{handle_nuke, identity_profile_get}, + notification::notification_get, + }, +}; + +#[derive(Deserialize)] +pub(crate) struct IdentityProfileQuery { + pub(crate) did: String, +} + +pub(crate) async fn handle_admin_identity_profile( + admin_ctx: AdminRequestContext, + Query(query): Query, +) -> Result { + let canonical_url = format!( + "https://{}/admin/identity_profile", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "identity_profiles"); + + let language = admin_ctx.language; + let web_context = admin_ctx.web_context; + + let render_template = select_template!("admin_identity_profile", false, false, language); + let error_template = select_template!(false, false, language); + + // Get the identity profile + let profile = identity_profile_get(&web_context.pool, &query.did).await; + if let Err(err) = profile { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + err + ); + } + let profile = profile.unwrap(); + + if profile.is_none() { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + "Identity profile not found" + ); + } + let profile = profile.unwrap(); + + // Get notification settings (may not exist for all profiles) + let notifications = notification_get(&web_context.pool, &query.did).await; + let notifications = notifications.ok().flatten(); + + // Convert profile to JSON for display + let profile_json = serde_json::to_string_pretty(&profile) + .unwrap_or_else(|_| "Error formatting JSON".to_string()); + + // Convert notifications to JSON if available + let notifications_json = notifications.as_ref().map(|n| { + serde_json::to_string_pretty(n).unwrap_or_else(|_| "Error formatting JSON".to_string()) + }); + + Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { ..default_context, ..template_context! { + did => query.did.clone(), + profile => profile, + profile_json => profile_json, + notifications => notifications, + notifications_json => notifications_json, + }}, + ) + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct BanDidForm { + did: String, +} + +#[derive(Deserialize)] +pub(crate) struct BanPdsForm { + did: String, + pds: String, +} + +#[derive(Deserialize)] +pub(crate) struct NukeIdentityForm { + did: String, +} + +pub(crate) async fn handle_admin_identity_profile_ban_did( + admin_ctx: AdminRequestContext, + HxRequest(hx_request): HxRequest, + Form(form): Form, +) -> Result { + let error_template = select_template!(false, false, admin_ctx.language); + + if form.did == admin_ctx.admin_handle.did { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! { + message => "You cannot ban your own identity." + }, + "You cannot ban your own identity." + ); + } + + let reason = format!( + "DID banned by admin {}", + admin_ctx.admin_handle.did.replace('\'', "") + ); + + if let Err(err) = denylist_add_or_update( + &admin_ctx.web_context.pool, + Cow::Borrowed(&form.did), + Cow::Owned(reason), + ) + .await + { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + let redirect_url = format!("/admin/identity_profile?did={}", form.did); + if hx_request { + let hx_redirect = HxRedirect::from(redirect_url.as_str()); + Ok((StatusCode::OK, hx_redirect, "").into_response()) + } else { + Ok(Redirect::to(&redirect_url).into_response()) + } +} + +pub(crate) async fn handle_admin_identity_profile_ban_pds( + admin_ctx: AdminRequestContext, + HxRequest(hx_request): HxRequest, + Form(form): Form, +) -> Result { + let error_template = select_template!(false, false, admin_ctx.language); + + let reason = format!( + "PDS banned by admin {}", + admin_ctx.admin_handle.did.replace('\'', "") + ); + + if let Err(err) = denylist_add_or_update( + &admin_ctx.web_context.pool, + Cow::Borrowed(&form.pds), + Cow::Owned(reason), + ) + .await + { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + let redirect_url = format!("/admin/identity_profile?did={}", form.did); + if hx_request { + let hx_redirect = HxRedirect::from(redirect_url.as_str()); + Ok((StatusCode::OK, hx_redirect, "").into_response()) + } else { + Ok(Redirect::to(&redirect_url).into_response()) + } +} + +pub(crate) async fn handle_admin_identity_profile_nuke( + admin_ctx: AdminRequestContext, + HxRequest(hx_request): HxRequest, + Form(form): Form, +) -> Result { + let error_template = select_template!(false, false, admin_ctx.language); + + if form.did == admin_ctx.admin_handle.did { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! { + message => "You cannot nuke your own identity." + }, + "You cannot nuke your own identity." + ); + } + + if let Err(err) = handle_nuke( + &admin_ctx.web_context.pool, + &form.did, + &admin_ctx.admin_handle.did, + ) + .await + { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + if hx_request { + let hx_redirect = HxRedirect::from("/admin/identity_profiles"); + Ok((StatusCode::OK, hx_redirect, "").into_response()) + } else { + Ok(Redirect::to("/admin/identity_profiles").into_response()) + } +} diff --git a/src/http/handle_admin_import_event.rs b/src/http/handle_admin_import_event.rs index 6f9267a..d7c2043 100644 --- a/src/http/handle_admin_import_event.rs +++ b/src/http/handle_admin_import_event.rs @@ -66,9 +66,10 @@ pub(crate) async fn handle_admin_import_event( } // Insert the handle if it doesn't exist - if let Err(err) = handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds, None) - .await - .map(|_is_new_user| ()) + if let Err(err) = + handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds, None) + .await + .map(|_is_new_user| ()) { tracing::warn!("Failed to warm up handle for {}: {}", repository, err); continue; diff --git a/src/http/handle_admin_import_rsvp.rs b/src/http/handle_admin_import_rsvp.rs index 14d17dc..c5d78a4 100644 --- a/src/http/handle_admin_import_rsvp.rs +++ b/src/http/handle_admin_import_rsvp.rs @@ -66,9 +66,10 @@ pub(crate) async fn handle_admin_import_rsvp( } // Insert the handle if it doesn't exist - if let Err(err) = handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds, None) - .await - .map(|_is_new_user| ()) + if let Err(err) = + handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds, None) + .await + .map(|_is_new_user| ()) { tracing::warn!("Failed to warm up handle for {}: {}", repository, err); continue; diff --git a/src/http/handle_admin_index.rs b/src/http/handle_admin_index.rs index aa07cd5..0fd764e 100644 --- a/src/http/handle_admin_index.rs +++ b/src/http/handle_admin_index.rs @@ -20,7 +20,7 @@ pub(crate) async fn handle_admin_index( "en-us/admin.html", admin_ctx.web_context.engine.clone(), template_context! { - ..admin_template_context(&admin_ctx, &canonical_url), + ..admin_template_context(&admin_ctx, &canonical_url, "index"), }, ) .into_response()) diff --git a/src/http/handle_admin_rsvp.rs b/src/http/handle_admin_rsvp.rs index 2e427ec..a2172a3 100644 --- a/src/http/handle_admin_rsvp.rs +++ b/src/http/handle_admin_rsvp.rs @@ -1,20 +1,27 @@ use anyhow::Result; use axum::{ - extract::{Query, State}, - response::IntoResponse, + Form, + extract::Query, + response::{IntoResponse, Redirect}, }; -use axum_extra::extract::Cached; +use axum_htmx::{HxRedirect, HxRequest}; use axum_template::RenderHtml; +use http::StatusCode; use minijinja::context as template_context; use serde::Deserialize; +use std::borrow::Cow; use crate::{ contextual_error, http::{ - context::WebContext, errors::WebError, middleware_auth::Auth, middleware_i18n::Language, + context::{AdminRequestContext, admin_template_context}, + errors::WebError, }, select_template, - storage::event::rsvp_get, + storage::{ + denylist::denylist_add_or_update, + event::{rsvp_delete, rsvp_get}, + }, }; #[derive(Deserialize)] @@ -23,18 +30,17 @@ pub(crate) struct RsvpRecordQuery { } pub(crate) async fn handle_admin_rsvp( - State(web_context): State, - Language(language): Language, - Cached(auth): Cached, + admin_ctx: AdminRequestContext, Query(query): Query, ) -> Result { - let current_handle = auth.require_admin(&web_context.config)?; + let canonical_url = format!( + "https://{}/admin/rsvp", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "rsvps"); - let default_context = template_context! { - language => language.to_string(), - current_handle, - canonical_url => format!("https://{}/admin/rsvp", web_context.config.external_base), - }; + let language = admin_ctx.language; + let web_context = admin_ctx.web_context; let render_template = select_template!("admin_rsvp", false, false, language); let error_template = select_template!(false, false, language); @@ -59,3 +65,56 @@ pub(crate) async fn handle_admin_rsvp( ) .into_response()) } + +#[derive(Deserialize)] +pub(crate) struct NukeRsvpForm { + aturi: String, +} + +pub(crate) async fn handle_admin_rsvp_nuke( + admin_ctx: AdminRequestContext, + HxRequest(hx_request): HxRequest, + Form(form): Form, +) -> Result { + let error_template = select_template!(false, false, admin_ctx.language); + + // Delete the RSVP record + if let Err(err) = rsvp_delete(&admin_ctx.web_context.pool, &form.aturi).await { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + // Add RSVP AT-URI to denylist + let reason = format!( + "RSVP nuked by admin {}", + admin_ctx.admin_handle.did.replace('\'', "") + ); + + if let Err(err) = denylist_add_or_update( + &admin_ctx.web_context.pool, + Cow::Borrowed(&form.aturi), + Cow::Owned(reason), + ) + .await + { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + if hx_request { + let hx_redirect = HxRedirect::from("/admin/rsvps"); + Ok((StatusCode::OK, hx_redirect, "").into_response()) + } else { + Ok(Redirect::to("/admin/rsvps").into_response()) + } +} diff --git a/src/http/handle_admin_rsvp_accept.rs b/src/http/handle_admin_rsvp_accept.rs new file mode 100644 index 0000000..41359b5 --- /dev/null +++ b/src/http/handle_admin_rsvp_accept.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use axum::{ + Form, + extract::Query, + response::{IntoResponse, Redirect}, +}; +use axum_htmx::{HxRedirect, HxRequest}; +use axum_template::RenderHtml; +use http::StatusCode; +use minijinja::context as template_context; +use serde::Deserialize; +use std::borrow::Cow; + +use crate::{ + contextual_error, + http::{ + context::{AdminRequestContext, admin_template_context}, + errors::WebError, + }, + select_template, + storage::{ + acceptance::{acceptance_record_delete, acceptance_record_get}, + denylist::denylist_add_or_update, + }, +}; + +#[derive(Deserialize)] +pub(crate) struct AcceptRecordQuery { + pub(crate) aturi: String, +} + +pub(crate) async fn handle_admin_rsvp_accept( + admin_ctx: AdminRequestContext, + Query(query): Query, +) -> Result { + let canonical_url = format!( + "https://{}/admin/rsvp-accept", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "rsvp-accepts"); + + let language = admin_ctx.language; + let web_context = admin_ctx.web_context; + + let render_template = select_template!("admin_rsvp_accept", false, false, language); + let error_template = select_template!(false, false, language); + + // Get the acceptance record by AT-URI + let accept = acceptance_record_get(&web_context.pool, &query.aturi).await; + if let Err(err) = accept { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + err + ); + } + let accept = accept.unwrap(); + + if accept.is_none() { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + "Acceptance record not found" + ); + } + let accept = accept.unwrap(); + + // Convert to JSON for display + let accept_json = serde_json::to_string_pretty(&accept) + .unwrap_or_else(|_| "Error formatting JSON".to_string()); + + Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { ..default_context, ..template_context! { + aturi => query.aturi.clone(), + accept => accept, + accept_json => accept_json, + }}, + ) + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct NukeAcceptForm { + aturi: String, +} + +pub(crate) async fn handle_admin_rsvp_accept_nuke( + admin_ctx: AdminRequestContext, + HxRequest(hx_request): HxRequest, + Form(form): Form, +) -> Result { + let error_template = select_template!(false, false, admin_ctx.language); + + // Delete the acceptance record + if let Err(err) = acceptance_record_delete(&admin_ctx.web_context.pool, &form.aturi).await { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + // Add acceptance AT-URI to denylist + let reason = format!( + "RSVP acceptance nuked by admin {}", + admin_ctx.admin_handle.did.replace('\'', "") + ); + + if let Err(err) = denylist_add_or_update( + &admin_ctx.web_context.pool, + Cow::Borrowed(&form.aturi), + Cow::Owned(reason), + ) + .await + { + return contextual_error!( + admin_ctx.web_context, + admin_ctx.language, + error_template, + template_context! {}, + err + ); + } + + if hx_request { + let hx_redirect = HxRedirect::from("/admin/rsvp-accepts"); + Ok((StatusCode::OK, hx_redirect, "").into_response()) + } else { + Ok(Redirect::to("/admin/rsvp-accepts").into_response()) + } +} diff --git a/src/http/handle_admin_rsvp_accepts.rs b/src/http/handle_admin_rsvp_accepts.rs new file mode 100644 index 0000000..138cd2f --- /dev/null +++ b/src/http/handle_admin_rsvp_accepts.rs @@ -0,0 +1,65 @@ +use anyhow::Result; +use axum::{extract::Query, response::IntoResponse}; +use axum_template::RenderHtml; +use minijinja::context as template_context; + +use crate::{ + contextual_error, + http::{ + context::{AdminRequestContext, admin_template_context}, + errors::WebError, + pagination::{Pagination, PaginationView}, + }, + select_template, + storage::acceptance::acceptance_record_list, +}; + +pub(crate) async fn handle_admin_rsvp_accepts( + admin_ctx: AdminRequestContext, + pagination: Query, +) -> Result { + let canonical_url = format!( + "https://{}/admin/rsvp-accepts", + admin_ctx.web_context.config.external_base + ); + let default_context = admin_template_context(&admin_ctx, &canonical_url, "rsvp-accepts"); + + let language = admin_ctx.language; + let web_context = admin_ctx.web_context; + + let render_template = select_template!("admin_rsvp_accepts", false, false, language); + let error_template = select_template!(false, false, language); + + let (page, page_size) = pagination.admin_clamped(); + + let accepts = acceptance_record_list(&web_context.pool, page, page_size).await; + if let Err(err) = accepts { + return contextual_error!( + web_context, + language.0, + error_template, + default_context, + err + ); + } + let (total_count, mut accepts) = accepts.unwrap(); + + let params: Vec<(&str, &str)> = vec![]; + + let pagination_view = PaginationView::new(page_size, accepts.len() as i64, page, params); + + if accepts.len() > page_size as usize { + accepts.truncate(page_size as usize); + } + + Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { ..default_context, ..template_context! { + accepts => accepts, + total_count => total_count, + pagination => pagination_view, + }}, + ) + .into_response()) +} diff --git a/src/http/handle_admin_rsvps.rs b/src/http/handle_admin_rsvps.rs index 07c896e..1eb87a4 100644 --- a/src/http/handle_admin_rsvps.rs +++ b/src/http/handle_admin_rsvps.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use crate::{ contextual_error, http::{ - context::AdminRequestContext, + context::{AdminRequestContext, admin_template_context}, errors::WebError, pagination::{Pagination, PaginationView}, }, @@ -29,22 +29,23 @@ pub(crate) async fn handle_admin_rsvps( admin_ctx: AdminRequestContext, Query(params): Query, ) -> Result { - let language = admin_ctx.language; - let web_context = admin_ctx.web_context; - let import_success = params.import_success; let imported_aturi = params.imported_aturi; - let canonical_url = format!("https://{}/admin/rsvps", web_context.config.external_base); + let canonical_url = format!( + "https://{}/admin/rsvps", + admin_ctx.web_context.config.external_base + ); + let base_context = admin_template_context(&admin_ctx, &canonical_url, "rsvps"); + + let language = admin_ctx.language; + let web_context = admin_ctx.web_context; // Create the context with all needed parameters - let default_context = template_context! { - language => language.to_string(), - current_handle => admin_ctx.admin_handle.clone(), - canonical_url => canonical_url, + let default_context = template_context! { ..base_context, ..template_context! { import_success => import_success, imported_aturi => imported_aturi, - }; + }}; let render_template = select_template!("admin_rsvps", false, false, language); let error_template = select_template!(false, false, language); @@ -74,16 +75,11 @@ pub(crate) async fn handle_admin_rsvps( Ok(RenderHtml( &render_template, web_context.engine.clone(), - template_context! { - language => language.to_string(), - current_handle => admin_ctx.admin_handle.clone(), - canonical_url => canonical_url, - import_success => import_success, - imported_aturi => imported_aturi, + template_context! { ..default_context, ..template_context! { rsvps => rsvps, total_count => total_count, pagination => pagination_view, - }, + }}, ) .into_response()) } diff --git a/src/http/handle_blob.rs b/src/http/handle_blob.rs index 649936e..40b0636 100644 --- a/src/http/handle_blob.rs +++ b/src/http/handle_blob.rs @@ -640,7 +640,8 @@ pub(crate) async fn upload_event_header( } } - let file_data = file_data.ok_or_else(|| anyhow!("error-smokesignal-blob-37 No header file provided"))?; + let file_data = + file_data.ok_or_else(|| anyhow!("error-smokesignal-blob-37 No header file provided"))?; // Validate image with 3MB limit crate::image::validate_image(&file_data, 3_000_000) diff --git a/src/http/handle_create_event.rs b/src/http/handle_create_event.rs index e8e1926..d493194 100644 --- a/src/http/handle_create_event.rs +++ b/src/http/handle_create_event.rs @@ -37,11 +37,11 @@ use crate::task_webhooks::TaskWork; use atproto_client::com::atproto::repo::{ CreateRecordRequest, CreateRecordResponse, create_record, }; +use atproto_record::lexicon::TypedBlob; use atproto_record::lexicon::community::lexicon::calendar::event::{ AspectRatio, Event, Media, Mode, NSID, Status, }; use atproto_record::lexicon::community::lexicon::location::Address; -use atproto_record::lexicon::TypedBlob; use atproto_record::typed::TypedLexicon; use super::cache_countries::cached_countries; diff --git a/src/http/handle_edit_event.rs b/src/http/handle_edit_event.rs index 1b69620..7fdfbf8 100644 --- a/src/http/handle_edit_event.rs +++ b/src/http/handle_edit_event.rs @@ -28,12 +28,12 @@ use crate::{ }, }; use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; +use atproto_record::lexicon::TypedBlob; use atproto_record::lexicon::community::lexicon::calendar::event::{ AspectRatio, Event as LexiconCommunityEvent, Media, Mode, NSID as LexiconCommunityEventNSID, Status, }; use atproto_record::lexicon::community::lexicon::location::Address; -use atproto_record::lexicon::TypedBlob; use atproto_record::typed::TypedLexicon; pub(crate) async fn handle_edit_event( diff --git a/src/http/handle_oauth_aip_callback.rs b/src/http/handle_oauth_aip_callback.rs index f3d0e3d..4792722 100644 --- a/src/http/handle_oauth_aip_callback.rs +++ b/src/http/handle_oauth_aip_callback.rs @@ -1,9 +1,9 @@ use std::{collections::HashMap, sync::Arc}; use crate::{ - config::OAuthBackendConfig, contextual_error, select_template, + config::OAuthBackendConfig, contextual_error, + http::handle_email_confirm::send_confirmation_email, select_template, storage::identity_profile::handle_warm_up, - http::handle_email_confirm::send_confirmation_email, }; use anyhow::Result; use atproto_client::errors::SimpleError; diff --git a/src/http/handle_view_event.rs b/src/http/handle_view_event.rs index fed9bfb..c80467d 100644 --- a/src/http/handle_view_event.rs +++ b/src/http/handle_view_event.rs @@ -56,7 +56,9 @@ fn default_collection() -> String { } /// Helper function to extract metadata from an acceptance record -fn extract_acceptance_metadata(acceptance_record: serde_json::Value) -> Option> { +fn extract_acceptance_metadata( + acceptance_record: serde_json::Value, +) -> Option> { match serde_json::from_value::( acceptance_record, ) { @@ -81,10 +83,7 @@ fn extract_acceptance_metadata(acceptance_record: serde_json::Value) -> Option Vec { +async fn fetch_recent_going_rsvps(pool: &StoragePool, aturi: &str) -> Vec { // Get recent going RSVPs (limited to 3, ordered by updated_at DESC) let rsvps = get_recent_going_rsvps(pool, aturi, 3) .await @@ -94,18 +93,17 @@ async fn fetch_recent_going_rsvps( let dids: Vec = rsvps.iter().map(|(did, _, _)| did.clone()).collect(); // Perform batch lookup for handles - let handle_profiles = handles_by_did(pool, dids) - .await - .unwrap_or_default(); + let handle_profiles = handles_by_did(pool, dids).await.unwrap_or_default(); // Create RsvpDisplay objects with handles, filtering out those without handles - rsvps.into_iter() + rsvps + .into_iter() .filter_map(|(did, status, validated_at)| { handle_profiles.get(&did).map(|profile| RsvpDisplay { did: did.clone(), handle: profile.handle.clone(), status, - verified: validated_at.is_some() + verified: validated_at.is_some(), }) }) .collect() @@ -180,9 +178,7 @@ async fn get_private_content_if_authorized( .0 .iter() .any(|criterion| match criterion.as_str() { - "going_confirmed" => { - user_rsvp_status == Some("going") && user_rsvp_is_validated - } + "going_confirmed" => user_rsvp_status == Some("going") && user_rsvp_is_validated, "going" => user_rsvp_status == Some("going"), "interested" => user_rsvp_status == Some("interested"), _ => false, @@ -216,8 +212,7 @@ async fn fetch_acceptance_data( }; // Only fetch acceptance data if the RSVP status is appropriate (going or interested) - let should_check_acceptance = - matches!(user_rsvp_status, Some("going") | Some("interested")); + let should_check_acceptance = matches!(user_rsvp_status, Some("going") | Some("interested")); if !should_check_acceptance { return (None, None, None); @@ -680,7 +675,12 @@ pub(crate) async fn handle_event_attendees( // Extract DIDs from all groups for batch lookup let mut all_dids: Vec = Vec::new(); all_dids.extend(grouped_data.going.iter().map(|(did, _, _)| did.clone())); - all_dids.extend(grouped_data.interested.iter().map(|(did, _, _)| did.clone())); + all_dids.extend( + grouped_data + .interested + .iter() + .map(|(did, _, _)| did.clone()), + ); all_dids.extend(grouped_data.notgoing.iter().map(|(did, _, _)| did.clone())); // Perform batch lookup for handles @@ -689,19 +689,20 @@ pub(crate) async fn handle_event_attendees( .unwrap_or_default(); // Helper closure to create RsvpDisplay objects - let create_attendees = |rsvps: Vec<(String, String, Option>)>| -> Vec { - rsvps - .into_iter() - .filter_map(|(did, status, validated_at)| { - handle_profiles.get(&did).map(|profile| RsvpDisplay { - did: did.clone(), - handle: profile.handle.clone(), - status, - verified: validated_at.is_some(), + let create_attendees = + |rsvps: Vec<(String, String, Option>)>| -> Vec { + rsvps + .into_iter() + .filter_map(|(did, status, validated_at)| { + handle_profiles.get(&did).map(|profile| RsvpDisplay { + did: did.clone(), + handle: profile.handle.clone(), + status, + verified: validated_at.is_some(), + }) }) - }) - .collect() - }; + .collect() + }; // Create separate attendee lists let going_attendees = create_attendees(grouped_data.going); diff --git a/src/http/mod.rs b/src/http/mod.rs index 2157788..2addd91 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -7,14 +7,19 @@ pub mod errors; pub mod event_form; pub mod event_view; pub mod handle_accept_rsvp; +pub mod handle_admin_content_list; +pub mod handle_admin_content_view; pub mod handle_admin_denylist; pub mod handle_admin_event; pub mod handle_admin_events; pub mod handle_admin_handles; +pub mod handle_admin_identity_profile; pub mod handle_admin_import_event; pub mod handle_admin_import_rsvp; pub mod handle_admin_index; pub mod handle_admin_rsvp; +pub mod handle_admin_rsvp_accept; +pub mod handle_admin_rsvp_accepts; pub mod handle_admin_rsvps; pub mod handle_blob; pub mod handle_bulk_accept_rsvps; diff --git a/src/http/server.rs b/src/http/server.rs index 679d8ee..14f7d5d 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -18,16 +18,24 @@ use tracing::Span; use crate::http::{ context::WebContext, handle_accept_rsvp::handle_accept_rsvp, + handle_admin_content_list::handle_admin_content_list, + handle_admin_content_view::{handle_admin_content_nuke, handle_admin_content_view}, handle_admin_denylist::{ handle_admin_denylist, handle_admin_denylist_add, handle_admin_denylist_remove, }, - handle_admin_event::handle_admin_event, + handle_admin_event::{handle_admin_event, handle_admin_event_nuke}, handle_admin_events::handle_admin_events, handle_admin_handles::{handle_admin_handles, handle_admin_nuke_identity}, + handle_admin_identity_profile::{ + handle_admin_identity_profile, handle_admin_identity_profile_ban_did, + handle_admin_identity_profile_ban_pds, handle_admin_identity_profile_nuke, + }, handle_admin_import_event::handle_admin_import_event, handle_admin_import_rsvp::handle_admin_import_rsvp, handle_admin_index::handle_admin_index, - handle_admin_rsvp::handle_admin_rsvp, + handle_admin_rsvp::{handle_admin_rsvp, handle_admin_rsvp_nuke}, + handle_admin_rsvp_accept::{handle_admin_rsvp_accept, handle_admin_rsvp_accept_nuke}, + handle_admin_rsvp_accepts::handle_admin_rsvp_accepts, handle_admin_rsvps::handle_admin_rsvps, handle_blob::{ delete_profile_avatar, delete_profile_banner, upload_event_header, upload_profile_avatar, @@ -138,20 +146,51 @@ pub fn build_router(web_context: WebContext) -> Router { router .route("/admin", get(handle_admin_index)) + .route("/admin/identity_profiles", get(handle_admin_handles)) + .route( + "/admin/identity_profile", + get(handle_admin_identity_profile), + ) + .route( + "/admin/identity_profile/ban-did", + post(handle_admin_identity_profile_ban_did), + ) + .route( + "/admin/identity_profile/ban-pds", + post(handle_admin_identity_profile_ban_pds), + ) + .route( + "/admin/identity_profile/nuke", + post(handle_admin_identity_profile_nuke), + ) .route("/admin/handles", get(handle_admin_handles)) .route( "/admin/handles/nuke/{did}", post(handle_admin_nuke_identity), ) + // Legacy routes - redirect to new combined view + .route("/admin/identities", get(handle_admin_handles)) + .route("/admin/identity", get(handle_admin_identity_profile)) + .route("/admin/content", get(handle_admin_content_list)) + .route("/admin/content/view", get(handle_admin_content_view)) + .route("/admin/content/nuke", post(handle_admin_content_nuke)) .route("/admin/denylist", get(handle_admin_denylist)) .route("/admin/denylist/add", post(handle_admin_denylist_add)) .route("/admin/denylist/remove", post(handle_admin_denylist_remove)) .route("/admin/events", get(handle_admin_events)) .route("/admin/events/import", post(handle_admin_import_event)) .route("/admin/event", get(handle_admin_event)) + .route("/admin/event/nuke", post(handle_admin_event_nuke)) .route("/admin/rsvps", get(handle_admin_rsvps)) .route("/admin/rsvp", get(handle_admin_rsvp)) + .route("/admin/rsvp/nuke", post(handle_admin_rsvp_nuke)) .route("/admin/rsvps/import", post(handle_admin_import_rsvp)) + .route("/admin/rsvp-accepts", get(handle_admin_rsvp_accepts)) + .route("/admin/rsvp-accept", get(handle_admin_rsvp_accept)) + .route( + "/admin/rsvp-accept/nuke", + post(handle_admin_rsvp_accept_nuke), + ) .route("/content/{cid}", get(handle_content)) .route("/logout", get(handle_logout)) .route("/language", post(handle_set_language)) @@ -204,7 +243,7 @@ pub fn build_router(web_context: WebContext) -> Router { .route("/event/location/datalist", get(handle_location_datalist)) .route( "/event/upload-header", - post(upload_event_header).layer(DefaultBodyLimit::max(5 * 1024 * 1024)) // 5MB limit + post(upload_event_header).layer(DefaultBodyLimit::max(5 * 1024 * 1024)), // 5MB limit ) .route("/ics/{*aturi}", get(handle_export_ics)) .route("/{handle_slug}/ics", get(handle_event_ics)) diff --git a/src/storage/acceptance.rs b/src/storage/acceptance.rs index 1fec16f..431d851 100644 --- a/src/storage/acceptance.rs +++ b/src/storage/acceptance.rs @@ -18,7 +18,7 @@ pub struct AcceptanceTicket { } /// Model for RSVP acceptance record -#[derive(Clone, FromRow, Debug)] +#[derive(Clone, FromRow, Debug, serde::Serialize)] pub struct AcceptanceRecord { pub aturi: String, pub cid: String, @@ -401,3 +401,44 @@ pub async fn rsvp_clear_event_validations( .await .map_err(StorageError::CannotCommitDatabaseTransaction) } + +/// List acceptance records with pagination +/// Returns (total_count, records) +/// page is 0-indexed +pub async fn acceptance_record_list( + pool: &StoragePool, + page: i64, + page_size: i64, +) -> Result<(i64, Vec), StorageError> { + let mut tx = pool + .begin() + .await + .map_err(StorageError::CannotBeginDatabaseTransaction)?; + + // Get total count + let total_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM acceptance_records") + .fetch_one(tx.as_mut()) + .await + .map_err(StorageError::UnableToExecuteQuery)?; + + // Fetch one extra record to determine if there are more pages + let offset = page * page_size; + let fetch_size = page_size + 1; + + let records = sqlx::query_as::<_, AcceptanceRecord>( + "SELECT * FROM acceptance_records + ORDER BY created_at DESC + LIMIT $1 OFFSET $2", + ) + .bind(fetch_size) + .bind(offset) + .fetch_all(tx.as_mut()) + .await + .map_err(StorageError::UnableToExecuteQuery)?; + + tx.commit() + .await + .map_err(StorageError::CannotCommitDatabaseTransaction)?; + + Ok((total_count, records)) +} diff --git a/src/storage/identity_profile.rs b/src/storage/identity_profile.rs index 0889f1d..a125cdd 100644 --- a/src/storage/identity_profile.rs +++ b/src/storage/identity_profile.rs @@ -447,6 +447,37 @@ pub async fn handles_by_did( )) } +/// Get an identity profile by DID +pub async fn identity_profile_get( + pool: &StoragePool, + did: &str, +) -> Result, StorageError> { + // Validate DID is not empty + if did.trim().is_empty() { + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( + "DID cannot be empty".into(), + ))); + } + + let mut tx = pool + .begin() + .await + .map_err(StorageError::CannotBeginDatabaseTransaction)?; + + let profile = + sqlx::query_as::<_, IdentityProfile>("SELECT * FROM identity_profiles WHERE did = $1") + .bind(did) + .fetch_optional(tx.as_mut()) + .await + .map_err(StorageError::UnableToExecuteQuery)?; + + tx.commit() + .await + .map_err(StorageError::CannotCommitDatabaseTransaction)?; + + Ok(profile) +} + #[cfg(test)] pub mod test { use sqlx::PgPool; diff --git a/src/storage/notification.rs b/src/storage/notification.rs index 7541f8e..7aec266 100644 --- a/src/storage/notification.rs +++ b/src/storage/notification.rs @@ -555,3 +555,55 @@ pub enum NotificationPreference { EmailOn24h, EmailOnRsvpSummary, } + +/// List identity notifications with pagination +/// Returns (total_count, records) +/// page is 0-indexed +pub async fn notification_list( + pool: &StoragePool, + page: i64, + page_size: i64, +) -> Result<(i64, Vec), StorageError> { + let mut tx = pool + .begin() + .await + .map_err(StorageError::CannotBeginDatabaseTransaction)?; + + // Get total count + let total_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM identity_notifications") + .fetch_one(tx.as_mut()) + .await + .map_err(StorageError::UnableToExecuteQuery)?; + + // Fetch one extra record to determine if there are more pages + let offset = page * page_size; + let fetch_size = page_size + 1; + + let records = sqlx::query_as::<_, NotificationSettingsWithEmail>( + "SELECT + n.did, + p.email, + n.confirmed_at, + n.email_on_rsvp, + n.email_on_event_change, + n.email_on_24h, + n.email_on_rsvp_summary, + n.created_at, + n.updated_at + FROM identity_notifications n + INNER JOIN identity_profiles p ON n.did = p.did + ORDER BY n.created_at DESC + LIMIT $1 OFFSET $2", + ) + .bind(fetch_size) + .bind(offset) + .fetch_all(tx.as_mut()) + .await + .map_err(StorageError::UnableToExecuteQuery)?; + + tx.commit() + .await + .map_err(StorageError::CannotCommitDatabaseTransaction)?; + + Ok((total_count, records)) +} diff --git a/src/storage/private_event_content.rs b/src/storage/private_event_content.rs index 690b1cb..38239ac 100644 --- a/src/storage/private_event_content.rs +++ b/src/storage/private_event_content.rs @@ -117,3 +117,44 @@ pub async fn private_event_content_delete( .await .map_err(StorageError::CannotCommitDatabaseTransaction) } + +/// List private event content with pagination +/// Returns (total_count, records) +/// page is 0-indexed +pub async fn private_event_content_list( + pool: &StoragePool, + page: i64, + page_size: i64, +) -> Result<(i64, Vec), StorageError> { + let mut tx = pool + .begin() + .await + .map_err(StorageError::CannotBeginDatabaseTransaction)?; + + // Get total count + let total_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM private_event_content") + .fetch_one(tx.as_mut()) + .await + .map_err(StorageError::UnableToExecuteQuery)?; + + // Fetch one extra record to determine if there are more pages + let offset = page * page_size; + let fetch_size = page_size + 1; + + let records = sqlx::query_as::<_, PrivateEventContent>( + "SELECT * FROM private_event_content + ORDER BY created_at DESC + LIMIT $1 OFFSET $2", + ) + .bind(fetch_size) + .bind(offset) + .fetch_all(tx.as_mut()) + .await + .map_err(StorageError::UnableToExecuteQuery)?; + + tx.commit() + .await + .map_err(StorageError::CannotCommitDatabaseTransaction)?; + + Ok((total_count, records)) +} diff --git a/templates/en-us/admin.html b/templates/en-us/admin.html index 461c4cd..c9fa046 100644 --- a/templates/en-us/admin.html +++ b/templates/en-us/admin.html @@ -1,21 +1,11 @@ -{% extends "en-us/base.html" %} -{% block title %}Smoke Signal Admin{% endblock %} -{% block head %}{% endblock %} -{% block content %} -
-
-

Smoke Signal Admin

-
-
-

Administration Tools

- -
-
+{% extends "en-us/admin_base.html" %} +{% block admin_title %}Dashboard{% endblock %} +{% block admin_content %} +
+

Admin Dashboard

+

Welcome to the Smoke Signal administration panel

+
+

Use the sidebar to navigate between different admin sections.

-
+ {% endblock %} \ No newline at end of file diff --git a/templates/en-us/admin_base.html b/templates/en-us/admin_base.html new file mode 100644 index 0000000..3cc1851 --- /dev/null +++ b/templates/en-us/admin_base.html @@ -0,0 +1,39 @@ +{% extends "en-us/base.html" %} +{% block title %}{% block admin_title %}Admin{% endblock %} - Smoke Signal{% endblock %} +{% block content %} +
+
+
+
+ +
+
+ {% block admin_content %}{% endblock %} +
+
+
+
+{% endblock %} diff --git a/templates/en-us/admin_content_list.html b/templates/en-us/admin_content_list.html new file mode 100644 index 0000000..cdd87a7 --- /dev/null +++ b/templates/en-us/admin_content_list.html @@ -0,0 +1,48 @@ +{% extends "en-us/admin_base.html" %} +{% include 'pagination.html' %} +{% block admin_title %}Content{% endblock %} +{% block admin_content %} +
+

Private Event Content Records ({{ total_count }})

+

View all private event content ordered by creation date

+ + + + + + + + + + + + + {% for item in content %} + + + + + + + + {% endfor %} + +
Event AT-URIContent PreviewDisplay CriteriaCreatedActions
{{ item.aturi }}{{ item.content_preview }} + {% if item.criteria_count > 0 %} + {{ item.criteria_count }} criteria + {% else %} + None + {% endif %} + {{ item.created_at }} + +
+ + {% if pagination %} + {{ view_pagination((canonical_url ~ "?"), pagination) }} + {% endif %} +
+{% endblock %} diff --git a/templates/en-us/admin_content_view.html b/templates/en-us/admin_content_view.html new file mode 100644 index 0000000..3f89955 --- /dev/null +++ b/templates/en-us/admin_content_view.html @@ -0,0 +1,42 @@ +{% extends "en-us/admin_base.html" %} +{% block admin_title %}Content - {{ aturi }}{% endblock %} +{% block admin_content %} + +
+ + +

Private Event Content

+

{{ aturi }}

+ +
+

JSON Representation

+
{{ content_json }}
+
+ +
+

Actions

+
+ +
+
+
+{% endblock %} diff --git a/templates/en-us/admin_denylist.html b/templates/en-us/admin_denylist.html index 2f66081..785238e 100644 --- a/templates/en-us/admin_denylist.html +++ b/templates/en-us/admin_denylist.html @@ -1,82 +1,73 @@ -{% extends "en-us/base.html" %} +{% extends "en-us/admin_base.html" %} {% include 'pagination.html' %} -{% block title %}Manage Denylist - Smoke Signal Admin{% endblock %} -{% block head %}{% endblock %} -{% block content %} -
-
- -
-
-
-
-
-

Add or Update Entry

-
-
- -
- -
-

URI of the content to block (at URI, DIDs, URLs, domains)

+{% block admin_title %}Denylist{% endblock %} +{% block admin_content %} +
+

Denylist Management ({{ total_count }})

+

Manage blocked identities and content

+ +
+

Add or Update Entry

+ +
+ +
+ + + +
+

URI of the content to block (AT-URI, DIDs, URLs, domains)

+
-
- -
- -
-

Reason for blocking this content

+
+ +
+
+

Reason for blocking this content

+
-
-
- -
+
+
+
- -
+
+
-
-
-
-
- - - - - - - - - - - {% for entry in entries %} - - - - - - - {% endfor %} - -
SubjectReasonUpdatedActions
{{ entry.subject }}{{ entry.reason }}{{ entry.updated_at }} -
- - -
-
- {% if pagination %} - {{ view_pagination((canonical_url ~ "?"), pagination) }} - {% endif %} -
-
-
-{% endblock %} \ No newline at end of file + + + + + + + + + + + {% for entry in entries %} + + + + + + + {% endfor %} + +
SubjectReasonUpdatedActions
{{ entry.subject }}{{ entry.reason }}{{ entry.updated_at }} +
+ + +
+
+ + {% if pagination %} + {{ view_pagination((canonical_url ~ "?"), pagination) }} + {% endif %} + +{% endblock %} diff --git a/templates/en-us/admin_event.html b/templates/en-us/admin_event.html index ef24328..b846e15 100644 --- a/templates/en-us/admin_event.html +++ b/templates/en-us/admin_event.html @@ -1,6 +1,6 @@ -{% extends "en-us/base.html" %} -{% block title %}Event Record - Smoke Signal Admin{% endblock %} -{% block head %} +{% extends "en-us/admin_base.html" %} +{% block admin_title %}Event - {{ aturi }}{% endblock %} +{% block admin_content %} -{% endblock %} -{% block content %} -
-
- +
+ + +

Event Record

+

{{ aturi }}

+ +
+

JSON Representation

+
{{ event_json }}
-
-
-
-
-

Event Record

-
-
{{ event_json }}
-
+ +
+

Actions

+
+
-
+ {% endblock %} \ No newline at end of file diff --git a/templates/en-us/admin_events.html b/templates/en-us/admin_events.html index 80547d1..b99a3c0 100644 --- a/templates/en-us/admin_events.html +++ b/templates/en-us/admin_events.html @@ -1,75 +1,60 @@ -{% extends "en-us/base.html" %} +{% extends "en-us/admin_base.html" %} {% include 'pagination.html' %} -{% block title %}Events - Smoke Signal Admin{% endblock %} -{% block head %}{% endblock %} -{% block content %} -
-
- +{% block admin_title %}Events{% endblock %} +{% block admin_content %} +
+

Event Records ({{ total_count }})

+

View all events ordered by recent updates

+ +
+

Import Event by AT-URI

+
+
+ +
+ + + + +
+

Enter the full AT-URI of the event to import

+
+
+
+ +
+
+
-
-
-
-
-

Event Records ({{ total_count }})

-

View all events ordered by recent updates

- -
-

Import Event by AT-URI

-
-
- -
- - - - -
-

Enter the full AT-URI of the event to import

-
-
-
- -
+ + + + + + + + + + + {% for event in events %} + + + + + + {% endfor %} + +
AT-URIUpdatedActions
{{ event.aturi }}{{ event.updated_at }} + - - - - - - - - - - - - - {% for event in events %} - - - - - - {% endfor %} - -
NameUpdatedActions
{{ event.aturi }}{{ event.updated_at }} - -
+
- {% if pagination %} - {{ view_pagination((canonical_url ~ "?"), pagination) }} - {% endif %} -
-
-
+ {% if pagination %} + {{ view_pagination((canonical_url ~ "?"), pagination) }} + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/templates/en-us/admin_handles.html b/templates/en-us/admin_handles.html index 7dffec5..07e3c85 100644 --- a/templates/en-us/admin_handles.html +++ b/templates/en-us/admin_handles.html @@ -1,64 +1,52 @@ -{% extends "en-us/base.html" %} +{% extends "en-us/admin_base.html" %} {% include 'pagination.html' %} -{% block title %}Handles - Smoke Signal Admin{% endblock %} -{% block head %}{% endblock %} -{% block content %} -
-
- -
-
-
-
-
-

Handle Records ({{ total_count }})

-

View known handles

- - - - - - - - - - - - - - - {% for handle in handles %} - - - - - - - - - - {% endfor %} - -
DIDHandlePDSLanguageTimezoneUpdatedActions
{{ handle.did }}{{ handle.handle }}{{ handle.pds }}{{ handle.language }}{{ handle.tz }}{{ handle.updated_at }} - -
+{% block admin_title %}Identities{% endblock %} +{% block admin_content %} +
+

Identity Records ({{ total_count }})

+

View all registered identities

- {% if pagination %} - {{ view_pagination((canonical_url ~ "?"), pagination) }} - {% endif %} -
-
-
+ + + + + + + + + + + + + {% for handle in handles %} + + + + + + + + + {% endfor %} + +
HandleDIDPDSEmailUpdatedActions
{{ handle.handle }}{{ handle.did }}{{ handle.pds }}{{ handle.email|default("—") }}{{ handle.updated_at }} +
+ + View + + +
+
+ + {% if pagination %} + {{ view_pagination((canonical_url ~ "?"), pagination) }} + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/templates/en-us/admin_identities.html b/templates/en-us/admin_identities.html new file mode 100644 index 0000000..7291406 --- /dev/null +++ b/templates/en-us/admin_identities.html @@ -0,0 +1,48 @@ +{% extends "en-us/admin_base.html" %} +{% include 'pagination.html' %} +{% block admin_title %}Identities{% endblock %} +{% block admin_content %} +
+

Identity Records ({{ total_count }})

+

View all identity notification settings ordered by creation date

+ + + + + + + + + + + + + {% for identity in identities %} + + + + + + + + {% endfor %} + +
DIDEmailConfirmedCreatedActions
{{ identity.did }}{{ identity.email|default("—") }} + {% if identity.confirmed_at %} + Yes + {% else %} + No + {% endif %} + {{ identity.created_at }} + +
+ + {% if pagination %} + {{ view_pagination((canonical_url ~ "?"), pagination) }} + {% endif %} +
+{% endblock %} diff --git a/templates/en-us/admin_identity.html b/templates/en-us/admin_identity.html new file mode 100644 index 0000000..074e5b8 --- /dev/null +++ b/templates/en-us/admin_identity.html @@ -0,0 +1,39 @@ +{% extends "en-us/admin_base.html" %} +{% block admin_title %}Identity - {{ did }}{% endblock %} +{% block admin_content %} +
+ + +

Identity Notification Settings

+

{{ did }}

+ +
+

JSON Representation

+
{{ identity_json }}
+
+ +
+

Actions

+
+ +
+ +
+ +
+
+
+
+ +
+
+
+
+
+{% endblock %} diff --git a/templates/en-us/admin_identity_profile.html b/templates/en-us/admin_identity_profile.html new file mode 100644 index 0000000..209937d --- /dev/null +++ b/templates/en-us/admin_identity_profile.html @@ -0,0 +1,197 @@ +{% extends "en-us/admin_base.html" %} +{% block admin_title %}Identity - {{ did }}{% endblock %} +{% block admin_content %} + +
+ + +

Identity Profile

+

+ {{ profile.handle }} + {{ profile.did }} +

+ +
+
+
+

Profile Information

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Handle{{ profile.handle }}
DID{{ profile.did }}
PDS{{ profile.pds }}
Email{{ profile.email|default("—") }}
Language{{ profile.language }}
Timezone{{ profile.tz }}
Created{{ profile.created_at }}
Updated{{ profile.updated_at }}
Last Active{{ profile.active_at|default("Never") }}
+
+
+ +
+
+

Notification Settings

+ {% if notifications %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Email Confirmed + {% if notifications.confirmed_at %} + Yes + {{ notifications.confirmed_at }} + {% else %} + No + {% endif %} +
RSVP Notifications + {% if notifications.email_on_rsvp %} + Enabled + {% else %} + Disabled + {% endif %} +
Event Change Notifications + {% if notifications.email_on_event_change %} + Enabled + {% else %} + Disabled + {% endif %} +
24h Reminder + {% if notifications.email_on_24h %} + Enabled + {% else %} + Disabled + {% endif %} +
RSVP Summary + {% if notifications.email_on_rsvp_summary %} + Enabled + {% else %} + Disabled + {% endif %} +
Settings Created{{ notifications.created_at }}
Settings Updated{{ notifications.updated_at }}
+ {% else %} +
+

No notification settings configured for this identity.

+

Settings are created when a user first accesses their notification preferences.

+
+ {% endif %} +
+
+
+ +
+

Profile JSON

+
{{ profile_json }}
+
+ + {% if notifications_json %} +
+

Notifications JSON

+
{{ notifications_json }}
+
+ {% endif %} + +
+

Actions

+ +
+ + + + + +
+
+
+{% endblock %} diff --git a/templates/en-us/admin_rsvp.html b/templates/en-us/admin_rsvp.html index fcfc889..8841e2b 100644 --- a/templates/en-us/admin_rsvp.html +++ b/templates/en-us/admin_rsvp.html @@ -1,6 +1,6 @@ -{% extends "en-us/base.html" %} -{% block title %}RSVP Record - Smoke Signal Admin{% endblock %} -{% block head %} +{% extends "en-us/admin_base.html" %} +{% block admin_title %}RSVP - {{ rsvp.aturi }}{% endblock %} +{% block admin_content %} -{% endblock %} -{% block content %} -
-
- +
+ + +

RSVP Record

+

{{ rsvp.aturi }}

+ +
+

JSON Representation

+
{{ rsvp_json }}
-
-
-
-
-

RSVP Record

- -
-
-
-

RSVP Details

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AT-URI{{ rsvp.aturi }}
CID{{ rsvp.cid }}
DID{{ rsvp.did }}
Lexicon{{ rsvp.lexicon }}
Status{{ rsvp.status }}
Event AT-URI{{ rsvp.event_aturi }}
Event CID{{ rsvp.event_cid }}
Updated{{ rsvp.updated_at }}
-
-
-
-
-

RSVP JSON

-
{{ rsvp_json }}
-
-
-
+ +
+

Actions

+
+
-
-{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/templates/en-us/admin_rsvp_accept.html b/templates/en-us/admin_rsvp_accept.html new file mode 100644 index 0000000..0d0f6ea --- /dev/null +++ b/templates/en-us/admin_rsvp_accept.html @@ -0,0 +1,42 @@ +{% extends "en-us/admin_base.html" %} +{% block admin_title %}RSVP Accept - {{ aturi }}{% endblock %} +{% block admin_content %} + +
+ + +

RSVP Acceptance Record

+

{{ aturi }}

+ +
+

JSON Representation

+
{{ accept_json }}
+
+ +
+

Actions

+
+ +
+
+
+{% endblock %} diff --git a/templates/en-us/admin_rsvp_accepts.html b/templates/en-us/admin_rsvp_accepts.html new file mode 100644 index 0000000..85de6e4 --- /dev/null +++ b/templates/en-us/admin_rsvp_accepts.html @@ -0,0 +1,42 @@ +{% extends "en-us/admin_base.html" %} +{% include 'pagination.html' %} +{% block admin_title %}RSVP Accepts{% endblock %} +{% block admin_content %} +
+

RSVP Acceptance Records ({{ total_count }})

+

View all RSVP acceptance records ordered by creation date

+ + + + + + + + + + + + + {% for accept in accepts %} + + + + + + + + {% endfor %} + +
AT-URIDIDCIDCreatedActions
{{ accept.aturi }}{{ accept.did }}{{ accept.cid }}{{ accept.created_at }} + +
+ + {% if pagination %} + {{ view_pagination((canonical_url ~ "?"), pagination) }} + {% endif %} +
+{% endblock %} diff --git a/templates/en-us/admin_rsvps.html b/templates/en-us/admin_rsvps.html index 245de5c..b0db7ae 100644 --- a/templates/en-us/admin_rsvps.html +++ b/templates/en-us/admin_rsvps.html @@ -1,89 +1,85 @@ -{% extends "en-us/base.html" %} +{% extends "en-us/admin_base.html" %} {% include 'pagination.html' %} -{% block title %}RSVPs - Smoke Signal Admin{% endblock %} -{% block head %}{% endblock %} -{% block content %} -
-
- -
-
+{% block admin_title %}RSVPs{% endblock %} +{% block admin_content %} +
+

RSVP Records ({{ total_count }})

+

View all RSVPs ordered by recent updates

+ + {% if import_success %} +
+
+ + + + + RSVP imported successfully! View RSVP + +
+
+ {% endif %} -
-
-
-

RSVP Records ({{ total_count }})

-

View all RSVPs ordered by recent updates

- - {% if import_success %} -
-
- - - - - RSVP imported successfully! View RSVP +
+

Import RSVP by AT-URI

+
+
+ +
+ + +
-
- {% endif %} +

Enter the AT-URI of an RSVP to import from the "community.lexicon.calendar.rsvp" collection

+
+
+
+ +
+
+ +
-
-

Import RSVP

-
-
- -
- -
-

Enter the AT-URI of an RSVP to import from the "community.lexicon.calendar.rsvp" collection

-
-
-
- -
+ + + + + + + + + + + + {% for rsvp in rsvps %} + + + + + + + + {% endfor %} + +
RSVP AT-URIEvent AT-URIStatusUpdatedActions
{{ rsvp.aturi }}{{ rsvp.event_aturi }} + {% if rsvp.status == "attending" %} + {{ rsvp.status }} + {% elif rsvp.status == "interested" %} + {{ rsvp.status }} + {% else %} + {{ rsvp.status }} + {% endif %} + {{ rsvp.updated_at }} + - - - - - - - - - - - - - - - {% for rsvp in rsvps %} - - - - - - - - {% endfor %} - -
RSVPEventStatusUpdatedActions
{{ rsvp.aturi }}{{ rsvp.event_aturi }}{{ rsvp.status }}{{ rsvp.updated_at }} - -
+
- {% if pagination %} - {{ view_pagination((canonical_url ~ "?"), pagination) }} - {% endif %} -
-
-
-{% endblock %} \ No newline at end of file + {% if pagination %} + {{ view_pagination((canonical_url ~ "?"), pagination) }} + {% endif %} +
+{% endblock %}