Skip to main content

teamcraft/services/requests/
triage.rs

1//! Triage and decision: status moves, assignment, accept/reject/defer, reopen, withdraw.
2
3use super::*;
4
5/// Moves the request to `to`, checking the transition rules.
6pub(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
28/// Starts the triage (new → in triage); an unassigned request is assigned to
29/// the triaging user.
30pub 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
67/// Assigns the request to a CAD editor or admin (`None` = nobody).
68pub 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        // The check below looks at every item; for items the actor cannot
106        // see it would tell who is a member of their group.
107        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
164/// Accepts, rejects or defers a request. Rejecting and deferring need a reason.
165/// In a group whose review policy has `requester_cannot_decide`, the
166/// requester may not decide (403) – an administrator neither.
167pub 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
259/// Takes a deferred request up again (deferred → in triage).
260pub 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
290/// The requester (or an admin) takes the request back before the decision.
291pub 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}