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
}
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
}
}

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 {
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)]

View File

@ -37,6 +37,11 @@ pub trait IntroToolService: Send + Sync + Clone + 'static {
api_key: &str,
) -> 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_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>;
async fn create_channel(
@ -48,11 +53,6 @@ pub trait IntroToolService: Send + Sync + Clone + 'static {
&self,
req: AddIntroToGuildRequest,
) -> 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 {
@ -97,6 +97,11 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
api_key: &str,
) -> 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_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>;
async fn create_channel(
@ -110,11 +115,6 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
guild_id: GuildId,
filename: String,
) -> 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 {

View File

@ -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
}
}

View File

@ -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),
// )

View File

@ -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<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>(
State(state): State<ApiState<S>>,
Path(guild_id): Path<u64>,
@ -165,3 +195,60 @@ pub(super) async fn upload_guild_intro<S: IntroToolService>(
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 crate::domain::intro_tool::models::guild::{
AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError,
AddIntroToGuildError, AddIntroToUserError, GetChannelError, GetGuildError, GetIntroError,
GetUserError,
};
pub(super) trait ErrorAsRedirect<T>: 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<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 {
fn from(value: AddIntroToGuildError) -> Self {
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)
}
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(())
}
}

View File

@ -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")