Skip to main content

teamcraft/services/requests/
implementation.rs

1//! Implementation through change orders: create or link one, implemented on release.
2
3use super::*;
4
5/// Result of [`create_change`].
6#[derive(Clone, Debug, Serialize)]
7pub struct CreatedChange {
8    pub change: Change,
9    /// Revisions added to the change order, e.g. `PRT-000001/B`.
10    pub added: Vec<String>,
11    /// Affected items that could not be added, with the reason.
12    pub skipped: Vec<String>,
13}
14
15async fn ensure_implementable(
16    conn: &mut PgConnection,
17    actor: &Actor,
18    request: &ChangeRequest,
19) -> AppResult<()> {
20    let projects = project_ids(conn, request.id).await?;
21    if !may_triage(actor, &projects) {
22        return Err(forbidden(request, "implement"));
23    }
24    if status_of(request) != RequestStatus::Accepted {
25        return Err(AppError::Conflict(format!(
26            "{} is {}; only accepted change requests get a change order.",
27            request.number,
28            status_of(request).label()
29        )));
30    }
31    Ok(())
32}
33
34pub(super) async fn link(
35    conn: &mut PgConnection,
36    actor: &Actor,
37    request_id: Uuid,
38    change_id: Uuid,
39) -> AppResult<bool> {
40    Ok(sqlx::query(
41        "INSERT INTO change_request_changes (request_id, change_id, linked_by) VALUES ($1, $2, $3)
42         ON CONFLICT DO NOTHING",
43    )
44    .bind(request_id)
45    .bind(change_id)
46    .bind(actor.id)
47    .execute(&mut *conn)
48    .await?
49    .rows_affected()
50        > 0)
51}
52
53/// Creates the draft change order of an accepted request with the in-work
54/// revisions of the affected items, all in one transaction. Items without a
55/// revision in work (or whose revision is in another open change, or in a
56/// project group of which the actor is no member) are skipped and reported.
57pub async fn create_change(
58    pool: &PgPool,
59    actor: &Actor,
60    id: Uuid,
61    title: Option<&str>,
62    priority: Option<Priority>,
63) -> AppResult<CreatedChange> {
64    let mut tx = pool.begin().await?;
65    let request = lock(&mut tx, actor, id).await?;
66    ensure_implementable(&mut tx, actor, &request).await?;
67    let title = match title.map(str::trim).filter(|s| !s.is_empty()) {
68        Some(t) => t.to_string(),
69        None => domain::truncate_chars(&format!("{}: {}", request.number, request.title), 200),
70    };
71    let description = domain::truncate_chars(
72        &format!(
73            "Implements {} – {}\n\n{}",
74            request.number, request.title, request.description
75        ),
76        20_000,
77    );
78    let priority = priority
79        .or_else(|| Priority::parse(&request.priority))
80        .unwrap_or(Priority::Normal);
81    let change_id = changes::create_in(
82        &mut tx,
83        actor,
84        changes::ChangeInput {
85            title,
86            description,
87            priority,
88        },
89    )
90    .await?;
91    link(&mut tx, actor, id, change_id).await?;
92    let affected = sqlx::query_as::<_, (Uuid, String, Option<Uuid>, Option<Uuid>)>(
93        "SELECT i.id, i.number, i.project_id,
94                (SELECT r.id FROM revisions r WHERE r.item_id = i.id AND r.state = 'in_work')
95         FROM change_request_items cri JOIN items i ON i.id = cri.item_id
96         WHERE cri.request_id = $1 ORDER BY i.number",
97    )
98    .bind(id)
99    .fetch_all(&mut *tx)
100    .await?;
101    let mut added = Vec::new();
102    // the items the entry names: added ones and skipped ones by number
103    let mut named_items = Vec::new();
104    let mut skipped = Vec::new();
105    for (item_id, number, project_id, in_work) in affected {
106        // An assignee (or triager) may see the request without seeing every
107        // item: a hidden item is skipped without being named.
108        if !actor.can_see_project(project_id) {
109            skipped.push(format!(
110                "{}: not visible to you – ask a member of its group",
111                access::RESTRICTED_LABEL
112            ));
113            continue;
114        }
115        named_items.push(item_id);
116        let Some(rev_id) = in_work else {
117            skipped.push(format!(
118                "{number}: no revision in work – revise it, then add it to the change"
119            ));
120            continue;
121        };
122        // User errors are raised before anything is written; skip and go on.
123        match changes::add_item_in(&mut tx, actor, change_id, rev_id).await {
124            Ok(label) => added.push(label),
125            Err(
126                e @ (AppError::Conflict(_)
127                | AppError::Forbidden(_)
128                | AppError::BadRequest(_)
129                | AppError::NotFound(_)),
130            ) => skipped.push(format!("{number}: {e}")),
131            Err(e) => return Err(e),
132        }
133    }
134    let change_number = sqlx::query_scalar::<_, String>("SELECT number FROM changes WHERE id = $1")
135        .bind(change_id)
136        .fetch_one(&mut *tx)
137        .await?;
138    touch(&mut tx, id).await?;
139    // Names the new change and its items: redacted for readers of the
140    // request who may not see them.
141    audit::record_mentioning(
142        &mut tx,
143        Some(actor),
144        "change_request.create_change",
145        "change_request",
146        Some(id),
147        &audit::Mentions::items(named_items).with_change(change_id),
148        format!("Created change {change_number} for {}", request.number),
149        json!({ "change_id": change_id, "added": added, "skipped": skipped }),
150    )
151    .await?;
152    tell_people_naming(
153        &mut tx,
154        actor,
155        &request,
156        (
157            format!("{change_number} implements {}", request.number),
158            format!(
159                "{} created the change order {change_number} for {}.",
160                actor.display_name, request.title
161            ),
162        ),
163        audit::Mentions::change(change_id),
164        (
165            format!("{} has a change order", request.number),
166            format!(
167                "{} created a change order for {}.",
168                actor.display_name, request.title
169            ),
170        ),
171    )
172    .await?;
173    tx.commit().await?;
174    Ok(CreatedChange {
175        change: changes::get(pool, actor, change_id).await?,
176        added,
177        skipped,
178    })
179}
180
181/// Links an existing open change order to an accepted request.
182pub async fn link_change(
183    pool: &PgPool,
184    actor: &Actor,
185    id: Uuid,
186    change_id: Uuid,
187) -> AppResult<Change> {
188    let mut tx = pool.begin().await?;
189    // the change first, then the request – the order of the release
190    // (`changes::review` → `on_change_released`), so the two never deadlock
191    let (change_number, change_status) = sqlx::query_as::<_, (String, String)>(
192        "SELECT number, status FROM changes WHERE id = $1 FOR UPDATE",
193    )
194    .bind(change_id)
195    .fetch_optional(&mut *tx)
196    .await?
197    .ok_or_else(|| AppError::not_found("Change not found."))?;
198    // A change the actor may not see answers like a missing one, before
199    // anything about it is said or written.
200    if !changes::is_visible(&mut tx, actor, change_id).await? {
201        return Err(AppError::not_found("Change not found."));
202    }
203    let request = lock(&mut tx, actor, id).await?;
204    ensure_implementable(&mut tx, actor, &request).await?;
205    let open = domain::ChangeStatus::parse(&change_status).is_some_and(|s| s.is_open());
206    if !open {
207        return Err(AppError::Conflict(format!(
208            "{change_number} is {change_status}; link a draft or an open change order."
209        )));
210    }
211    if !link(&mut tx, actor, id, change_id).await? {
212        return Err(AppError::Conflict(format!(
213            "{change_number} is already linked to {}.",
214            request.number
215        )));
216    }
217    touch(&mut tx, id).await?;
218    audit::record_mentioning(
219        &mut tx,
220        Some(actor),
221        "change_request.link_change",
222        "change_request",
223        Some(id),
224        &audit::Mentions::change(change_id),
225        format!("Linked {change_number} to {}", request.number),
226        json!({ "change_id": change_id }),
227    )
228    .await?;
229    tell_people_naming(
230        &mut tx,
231        actor,
232        &request,
233        (
234            format!("{change_number} implements {}", request.number),
235            format!(
236                "{} linked the change order {change_number} to {}.",
237                actor.display_name, request.title
238            ),
239        ),
240        audit::Mentions::change(change_id),
241        (
242            format!("{} has a change order", request.number),
243            format!(
244                "{} linked a change order to {}.",
245                actor.display_name, request.title
246            ),
247        ),
248    )
249    .await?;
250    tx.commit().await?;
251    changes::get(pool, actor, change_id).await
252}
253
254/// Called inside the transaction that releases `change`: every accepted
255/// request it implements becomes *implemented*.
256pub(crate) async fn on_change_released(
257    conn: &mut PgConnection,
258    actor: &Actor,
259    change: &Change,
260) -> AppResult<()> {
261    let ids = sqlx::query_scalar::<_, Uuid>(
262        "SELECT cr.id FROM change_requests cr
263         JOIN change_request_changes x ON x.request_id = cr.id
264         WHERE x.change_id = $1 AND cr.status = 'accepted'
265         ORDER BY cr.number",
266    )
267    .bind(change.id)
268    .fetch_all(&mut *conn)
269    .await?;
270    for id in ids {
271        let request = lock_unchecked(conn, id).await?;
272        set_status(conn, &request, RequestStatus::Implemented).await?;
273        // Readers of the request who may not see the change learn only that
274        // the request is implemented.
275        audit::record_mentioning(
276            conn,
277            Some(actor),
278            "change_request.implemented",
279            "change_request",
280            Some(id),
281            &audit::Mentions::change(change.id),
282            format!(
283                "{} is implemented – {} was released",
284                request.number, change.number
285            ),
286            json!({ "change_id": change.id }),
287        )
288        .await?;
289        tell_people_naming(
290            conn,
291            actor,
292            &request,
293            (
294                format!("{} is implemented", request.number),
295                format!(
296                    "{} – released with {} ({}).",
297                    request.title, change.number, change.title
298                ),
299            ),
300            audit::Mentions::change(change.id),
301            (
302                format!("{} is implemented", request.number),
303                format!("{} – the change order was released.", request.title),
304            ),
305        )
306        .await?;
307    }
308    Ok(())
309}