1use super::*;
4
5pub(super) async fn set_status(
7 conn: &mut PgConnection,
8 request: &ChangeRequest,
9 to: RequestStatus,
10) -> AppResult<()> {
11 if !status_of(request).can_transition_to(to) {
12 return Err(not_now(request));
13 }
14 let closes = !to.is_open();
15 sqlx::query(
16 "UPDATE change_requests SET status = $2, updated_at = now(),
17 closed_at = CASE WHEN $3 THEN now() ELSE NULL END
18 WHERE id = $1",
19 )
20 .bind(request.id)
21 .bind(to.as_str())
22 .bind(closes)
23 .execute(&mut *conn)
24 .await?;
25 Ok(())
26}
27
28pub async fn start_triage(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ChangeRequest> {
31 let mut tx = pool.begin().await?;
32 let request = lock(&mut tx, actor, id).await?;
33 let projects = project_ids(&mut tx, id).await?;
34 if !may_triage(actor, &projects) {
35 return Err(forbidden(&request, "triage"));
36 }
37 set_status(&mut tx, &request, RequestStatus::InTriage).await?;
38 if request.assignee.is_none() {
39 sqlx::query("UPDATE change_requests SET assignee = $2 WHERE id = $1")
40 .bind(id)
41 .bind(actor.id)
42 .execute(&mut *tx)
43 .await?;
44 }
45 audit::record(
46 &mut tx,
47 Some(actor),
48 "change_request.triage",
49 "change_request",
50 Some(id),
51 format!("Started the triage of {}", request.number),
52 json!({}),
53 )
54 .await?;
55 tell_people(
56 &mut tx,
57 actor,
58 &request,
59 format!("{} is in triage", request.number),
60 format!("{} – {} looks into it.", request.title, actor.display_name),
61 )
62 .await?;
63 tx.commit().await?;
64 get(pool, actor, id).await
65}
66
67pub async fn assign(
69 pool: &PgPool,
70 actor: &Actor,
71 id: Uuid,
72 username: Option<&str>,
73) -> AppResult<ChangeRequest> {
74 let assignee = match username.map(str::trim).filter(|s| !s.is_empty()) {
75 None => None,
76 Some(name) => {
77 let user = users::find_by_username(pool, name)
78 .await?
79 .ok_or_else(|| AppError::BadRequest(format!("Unknown user '{name}'.")))?;
80 if !user.active {
81 return Err(AppError::BadRequest(format!(
82 "{} is deactivated.",
83 user.display_name
84 )));
85 }
86 if Role::parse(&user.role).unwrap_or(Role::Viewer) < Role::Editor {
87 return Err(AppError::BadRequest(format!(
88 "{} has the viewer role; assign a CAD editor or an admin.",
89 user.display_name
90 )));
91 }
92 Some(user)
93 }
94 };
95 let mut tx = pool.begin().await?;
96 let request = lock(&mut tx, actor, id).await?;
97 let projects = project_ids(&mut tx, id).await?;
98 if !may_triage(actor, &projects) {
99 return Err(forbidden(&request, "assign"));
100 }
101 if !status_of(&request).is_open() {
102 return Err(not_now(&request));
103 }
104 if let Some(user) = &assignee {
105 if !projects.iter().all(|p| actor.can_see_project(*p)) {
108 return Err(AppError::Forbidden(
109 "You can no longer see all affected items of this request.".into(),
110 ));
111 }
112 let blind = sqlx::query_scalar::<_, bool>(&format!(
113 "SELECT EXISTS(SELECT 1 FROM change_request_items cri
114 JOIN items i ON i.id = cri.item_id
115 JOIN users u ON u.id = $2
116 WHERE cri.request_id = $1 AND NOT {})",
117 access::user_sees_project("u", "i.project_id")
118 ))
119 .bind(id)
120 .bind(user.id)
121 .fetch_one(&mut *tx)
122 .await?;
123 if blind {
124 return Err(AppError::Conflict(format!(
125 "{} cannot see every affected item of {} (not a member of a restricted project group).",
126 user.display_name, request.number
127 )));
128 }
129 }
130 sqlx::query("UPDATE change_requests SET assignee = $2, updated_at = now() WHERE id = $1")
131 .bind(id)
132 .bind(assignee.as_ref().map(|u| u.id))
133 .execute(&mut *tx)
134 .await?;
135 let who = assignee
136 .as_ref()
137 .map(|u| u.display_name.clone())
138 .unwrap_or_else(|| "nobody".into());
139 audit::record(
140 &mut tx,
141 Some(actor),
142 "change_request.assign",
143 "change_request",
144 Some(id),
145 format!("Assigned {} to {who}", request.number),
146 json!({ "assignee": assignee.as_ref().map(|u| u.id) }),
147 )
148 .await?;
149 if let Some(user) = &assignee {
150 tell(
151 &mut tx,
152 actor,
153 &request,
154 &[user.id],
155 format!("{} was assigned to you", request.number),
156 format!("{} – assigned by {}.", request.title, actor.display_name),
157 )
158 .await?;
159 }
160 tx.commit().await?;
161 get(pool, actor, id).await
162}
163
164pub async fn decide(
168 pool: &PgPool,
169 actor: &Actor,
170 id: Uuid,
171 decision: RequestDecision,
172 comment: &str,
173) -> AppResult<ChangeRequest> {
174 let comment = domain::limit_text("Comment", comment, 4000)?;
175 if decision != RequestDecision::Accept && comment.is_empty() {
176 return Err(AppError::BadRequest(
177 "Please give a reason when you reject or defer a change request.".into(),
178 ));
179 }
180 let mut tx = pool.begin().await?;
181 let request = lock(&mut tx, actor, id).await?;
182 let projects = project_ids(&mut tx, id).await?;
183 if !may_triage(actor, &projects) {
184 return Err(forbidden(&request, "decide on"));
185 }
186 if request.requested_by == actor.id
187 && crate::services::project_policy::requester_cannot_decide(&mut tx, &projects).await?
188 {
189 return Err(AppError::Forbidden(format!(
190 "You raised {}; in its project group another CAD editor decides on it.",
191 request.number
192 )));
193 }
194 let to = decision.status();
195 set_status(&mut tx, &request, to).await?;
196 sqlx::query(
197 "UPDATE change_requests SET decision_comment = $2, decided_by = $3, decided_at = now()
198 WHERE id = $1",
199 )
200 .bind(id)
201 .bind(&comment)
202 .bind(actor.id)
203 .execute(&mut *tx)
204 .await?;
205 let verb = match decision {
206 RequestDecision::Accept => "accepted",
207 RequestDecision::Reject => "rejected",
208 RequestDecision::Defer => "deferred",
209 };
210 audit::record(
211 &mut tx,
212 Some(actor),
213 &format!("change_request.{verb}"),
214 "change_request",
215 Some(id),
216 format!(
217 "{} {verb}{}",
218 request.number,
219 if comment.is_empty() {
220 String::new()
221 } else {
222 format!(": {comment}")
223 }
224 ),
225 json!({ "comment": comment }),
226 )
227 .await?;
228 tell_people(
229 &mut tx,
230 actor,
231 &request,
232 format!("{} was {verb} by {}", request.number, actor.display_name),
233 if comment.is_empty() {
234 match decision {
235 RequestDecision::Accept => {
236 format!("{} – a change order follows.", request.title)
237 }
238 _ => request.title.clone(),
239 }
240 } else {
241 format!("{} – {comment}", request.title)
242 },
243 )
244 .await?;
245 let decided = lock(&mut tx, actor, id).await?;
246 webhooks::emit(
247 &mut tx,
248 Some(actor),
249 WebhookEvent::ChangeRequestDecided,
250 &projects,
251 json!({ "change_request": webhooks::request_object(&decided),
252 "decision": { "decision": verb, "comment": comment, "decided_by": actor.username } }),
253 )
254 .await?;
255 tx.commit().await?;
256 get(pool, actor, id).await
257}
258
259pub async fn reopen(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ChangeRequest> {
261 let mut tx = pool.begin().await?;
262 let request = lock(&mut tx, actor, id).await?;
263 let projects = project_ids(&mut tx, id).await?;
264 if !may_triage(actor, &projects) {
265 return Err(forbidden(&request, "reopen"));
266 }
267 set_status(&mut tx, &request, RequestStatus::InTriage).await?;
268 audit::record(
269 &mut tx,
270 Some(actor),
271 "change_request.reopen",
272 "change_request",
273 Some(id),
274 format!("Took up {} again", request.number),
275 json!({}),
276 )
277 .await?;
278 tell_people(
279 &mut tx,
280 actor,
281 &request,
282 format!("{} is in triage again", request.number),
283 format!("{} – reopened by {}.", request.title, actor.display_name),
284 )
285 .await?;
286 tx.commit().await?;
287 get(pool, actor, id).await
288}
289
290pub async fn withdraw(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ChangeRequest> {
292 let mut tx = pool.begin().await?;
293 let request = lock(&mut tx, actor, id).await?;
294 if request.requested_by != actor.id && !actor.is_admin() {
295 return Err(AppError::Forbidden(format!(
296 "Only the requester or an administrator can withdraw {}.",
297 request.number
298 )));
299 }
300 set_status(&mut tx, &request, RequestStatus::Withdrawn).await?;
301 audit::record(
302 &mut tx,
303 Some(actor),
304 "change_request.withdraw",
305 "change_request",
306 Some(id),
307 format!("Withdrew {}", request.number),
308 json!({}),
309 )
310 .await?;
311 tell_people(
312 &mut tx,
313 actor,
314 &request,
315 format!("{} was withdrawn", request.number),
316 format!("{} – withdrawn by {}.", request.title, actor.display_name),
317 )
318 .await?;
319 tx.commit().await?;
320 get(pool, actor, id).await
321}