use db::conn::sea_orm::IntoActiveModel; use db::project::project as project_entity; use db::project::project_activities as activity_entity; use db::project::project_members as member_entity; use db::project::project_members::ProjectMemberRole; use db::project::project_state::ProjectState as DbState; use sea_orm::{ActiveModelTrait, Set}; use time::OffsetDateTime; use uuid::Uuid; use crate::error::{ProjectError, ProjectResult}; use crate::features::ProjectService; use crate::permission; use super::{ CreateProjectCmd, ListProjectQuery, ProjectPage, ProjectView, UpdateProjectCmd, }; pub async fn create_project( svc: &ProjectService, actor: Uuid, cmd: CreateProjectCmd, ) -> ProjectResult { let perms = crate::features::authz::resolve(svc, actor, cmd.space_id, Uuid::nil()).await?; perms.require(permission::VIEW_PROJECT).map_err(|a| { ProjectError::Forbidden(format!("caller not authorized: missing {a}")) })?; let id = Uuid::new_v4(); let now = OffsetDateTime::now_utc(); let model = project_entity::ActiveModel { id: Set(id), space_id: Set(cmd.space_id), creator_id: Set(actor), name: Set(cmd.name), description: Set(cmd.description), lead_id: Set(cmd.lead_id), icon: Set(cmd.icon), color: Set(cmd.color), state: Set(DbState::Backlog), target_date: Set(cmd.target_date), started_at: Set(None), completed_at: Set(None), cancelled_at: Set(None), health: Set(None), sort_order: Set(0), starred: Set(0), created_at: Set(now), updated_at: Set(None), }; let inserted = model.insert(&svc.db.writer()).await?; let _ = member_entity::ActiveModel { id: Set(Uuid::new_v4()), project_id: Set(id), user_id: Set(actor), role: Set(ProjectMemberRole::Owner), joined_at: Set(now), updated_at: Set(None), } .insert(&svc.db.writer()) .await?; log_activity(svc, id, actor, "created", "self", None, None).await; Ok(view_from(&inserted)) } pub async fn get_project( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, ) -> ProjectResult { crate::features::authz::require(svc, actor, space_id, project_id, permission::VIEW_PROJECT) .await?; use db::conn::sea_orm::EntityTrait; let row = project_entity::Entity::find_by_id(project_id) .one(&svc.db.reader()) .await? .ok_or_else(|| ProjectError::NotFound(format!("project {project_id}")))?; Ok(view_from(&row)) } pub async fn list_projects( svc: &ProjectService, actor: Uuid, q: ListProjectQuery, ) -> ProjectResult { crate::features::authz::resolve(svc, actor, q.space_id, Uuid::nil()).await?; let page = q.page.max(1); let per_page = q.per_page.clamp(1, svc.config.max_page_size); let offset = (page - 1) * per_page; use db::conn::sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, PaginatorTrait}; let mut query = project_entity::Entity::find() .filter(project_entity::Column::SpaceId.eq(q.space_id)) .order_by_asc(project_entity::Column::SortOrder) .order_by_desc(project_entity::Column::CreatedAt); if let Some(state) = q.state { query = query.filter(project_entity::Column::State.eq(state as i32)); } if let Some(lead) = q.lead_id { query = query.filter(project_entity::Column::LeadId.eq(lead)); } let reader = svc.db.reader(); let paginator = query.paginate(&reader, per_page); let total = paginator.num_items().await?; let rows = paginator.fetch_page(offset / per_page).await?; let items = rows.iter().map(view_from).collect(); Ok(ProjectPage { total, items }) } pub async fn update_project( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, cmd: UpdateProjectCmd, ) -> ProjectResult { crate::features::authz::require(svc, actor, space_id, project_id, permission::EDIT_PROJECT) .await?; let current = { use db::conn::sea_orm::EntityTrait; project_entity::Entity::find_by_id(project_id) .one(&svc.db.reader()) .await? .ok_or_else(|| ProjectError::NotFound(format!("project {project_id}")))? }; let mut active: project_entity::ActiveModel = current.into_active_model(); if let Some(name) = cmd.name { active.name = Set(name); } if let Some(desc) = cmd.description { active.description = Set(Some(desc)); } if cmd.lead_id.is_some() { active.lead_id = Set(cmd.lead_id); } if cmd.icon.is_some() { active.icon = Set(cmd.icon); } if cmd.color.is_some() { active.color = Set(cmd.color); } if let Some(state) = cmd.state { apply_state_transition(&mut active, state); } if cmd.target_date.is_some() { active.target_date = Set(cmd.target_date); } if let Some(h) = cmd.health { let db_h = match h { crate::features::lifecycle::ProjectHealth::OnTrack => { Some(db::project::project_health::ProjectHealth::OnTrack) } crate::features::lifecycle::ProjectHealth::AtRisk => { Some(db::project::project_health::ProjectHealth::AtRisk) } crate::features::lifecycle::ProjectHealth::OffTrack => { Some(db::project::project_health::ProjectHealth::OffTrack) } crate::features::lifecycle::ProjectHealth::None => None, }; active.health = Set(db_h); } if let Some(so) = cmd.sort_order { active.sort_order = Set(so); } active.updated_at = Set(Some(OffsetDateTime::now_utc())); let updated = active.update(&svc.db.writer()).await?; let _ = svc.cache.invalidate_project(project_id).await; log_activity(svc, project_id, actor, "updated", "self", None, None).await; Ok(view_from(&updated)) } pub async fn delete_project( svc: &ProjectService, actor: Uuid, space_id: Uuid, project_id: Uuid, ) -> ProjectResult<()> { crate::features::authz::require( svc, actor, space_id, project_id, permission::DELETE_PROJECT, ) .await?; use db::conn::sea_orm::EntityTrait; project_entity::Entity::delete_by_id(project_id) .exec(&svc.db.writer()) .await?; let _ = svc.cache.invalidate_project(project_id).await; Ok(()) } fn apply_state_transition( active: &mut project_entity::ActiveModel, new: super::ProjectState, ) { let now = OffsetDateTime::now_utc(); let now_db = match new { super::ProjectState::InProgress => DbState::InProgress, super::ProjectState::Paused => DbState::Paused, super::ProjectState::Planned => DbState::Planned, super::ProjectState::Backlog => DbState::Backlog, super::ProjectState::Completed => DbState::Completed, super::ProjectState::Cancelled => DbState::Cancelled, }; active.state = Set(now_db); match now_db { DbState::InProgress => active.started_at = Set(Some(now)), DbState::Completed => active.completed_at = Set(Some(now)), DbState::Cancelled => active.cancelled_at = Set(Some(now)), _ => {} } } pub async fn log_activity( svc: &ProjectService, project_id: Uuid, actor: Uuid, verb: &str, target_kind: &str, target_id: Option, payload: Option, ) { let _ = activity_entity::ActiveModel { id: Set(Uuid::new_v4()), project_id: Set(project_id), actor_id: Set(actor), verb: Set(verb.to_string()), target_kind: Set(target_kind.to_string()), target_id: Set(target_id), payload: Set(payload), created_at: Set(OffsetDateTime::now_utc()), } .insert(&svc.db.writer()) .await; } pub fn view_from(m: &project_entity::Model) -> ProjectView { ProjectView { id: m.id, space_id: m.space_id, creator_id: m.creator_id, lead_id: m.lead_id, name: m.name.clone(), description: m.description.clone(), icon: m.icon.clone(), color: m.color.clone(), state: m.state.into(), target_date: m.target_date, started_at: m.started_at, completed_at: m.completed_at, cancelled_at: m.cancelled_at, health: m.health.into(), sort_order: m.sort_order, starred: m.starred, created_at: m.created_at, updated_at: m.updated_at, } }