diff --git a/server/migrations/1_init.sql b/server/migrations/1_init.sql index 739420b0..e17ee91d 100644 --- a/server/migrations/1_init.sql +++ b/server/migrations/1_init.sql @@ -18,3 +18,16 @@ CREATE TABLE IF NOT EXISTS `messages` ( `expires_at` TIMESTAMP NOT NULL, PRIMARY KEY (`id`) ); + +CREATE TABLE IF NOT EXISTS `reactions` ( + `id` BINARY(16) NOT NULL, + `user_id` BINARY(16) NOT NULL, + `position_x` INT UNSIGNED NOT NULL, + `position_y` INT UNSIGNED NOT NULL, + `kind` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `expires_at` TIMESTAMP NOT NULL, + PRIMARY KEY (`id`), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +); diff --git a/server/src/reaction.rs b/server/src/reaction.rs index 8b82a210..1c1755ad 100644 --- a/server/src/reaction.rs +++ b/server/src/reaction.rs @@ -1,10 +1,18 @@ //! `reaction.proto` +pub mod error; +pub mod grpc; +mod r#impl; + +use std::sync::Arc; + use futures::future::BoxFuture; use serde::{Deserialize, Serialize}; use crate::prelude::{IntoStatus, Timestamp}; +pub use error::Error; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(transparent)] pub struct ReactionId(pub uuid::Uuid); @@ -26,6 +34,7 @@ pub struct GetReactionParams { #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] pub struct CreateReactionParams { + pub user_id: crate::user::UserId, pub position: crate::world::Coordinate, pub kind: String, } @@ -73,6 +82,18 @@ pub trait ProvideReactionService: Send + Sync + 'static { let ctx = self.context(); self.reaction_service().create_reaction(ctx, params) } +} - // TODO: build_server(this: Arc) -> ReactionServiceServer<...> +pub fn build_server(this: Arc) -> ReactionServiceServer +where + State: ProvideReactionService + crate::session::ProvideSessionService, +{ + let service = grpc::ServiceImpl::new(this); + ReactionServiceServer::new(service) } + +#[derive(Debug, Clone, Copy, Default)] +pub struct ReactionServiceImpl; + +pub type ReactionServiceServer = + schema::reaction::reaction_service_server::ReactionServiceServer>; diff --git a/server/src/reaction/error.rs b/server/src/reaction/error.rs new file mode 100644 index 00000000..613693ed --- /dev/null +++ b/server/src/reaction/error.rs @@ -0,0 +1,27 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Not found")] + NotFound, + #[error("Database error")] + Sqlx(#[from] sqlx::Error), + #[error(transparent)] + Status(#[from] tonic::Status), +} + +impl From for tonic::Status { + fn from(value: Error) -> Self { + match value { + Error::NotFound => tonic::Status::not_found("Not found"), + Error::Sqlx(e) => { + tracing::error!(error = &e as &dyn std::error::Error); + tonic::Status::internal("Database error") + } + Error::Status(e) => { + tracing::error!(error = &e as &dyn std::error::Error); + tonic::Status::internal("Status error") + } + } + } +} + +pub type Result = std::result::Result; diff --git a/server/src/reaction/grpc.rs b/server/src/reaction/grpc.rs new file mode 100644 index 00000000..7093ed3f --- /dev/null +++ b/server/src/reaction/grpc.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use schema::reaction as schema; + +use crate::prelude::IntoStatus; + +// MARK: type conversions + +impl From for schema::Reaction { + fn from(value: super::Reaction) -> Self { + let super::Reaction { + id, + user_id, + position, + kind, + created_at, + updated_at: _, + } = value; + Self { + id: id.0.to_string(), + user_id: user_id.0.to_string(), + position: Some(position.into()), + kind, + created_at: Some(created_at.into()), + // TODO: duration設定 + expires_at: Some(super::Timestamp(created_at.0 + chrono::Duration::seconds(10)).into()), + } + } +} + +// MARK: ServiceImpl + +pub struct ServiceImpl { + state: Arc, +} + +impl Clone for ServiceImpl +where + State: super::ProvideReactionService, +{ + fn clone(&self) -> Self { + Self { + state: Arc::clone(&self.state), + } + } +} + +impl ServiceImpl +where + State: super::ProvideReactionService + crate::session::ProvideSessionService, +{ + pub(super) fn new(state: Arc) -> Self { + Self { state } + } +} + +#[async_trait::async_trait] +impl schema::reaction_service_server::ReactionService for ServiceImpl +where + State: super::ProvideReactionService + crate::session::ProvideSessionService, +{ + async fn get_reaction( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let (_, _, schema::GetReactionRequest { id }) = request.into_parts(); + let params = super::GetReactionParams { + id: super::ReactionId( + uuid::Uuid::parse_str(&id) + .map_err(|_| tonic::Status::invalid_argument("Invalid UUID"))?, + ), + }; + let reaction = self + .state + .get_reaction(params) + .await + .map_err(IntoStatus::into_status)? + .into(); + let res = schema::GetReactionResponse { + reaction: Some(reaction), + }; + Ok(tonic::Response::new(res)) + } + + async fn create_reaction( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let (meta, _, schema::CreateReactionRequest { kind, position }) = request.into_parts(); + let Some(position) = position else { + return Err(tonic::Status::invalid_argument("Position is required")); + }; + + let header_map = meta.into_headers(); + let user_id = self + .state + .extract(crate::session::ExtractParams(&header_map)) + .await + .map_err(IntoStatus::into_status)? + .user_id; + + let params = super::CreateReactionParams { + user_id, + kind, + position: position.into(), + }; + let reaction = self + .state + .create_reaction(params) + .await + .map_err(IntoStatus::into_status)? + .into(); + let res = schema::CreateReactionResponse { + reaction: Some(reaction), + }; + Ok(tonic::Response::new(res)) + } +} diff --git a/server/src/reaction/impl.rs b/server/src/reaction/impl.rs new file mode 100644 index 00000000..e4077541 --- /dev/null +++ b/server/src/reaction/impl.rs @@ -0,0 +1,125 @@ +use futures::{future, FutureExt}; +use serde::{Deserialize, Serialize}; +use sqlx::MySqlPool; + +impl super::ReactionService for super::ReactionServiceImpl +where + Context: AsRef + crate::event::ProvideEventService, +{ + type Error = super::Error; + + fn get_reaction<'a>( + &'a self, + ctx: &'a Context, + params: super::GetReactionParams, + ) -> future::BoxFuture<'a, Result> { + get_reaction(ctx.as_ref(), params).boxed() + } + + fn create_reaction<'a>( + &'a self, + ctx: &'a Context, + params: super::CreateReactionParams, + ) -> future::BoxFuture<'a, Result> { + create_reaction(ctx, ctx.as_ref(), params).boxed() + } +} + +// MARK: DB operations + +#[derive(Debug, Clone, Hash, Deserialize, Serialize, sqlx::FromRow)] +struct ReactionRow { + pub id: uuid::Uuid, + pub user_id: uuid::Uuid, + pub position_x: u32, + pub position_y: u32, + pub kind: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub expires_at: chrono::DateTime, +} + +impl From for super::Reaction { + fn from(value: ReactionRow) -> Self { + Self { + id: super::ReactionId(value.id), + user_id: crate::user::UserId(value.user_id), + position: crate::world::Coordinate { + x: value.position_x, + y: value.position_y, + }, + kind: value.kind, + created_at: super::Timestamp(value.created_at), + updated_at: super::Timestamp(value.updated_at), + } + } +} + +async fn get_reaction( + pool: &MySqlPool, + params: super::GetReactionParams, +) -> Result { + let super::GetReactionParams { + id: super::ReactionId(id), + } = params; + let reaction: Option = + sqlx::query_as(r#"SELECT * FROM `reactions` WHERE `id` = ?"#) + .bind(id) + .fetch_optional(pool) + .await?; + reaction.map(Into::into).ok_or(super::Error::NotFound) +} + +async fn create_reaction( + event_service: &P, + pool: &MySqlPool, + params: super::CreateReactionParams, +) -> Result { + let super::CreateReactionParams { + user_id, + position, + kind, + } = params; + let reaction = ReactionRow { + id: uuid::Uuid::now_v7(), + user_id: user_id.0, + position_x: position.x, + position_y: position.y, + kind, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + expires_at: chrono::Utc::now() + chrono::Duration::seconds(10), + }; + sqlx::query( + r#" + INSERT INTO `reactions` + (`id`, `user_id`, `position_x`, `position_y`, `kind`, `created_at`, `updated_at`, `expires_at`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(reaction.id) + .bind(reaction.user_id) + .bind(reaction.position_x) + .bind(reaction.position_y) + .bind(reaction.kind) + .bind(reaction.created_at) + .bind(reaction.updated_at) + .bind(reaction.expires_at) + .execute(pool) + .await?; + tracing::info!(id = %reaction.id, "Created a reaction"); + let reaction = get_reaction( + pool, + super::GetReactionParams { + id: super::ReactionId(reaction.id), + }, + ) + .await?; + + event_service + .publish_event(crate::event::Event::Reaction(reaction.clone())) + .await + .map_err(crate::prelude::IntoStatus::into_status)?; + + Ok(reaction) +}