From c9a91d3d36d2438da37a3b7eb6d1e9ac9c3652a5 Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Fri, 17 Oct 2025 20:09:10 -0500 Subject: [PATCH] set user intro --- src/lib/domain/intro_tool/debug_service.rs | 4 +- src/lib/domain/intro_tool/models/guild.rs | 20 ++++- src/lib/domain/intro_tool/ports.rs | 20 ++--- src/lib/domain/intro_tool/service.rs | 4 +- src/lib/inbound/http.rs | 8 +- src/lib/inbound/http/handlers.rs | 91 +++++++++++++++++++++- src/lib/inbound/response.rs | 53 ++++++++++++- src/lib/outbound/sqlite.rs | 35 ++++++++- src/main.rs | 2 + 9 files changed, 210 insertions(+), 27 deletions(-) diff --git a/src/lib/domain/intro_tool/debug_service.rs b/src/lib/domain/intro_tool/debug_service.rs index 26f9197..797e627 100644 --- a/src/lib/domain/intro_tool/debug_service.rs +++ b/src/lib/domain/intro_tool/debug_service.rs @@ -116,10 +116,10 @@ where self.wrapped_service.add_intro_to_guild(req).await } - async fn add_intro_to_user( + async fn set_user_intro( &self, req: models::guild::AddIntroToUserRequest, ) -> Result<(), models::guild::AddIntroToUserError> { - self.wrapped_service.add_intro_to_user(req).await + self.wrapped_service.set_user_intro(req).await } } diff --git a/src/lib/domain/intro_tool/models/guild.rs b/src/lib/domain/intro_tool/models/guild.rs index 2abd84c..4278dcd 100644 --- a/src/lib/domain/intro_tool/models/guild.rs +++ b/src/lib/domain/intro_tool/models/guild.rs @@ -66,6 +66,18 @@ impl std::fmt::Display for GuildId { } } +impl std::fmt::Display for UserName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::fmt::Display for ChannelName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + impl std::fmt::Display for IntroId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -271,10 +283,10 @@ pub enum IntroRequestData { } pub struct AddIntroToUserRequest { - user: UserName, - guild_id: GuildId, - channel_name: ChannelName, - intro_id: IntroId, + pub user: UserName, + pub guild_id: GuildId, + pub channel_name: ChannelName, + pub intro_id: IntroId, } #[derive(Debug, Error)] diff --git a/src/lib/domain/intro_tool/ports.rs b/src/lib/domain/intro_tool/ports.rs index 98c29b7..5879782 100644 --- a/src/lib/domain/intro_tool/ports.rs +++ b/src/lib/domain/intro_tool/ports.rs @@ -37,6 +37,11 @@ pub trait IntroToolService: Send + Sync + Clone + 'static { api_key: &str, ) -> impl Future> + Send; + fn set_user_intro( + &self, + req: AddIntroToUserRequest, + ) -> impl Future> + Send; + async fn create_guild(&self, req: CreateGuildRequest) -> Result; async fn create_user(&self, req: CreateUserRequest) -> Result; async fn create_channel( @@ -48,11 +53,6 @@ pub trait IntroToolService: Send + Sync + Clone + 'static { &self, req: AddIntroToGuildRequest, ) -> impl Future> + Send; - - async fn add_intro_to_user( - &self, - req: AddIntroToUserRequest, - ) -> Result<(), AddIntroToUserError>; } pub trait IntroToolRepository: Send + Sync + Clone + 'static { @@ -97,6 +97,11 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static { api_key: &str, ) -> impl Future> + Send; + fn set_user_intro( + &self, + req: AddIntroToUserRequest, + ) -> impl Future> + Send; + async fn create_guild(&self, req: CreateGuildRequest) -> Result; async fn create_user(&self, req: CreateUserRequest) -> Result; async fn create_channel( @@ -110,11 +115,6 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static { guild_id: GuildId, filename: String, ) -> impl Future> + Send; - - async fn add_intro_to_user( - &self, - req: AddIntroToUserRequest, - ) -> Result<(), AddIntroToUserError>; } pub trait RemoteAudioFetcher: Send + Sync + Clone + 'static { diff --git a/src/lib/domain/intro_tool/service.rs b/src/lib/domain/intro_tool/service.rs index ee36e63..98e3f01 100644 --- a/src/lib/domain/intro_tool/service.rs +++ b/src/lib/domain/intro_tool/service.rs @@ -124,10 +124,10 @@ where .await } - async fn add_intro_to_user( + async fn set_user_intro( &self, req: guild::AddIntroToUserRequest, ) -> Result<(), guild::AddIntroToUserError> { - self.repo.add_intro_to_user(req).await + self.repo.set_user_intro(req).await } } diff --git a/src/lib/inbound/http.rs b/src/lib/inbound/http.rs index 972d6c6..1c63c37 100644 --- a/src/lib/inbound/http.rs +++ b/src/lib/inbound/http.rs @@ -121,6 +121,10 @@ where "/v2/intros/:guild/upload", post(handlers::upload_guild_intro), ) + .route( + "/v2/intros/add/:guild_id/:channel", + post(handlers::set_user_intro), + ) // .route("/guild/:guild_id/setup", get(routes::guild_setup)) // .route( @@ -133,10 +137,6 @@ where // ) // .route("/v2/auth", get(routes::v2_auth)) // .route( - // "/v2/intros/add/:guild_id/:channel", - // post(routes::v2_add_intro_to_user), - // ) - // .route( // "/v2/intros/remove/:guild_id/:channel", // post(routes::v2_remove_intro_from_user), // ) diff --git a/src/lib/inbound/http/handlers.rs b/src/lib/inbound/http/handlers.rs index 7ea0e17..459805f 100644 --- a/src/lib/inbound/http/handlers.rs +++ b/src/lib/inbound/http/handlers.rs @@ -3,15 +3,20 @@ use std::collections::HashMap; use axum::{ extract::{Multipart, Path, Query, State}, http::{HeaderMap, HeaderValue}, + response::Html, }; use crate::{ domain::intro_tool::{ - models::guild::{AddIntroToGuildRequest, GuildId, IntroRequestData, User}, + models::guild::{ + AddIntroToGuildRequest, AddIntroToUserRequest, ChannelName, GuildId, IntroRequestData, + User, UserName, + }, ports::IntroToolService, }, + htmx::Build, inbound::{ - http::ApiState, + http::{page, ApiState}, response::{ApiError, ErrorAsRedirect}, }, }; @@ -102,6 +107,31 @@ impl FromApi for AddIntroToGuildRequest { } } +impl FromApi for AddIntroToUserRequest { + async fn from_api( + mut value: Multipart, + (guild_id, user, channel_name): (GuildId, UserName, ChannelName), + ) -> Result { + let intro_id = value + .next_field() + .await + .map_err(|err| ApiError::bad_request(format!("expected intro id: {err:?}")))? + .ok_or(ApiError::bad_request("intro id is required"))? + .name() + .ok_or(ApiError::bad_request("intro id is required"))? + .parse::() + .map_err(|err| ApiError::bad_request(format!("invalid intro id: {err:?}")))? + .into(); + + Ok(Self { + user, + guild_id, + channel_name, + intro_id, + }) + } +} + pub(super) async fn add_guild_intro( State(state): State>, Path(guild_id): Path, @@ -165,3 +195,60 @@ pub(super) async fn upload_guild_intro( Ok(headers) } + +pub(super) async fn set_user_intro( + State(state): State>, + Path((guild_id, channel)): Path<(u64, String)>, + user: User, + form_data: Multipart, +) -> Result, ApiError> { + let req = form_data + .into_domain(( + guild_id.into(), + user.name().to_string().into(), + channel.clone().into(), + )) + .await?; + + let guild = state.intro_tool_service.get_guild(guild_id).await?; + let user_guilds = state + .intro_tool_service + .get_user_guilds(user.name()) + .await?; + + // does user have access to this guild + if !user_guilds + .iter() + .any(|guild_ref| guild_ref.id() == guild.id()) + { + return Err(ApiError::forbidden( + "You do not have access to this guild".to_string(), + )); + } + + // TODO: check if channel exists + + state.intro_tool_service.set_user_intro(req).await?; + let user = state.intro_tool_service.get_user(user.name()).await?; + + let guild_intros = state + .intro_tool_service + .get_guild_intros(guild_id.into()) + .await?; + let intros = user + .intros() + .get(&(guild.id(), channel.clone().into())) + .map(|intros| intros.iter()) + .unwrap_or_default(); + + Ok(Html( + page::channel_intro_selector( + &state.origin, + guild_id, + &channel.into(), + intros, + guild_intros.iter(), + ) + .build(), + )) +} diff --git a/src/lib/inbound/response.rs b/src/lib/inbound/response.rs index d035c1e..2f06088 100644 --- a/src/lib/inbound/response.rs +++ b/src/lib/inbound/response.rs @@ -8,7 +8,8 @@ use reqwest::StatusCode; use serde::Serialize; use crate::domain::intro_tool::models::guild::{ - AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError, + AddIntroToGuildError, AddIntroToUserError, GetChannelError, GetGuildError, GetIntroError, + GetUserError, }; pub(super) trait ErrorAsRedirect: Sized { @@ -126,6 +127,8 @@ impl ApiError { impl IntoResponse for ApiError { fn into_response(self) -> axum::response::Response { + tracing::error!(err = ?self, "error"); + (self.status_code(), Json(self)).into_response() } } @@ -153,6 +156,29 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(value: GetUserError) -> Self { + match value { + GetUserError::NotFound => Self::not_found("User not found"), + GetUserError::CouldNotFetchGuilds(get_guild_error) => { + tracing::error!(err = ?get_guild_error, "could not fetch guilds from user"); + + Self::internal("Could not fetch guilds from user".to_string()) + } + GetUserError::CouldNotFetchChannelIntros(get_channel_intro_error) => { + tracing::error!(err = ?get_channel_intro_error, "could not fetch channel intros from user"); + + Self::internal("Could not fetch channel intros from user".to_string()) + } + GetUserError::Unknown(error) => { + tracing::error!(err = ?error, "unknown error"); + + Self::internal(error.to_string()) + } + } + } +} + impl From for ApiError { fn from(value: AddIntroToGuildError) -> Self { match value { @@ -164,3 +190,28 @@ impl From for ApiError { } } } + +impl From for ApiError { + fn from(value: AddIntroToUserError) -> Self { + match value { + AddIntroToUserError::Unknown(error) => { + tracing::error!(err = ?error, "unknown error"); + + Self::internal(error.to_string()) + } + } + } +} + +impl From for ApiError { + fn from(value: GetIntroError) -> Self { + match value { + GetIntroError::NotFound => Self::not_found("Intro not found"), + GetIntroError::Unknown(error) => { + tracing::error!(err = ?error, "unknown error"); + + Self::internal(error.to_string()) + } + } + } +} diff --git a/src/lib/outbound/sqlite.rs b/src/lib/outbound/sqlite.rs index 35c79ee..4c0bc75 100644 --- a/src/lib/outbound/sqlite.rs +++ b/src/lib/outbound/sqlite.rs @@ -390,10 +390,41 @@ impl IntroToolRepository for Sqlite { Ok(intro_id) } - async fn add_intro_to_user( + async fn set_user_intro( &self, req: AddIntroToUserRequest, ) -> Result<(), guild::AddIntroToUserError> { - todo!() + let conn = self.conn.lock().await; + + conn.execute( + " + DELETE FROM UserIntro + WHERE username = ?1 + AND guild_id = ?2 + AND channel_name = ?3 + ", + [ + &req.user.to_string(), + &req.guild_id.to_string(), + &req.channel_name.to_string(), + ], + ) + .context("failed to delete user intros")?; + + conn.execute( + " + INSERT INTO + UserIntro (username, guild_id, channel_name, intro_id) + VALUES (?1, ?2, ?3, ?4)", + [ + &req.user.to_string(), + &req.guild_id.to_string(), + &req.channel_name.to_string(), + &req.intro_id.to_string(), + ], + ) + .context("failed to insert user intro")?; + + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 97c13d1..7618f8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -238,6 +238,8 @@ async fn main() -> std::io::Result<()> { dotenv::dotenv().ok(); tracing_subscriber::fmt::init(); + tracing::info!("tracing initialized"); + let secrets = auth::DiscordSecret { client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"), client_secret: env::var("DISCORD_CLIENT_SECRET")