use db::project::project_documents as doc_entity; use db::project::project_documents::Column as Dc; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, }; use time::OffsetDateTime; use uuid::Uuid; use crate::error::{ProjectError, ProjectResult}; use crate::features::ProjectService; use crate::features::lifecycle::usecase::log_activity; use crate::metrics; use crate::permission; use super::{CreateDocumentCmd, DocumentPage, DocumentView, UpdateDocumentCmd}; pub async fn create_document( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, cmd: CreateDocumentCmd, ) -> ProjectResult { let _timer = metrics::Timer::start(metrics::FEATURE_PROJECT_DOCS_CREATE); let result: ProjectResult = async { crate::features::authz::require( svc, actor, space_id, project_id, permission::MANAGE_DOCUMENTS, ) .await?; let slug_exists = doc_entity::Entity::find() .filter(Dc::ProjectId.eq(project_id)) .filter(Dc::Slug.eq(&cmd.slug)) .count(&svc.db.reader()) .await?; if slug_exists > 0 { return Err(ProjectError::Conflict(format!( "document slug '{}' already exists in project {project_id}", cmd.slug ))); } let id = Uuid::now_v7(); let now = OffsetDateTime::now_utc(); let row = doc_entity::ActiveModel { id: Set(id), project_id: Set(project_id), parent_id: Set(cmd.parent_id), creator_id: Set(actor), last_editor_id: Set(Some(actor)), title: Set(cmd.title), slug: Set(cmd.slug), content: Set(cmd.content), icon: Set(cmd.icon), created_at: Set(now), updated_at: Set(Some(now)), } .insert(&svc.db.writer()) .await?; log_activity( svc, project_id, actor, "document_created", "document", Some(id), None, ) .await; Ok(document_view(&row)) } .await; metrics::record_outcome(metrics::FEATURE_PROJECT_DOCS_CREATE, &result); result } pub async fn list_documents( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, ) -> ProjectResult { let _timer = metrics::Timer::start(metrics::FEATURE_PROJECT_DOCS_LIST); let result: ProjectResult = async { crate::features::authz::require(svc, actor, space_id, project_id, permission::VIEW_PROJECT) .await?; let rows = doc_entity::Entity::find() .filter(Dc::ProjectId.eq(project_id)) .order_by_asc(Dc::Title) .all(&svc.db.reader()) .await?; let items = rows.iter().map(document_view).collect::>(); let total = items.len() as u64; Ok(DocumentPage { total, items }) } .await; metrics::record_outcome(metrics::FEATURE_PROJECT_DOCS_LIST, &result); result } pub async fn get_document( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, doc_id: Uuid, ) -> ProjectResult { let _timer = metrics::Timer::start(metrics::FEATURE_PROJECT_DOCS_GET); let result: ProjectResult = async { crate::features::authz::require(svc, actor, space_id, project_id, permission::VIEW_PROJECT) .await?; let row = doc_entity::Entity::find_by_id(doc_id) .filter(Dc::ProjectId.eq(project_id)) .one(&svc.db.reader()) .await? .ok_or_else(|| { ProjectError::NotFound(format!("document {doc_id} in project {project_id}")) })?; Ok(document_view(&row)) } .await; metrics::record_outcome(metrics::FEATURE_PROJECT_DOCS_GET, &result); result } pub async fn update_document( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, doc_id: Uuid, cmd: UpdateDocumentCmd, ) -> ProjectResult { let _timer = metrics::Timer::start(metrics::FEATURE_PROJECT_DOCS_UPDATE); let result: ProjectResult = async { crate::features::authz::require( svc, actor, space_id, project_id, permission::MANAGE_DOCUMENTS, ) .await?; let current = doc_entity::Entity::find_by_id(doc_id) .filter(Dc::ProjectId.eq(project_id)) .one(&svc.db.reader()) .await? .ok_or_else(|| { ProjectError::NotFound(format!("document {doc_id} in project {project_id}")) })?; if let Some(slug) = &cmd.slug && slug != ¤t.slug { let slug_exists = doc_entity::Entity::find() .filter(Dc::ProjectId.eq(project_id)) .filter(Dc::Slug.eq(slug)) .filter(Dc::Id.ne(doc_id)) .count(&svc.db.reader()) .await?; if slug_exists > 0 { return Err(ProjectError::Conflict(format!( "document slug '{slug}' already exists in project {project_id}" ))); } } let mut active: doc_entity::ActiveModel = current.into(); if cmd.parent_id.is_some() { active.parent_id = Set(cmd.parent_id); } if let Some(title) = cmd.title { active.title = Set(title); } if let Some(slug) = cmd.slug { active.slug = Set(slug); } if let Some(content) = cmd.content { active.content = Set(content); } if cmd.icon.is_some() { active.icon = Set(cmd.icon); } active.last_editor_id = Set(Some(actor)); active.updated_at = Set(Some(OffsetDateTime::now_utc())); let updated = active.update(&svc.db.writer()).await?; log_activity( svc, project_id, actor, "document_updated", "document", Some(doc_id), None, ) .await; Ok(document_view(&updated)) } .await; metrics::record_outcome(metrics::FEATURE_PROJECT_DOCS_UPDATE, &result); result } pub async fn delete_document( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, doc_id: Uuid, ) -> ProjectResult<()> { let _timer = metrics::Timer::start(metrics::FEATURE_PROJECT_DOCS_DELETE); let result: ProjectResult<()> = async { crate::features::authz::require( svc, actor, space_id, project_id, permission::MANAGE_DOCUMENTS, ) .await?; let deleted = doc_entity::Entity::delete_many() .filter(Dc::Id.eq(doc_id)) .filter(Dc::ProjectId.eq(project_id)) .exec(&svc.db.writer()) .await?; if deleted.rows_affected == 0 { return Err(ProjectError::NotFound(format!( "document {doc_id} in project {project_id}" ))); } log_activity( svc, project_id, actor, "document_deleted", "document", Some(doc_id), None, ) .await; Ok(()) } .await; metrics::record_outcome(metrics::FEATURE_PROJECT_DOCS_DELETE, &result); result } fn document_view(m: &doc_entity::Model) -> DocumentView { DocumentView { id: m.id, project_id: m.project_id, parent_id: m.parent_id, creator_id: m.creator_id, last_editor_id: m.last_editor_id, title: m.title.clone(), slug: m.slug.clone(), content: m.content.clone(), icon: m.icon.clone(), created_at: m.created_at, updated_at: m.updated_at, } }