set user intro

hexagon
Patrick Cleavelin 2025-10-17 20:09:10 -05:00
parent a1b3bbb999
commit c9a91d3d36
9 changed files with 210 additions and 27 deletions

View File

@ -116,10 +116,10 @@ where
self.wrapped_service.add_intro_to_guild(req).await self.wrapped_service.add_intro_to_guild(req).await
} }
async fn add_intro_to_user( async fn set_user_intro(
&self, &self,
req: models::guild::AddIntroToUserRequest, req: models::guild::AddIntroToUserRequest,
) -> Result<(), models::guild::AddIntroToUserError> { ) -> Result<(), models::guild::AddIntroToUserError> {
self.wrapped_service.add_intro_to_user(req).await self.wrapped_service.set_user_intro(req).await
} }
} }

View File

@ -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 { impl std::fmt::Display for IntroId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0) write!(f, "{}", self.0)
@ -271,10 +283,10 @@ pub enum IntroRequestData {
} }
pub struct AddIntroToUserRequest { pub struct AddIntroToUserRequest {
user: UserName, pub user: UserName,
guild_id: GuildId, pub guild_id: GuildId,
channel_name: ChannelName, pub channel_name: ChannelName,
intro_id: IntroId, pub intro_id: IntroId,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]

View File

@ -37,6 +37,11 @@ pub trait IntroToolService: Send + Sync + Clone + 'static {
api_key: &str, api_key: &str,
) -> impl Future<Output = Result<User, GetUserError>> + Send; ) -> impl Future<Output = Result<User, GetUserError>> + Send;
fn set_user_intro(
&self,
req: AddIntroToUserRequest,
) -> impl Future<Output = Result<(), AddIntroToUserError>> + Send;
async fn create_guild(&self, req: CreateGuildRequest) -> Result<Guild, CreateGuildError>; async fn create_guild(&self, req: CreateGuildRequest) -> Result<Guild, CreateGuildError>;
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>; async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>;
async fn create_channel( async fn create_channel(
@ -48,11 +53,6 @@ pub trait IntroToolService: Send + Sync + Clone + 'static {
&self, &self,
req: AddIntroToGuildRequest, req: AddIntroToGuildRequest,
) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send; ) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send;
async fn add_intro_to_user(
&self,
req: AddIntroToUserRequest,
) -> Result<(), AddIntroToUserError>;
} }
pub trait IntroToolRepository: Send + Sync + Clone + 'static { pub trait IntroToolRepository: Send + Sync + Clone + 'static {
@ -97,6 +97,11 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
api_key: &str, api_key: &str,
) -> impl Future<Output = Result<User, GetUserError>> + Send; ) -> impl Future<Output = Result<User, GetUserError>> + Send;
fn set_user_intro(
&self,
req: AddIntroToUserRequest,
) -> impl Future<Output = Result<(), AddIntroToUserError>> + Send;
async fn create_guild(&self, req: CreateGuildRequest) -> Result<Guild, CreateGuildError>; async fn create_guild(&self, req: CreateGuildRequest) -> Result<Guild, CreateGuildError>;
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>; async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>;
async fn create_channel( async fn create_channel(
@ -110,11 +115,6 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
guild_id: GuildId, guild_id: GuildId,
filename: String, filename: String,
) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send; ) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send;
async fn add_intro_to_user(
&self,
req: AddIntroToUserRequest,
) -> Result<(), AddIntroToUserError>;
} }
pub trait RemoteAudioFetcher: Send + Sync + Clone + 'static { pub trait RemoteAudioFetcher: Send + Sync + Clone + 'static {

View File

@ -124,10 +124,10 @@ where
.await .await
} }
async fn add_intro_to_user( async fn set_user_intro(
&self, &self,
req: guild::AddIntroToUserRequest, req: guild::AddIntroToUserRequest,
) -> Result<(), guild::AddIntroToUserError> { ) -> Result<(), guild::AddIntroToUserError> {
self.repo.add_intro_to_user(req).await self.repo.set_user_intro(req).await
} }
} }

View File

@ -121,6 +121,10 @@ where
"/v2/intros/:guild/upload", "/v2/intros/:guild/upload",
post(handlers::upload_guild_intro), 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("/guild/:guild_id/setup", get(routes::guild_setup))
// .route( // .route(
@ -133,10 +137,6 @@ where
// ) // )
// .route("/v2/auth", get(routes::v2_auth)) // .route("/v2/auth", get(routes::v2_auth))
// .route( // .route(
// "/v2/intros/add/:guild_id/:channel",
// post(routes::v2_add_intro_to_user),
// )
// .route(
// "/v2/intros/remove/:guild_id/:channel", // "/v2/intros/remove/:guild_id/:channel",
// post(routes::v2_remove_intro_from_user), // post(routes::v2_remove_intro_from_user),
// ) // )

View File

@ -3,15 +3,20 @@ use std::collections::HashMap;
use axum::{ use axum::{
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::{HeaderMap, HeaderValue}, http::{HeaderMap, HeaderValue},
response::Html,
}; };
use crate::{ use crate::{
domain::intro_tool::{ domain::intro_tool::{
models::guild::{AddIntroToGuildRequest, GuildId, IntroRequestData, User}, models::guild::{
AddIntroToGuildRequest, AddIntroToUserRequest, ChannelName, GuildId, IntroRequestData,
User, UserName,
},
ports::IntroToolService, ports::IntroToolService,
}, },
htmx::Build,
inbound::{ inbound::{
http::ApiState, http::{page, ApiState},
response::{ApiError, ErrorAsRedirect}, response::{ApiError, ErrorAsRedirect},
}, },
}; };
@ -102,6 +107,31 @@ impl FromApi<Multipart, GuildId> for AddIntroToGuildRequest {
} }
} }
impl FromApi<Multipart, (GuildId, UserName, ChannelName)> for AddIntroToUserRequest {
async fn from_api(
mut value: Multipart,
(guild_id, user, channel_name): (GuildId, UserName, ChannelName),
) -> Result<Self, ApiError> {
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::<i32>()
.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<S: IntroToolService>( pub(super) async fn add_guild_intro<S: IntroToolService>(
State(state): State<ApiState<S>>, State(state): State<ApiState<S>>,
Path(guild_id): Path<u64>, Path(guild_id): Path<u64>,
@ -165,3 +195,60 @@ pub(super) async fn upload_guild_intro<S: IntroToolService>(
Ok(headers) Ok(headers)
} }
pub(super) async fn set_user_intro<S: IntroToolService>(
State(state): State<ApiState<S>>,
Path((guild_id, channel)): Path<(u64, String)>,
user: User,
form_data: Multipart,
) -> Result<Html<String>, 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(),
))
}

View File

@ -8,7 +8,8 @@ use reqwest::StatusCode;
use serde::Serialize; use serde::Serialize;
use crate::domain::intro_tool::models::guild::{ use crate::domain::intro_tool::models::guild::{
AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError, AddIntroToGuildError, AddIntroToUserError, GetChannelError, GetGuildError, GetIntroError,
GetUserError,
}; };
pub(super) trait ErrorAsRedirect<T>: Sized { pub(super) trait ErrorAsRedirect<T>: Sized {
@ -126,6 +127,8 @@ impl ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
tracing::error!(err = ?self, "error");
(self.status_code(), Json(self)).into_response() (self.status_code(), Json(self)).into_response()
} }
} }
@ -153,6 +156,29 @@ impl From<GetGuildError> for ApiError {
} }
} }
impl From<GetUserError> 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<AddIntroToGuildError> for ApiError { impl From<AddIntroToGuildError> for ApiError {
fn from(value: AddIntroToGuildError) -> Self { fn from(value: AddIntroToGuildError) -> Self {
match value { match value {
@ -164,3 +190,28 @@ impl From<AddIntroToGuildError> for ApiError {
} }
} }
} }
impl From<AddIntroToUserError> 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<GetIntroError> 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())
}
}
}
}

View File

@ -390,10 +390,41 @@ impl IntroToolRepository for Sqlite {
Ok(intro_id) Ok(intro_id)
} }
async fn add_intro_to_user( async fn set_user_intro(
&self, &self,
req: AddIntroToUserRequest, req: AddIntroToUserRequest,
) -> Result<(), guild::AddIntroToUserError> { ) -> 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(())
} }
} }

View File

@ -238,6 +238,8 @@ async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok(); dotenv::dotenv().ok();
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
tracing::info!("tracing initialized");
let secrets = auth::DiscordSecret { let secrets = auth::DiscordSecret {
client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"), client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"),
client_secret: env::var("DISCORD_CLIENT_SECRET") client_secret: env::var("DISCORD_CLIENT_SECRET")