diff --git a/api/migration/src/lib.rs b/api/migration/src/lib.rs index 93313c3..ac97e7c 100644 --- a/api/migration/src/lib.rs +++ b/api/migration/src/lib.rs @@ -10,6 +10,7 @@ mod m20260508_075858_add_web_push_subscription; mod m20260525_164927_add_time_recurring_yearly; mod m20260529_073701_add_delete_time; mod m20260610_061458_add_todo_reminders; +mod m20260907_124516_add_organisations; pub struct Migrator; @@ -27,6 +28,7 @@ impl MigratorTrait for Migrator { Box::new(m20260525_164927_add_time_recurring_yearly::Migration), Box::new(m20260529_073701_add_delete_time::Migration), Box::new(m20260610_061458_add_todo_reminders::Migration), + Box::new(m20260907_124516_add_organisations::Migration), ] } } diff --git a/api/migration/src/m20260907_124516_add_organisations.rs b/api/migration/src/m20260907_124516_add_organisations.rs new file mode 100644 index 0000000..79a6344 --- /dev/null +++ b/api/migration/src/m20260907_124516_add_organisations.rs @@ -0,0 +1,67 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Organisation::Table) + .if_not_exists() + .col(pk_uuid(Organisation::Id)) + .col(string(Organisation::Name)) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(OrganisationUser::Table) + .if_not_exists() + .primary_key( + Index::create() + .col(OrganisationUser::OrganisationId) + .col(OrganisationUser::UserId), + ) + .col(uuid(OrganisationUser::OrganisationId)) + .col(string(OrganisationUser::UserId)) + .foreign_key( + ForeignKey::create() + .from(OrganisationUser::Table, OrganisationUser::OrganisationId) + .to(Organisation::Table, Organisation::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(OrganisationUser::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(Organisation::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum Organisation { + Table, + Id, + Name, +} + +#[derive(DeriveIden)] +pub enum OrganisationUser { + Table, + UserId, + OrganisationId, +} diff --git a/api/orm/src/entities/mod.rs b/api/orm/src/entities/mod.rs index 692c24c..67b1f17 100644 --- a/api/orm/src/entities/mod.rs +++ b/api/orm/src/entities/mod.rs @@ -4,6 +4,8 @@ pub mod prelude; pub mod category; pub mod check; +pub mod organisation; +pub mod organisation_user; pub mod reminder; pub mod sea_orm_active_enums; pub mod tag; diff --git a/api/orm/src/entities/organisation.rs b/api/orm/src/entities/organisation.rs new file mode 100644 index 0000000..5a27abd --- /dev/null +++ b/api/orm/src/entities/organisation.rs @@ -0,0 +1,16 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "organisation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + #[sea_orm(has_many)] + pub organisation_users: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/api/orm/src/entities/organisation_user.rs b/api/orm/src/entities/organisation_user.rs new file mode 100644 index 0000000..c88cf74 --- /dev/null +++ b/api/orm/src/entities/organisation_user.rs @@ -0,0 +1,23 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "organisation_user")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub organisation_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: String, + #[sea_orm( + belongs_to, + from = "organisation_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub organisation: BelongsTo, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/api/orm/src/entities/prelude.rs b/api/orm/src/entities/prelude.rs index 76824d7..2962ece 100644 --- a/api/orm/src/entities/prelude.rs +++ b/api/orm/src/entities/prelude.rs @@ -2,6 +2,8 @@ pub use super::category::Entity as Category; pub use super::check::Entity as Check; +pub use super::organisation::Entity as Organisation; +pub use super::organisation_user::Entity as OrganisationUser; pub use super::reminder::Entity as Reminder; pub use super::tag::Entity as Tag; pub use super::time::Entity as Time; diff --git a/api/services/src/lib.rs b/api/services/src/lib.rs index b6ab1e7..55ad1c6 100644 --- a/api/services/src/lib.rs +++ b/api/services/src/lib.rs @@ -1,5 +1,6 @@ pub mod categories; pub mod events; +pub mod organisation; pub mod tags; pub mod todos; pub mod webpush; diff --git a/api/services/src/organisation.rs b/api/services/src/organisation.rs new file mode 100644 index 0000000..42af591 --- /dev/null +++ b/api/services/src/organisation.rs @@ -0,0 +1,96 @@ +use orm::{OrmConnection, entities::organisation, entities::organisation_user}; +use sea_orm::{ActiveValue::Set, EntityTrait, QueryFilter, SelectExt}; +use thiserror::Error; +use types::organisation::Organisation; +use uuid::Uuid; + +use crate::User; + +#[derive(Error, Debug, PartialEq)] +pub enum OrganisationServiceError { + #[error("database error - caused by: {0}")] + DbError(#[from] sea_orm::DbErr), +} + +pub struct OrganisationService; + +impl OrganisationService { + pub async fn get_all( + user: &User, + db: &OrmConnection, + ) -> Result, OrganisationServiceError> { + let organisations = organisation::Entity::find() + .find_also_related(organisation_user::Entity) + .filter(organisation_user::COLUMN.user_id.eq(user.id.clone())) + .all(db) + .await?; + + Ok(organisations + .into_iter() + .map(|(org, _)| to_organisation_dto(&org.into_ex())) + .collect()) + } + + pub async fn exists( + db: &OrmConnection, + organisation: Uuid, + ) -> Result { + let exists = organisation::Entity::find() + .filter(organisation::COLUMN.id.eq(organisation)) + .exists(db) + .await?; + + Ok(exists) + } + + pub async fn create( + db: &OrmConnection, + organisation: Organisation, + ) -> Result { + let organisation = organisation::Entity::insert(organisation::ActiveModel { + id: Set(organisation.uuid), + name: Set(organisation.name), + }) + .exec_with_returning(db) + .await?; + + Ok(to_organisation_dto(&organisation.into_ex())) + } + + pub async fn join( + user: &User, + db: &OrmConnection, + organisation: Uuid, + ) -> Result<(), OrganisationServiceError> { + organisation_user::Entity::insert(organisation_user::ActiveModel { + organisation_id: Set(organisation), + user_id: Set(user.id.clone()), + }) + .exec_with_returning(db) + .await?; + + Ok(()) + } + + pub async fn leave( + user: &User, + db: &OrmConnection, + organisation: Uuid, + ) -> Result<(), OrganisationServiceError> { + organisation_user::Entity::delete(organisation_user::ActiveModel { + organisation_id: Set(organisation), + user_id: Set(user.id.clone()), + }) + .exec_with_returning(db) + .await?; + + Ok(()) + } +} + +pub fn to_organisation_dto(value: &organisation::ModelEx) -> Organisation { + Organisation { + uuid: value.id, + name: value.name.clone(), + } +} diff --git a/api/services/src/todos.rs b/api/services/src/todos.rs index 76ea23f..aff51ab 100644 --- a/api/services/src/todos.rs +++ b/api/services/src/todos.rs @@ -12,7 +12,7 @@ use sea_orm::{ }; use thiserror::Error; use tracing::warn; -use types::models::TodoPosition; +use types::todo::TodoPosition; use types::todo::reminder::{Reminder, ReminderRelative, ReminderTime}; use types::todo::time::{Time, TimePoint, TimeRange, TimeRecurring}; use types::todo::{CreateTodoData, Todo}; diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index f542599..84f7633 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -3,6 +3,7 @@ use axum::Router; use crate::AppState; mod categories; +mod organisation; mod sse; mod tags; mod todo; @@ -15,4 +16,5 @@ pub fn routes(state: &AppState) -> Router { .nest("/todos", todo::routes(state.clone())) .nest("/subscription", webpush::routes(state.clone())) .nest("/updates", sse::routes(state.clone())) + .nest("/organisation", organisation::routes(state.clone())) } diff --git a/api/src/routes/organisation.rs b/api/src/routes/organisation.rs new file mode 100644 index 0000000..65ef683 --- /dev/null +++ b/api/src/routes/organisation.rs @@ -0,0 +1,107 @@ +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use http::StatusCode; +use services::{ + User, + organisation::{OrganisationService, OrganisationServiceError}, +}; +use thiserror::Error; +use tracing::warn; +use types::organisation::{JoinOrganisation, Organisation}; + +use crate::AppState; + +#[derive(Error, Debug, PartialEq)] +enum ApiError { + #[error(transparent)] + ServiceError(#[from] OrganisationServiceError), + + #[error("organisation already exists")] + OrganisationAlreadyExists, + + #[error("organisation does not exists")] + OrganisationDoesNotExists, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = match &self { + ApiError::ServiceError(_) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::OrganisationAlreadyExists => StatusCode::BAD_REQUEST, + ApiError::OrganisationDoesNotExists => StatusCode::BAD_REQUEST, + }; + + warn!("{:?}", self); + status.into_response() + } +} + +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/", get(get_organisations)) + .route("/", post(add_organisation)) + .route("/join", post(join_organisation)) + .route("/leave", post(leave_organisation)) + .with_state(state) +} + +async fn get_organisations( + state: State, + user: User, +) -> Result>, ApiError> { + let organisations = OrganisationService::get_all(&user, &state.db_connection).await?; + Ok(Json(organisations)) +} + +async fn add_organisation( + state: State, + user: User, + Json(organisation): Json, +) -> Result, ApiError> { + let org_uuid = organisation.uuid.clone(); + + if OrganisationService::exists(&state.db_connection, org_uuid).await? { + return Err(ApiError::OrganisationAlreadyExists); + } + + let new_organisation = OrganisationService::create(&state.db_connection, organisation).await?; + OrganisationService::join(&user, &state.db_connection, org_uuid).await?; + + Ok(Json(new_organisation)) +} + +async fn join_organisation( + state: State, + user: User, + Json(organisation): Json, +) -> Result { + let org_uuid = organisation.uuid.clone(); + + if !OrganisationService::exists(&state.db_connection, org_uuid).await? { + return Err(ApiError::OrganisationDoesNotExists); + } + + OrganisationService::join(&user, &state.db_connection, organisation.uuid).await?; + + Ok(StatusCode::OK) +} + +async fn leave_organisation( + state: State, + user: User, + Json(organisation): Json, +) -> Result { + let org_uuid = organisation.uuid.clone(); + + if !OrganisationService::exists(&state.db_connection, org_uuid).await? { + return Err(ApiError::OrganisationDoesNotExists); + } + + OrganisationService::leave(&user, &state.db_connection, organisation.uuid).await?; + + Ok(StatusCode::OK) +} diff --git a/api/src/routes/todo.rs b/api/src/routes/todo.rs index 7249915..c7febbc 100644 --- a/api/src/routes/todo.rs +++ b/api/src/routes/todo.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use thiserror::Error; use tracing::warn; use types::{ - models::{AddTodoModel, TodoPosition}, + todo::{AddTodoModel, TodoPosition}, todo::{CreateTodoData, Todo}, }; use uuid::Uuid; diff --git a/api/tests/common/mod.rs b/api/tests/common/mod.rs index 4d5881b..a36f4d4 100644 --- a/api/tests/common/mod.rs +++ b/api/tests/common/mod.rs @@ -10,6 +10,7 @@ use serde_json::json; use services::User; use tracing::Level; use types::{ + organisation::Organisation, tag::{Category, Tag}, todo::Todo, }; @@ -103,6 +104,30 @@ impl Client { self.delete(&format!("todos/check/{todo_uuid}")).await } + pub async fn add_organisation(&self, organisation: serde_json::Value) -> TestResponse { + self.post_json("organisation", organisation).await + } + + pub async fn add_organisation_json(&self, organisation: serde_json::Value) -> Organisation { + Self::json(&self.add_organisation(organisation).await) + } + + pub async fn leave_organisation(&self, organisation: serde_json::Value) -> TestResponse { + self.post_json("organisation/leave", organisation).await + } + + pub async fn join_organisation(&self, organisation: serde_json::Value) -> TestResponse { + self.post_json("organisation/join", organisation).await + } + + pub async fn get_organisations(&self) -> TestResponse { + self.get("organisation").await + } + + pub async fn get_organisations_json(&self) -> Vec { + Self::json(&self.get_organisations().await) + } + pub async fn get_tags(&self) -> TestResponse { self.get("tags").await } @@ -180,9 +205,7 @@ static TRACING: OnceLock<()> = OnceLock::new(); pub async fn get_client() -> Client { TRACING.get_or_init(|| { - tracing_subscriber::fmt() - .with_max_level(Level::ERROR) - .init(); + tracing_subscriber::fmt().with_max_level(Level::WARN).init(); }); let app = app( diff --git a/api/tests/organisation.rs b/api/tests/organisation.rs new file mode 100644 index 0000000..8a825f3 --- /dev/null +++ b/api/tests/organisation.rs @@ -0,0 +1,181 @@ +use serde_json::json; +use types::organisation::Organisation; +use uuid::Uuid; + +use crate::common::get_client; + +mod common; + +#[tokio::test] +async fn add_organisation() { + let client = get_client().await; + + let organisation_uuid = Uuid::new_v4(); + let organisation = Organisation { + uuid: organisation_uuid, + name: "Test Organisation".to_string(), + }; + + let response = client + .add_organisation(json!( + { + "uuid": organisation_uuid, + "name": "Test Organisation", + } + )) + .await; + + response.assert_status_success(); + response.assert_json(&organisation); + + let response = client.get_organisations().await; + response.assert_status_success(); + let organisations: Vec = response.json(); + assert_eq!(organisations.len(), 1); + assert_eq!(organisations.first().unwrap(), &organisation); +} + +#[tokio::test] +async fn add_organisation_known_uuid() { + let client = get_client().await; + + let organisation_uuid = Uuid::new_v4(); + + client + .add_organisation_json(json!( + { + "uuid": organisation_uuid, + "name": "Test Organisation", + } + )) + .await; + + let response = client + .add_organisation(json!( + { + "uuid": organisation_uuid, + "name": "Test Organisation", + } + )) + .await; + + response.assert_status_bad_request(); +} + +#[tokio::test] +async fn add_invalid_organisation() { + let client = get_client().await; + + let organisation_uuid = Uuid::new_v4(); + + let response = client + .add_organisation(json!( + { + "uuid": organisation_uuid, + } + )) + .await; + + response.assert_status_unprocessable_entity(); +} + +#[tokio::test] +async fn leave_organisation() { + let client = get_client().await; + + let organisation_uuid = Uuid::new_v4(); + + client + .add_organisation_json(json!( + { + "uuid": organisation_uuid, + "name": "Test Organisation", + } + )) + .await; + + let response = client + .leave_organisation(json!( + { + "uuid": organisation_uuid, + } + )) + .await; + + response.assert_status_success(); + + let organisations = client.get_organisations_json().await; + assert_eq!(organisations.len(), 0); +} + +#[tokio::test] +async fn leave_unknown_organisation() { + let client = get_client().await; + + let organisation_uuid = Uuid::new_v4(); + + let response = client + .leave_organisation(json!( + { + "uuid": organisation_uuid, + } + )) + .await; + + response.assert_status_bad_request(); +} + +#[tokio::test] +async fn join_organisation() { + let client = get_client().await; + + let organisation_uuid = Uuid::new_v4(); + + client + .add_organisation_json(json!( + { + "uuid": organisation_uuid, + "name": "Test Organisation", + } + )) + .await; + + client + .leave_organisation(json!( + { + "uuid": organisation_uuid, + } + )) + .await + .assert_status_success(); + + let response = client + .join_organisation(json!( + { + "uuid": organisation_uuid, + } + )) + .await; + + response.assert_status_success(); + + let organisations = client.get_organisations_json().await; + assert_eq!(organisations.len(), 1); +} + +#[tokio::test] +async fn join_unknown_organisation() { + let client = get_client().await; + + let organisation_uuid = Uuid::new_v4(); + + let response = client + .join_organisation(json!( + { + "uuid": organisation_uuid, + } + )) + .await; + + response.assert_status_bad_request(); +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 3cfb383..13a0348 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -7,7 +7,7 @@ use client::fetch::{add_todo, fetch_categories, fetch_tags}; use color_eyre::eyre; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; -use types::{models::AddTodoModel, todo::CreateTodoData}; +use types::{todo::AddTodoModel, todo::CreateTodoData}; #[derive(Debug, Parser)] #[command(name = "task-line")] diff --git a/libs/client/src/fetch.rs b/libs/client/src/fetch.rs index db2d0c4..f9f1bde 100644 --- a/libs/client/src/fetch.rs +++ b/libs/client/src/fetch.rs @@ -4,9 +4,8 @@ use reqwest_sse::EventSource; use std::time::Duration; use tokio::sync::mpsc::{self, Receiver}; use types::{ - models::AddTodoModel, tag::{Category, Tag}, - todo::Todo, + todo::{AddTodoModel, Todo}, }; #[must_use] diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index f5ba694..bfd5635 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -1,3 +1,3 @@ -pub mod models; +pub mod organisation; pub mod tag; pub mod todo; diff --git a/libs/types/src/models.rs b/libs/types/src/models.rs deleted file mode 100644 index 524b46a..0000000 --- a/libs/types/src/models.rs +++ /dev/null @@ -1,19 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::todo::CreateTodoData; - -#[derive(Debug, Deserialize, Serialize)] -pub struct AddTodoModel { - pub data: CreateTodoData, - pub position: Option, - #[serde(rename = "previousId")] - pub previous_id: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum TodoPosition { - Top, - Bottom, -} diff --git a/libs/types/src/organisation.rs b/libs/types/src/organisation.rs new file mode 100644 index 0000000..00eb3ac --- /dev/null +++ b/libs/types/src/organisation.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Organisation { + pub uuid: Uuid, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct JoinOrganisation { + pub uuid: Uuid, +} diff --git a/libs/types/src/todo/mod.rs b/libs/types/src/todo/mod.rs index e0f0884..751c677 100644 --- a/libs/types/src/todo/mod.rs +++ b/libs/types/src/todo/mod.rs @@ -16,6 +16,21 @@ pub enum TodoError { InvalidTimeRange, } +#[derive(Debug, Deserialize, Serialize)] +pub struct AddTodoModel { + pub data: CreateTodoData, + pub position: Option, + #[serde(rename = "previousId")] + pub previous_id: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TodoPosition { + Top, + Bottom, +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct CreateTodoData { pub uuid: Uuid,