From e864d2653d50ba1c920776aaa14a1625c9fc9da4 Mon Sep 17 00:00:00 2001 From: Tolmachev Igor Date: Wed, 22 Oct 2025 17:00:28 +0300 Subject: Add invite tokens --- Cargo.toml | 2 +- entity/src/invite_tokens.rs | 2 +- migration/src/m0_init_tables.rs | 4 +- src/api.rs | 4 +- src/error/client.rs | 8 ++ src/main.rs | 3 +- src/models.rs | 29 ++++- src/routers/account.rs | 30 ++--- src/routers/queue/access.rs | 257 +++++++++++++++++++++++++++++++++++++++- src/routers/queue/manage.rs | 78 ++++++------ src/routers/queue/mod.rs | 2 +- src/util.rs | 51 ++++++++ 12 files changed, 405 insertions(+), 65 deletions(-) create mode 100644 src/util.rs diff --git a/Cargo.toml b/Cargo.toml index 505232c..7232c2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ tower = "0.5.2" tower-http = { version = "0.6.6", features = ["trace"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "preserve_order", "preserve_path_order"] } +utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "preserve_order", "preserve_path_order", "uuid"] } utoipa-axum = "0.2.0" utoipa-scalar = { version = "0.3.0", features = ["axum"] } uuid = { version = "1.18.1", features = ["v4", "serde"] } diff --git a/entity/src/invite_tokens.rs b/entity/src/invite_tokens.rs index c0b59f7..51f09ff 100644 --- a/entity/src/invite_tokens.rs +++ b/entity/src/invite_tokens.rs @@ -10,7 +10,7 @@ pub struct Model { pub token: Uuid, pub queue_id: i64, pub name: String, - pub is_revoked: bool, + pub expiration_date: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/migration/src/m0_init_tables.rs b/migration/src/m0_init_tables.rs index 5e9a621..6ce86e1 100644 --- a/migration/src/m0_init_tables.rs +++ b/migration/src/m0_init_tables.rs @@ -30,7 +30,7 @@ enum InviteTokens { Token, QueueId, Name, - IsRevoked, + ExpirationDate, } #[derive(DeriveIden)] @@ -121,7 +121,7 @@ impl MigrationTrait for Migration { .on_update(ForeignKeyAction::Cascade), ) .col(string(InviteTokens::Name)) - .col(boolean(InviteTokens::IsRevoked).default(false)) + .col(timestamp_null(InviteTokens::ExpirationDate)) .to_owned(), ) .await?; diff --git a/src/api.rs b/src/api.rs index eb77237..dd50b77 100644 --- a/src/api.rs +++ b/src/api.rs @@ -8,7 +8,7 @@ use crate::ErrorResponse; pub mod tags { pub const ACCOUNT: &str = "Account"; pub const QUEUE: &str = "Queue"; - pub const QUEUE_ACCESS: &str = "Queue access"; + pub const INVITE_TOKEN: &str = "Invite token"; } struct AuthModifier; @@ -53,7 +53,7 @@ impl Modify for AuthModifier { tags( (name=tags::ACCOUNT, description="Account management methods"), (name=tags::QUEUE, description="Queue management methods"), - (name=tags::QUEUE_ACCESS, description="Queue access management methods") + (name=tags::INVITE_TOKEN, description="Invite token management methods") ), components( schemas(ErrorResponse) diff --git a/src/error/client.rs b/src/error/client.rs index 9b2c89b..b678d66 100644 --- a/src/error/client.rs +++ b/src/error/client.rs @@ -10,6 +10,8 @@ pub enum ClientError { UserNotFound { id: i64 }, NotQueueOwner { id: i64 }, QueueNotFound { id: i64 }, + NoInviteTokenAccess { id: i64 }, + InviteTokenNotFound { id: i64 }, } impl ClientError { @@ -26,6 +28,8 @@ impl ClientError { Self::UserNotFound { .. } => "UserNotFound", Self::NotQueueOwner { .. } => "NotQueueOwner", Self::QueueNotFound { .. } => "QueueNotFound", + Self::NoInviteTokenAccess { .. } => "NoInviteTokenAccess", + Self::InviteTokenNotFound { .. } => "InviteTokenNotFound", } .to_string() } @@ -50,6 +54,10 @@ impl ClientError { format!("you are not the owner of the queue with id `{}`", id) } Self::QueueNotFound { id } => format!("queue with id `{}` not found", id), + Self::NoInviteTokenAccess { id } => { + format!("you don't have access to the invite token with id `{}`", id) + } + Self::InviteTokenNotFound { id } => format!("invite token with id `{}` not found", id), } } } diff --git a/src/main.rs b/src/main.rs index 4541646..7ed0fdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,11 @@ mod api; mod auth; mod error; mod extract; -pub mod models; +mod models; mod response; mod routers; mod state; +mod util; pub use api::{AppOpenApi, tags}; pub use auth::{JwtClaims, create_jwt, create_password, validate_jwt, validate_password}; diff --git a/src/models.rs b/src/models.rs index 4821ec3..e815b73 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,8 @@ -use entity::{queues, users}; +use chrono::NaiveDateTime; +use entity::{invite_tokens, queues, users}; use serde::Serialize; use utoipa::ToSchema; +use uuid::Uuid; #[derive(Serialize, ToSchema)] #[schema(description = "Account information")] @@ -42,3 +44,28 @@ impl From for Queue { } } } + +#[derive(Serialize, ToSchema)] +pub struct InviteToken { + #[schema(examples(1))] + pub id: i64, + pub token: Uuid, + #[schema(examples(1))] + pub queue_id: i64, + #[schema(examples("For classmates", "Для однокурсников"))] + pub name: String, + #[schema(examples(false))] + pub expiration_date: Option, +} + +impl From for InviteToken { + fn from(value: invite_tokens::Model) -> Self { + Self { + id: value.id, + token: value.token, + queue_id: value.queue_id, + name: value.name, + expiration_date: value.expiration_date, + } + } +} diff --git a/src/routers/account.rs b/src/routers/account.rs index 51ce911..e39327a 100644 --- a/src/routers/account.rs +++ b/src/routers/account.rs @@ -2,30 +2,23 @@ use axum::extract::State; use chrono::{DateTime, Duration, Utc}; use entity::users::{self}; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, - IntoActiveModel, ModelTrait, QueryFilter, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, + QueryFilter, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::{ - ApiError, ApiResult, AppState, ClientError, GlobalResponses, JwtClaims, ServerError, - SuccessResponse, create_jwt, create_password, + ApiResult, AppState, ClientError, GlobalResponses, JwtClaims, ServerError, SuccessResponse, + create_jwt, create_password, extract::{ApiJson, Auth}, models::Account, tags::ACCOUNT, + util::username_exists, validate_password, }; -async fn username_exists(username: &str, db: &DatabaseConnection) -> Result { - Ok(users::Entity::find() - .filter(users::Column::Username.eq(username)) - .one(db) - .await? - .is_some()) -} - #[derive(Serialize, ToSchema)] #[schema(description = "Authorization token information")] struct Token { @@ -205,7 +198,7 @@ async fn login( } #[utoipa::path( - put, + patch, path = "/update/password", tag = ACCOUNT, summary = "Change password", @@ -214,7 +207,7 @@ async fn login( responses( ( status = 200, body = SuccessResponse, - description = "Success response with the updated account data" + description = "Success response with the changed account data" ), GlobalResponses ), @@ -238,7 +231,7 @@ async fn update_password( } #[utoipa::path( - put, + patch, path = "/update/username", tag = ACCOUNT, summary = "Change username", @@ -247,7 +240,7 @@ async fn update_password( responses( ( status = 200, body = SuccessResponse, - description = "Success response with the updated account data" + description = "Success response with the changed account data" ), GlobalResponses ), @@ -273,7 +266,7 @@ async fn update_username( } #[utoipa::path( - put, + patch, path = "/update/name", tag = ACCOUNT, summary = "Change name", @@ -282,7 +275,7 @@ async fn update_username( responses( ( status = 200, body = SuccessResponse, - description = "Success response with the updated account data" + description = "Success response with the changed account data" ), GlobalResponses ), @@ -332,7 +325,6 @@ async fn delete( } user.clone().delete(&state.db).await?; - Ok(SuccessResponse::ok(user.into())) } diff --git a/src/routers/queue/access.rs b/src/routers/queue/access.rs index 1cba0b1..1aee8da 100644 --- a/src/routers/queue/access.rs +++ b/src/routers/queue/access.rs @@ -1,7 +1,260 @@ -use utoipa_axum::router::OpenApiRouter; +use axum::extract::State; +use chrono::{DateTime, Utc}; +use entity::invite_tokens; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, IntoActiveModel, ModelTrait}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; +use utoipa_axum::{router::OpenApiRouter, routes}; -use crate::AppState; +use crate::{ + ApiResult, AppState, GlobalResponses, SuccessResponse, + extract::{ApiJson, ApiQuery, Auth}, + models::InviteToken, + tags::INVITE_TOKEN, + util::{get_owned_invite_token, get_owned_queue}, +}; + +#[derive(Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +struct GetInviteTokenByIdQuery { + #[param(example = 1)] + id: i64, +} + +#[derive(Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +struct GetInviteTokenByQueueIdQuery { + #[param(example = 1)] + queue_id: i64, +} + +#[derive(Deserialize, ToSchema)] +#[schema(description = "Body of the create invite token request")] +struct CreateInviteTokenRequest { + #[schema(examples(1))] + queue_id: i64, + #[schema(examples("For classmates", "Для однокурсников"))] + name: String, + expiration_date: Option>, +} + +#[derive(Deserialize, ToSchema)] +#[schema(description = "Body of the expire invite token request")] +struct ExpireInviteTokenRequest { + #[schema(examples(1))] + id: i64, +} + +#[derive(Deserialize, ToSchema)] +#[schema(description = "Body of the change invite token expiration date request")] +struct ChangeInviteTokenExpirationDateRequest { + #[schema(examples(1))] + id: i64, + #[schema(examples("2000-01-01 00:00:00Z", "2000-01-01 03:00:00+03:00", json!(null)))] + expiration_date: Option>, +} + +#[derive(Deserialize, ToSchema)] +#[schema(description = "Body of the delete invite token request")] +struct DeleteInviteTokenRequest { + #[schema(examples(1))] + id: i64, +} + +#[utoipa::path( + get, + path = "/get/by_id", + tag = INVITE_TOKEN, + summary = "Get by id", + description = "Get the invite token by id", + params(GetInviteTokenByIdQuery), + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with the requested invite token" + ), + GlobalResponses + ), + security(("auth" = [])), +)] +async fn get_by_id( + State(state): State, + Auth(user): Auth, + ApiQuery(req): ApiQuery, +) -> ApiResult { + Ok(SuccessResponse::ok( + get_owned_invite_token(req.id, user.id, &state.db) + .await? + .into(), + )) +} + +#[utoipa::path( + get, + path = "/get/by_queue_id", + tag = INVITE_TOKEN, + summary = "Get by queue id", + description = "Get the invite token by the queue id", + params(GetInviteTokenByQueueIdQuery), + responses( + ( + status = 200, body = SuccessResponse>, + description = "Success response with the requested invite tokens" + ), + GlobalResponses + ), + security(("auth" = [])), +)] +async fn get_by_queue_id( + State(state): State, + Auth(user): Auth, + ApiQuery(req): ApiQuery, +) -> ApiResult> { + let queue = get_owned_queue(req.queue_id, user.id, &state.db).await?; + + Ok(SuccessResponse::ok( + queue + .find_related(invite_tokens::Entity) + .all(&state.db) + .await? + .into_iter() + .map(Into::into) + .collect(), + )) +} + +#[utoipa::path( + post, + path = "/create", + tag = INVITE_TOKEN, + summary = "Create", + description = "Create a new invite token", + request_body = CreateInviteTokenRequest, + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with the created invite token" + ), + GlobalResponses + ), + security(("auth" = [])), +)] +async fn create( + State(state): State, + Auth(user): Auth, + ApiJson(req): ApiJson, +) -> ApiResult { + let queue = get_owned_queue(req.queue_id, user.id, &state.db).await?; + + Ok(SuccessResponse::ok( + invite_tokens::ActiveModel { + token: Set(uuid::Uuid::new_v4()), + queue_id: Set(queue.id), + name: Set(req.name), + expiration_date: Set(req.expiration_date.as_ref().map(DateTime::naive_utc)), + ..Default::default() + } + .insert(&state.db) + .await? + .into(), + )) +} + +#[utoipa::path( + patch, + path = "/expire", + tag = INVITE_TOKEN, + summary = "Expire", + description = "Expire the invite token", + request_body = ExpireInviteTokenRequest, + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with the changed invite token data" + ), + GlobalResponses + ), + security(("auth" = [])), +)] +async fn expire( + State(state): State, + Auth(user): Auth, + ApiJson(req): ApiJson, +) -> ApiResult { + let mut active_invite_token = get_owned_invite_token(req.id, user.id, &state.db) + .await? + .into_active_model(); + + active_invite_token.expiration_date = Set(Some(Utc::now().naive_utc())); + + let invite_token = active_invite_token.update(&state.db).await?; + Ok(SuccessResponse::ok(invite_token.into())) +} + +#[utoipa::path( + patch, + path = "/update/expiration_date", + tag = INVITE_TOKEN, + summary = "Change expiration date", + description = "Change invite token expiration date", + request_body = ChangeInviteTokenExpirationDateRequest, + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with the changed invite token data" + ), + GlobalResponses + ), + security(("auth" = [])), +)] +async fn update_expiration_date( + State(state): State, + Auth(user): Auth, + ApiJson(req): ApiJson, +) -> ApiResult { + let mut active_invite_token = get_owned_invite_token(req.id, user.id, &state.db) + .await? + .into_active_model(); + + active_invite_token.expiration_date = + Set(req.expiration_date.as_ref().map(DateTime::naive_utc)); + + let invite_token = active_invite_token.update(&state.db).await?; + Ok(SuccessResponse::ok(invite_token.into())) +} + +#[utoipa::path( + delete, + path = "/delete", + tag = INVITE_TOKEN, + summary = "Delete", + description = "Delete the invite token", + request_body = DeleteInviteTokenRequest, + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with the deleted invite token data" + ), + GlobalResponses + ), + security(("auth" = [])), +)] +async fn delete( + State(state): State, + Auth(user): Auth, + ApiJson(req): ApiJson, +) -> ApiResult { + let invite_token = get_owned_invite_token(req.id, user.id, &state.db).await?; + invite_token.clone().delete(&state.db).await?; + Ok(SuccessResponse::ok(invite_token.into())) +} pub fn router() -> OpenApiRouter { OpenApiRouter::new() + .routes(routes!(get_by_id)) + .routes(routes!(get_by_queue_id)) + .routes(routes!(create)) + .routes(routes!(expire)) + .routes(routes!(update_expiration_date)) + .routes(routes!(delete)) } diff --git a/src/routers/queue/manage.rs b/src/routers/queue/manage.rs index 8f42e07..b799458 100644 --- a/src/routers/queue/manage.rs +++ b/src/routers/queue/manage.rs @@ -1,50 +1,32 @@ use axum::extract::State; -use entity::{queues, users}; +use entity::queues; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, - IntoActiveModel, ModelTrait, QueryFilter, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, + QueryFilter, }; use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::{ - ApiError, ApiResult, AppState, ClientError, GlobalResponses, SuccessResponse, + ApiResult, AppState, ClientError, GlobalResponses, SuccessResponse, extract::{ApiJson, ApiQuery, Auth}, models::Queue, tags::QUEUE, + util::{get_owned_queue, user_exists}, }; -async fn user_exists(id: i64, db: &DatabaseConnection) -> Result { - Ok(users::Entity::find_by_id(id).one(db).await?.is_some()) -} - -async fn get_owned_queue( - id: i64, - owner_id: i64, - db: &DatabaseConnection, -) -> Result { - let queue = queues::Entity::find_by_id(id) - .one(db) - .await? - .ok_or(ClientError::QueueNotFound { id })?; - - if queue.owner_id != owner_id { - return Err(ClientError::NotQueueOwner { id: queue.id }.into()); - } - - Ok(queue) -} - #[derive(Deserialize, IntoParams)] #[into_params(parameter_in = Query)] -struct GetByIdQueueQuery { +struct GetQueueByIdQuery { + #[param(example = 1)] id: i64, } #[derive(Deserialize, IntoParams)] #[into_params(parameter_in = Query)] struct GetByOwnerIdQuery { + #[param(example = 1)] owner_id: i64, } @@ -82,11 +64,11 @@ struct DeleteQueueRequest { #[utoipa::path( get, - path = "/get/id", + path = "/get/by_id", tag = QUEUE, summary = "Get by id", description = "Get the queue by id", - params(GetByIdQueueQuery), + params(GetQueueByIdQuery), responses( ( status = 200, body = SuccessResponse>, @@ -97,7 +79,7 @@ struct DeleteQueueRequest { )] async fn get_by_id( State(state): State, - ApiQuery(req): ApiQuery, + ApiQuery(req): ApiQuery, ) -> ApiResult> { Ok(SuccessResponse::ok( queues::Entity::find_by_id(req.id) @@ -109,10 +91,10 @@ async fn get_by_id( #[utoipa::path( get, - path = "/get/owner", + path = "/get/by_owner", tag = QUEUE, summary = "Get by owner", - description = "Get queues for a given owner", + description = "Get queues by the owner id", params(GetByOwnerIdQuery), responses( ( @@ -126,7 +108,7 @@ async fn get_by_owner( State(state): State, ApiQuery(req): ApiQuery, ) -> ApiResult> { - return Ok(SuccessResponse::ok( + Ok(SuccessResponse::ok( queues::Entity::find() .filter(queues::Column::OwnerId.eq(req.owner_id)) .all(&state.db) @@ -134,7 +116,32 @@ async fn get_by_owner( .into_iter() .map(Into::into) .collect(), - )); + )) +} + +#[utoipa::path( + get, + path = "/get/owned", + tag = QUEUE, + summary = "Get owned", + description = "Get your queues", + responses( + ( + status = 200, body = SuccessResponse>, + description = "Success response with queues owned by you" + ), + GlobalResponses + ), +)] +async fn get_owned(State(state): State, Auth(user): Auth) -> ApiResult> { + Ok(SuccessResponse::ok( + user.find_related(queues::Entity) + .all(&state.db) + .await? + .into_iter() + .map(Into::into) + .collect(), + )) } #[utoipa::path( @@ -171,7 +178,7 @@ async fn create( } #[utoipa::path( - put, + patch, path = "/update/name", tag = QUEUE, summary = "Change name", @@ -202,7 +209,7 @@ async fn update_name( } #[utoipa::path( - put, + patch, path = "/update/owner", tag = QUEUE, summary = "Change owner", @@ -269,6 +276,7 @@ pub fn router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(get_by_id)) .routes(routes!(get_by_owner)) + .routes(routes!(get_owned)) .routes(routes!(create)) .routes(routes!(update_name)) .routes(routes!(update_owner)) diff --git a/src/routers/queue/mod.rs b/src/routers/queue/mod.rs index dd03956..9af1f01 100644 --- a/src/routers/queue/mod.rs +++ b/src/routers/queue/mod.rs @@ -8,5 +8,5 @@ use crate::AppState; pub fn router() -> OpenApiRouter { OpenApiRouter::new() .merge(manage::router()) - .merge(access::router()) + .nest("/access", access::router()) } diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..1d93085 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,51 @@ +use entity::{invite_tokens, queues, users}; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; + +use crate::{ApiError, ClientError}; + +pub async fn user_exists(id: i64, db: &DatabaseConnection) -> Result { + Ok(users::Entity::find_by_id(id).one(db).await?.is_some()) +} + +pub async fn username_exists(username: &str, db: &DatabaseConnection) -> Result { + Ok(users::Entity::find() + .filter(users::Column::Username.eq(username)) + .one(db) + .await? + .is_some()) +} + +pub async fn get_owned_queue( + id: i64, + owner_id: i64, + db: &DatabaseConnection, +) -> Result { + let queue = queues::Entity::find_by_id(id) + .one(db) + .await? + .ok_or(ClientError::QueueNotFound { id })?; + + if queue.owner_id != owner_id { + return Err(ClientError::NotQueueOwner { id: queue.id }.into()); + } + + Ok(queue) +} + +pub async fn get_owned_invite_token( + id: i64, + owner_id: i64, + db: &DatabaseConnection, +) -> Result { + let (invite_token, queue) = invite_tokens::Entity::find_by_id(id) + .find_also_related(queues::Entity) + .one(db) + .await? + .ok_or(ClientError::InviteTokenNotFound { id })?; + + if queue.unwrap().owner_id != owner_id { + return Err(ClientError::NoInviteTokenAccess { id }.into()); + } + + Ok(invite_token) +} -- cgit v1.2.3