Skip to content
Clone

feat(project): add lifecycle usecase

zhenyi committed
parent b969f09 commit 733c288 Browse files

1 file changed

+272 −0

Added lib/project/features/lifecycle/usecase.rs View lib/project/features/lifecycle/usecase.rs
@@ -0,0 +1,272 @@
1use db::conn::sea_orm::IntoActiveModel;
2use db::project::project as project_entity;
3use db::project::project_activities as activity_entity;
4use db::project::project_members as member_entity;
5use db::project::project_members::ProjectMemberRole;
6use db::project::project_state::ProjectState as DbState;
7use sea_orm::{ActiveModelTrait, Set};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::error::{ProjectError, ProjectResult};
12use crate::features::ProjectService;
13use crate::permission;
14
15use super::{
16 CreateProjectCmd, ListProjectQuery, ProjectPage, ProjectView, UpdateProjectCmd,
17};
18
19pub async fn create_project(
20 svc: &ProjectService,
21 actor: Uuid,
22 cmd: CreateProjectCmd,
23) -> ProjectResult<ProjectView> {
24 let perms =
25 crate::features::authz::resolve(svc, actor, cmd.space_id, Uuid::nil()).await?;
26 perms.require(permission::VIEW_PROJECT).map_err(|a| {
27 ProjectError::Forbidden(format!("caller not authorized: missing {a}"))
28 })?;
29
30 let id = Uuid::new_v4();
31 let now = OffsetDateTime::now_utc();
32 let model = project_entity::ActiveModel {
33 id: Set(id),
34 space_id: Set(cmd.space_id),
35 creator_id: Set(actor),
36 name: Set(cmd.name),
37 description: Set(cmd.description),
38 lead_id: Set(cmd.lead_id),
39 icon: Set(cmd.icon),
40 color: Set(cmd.color),
41 state: Set(DbState::Backlog),
42 target_date: Set(cmd.target_date),
43 started_at: Set(None),
44 completed_at: Set(None),
45 cancelled_at: Set(None),
46 health: Set(None),
47 sort_order: Set(0),
48 starred: Set(0),
49 created_at: Set(now),
50 updated_at: Set(None),
51 };
52 let inserted = model.insert(&svc.db.writer()).await?;
53
54 let _ = member_entity::ActiveModel {
55 id: Set(Uuid::new_v4()),
56 project_id: Set(id),
57 user_id: Set(actor),
58 role: Set(ProjectMemberRole::Owner),
59 joined_at: Set(now),
60 updated_at: Set(None),
61 }
62 .insert(&svc.db.writer())
63 .await?;
64
65 log_activity(svc, id, actor, "created", "self", None, None).await;
66
67 Ok(view_from(&inserted))
68}
69
70pub async fn get_project(
71 svc: &ProjectService,
72 actor: Uuid,
73 space_id: Uuid,
74 project_id: Uuid,
75) -> ProjectResult<ProjectView> {
76 crate::features::authz::require(svc, actor, space_id, project_id, permission::VIEW_PROJECT)
77 .await?;
78 use db::conn::sea_orm::EntityTrait;
79 let row = project_entity::Entity::find_by_id(project_id)
80 .one(&svc.db.reader())
81 .await?
82 .ok_or_else(|| ProjectError::NotFound(format!("project {project_id}")))?;
83 Ok(view_from(&row))
84}
85
86pub async fn list_projects(
87 svc: &ProjectService,
88 actor: Uuid,
89 q: ListProjectQuery,
90) -> ProjectResult<ProjectPage> {
91 crate::features::authz::resolve(svc, actor, q.space_id, Uuid::nil()).await?;
92 let page = q.page.max(1);
93 let per_page = q.per_page.clamp(1, svc.config.max_page_size);
94 let offset = (page - 1) * per_page;
95
96 use db::conn::sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, PaginatorTrait};
97 let mut query = project_entity::Entity::find()
98 .filter(project_entity::Column::SpaceId.eq(q.space_id))
99 .order_by_asc(project_entity::Column::SortOrder)
100 .order_by_desc(project_entity::Column::CreatedAt);
101
102 if let Some(state) = q.state {
103 query = query.filter(project_entity::Column::State.eq(state as i32));
104 }
105 if let Some(lead) = q.lead_id {
106 query = query.filter(project_entity::Column::LeadId.eq(lead));
107 }
108
109 let reader = svc.db.reader();
110 let paginator = query.paginate(&reader, per_page);
111 let total = paginator.num_items().await?;
112 let rows = paginator.fetch_page(offset / per_page).await?;
113 let items = rows.iter().map(view_from).collect();
114 Ok(ProjectPage { total, items })
115}
116
117pub async fn update_project(
118 svc: &ProjectService,
119 actor: Uuid,
120 space_id: Uuid,
121 project_id: Uuid,
122 cmd: UpdateProjectCmd,
123) -> ProjectResult<ProjectView> {
124 crate::features::authz::require(svc, actor, space_id, project_id, permission::EDIT_PROJECT)
125 .await?;
126
127 let current = {
128 use db::conn::sea_orm::EntityTrait;
129 project_entity::Entity::find_by_id(project_id)
130 .one(&svc.db.reader())
131 .await?
132 .ok_or_else(|| ProjectError::NotFound(format!("project {project_id}")))?
133 };
134
135 let mut active: project_entity::ActiveModel = current.into_active_model();
136 if let Some(name) = cmd.name {
137 active.name = Set(name);
138 }
139 if let Some(desc) = cmd.description {
140 active.description = Set(Some(desc));
141 }
142 if cmd.lead_id.is_some() {
143 active.lead_id = Set(cmd.lead_id);
144 }
145 if cmd.icon.is_some() {
146 active.icon = Set(cmd.icon);
147 }
148 if cmd.color.is_some() {
149 active.color = Set(cmd.color);
150 }
151 if let Some(state) = cmd.state {
152 apply_state_transition(&mut active, state);
153 }
154 if cmd.target_date.is_some() {
155 active.target_date = Set(cmd.target_date);
156 }
157 if let Some(h) = cmd.health {
158 let db_h = match h {
159 crate::features::lifecycle::ProjectHealth::OnTrack => {
160 Some(db::project::project_health::ProjectHealth::OnTrack)
161 }
162 crate::features::lifecycle::ProjectHealth::AtRisk => {
163 Some(db::project::project_health::ProjectHealth::AtRisk)
164 }
165 crate::features::lifecycle::ProjectHealth::OffTrack => {
166 Some(db::project::project_health::ProjectHealth::OffTrack)
167 }
168 crate::features::lifecycle::ProjectHealth::None => None,
169 };
170 active.health = Set(db_h);
171 }
172 if let Some(so) = cmd.sort_order {
173 active.sort_order = Set(so);
174 }
175 active.updated_at = Set(Some(OffsetDateTime::now_utc()));
176
177 let updated = active.update(&svc.db.writer()).await?;
178 let _ = svc.cache.invalidate_project(project_id).await;
179
180 log_activity(svc, project_id, actor, "updated", "self", None, None).await;
181 Ok(view_from(&updated))
182}
183
184pub async fn delete_project(
185 svc: &ProjectService,
186 actor: Uuid,
187 space_id: Uuid,
188 project_id: Uuid,
189) -> ProjectResult<()> {
190 crate::features::authz::require(
191 svc,
192 actor,
193 space_id,
194 project_id,
195 permission::DELETE_PROJECT,
196 )
197 .await?;
198 use db::conn::sea_orm::EntityTrait;
199 project_entity::Entity::delete_by_id(project_id)
200 .exec(&svc.db.writer())
201 .await?;
202 let _ = svc.cache.invalidate_project(project_id).await;
203 Ok(())
204}
205
206fn apply_state_transition(
207 active: &mut project_entity::ActiveModel,
208 new: super::ProjectState,
209) {
210 let now = OffsetDateTime::now_utc();
211 let now_db = match new {
212 super::ProjectState::InProgress => DbState::InProgress,
213 super::ProjectState::Paused => DbState::Paused,
214 super::ProjectState::Planned => DbState::Planned,
215 super::ProjectState::Backlog => DbState::Backlog,
216 super::ProjectState::Completed => DbState::Completed,
217 super::ProjectState::Cancelled => DbState::Cancelled,
218 };
219 active.state = Set(now_db);
220 match now_db {
221 DbState::InProgress => active.started_at = Set(Some(now)),
222 DbState::Completed => active.completed_at = Set(Some(now)),
223 DbState::Cancelled => active.cancelled_at = Set(Some(now)),
224 _ => {}
225 }
226}
227
228pub async fn log_activity(
229 svc: &ProjectService,
230 project_id: Uuid,
231 actor: Uuid,
232 verb: &str,
233 target_kind: &str,
234 target_id: Option<Uuid>,
235 payload: Option<serde_json::Value>,
236) {
237 let _ = activity_entity::ActiveModel {
238 id: Set(Uuid::new_v4()),
239 project_id: Set(project_id),
240 actor_id: Set(actor),
241 verb: Set(verb.to_string()),
242 target_kind: Set(target_kind.to_string()),
243 target_id: Set(target_id),
244 payload: Set(payload),
245 created_at: Set(OffsetDateTime::now_utc()),
246 }
247 .insert(&svc.db.writer())
248 .await;
249}
250
251pub fn view_from(m: &project_entity::Model) -> ProjectView {
252 ProjectView {
253 id: m.id,
254 space_id: m.space_id,
255 creator_id: m.creator_id,
256 lead_id: m.lead_id,
257 name: m.name.clone(),
258 description: m.description.clone(),
259 icon: m.icon.clone(),
260 color: m.color.clone(),
261 state: m.state.into(),
262 target_date: m.target_date,
263 started_at: m.started_at,
264 completed_at: m.completed_at,
265 cancelled_at: m.cancelled_at,
266 health: m.health.into(),
267 sort_order: m.sort_order,
268 starred: m.starred,
269 created_at: m.created_at,
270 updated_at: m.updated_at,
271 }
272}