From 78cb70d8e8744497efb982851bc740fccaedd120 Mon Sep 17 00:00:00 2001 From: Nell Date: Sun, 30 Aug 2026 16:08:57 +0200 Subject: [PATCH] init --- Cargo.lock | 12 +- Cargo.toml | 4 +- event_bus/Cargo.toml | 2 +- frontend/src/layouts/AppLayout.vue | 56 +++++++- frontend/src/pages/auth/join.vue | 9 +- frontend/src/pages/auth/login.vue | 7 +- frontend/src/pages/server/channel/index.vue | 5 + frontend/src/pages/server/index.vue | 128 ++++++++++++++++-- frontend/src/pages/server/invite.vue | 33 +++++ frontend/src/router/index.ts | 11 +- frontend/src/stores/server.ts | 29 ++++ src/domain/dto/core.rs | 2 + src/domain/dto/mod.rs | 1 + src/domain/dto/server.rs | 5 + src/domain/dto/server_item_order.rs | 29 ++++ src/repositories/read_state.rs | 57 +++++++- src/repositories/server.rs | 23 +++- src/routes/core/handlers.rs | 27 +++- src/routes/message/handlers.rs | 8 ++ src/routes/mod.rs | 2 + src/routes/openapi.rs | 6 + src/routes/server/handlers.rs | 47 ++++++- src/routes/server/routes.rs | 3 +- src/routes/server_item_order/handlers.rs | 40 ++++++ src/routes/server_item_order/mod.rs | 2 + src/routes/server_item_order/routes.rs | 7 + src/services/server.rs | 84 +++++++++++- src/services/server_order.rs | 143 +++++++++++++++++++- 28 files changed, 740 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/server/invite.vue create mode 100644 src/domain/dto/server_item_order.rs create mode 100644 src/routes/server_item_order/handlers.rs create mode 100644 src/routes/server_item_order/mod.rs create mode 100644 src/routes/server_item_order/routes.rs diff --git a/Cargo.lock b/Cargo.lock index f2e0ec2..0a5509a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,9 +145,9 @@ dependencies = [ [[package]] name = "argon2" -version = "0.6.0-rc.8" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", @@ -656,9 +656,9 @@ dependencies = [ [[package]] name = "blake2" -version = "0.11.0-rc.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ "digest 0.11.3", ] @@ -4241,9 +4241,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.24.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 9b53547..32c0e6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ parking_lot = "0.12.5" serde = "1.0.229" serde_json = "1.0.151" toml = "1.1.4" -uuid = { version = "1.24.1", features = ["v4", "v7", "fast-rng", "serde"] } +uuid = { version = "1.26.0", features = ["v4", "v7", "fast-rng", "serde"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] } thiserror = "2" @@ -30,7 +30,7 @@ utoipa = { version = "5", features = ["uuid", "chrono"] } utoipa-swagger-ui = { version = "9", features = ["axum"] } log = "0.4" bitflags = "2.13.1" -argon2 = { version = "0.6.0-rc.8", features = ["password-hash"] } +argon2 = { version = "0.6.0", features = ["password-hash"] } jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.7.0", features = ["catch-panic", "cors", "trace"] } diff --git a/event_bus/Cargo.toml b/event_bus/Cargo.toml index b4475bd..a092a5a 100644 --- a/event_bus/Cargo.toml +++ b/event_bus/Cargo.toml @@ -16,7 +16,7 @@ harness = false tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] } parking_lot = "0.12.5" tracing = "0.1" -uuid = { version = "1.24.1", features = ["v4"] } +uuid = { version = "1.26.0", features = ["v4"] } [dev-dependencies] tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] } diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 6b52dc5..cda0a07 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -26,12 +26,14 @@ watch(isServerContext, (isActive) => { }) const showDialog = ref(false) +const dialogTab = ref('create') const formData = ref({ name: '', password: '', is_default: false }) const isSubmitting = ref(false) +const joinData = ref({serverId: '', password: ''}) const resetForm = () => { formData.value = { @@ -39,6 +41,8 @@ const resetForm = () => { password: '', is_default: false } + joinData.value = {serverId: '', password: ''} + dialogTab.value = 'create' } const handleSubmit = async () => { @@ -64,6 +68,21 @@ const handleSubmit = async () => { } } +const handleJoin = async () => { + if (!joinData.value.serverId.trim()) return + isSubmitting.value = true + try { + const server = await serverStore.joinServer(joinData.value.serverId.trim(), joinData.value.password || null) + showDialog.value = false + resetForm() + router.push(`/server/${server.id}`) + } catch (error) { + console.error('Failed to join server:', error) + } finally { + isSubmitting.value = false + } +} + const handleCancel = () => { showDialog.value = false; resetForm(); @@ -97,6 +116,12 @@ function onServerContextMenu(event: MouseEvent, server: Server) { selectedServerName.value = server.name showServerSettings.value = true }, + }, { + label: 'Copier le lien d’invitation', + icon: 'mdi-link-variant', + action: async () => { + await navigator.clipboard.writeText(`${window.location.origin}/${server.id}`) + }, }]) } @@ -191,9 +216,13 @@ function onServerContextMenu(event: MouseEvent, server: Server) { - Create Server + Add a server + + Create + Join + -
+
+
+ + +
@@ -222,13 +268,13 @@ function onServerContextMenu(event: MouseEvent, server: Server) { Cancel - Create + {{ dialogTab === 'create' ? 'Create' : 'Join' }} diff --git a/frontend/src/pages/auth/join.vue b/frontend/src/pages/auth/join.vue index e3d1e68..a204e15 100644 --- a/frontend/src/pages/auth/join.vue +++ b/frontend/src/pages/auth/join.vue @@ -1,12 +1,13 @@ + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index dca3bc8..0b73a87 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -41,6 +41,12 @@ const router = createRouter({ path: '/', component: AppLayout, children: [ + { + path: ':serverId([0-9a-fA-F-]{36})', + name: 'server-invite', + component: () => import('@/pages/server/invite.vue'), + props: true, + }, { path: '', name: 'home', @@ -101,7 +107,10 @@ router.beforeEach(async (to) => { if (authRequired && !authStore.isAuthenticated) { // Non connecté -> Login - return '/auth/login' + if (to.name === 'server-invite') { + return {name: 'join', query: {serverId: String(to.params.serverId)}} + } + return {name: 'login', query: {redirect: to.fullPath}} } else if (to.name === 'login' && authStore.isAuthenticated) { // Déjà connecté -> Accueil return '/' diff --git a/frontend/src/stores/server.ts b/frontend/src/stores/server.ts index e19adb6..876306f 100644 --- a/frontend/src/stores/server.ts +++ b/frontend/src/stores/server.ts @@ -13,6 +13,20 @@ export interface Server { unread_count?: number } +export type OrderedResourceType = 'channel' | 'category' + +export interface ServerItemOrderReference { + resource_id: string + resource_type: OrderedResourceType +} + +export interface ReorderServerItemPayload extends ServerItemOrderReference { + server_id: string + parent_category_id: string | null + reference: ServerItemOrderReference | null + position: 'before' | 'after' +} + export const useServerStore = defineStore("server", { state: () => ({ servers: [] as Server[], @@ -62,6 +76,14 @@ export const useServerStore = defineStore("server", { this.loading = false; } }, + async joinServer(serverId: string, password?: string | null) { + const response = await useApi().post(`/servers/${serverId}/join`, {password: password || null}); + const error = !response.ok ? await response.json().catch(() => null) : null; + if (!response.ok) throw new Error(error?.error || 'Failed to join server'); + const server: Server = await response.json(); + if (!this.servers.some(item => item.id === server.id)) this.servers.push(server); + return server; + }, async updateServer(serverId: string, payload: { name: string; is_default?: boolean }) { const api = useApi(); const response = await api.put(`/servers/${serverId}`, { @@ -111,6 +133,13 @@ export const useServerStore = defineStore("server", { return tree.items; }, + async reorderItem(payload: ReorderServerItemPayload) { + const response = await useApi().put('/server-item-orders/reorder', payload) + if (!response.ok) { + const error = await response.json().catch(() => null) + throw new Error(error?.error || 'Failed to reorder server item') + } + }, applyChannelReadState(serverId: string, channelId: string, unreadCount: number) { let previousUnreadCount = 0; diff --git a/src/domain/dto/core.rs b/src/domain/dto/core.rs index c3fc8d9..3b142b7 100644 --- a/src/domain/dto/core.rs +++ b/src/domain/dto/core.rs @@ -1,6 +1,7 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; +use uuid::Uuid; #[derive(Deserialize, Validate, ToSchema)] pub struct JoinRequest { @@ -11,4 +12,5 @@ pub struct JoinRequest { #[validate(must_match(other = "password", message = "Passwords do not match"))] pub password_valid: String, pub superuser_token: Option, + pub server_id: Option, } diff --git a/src/domain/dto/mod.rs b/src/domain/dto/mod.rs index 16badc0..c8edaa4 100644 --- a/src/domain/dto/mod.rs +++ b/src/domain/dto/mod.rs @@ -9,4 +9,5 @@ pub mod message; pub mod reaction; pub mod role; pub mod server; +pub mod server_item_order; pub mod user; diff --git a/src/domain/dto/server.rs b/src/domain/dto/server.rs index 2f9ba23..f88159a 100644 --- a/src/domain/dto/server.rs +++ b/src/domain/dto/server.rs @@ -14,6 +14,11 @@ pub struct CreateServerRequest { pub is_default: bool, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct JoinServerRequest { + pub password: Option, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateServerRequest { pub name: String, diff --git a/src/domain/dto/server_item_order.rs b/src/domain/dto/server_item_order.rs new file mode 100644 index 0000000..cfddc6d --- /dev/null +++ b/src/domain/dto/server_item_order.rs @@ -0,0 +1,29 @@ +use crate::models::server_item_order::OrderedResourceType; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ServerItemOrderReference { + pub resource_id: Uuid, + pub resource_type: OrderedResourceType, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ServerItemOrderPosition { + Before, + After, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ReorderServerItemRequest { + pub server_id: Uuid, + pub resource_id: Uuid, + pub resource_type: OrderedResourceType, + /// Nouvelle catégorie parente du canal. Doit être nul pour une catégorie. + pub parent_category_id: Option, + /// Élément devant ou derrière lequel insérer la ressource. + pub reference: Option, + pub position: ServerItemOrderPosition, +} diff --git a/src/repositories/read_state.rs b/src/repositories/read_state.rs index 42cede1..8f4138f 100644 --- a/src/repositories/read_state.rs +++ b/src/repositories/read_state.rs @@ -1,7 +1,10 @@ use crate::models::{channel, channel_user_read_state, message}; use crate::repositories::{AnyResult, RepositoryContext}; use chrono::Utc; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, + RelationTrait, sea_query::OnConflict, +}; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -12,6 +15,58 @@ pub struct ReadStateRepository { } impl ReadStateRepository { + /// Marque tous les canaux d'un serveur comme lus en une opération groupée. + pub async fn mark_server_read(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<()> { + let last_messages = message::Entity::find() + .select_only() + .column(message::Column::ChannelId) + .column_as(message::Column::Id.max(), "last_read_message_id") + .join( + sea_orm::JoinType::InnerJoin, + message::Relation::Channel.def(), + ) + .filter(channel::Column::ServerId.eq(server_id)) + .group_by(message::Column::ChannelId) + .into_tuple::<(Uuid, Uuid)>() + .all(&self.context.db) + .await?; + + if last_messages.is_empty() { + return Ok(()); + } + + let now = Utc::now(); + let states = last_messages + .into_iter() + .map(|(channel_id, last_read_message_id)| { + channel_user_read_state::ActiveModel { + id: Set(Uuid::now_v7()), + channel_id: Set(channel_id), + user_id: Set(user_id), + last_read_message_id: Set(Some(last_read_message_id)), + updated_at: Set(now), + } + }) + .collect::>(); + + channel_user_read_state::Entity::insert_many(states) + .on_conflict( + OnConflict::columns([ + channel_user_read_state::Column::ChannelId, + channel_user_read_state::Column::UserId, + ]) + .update_columns([ + channel_user_read_state::Column::LastReadMessageId, + channel_user_read_state::Column::UpdatedAt, + ]) + .to_owned(), + ) + .exec(&self.context.db) + .await?; + + Ok(()) + } + pub async fn get( &self, channel_id: Uuid, diff --git a/src/repositories/server.rs b/src/repositories/server.rs index 9783ded..b66d73b 100644 --- a/src/repositories/server.rs +++ b/src/repositories/server.rs @@ -1,7 +1,7 @@ use super::{AnyResult, RepositoryContext}; use crate::models::{role, server, server_role_permission, server_user, server_user_permission}; use sea_orm::prelude::*; -use sea_orm::{ActiveModelTrait, QuerySelect, Set}; +use sea_orm::{ActiveModelTrait, JoinType, QuerySelect, RelationTrait, Set}; use sea_orm::sea_query::OnConflict; use std::sync::Arc; @@ -17,6 +17,27 @@ impl ServerRepository { Ok(server::Entity::find().all(&self.context.db).await?) } + pub async fn get_all_for_user(&self, user_id: Uuid) -> AnyResult> { + Ok(server::Entity::find() + .join(JoinType::InnerJoin, server::Relation::ServerUser.def()) + .filter(server_user::Column::UserId.eq(user_id)) + .distinct() + .all(&self.context.db) + .await?) + } + + pub async fn get_by_id_for_user( + &self, + id: Uuid, + user_id: Uuid, + ) -> AnyResult> { + Ok(server::Entity::find_by_id(id) + .join(JoinType::InnerJoin, server::Relation::ServerUser.def()) + .filter(server_user::Column::UserId.eq(user_id)) + .one(&self.context.db) + .await?) + } + pub async fn get_by_id(&self, id: Uuid) -> AnyResult> { Ok(server::Entity::find_by_id(id).one(&self.context.db).await?) } diff --git a/src/routes/core/handlers.rs b/src/routes/core/handlers.rs index 50e0a7e..1787ab0 100644 --- a/src/routes/core/handlers.rs +++ b/src/routes/core/handlers.rs @@ -21,6 +21,23 @@ pub async fn join( State(state): State, ValidatedJson(payload): ValidatedJson, ) -> Result { + let target_server_id = if let Some(server_id) = payload.server_id { + let server = state + .repositories + .server + .get_by_id(server_id) + .await? + .ok_or(HTTPError::NotFound)?; + if server.password.is_some() { + return Err(HTTPError::BadRequest( + "This server requires a password; join it after creating your account".into(), + )); + } + server_id + } else { + state.default_server.id + }; + let user_exists = state .repositories .user @@ -56,12 +73,18 @@ pub async fn join( state .repositories .server - .add_user(state.default_server.id, user.id), + .add_user(target_server_id, user.id), state .repositories .role - .add_to_default(user.id, state.default_server.id) + .add_to_default(user.id, target_server_id) )?; + state + .repositories + .read_state + .mark_server_read(target_server_id, user.id) + .await?; + Ok(StatusCode::CREATED) } diff --git a/src/routes/message/handlers.rs b/src/routes/message/handlers.rs index 25f97d1..1c05f5f 100644 --- a/src/routes/message/handlers.rs +++ b/src/routes/message/handlers.rs @@ -214,6 +214,14 @@ pub async fn create( ) .await .map_err(|error| HTTPError::BadRequest(error.to_string()))?; + + // L'auteur a forcément lu le message qu'il vient d'envoyer. + state + .repositories + .read_state + .set(message.channel_id, user.id, Some(message.id)) + .await?; + let attachments = state .repositories .message diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4dfe0e9..53cd712 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -16,12 +16,14 @@ pub mod message; pub mod openapi; pub mod role; pub mod server; +pub mod server_item_order; pub mod user; pub fn router() -> OxRouter { // Routes nécessitant une authentification let secure_routes = Router::new() .merge(server::routes::router()) + .merge(server_item_order::routes::router()) .merge(category::routes::router()) .merge(channel::routes::router()) .merge(conversation::routes::router()) diff --git a/src/routes/openapi.rs b/src/routes/openapi.rs index 2299f86..e54adcb 100644 --- a/src/routes/openapi.rs +++ b/src/routes/openapi.rs @@ -18,9 +18,11 @@ use utoipa::{Modify, OpenApi}; server::handlers::get_all, server::handlers::get_by_id, server::handlers::create, + server::handlers::join, server::handlers::update, server::handlers::delete, server::handlers::get_tree, + server_item_order::handlers::reorder, category::handlers::get_all, category::handlers::get_by_id, category::handlers::create, @@ -64,7 +66,11 @@ use utoipa::{Modify, OpenApi}; crate::domain::dto::user::UpdateUserRequest, crate::domain::dto::server::ServerResponse, crate::domain::dto::server::CreateServerRequest, + crate::domain::dto::server::JoinServerRequest, crate::domain::dto::server::UpdateServerRequest, + crate::domain::dto::server_item_order::ReorderServerItemRequest, + crate::domain::dto::server_item_order::ServerItemOrderReference, + crate::domain::dto::server_item_order::ServerItemOrderPosition, crate::domain::dto::category::CategoryResponse, crate::domain::dto::category::CreateCategoryRequest, crate::domain::dto::category::UpdateCategoryRequest, diff --git a/src/routes/server/handlers.rs b/src/routes/server/handlers.rs index b3ff11b..831287e 100644 --- a/src/routes/server/handlers.rs +++ b/src/routes/server/handlers.rs @@ -1,6 +1,7 @@ use crate::core::state::AppState; use crate::domain::dto::server::{ - CreateServerRequest, ServerResponse, ServerRolePermissionResponse, ServerTreeResponse, + CreateServerRequest, JoinServerRequest, ServerResponse, ServerRolePermissionResponse, + ServerTreeResponse, ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest, }; use crate::http::context::{CurrentUser, Superuser}; @@ -54,7 +55,7 @@ pub async fn get_all( user: CurrentUser, State(state): State, ) -> Result>, HTTPError> { - let servers = state.repositories.server.get_all().await?; + let servers = state.repositories.server.get_all_for_user(user.id).await?; let unread_counts = state .repositories .read_state @@ -117,14 +118,14 @@ pub async fn get_by_id( ) )] pub async fn create( - _admin: Superuser, + user: CurrentUser, State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), HTTPError> { let server = state .services .server - .create_server(payload.name, payload.is_default) + .create_server(payload.name, payload.password, payload.is_default, user.id) .await?; Ok(( StatusCode::CREATED, @@ -132,6 +133,41 @@ pub async fn create( )) } +#[utoipa::path( + post, + path = "/servers/{id}/join", + request_body = JoinServerRequest, + responses( + (status = 200, description = "Serveur rejoint", body = ServerResponse), + (status = 404, description = "Serveur introuvable"), + (status = 400, description = "Mot de passe incorrect") + ), + params(("id" = Uuid, Path, description = "ID du serveur")), + tag = "Servers", + security(("bearerAuth" = [])) +)] +pub async fn join( + user: CurrentUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, HTTPError> { + let result = state + .services + .server + .join_server(id, user.id, payload.password) + .await; + + match result { + Ok(server) => Ok(Json(mapper::server_model_to_server_response(server))), + Err(error) if error.to_string() == "Server not found" => Err(HTTPError::NotFound), + Err(error) if error.to_string() == "Invalid server password" => { + Err(HTTPError::BadRequest("Invalid server password".into())) + } + Err(error) => Err(HTTPError::Internal(error)), + } +} + /// Met à jour un serveur existant #[utoipa::path( put, @@ -251,6 +287,7 @@ pub async fn list_user_permissions( .get_by_id(server_id) .await? .ok_or(HTTPError::NotFound)?; + let permissions = state .repositories .server @@ -491,7 +528,7 @@ pub async fn get_tree( state .repositories .server - .get_by_id(server_id) + .get_by_id_for_user(server_id, user.id) .await? .ok_or(HTTPError::NotFound)?; diff --git a/src/routes/server/routes.rs b/src/routes/server/routes.rs index 6bb7540..d6c2b13 100644 --- a/src/routes/server/routes.rs +++ b/src/routes/server/routes.rs @@ -1,10 +1,11 @@ use super::handlers; use crate::core::state::AppState; -use axum::{Router, routing::get}; +use axum::{Router, routing::{get, post}}; pub fn router() -> Router { Router::new() .route("/servers", get(handlers::get_all).post(handlers::create)) + .route("/servers/{id}/join", post(handlers::join)) .route( "/servers/{id}", get(handlers::get_by_id) diff --git a/src/routes/server_item_order/handlers.rs b/src/routes/server_item_order/handlers.rs new file mode 100644 index 0000000..3392fa2 --- /dev/null +++ b/src/routes/server_item_order/handlers.rs @@ -0,0 +1,40 @@ +use crate::core::state::AppState; +use crate::domain::dto::server_item_order::ReorderServerItemRequest; +use crate::domain::events::server_tree::ServerTreeInvalidatedEvent; +use crate::http::context::CurrentUser; +use crate::http::error::HTTPError; +use axum::http::StatusCode; +use axum::{Json, extract::State}; + +#[utoipa::path( + put, + path = "/server-item-orders/reorder", + request_body = ReorderServerItemRequest, + responses((status = 204, description = "Ordre mis à jour")), + tag = "Server item orders", + security(("bearerAuth" = [])) +)] +pub async fn reorder( + _user: CurrentUser, + State(state): State, + Json(payload): Json, +) -> Result { + let server_id = payload.server_id; + state + .repositories + .server + .get_by_id(server_id) + .await? + .ok_or(HTTPError::NotFound)?; + + state.services.server_order.reorder(payload).await?; + state.event_bus.emit( + "server_tree_invalidated", + ServerTreeInvalidatedEvent { + server_id, + user_ids: None, + }, + ); + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/routes/server_item_order/mod.rs b/src/routes/server_item_order/mod.rs new file mode 100644 index 0000000..c0c696a --- /dev/null +++ b/src/routes/server_item_order/mod.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod routes; diff --git a/src/routes/server_item_order/routes.rs b/src/routes/server_item_order/routes.rs new file mode 100644 index 0000000..06bde9c --- /dev/null +++ b/src/routes/server_item_order/routes.rs @@ -0,0 +1,7 @@ +use super::handlers; +use crate::core::state::AppState; +use axum::{Router, routing::put}; + +pub fn router() -> Router { + Router::new().route("/server-item-orders/reorder", put(handlers::reorder)) +} diff --git a/src/services/server.rs b/src/services/server.rs index 7fedcef..b219c3f 100644 --- a/src/services/server.rs +++ b/src/services/server.rs @@ -1,5 +1,4 @@ -use crate::models::{role, server, server_user}; -use crate::repositories::Repositories; +use crate::models::{role, role_user, server, server_user}; use crate::services::ServicesContext; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, @@ -21,7 +20,9 @@ impl ServerService { pub async fn create_server( &self, name: String, + password: Option, is_default: bool, + owner_id: Uuid, ) -> Result { let db = &self.service_context.repositories.server.context.db; let event_bus = &self.service_context.event_bus; @@ -30,7 +31,9 @@ impl ServerService { let active = server::ActiveModel { name: Set(name), + password: Set(password), is_default: Set(is_default), + owner_id: Set(Some(owner_id)), ..Default::default() }; let srv = active.insert(&txn).await?; @@ -41,7 +44,22 @@ impl ServerService { is_default: Set(true), ..Default::default() }; - default_group.insert(&txn).await?; + let default_group = default_group.insert(&txn).await?; + + server_user::ActiveModel { + server_id: Set(srv.id), + user_id: Set(owner_id), + ..Default::default() + } + .insert(&txn) + .await?; + + role_user::ActiveModel { + role_id: Set(default_group.id), + user_id: Set(owner_id), + } + .insert(&txn) + .await?; txn.commit().await?; @@ -50,6 +68,66 @@ impl ServerService { Ok(srv) } + pub async fn join_server( + &self, + server_id: Uuid, + user_id: Uuid, + password: Option, + ) -> Result { + let db = &self.service_context.repositories.server.context.db; + let event_bus = &self.service_context.event_bus; + let txn = db.begin().await?; + let srv = server::Entity::find_by_id(server_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow::anyhow!("Server not found"))?; + + if srv.password != password { + return Err(anyhow::anyhow!("Invalid server password")); + } + + if server_user::Entity::find() + .filter(server_user::Column::ServerId.eq(server_id)) + .filter(server_user::Column::UserId.eq(user_id)) + .one(&txn) + .await? + .is_some() + { + txn.commit().await?; + return Ok(srv); + } + + server_user::ActiveModel { + server_id: Set(server_id), + user_id: Set(user_id), + ..Default::default() + } + .insert(&txn) + .await?; + + let default_role = role::Entity::find() + .filter(role::Column::ServerId.eq(server_id)) + .filter(role::Column::IsDefault.eq(true)) + .one(&txn) + .await? + .ok_or_else(|| anyhow::anyhow!("Default role not found"))?; + role_user::ActiveModel { + role_id: Set(default_role.id), + user_id: Set(user_id), + } + .insert(&txn) + .await?; + + txn.commit().await?; + event_bus.emit("server_user_created", (server_id, user_id)); + self.service_context + .repositories + .read_state + .mark_server_read(server_id, user_id) + .await?; + Ok(srv) + } + pub async fn update_server( &self, id: Uuid, diff --git a/src/services/server_order.rs b/src/services/server_order.rs index e74ac43..5684dcc 100644 --- a/src/services/server_order.rs +++ b/src/services/server_order.rs @@ -1,7 +1,10 @@ +use crate::domain::dto::server_item_order::{ReorderServerItemRequest, ServerItemOrderPosition}; use crate::models::server_item_order::{self, OrderedResourceType}; +use crate::models::{category, channel}; use crate::services::ServicesContext; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, Set, + ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder, + QuerySelect, Set, TransactionTrait, }; use std::sync::Arc; use uuid::Uuid; @@ -107,6 +110,144 @@ impl ServerOrderService { Ok(()) } + pub async fn reorder(&self, request: ReorderServerItemRequest) -> Result<(), anyhow::Error> { + let db = &self.service_context.repositories.server.context.db; + let txn = db.begin().await?; + + let mut orders = server_item_order::Entity::find() + .filter(server_item_order::Column::ServerId.eq(request.server_id)) + .order_by_asc(server_item_order::Column::ParentCategoryId) + .order_by_asc(server_item_order::Column::OrderKey) + .all(&txn) + .await?; + + let target_index = orders + .iter() + .position(|order| { + order.resource_id == request.resource_id + && order.resource_type == request.resource_type + }) + .ok_or_else(|| anyhow::anyhow!("Ordered resource not found"))?; + let target = orders.remove(target_index); + + if request.resource_type == OrderedResourceType::Category + && request.parent_category_id.is_some() + { + return Err(anyhow::anyhow!("A category cannot have a parent category")); + } + + if let Some(category_id) = request.parent_category_id { + let parent = category::Entity::find_by_id(category_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow::anyhow!("Parent category not found"))?; + if parent.server_id != request.server_id { + return Err(anyhow::anyhow!("Parent category belongs to another server")); + } + } + + if request.resource_type == OrderedResourceType::Channel { + let channel_model = channel::Entity::find_by_id(request.resource_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow::anyhow!("Channel not found"))?; + if channel_model.server_id != Some(request.server_id) { + return Err(anyhow::anyhow!("Channel belongs to another server")); + } + if channel_model.category_id != request.parent_category_id { + let mut active: channel::ActiveModel = channel_model.into(); + active.category_id = Set(request.parent_category_id); + active.update(&txn).await?; + } + } else { + let category_model = category::Entity::find_by_id(request.resource_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow::anyhow!("Category not found"))?; + if category_model.server_id != request.server_id { + return Err(anyhow::anyhow!("Category belongs to another server")); + } + } + + let reference_index = request.reference.as_ref().map(|reference| { + orders.iter().position(|order| { + order.resource_id == reference.resource_id + && order.resource_type == reference.resource_type + }) + }); + let Some(reference_index) = reference_index.flatten() else { + if request.reference.is_some() { + return Err(anyhow::anyhow!("Order reference not found")); + } + let insertion_index = orders + .iter() + .filter(|order| order.parent_category_id == request.parent_category_id) + .count(); + let destination_indices: Vec = orders + .iter() + .enumerate() + .filter_map(|(index, order)| { + (order.parent_category_id == request.parent_category_id).then_some(index) + }) + .collect(); + let insert_at = destination_indices + .get(insertion_index.saturating_sub(1)) + .map(|index| index + 1) + .unwrap_or_else(|| { + orders + .iter() + .position(|order| order.parent_category_id == request.parent_category_id) + .unwrap_or(orders.len()) + }); + orders.insert( + insert_at, + server_item_order::Model { + parent_category_id: request.parent_category_id, + order_key: target.order_key, + ..target + }, + ); + self.persist_reordered(&txn, orders).await?; + txn.commit().await?; + return Ok(()); + }; + + let reference = orders[reference_index].clone(); + if reference.parent_category_id != request.parent_category_id { + return Err(anyhow::anyhow!("Order reference belongs to another list")); + } + + let insert_at = match request.position { + ServerItemOrderPosition::Before => reference_index, + ServerItemOrderPosition::After => reference_index + 1, + }; + orders.insert( + insert_at, + server_item_order::Model { + parent_category_id: request.parent_category_id, + order_key: target.order_key, + ..target + }, + ); + + self.persist_reordered(&txn, orders).await?; + txn.commit().await?; + Ok(()) + } + + async fn persist_reordered( + &self, + txn: &DatabaseTransaction, + orders: Vec, + ) -> Result<(), anyhow::Error> { + for (index, order) in orders.into_iter().enumerate() { + let mut active: server_item_order::ActiveModel = order.into(); + active.order_key = Set((index as i64) + 1); + active.update(txn).await?; + } + Ok(()) + } + async fn next_order_key( &self, txn: &DatabaseTransaction,