Skip to content
Clone
d5939db
272 lines 8.5 KB Rust
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<ProjectView> {
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<ProjectView> {
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<ProjectPage> {
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<ProjectView> {
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<Uuid>,
payload: Option<serde_json::Value>,
) {
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,
}
}