Skip to main content

teamcraft/services/
changes.rs

1//! Change orders: the only way to release revisions.
2//!
3//! ```text
4//!            submit             all approve
5//!   draft ───────────▶ in_review ───────────▶ released
6//!     ▲  ◀───────────      │
7//!     │    withdraw        │ any reject
8//!     │                    ▼
9//!     └──── reopen ─── rejected
10//!   draft/rejected ── cancel ──▶ cancelled
11//! ```
12//!
13//! While a change is in review, its revisions are `in_review` (frozen). On
14//! approval they become `released`; on rejection or withdrawal they return
15//! to `in_work`.
16
17use serde_json::json;
18use sqlx::{PgConnection, PgPool};
19use uuid::Uuid;
20
21use crate::{
22    auth::Actor,
23    domain::{
24        self, ChangeStatus, Decision, LifecycleState, LineAction, NotificationKind, Priority,
25        ReviewOutcome, Role, WebhookEvent,
26    },
27    error::{AppError, AppResult},
28    models::{Change, ChangeItem, ChangeReview, ChangeReviewRound},
29    services::{
30        access, as_built, audit, impacts, interchange, items, next_counter,
31        notifications::{self, Notice},
32        project_policy, projects, readiness, requests, revisions, users, webhooks, Page,
33    },
34};
35
36/// How a reviewer signs off a decision (#152 step 4).
37#[derive(Clone, Debug)]
38pub struct SignOff {
39    pub method: domain::SignOffMethod,
40    /// What the approval means, e.g. "approved as design owner" (may be empty).
41    pub meaning: String,
42}
43
44impl SignOff {
45    pub fn new(method: domain::SignOffMethod, meaning: &str) -> SignOff {
46        SignOff {
47            method,
48            meaning: meaning.to_string(),
49        }
50    }
51}
52
53/// The change order that released each of these revisions, with its
54/// approvers (for the package manifest, #152 step 8): revision id →
55/// `{change, title, released_at, approvals: [{reviewer, name, approved_at}]}`,
56/// or `{"restricted": true}` when the actor may not see that change order.
57/// Revisions released before change orders tracked them (or not released
58/// at all) are missing.
59pub(crate) async fn releases_of(
60    pool: &PgPool,
61    actor: &Actor,
62    revision_ids: &[Uuid],
63) -> AppResult<std::collections::HashMap<Uuid, serde_json::Value>> {
64    let mut out = std::collections::HashMap::new();
65    if revision_ids.is_empty() {
66        return Ok(out);
67    }
68    let changes = sqlx::query_as::<
69        _,
70        (
71            Uuid,
72            Uuid,
73            String,
74            String,
75            Option<chrono::DateTime<chrono::Utc>>,
76            bool,
77        ),
78    >(&format!(
79        "SELECT ci.revision_id, c.id, c.number, c.title, c.closed_at, {visible}
80         FROM change_items ci JOIN changes c ON c.id = ci.change_id
81         WHERE ci.revision_id = ANY($1) AND ci.action = 'release' AND c.status = 'released'",
82        visible = access::change_visible("c", 2, 3),
83    ))
84    .bind(revision_ids)
85    .bind(actor.id)
86    .bind(&actor.hidden_projects)
87    .fetch_all(pool)
88    .await?;
89    let visible_ids: Vec<Uuid> = changes.iter().filter(|c| c.5).map(|c| c.1).collect();
90    let approvals =
91        sqlx::query_as::<_, (Uuid, String, String, Option<chrono::DateTime<chrono::Utc>>)>(
92            "SELECT cr.change_id, u.username, u.display_name, cr.decided_at
93         FROM change_reviews cr JOIN users u ON u.id = cr.reviewer_id
94         WHERE cr.change_id = ANY($1) AND cr.decision = 'approved'
95         ORDER BY cr.decided_at, u.username",
96        )
97        .bind(&visible_ids)
98        .fetch_all(pool)
99        .await?;
100    for (revision_id, change_id, number, title, released_at, visible) in changes {
101        let value = if visible {
102            json!({
103                "change": number,
104                "title": title,
105                "released_at": released_at,
106                "approvals": approvals
107                    .iter()
108                    .filter(|a| a.0 == change_id)
109                    .map(|a| json!({ "reviewer": a.1, "name": a.2, "approved_at": a.3 }))
110                    .collect::<Vec<_>>(),
111            })
112        } else {
113            json!({ "restricted": true })
114        };
115        out.insert(revision_id, value);
116    }
117    Ok(out)
118}
119
120pub(crate) const CHANGE_SELECT: &str = "
121    SELECT c.id, c.number, c.title, c.description, c.priority, c.status,
122           c.created_by, u.display_name AS created_by_name,
123           c.created_at, c.updated_at, c.submitted_at, c.closed_at
124    FROM changes c JOIN users u ON u.id = c.created_by";
125
126pub fn status_of(change: &Change) -> ChangeStatus {
127    ChangeStatus::parse(&change.status).unwrap_or(ChangeStatus::Cancelled)
128}
129
130/// Change orders the actor may see (see `access::change_visible`); the
131/// total counts the same rows.
132pub async fn list(
133    pool: &PgPool,
134    actor: &Actor,
135    status: Option<ChangeStatus>,
136    page: Page,
137) -> AppResult<(Vec<Change>, i64)> {
138    let status = status.map(ChangeStatus::as_str);
139    let guard = access::change_visible("c", 4, 5);
140    let rows = sqlx::query_as::<_, Change>(&format!(
141        "{CHANGE_SELECT} WHERE ($1::text IS NULL OR c.status = $1) AND {guard}
142         ORDER BY c.created_at DESC LIMIT $2 OFFSET $3"
143    ))
144    .bind(status)
145    .bind(page.limit)
146    .bind(page.offset)
147    .bind(actor.id)
148    .bind(&actor.hidden_projects)
149    .fetch_all(pool)
150    .await?;
151    let total = sqlx::query_scalar::<_, i64>(&format!(
152        "SELECT COUNT(*) FROM changes c WHERE ($1::text IS NULL OR c.status = $1) AND {}",
153        access::change_visible("c", 2, 3)
154    ))
155    .bind(status)
156    .bind(actor.id)
157    .bind(&actor.hidden_projects)
158    .fetch_one(pool)
159    .await?;
160    Ok((rows, total))
161}
162
163/// Whether the actor may see the change (`access::change_visible`).
164pub(crate) async fn is_visible(
165    conn: &mut PgConnection,
166    actor: &Actor,
167    change_id: Uuid,
168) -> AppResult<bool> {
169    Ok(sqlx::query_scalar::<_, bool>(&format!(
170        "SELECT EXISTS(SELECT 1 FROM changes c WHERE c.id = $1 AND {})",
171        access::change_visible("c", 2, 3)
172    ))
173    .bind(change_id)
174    .bind(actor.id)
175    .bind(&actor.hidden_projects)
176    .fetch_one(&mut *conn)
177    .await?)
178}
179
180/// Whether the actor sees every affected item of the change (required to
181/// submit, review or reopen it).
182pub(crate) async fn sees_all_items(
183    conn: &mut PgConnection,
184    actor: &Actor,
185    change_id: Uuid,
186) -> AppResult<bool> {
187    if actor.hidden_projects.is_empty() {
188        return Ok(true);
189    }
190    Ok(!sqlx::query_scalar::<_, bool>(&format!(
191        "SELECT EXISTS(SELECT 1 FROM change_items ci
192                       JOIN revisions r ON r.id = ci.revision_id
193                       JOIN items i ON i.id = r.item_id
194                       WHERE ci.change_id = $1 AND NOT {})",
195        access::item_visible("i", 2)
196    ))
197    .bind(change_id)
198    .bind(&actor.hidden_projects)
199    .fetch_one(&mut *conn)
200    .await?)
201}
202
203fn ensure_sees_all(sees_all: bool) -> AppResult<()> {
204    if sees_all {
205        Ok(())
206    } else {
207        Err(AppError::Forbidden(
208            "You can no longer see all items of this change.".into(),
209        ))
210    }
211}
212
213pub(crate) async fn get_unchecked(pool: &PgPool, id: Uuid) -> AppResult<Change> {
214    sqlx::query_as::<_, Change>(&format!("{CHANGE_SELECT} WHERE c.id = $1"))
215        .bind(id)
216        .fetch_optional(pool)
217        .await?
218        .ok_or_else(|| AppError::not_found("Change not found."))
219}
220
221/// A change the actor may see; hidden changes answer like missing ones.
222pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Change> {
223    let change = get_unchecked(pool, id).await?;
224    let mut conn = pool.acquire().await?;
225    if !is_visible(&mut conn, actor, id).await? {
226        return Err(AppError::not_found("Change not found."));
227    }
228    Ok(change)
229}
230
231/// A change by number, if the actor may see it.
232pub async fn get_by_number(pool: &PgPool, actor: &Actor, number: &str) -> AppResult<Change> {
233    let number = number.trim().to_ascii_uppercase();
234    let missing = || AppError::not_found(format!("Change '{number}' not found."));
235    let change = sqlx::query_as::<_, Change>(&format!("{CHANGE_SELECT} WHERE c.number = $1"))
236        .bind(&number)
237        .fetch_optional(pool)
238        .await?
239        .ok_or_else(missing)?;
240    let mut conn = pool.acquire().await?;
241    if !is_visible(&mut conn, actor, change.id).await? {
242        return Err(missing());
243    }
244    Ok(change)
245}
246
247/// Locks a change the actor may see (hidden ones answer "not found").
248async fn lock(conn: &mut PgConnection, actor: &Actor, id: Uuid) -> AppResult<Change> {
249    let change =
250        sqlx::query_as::<_, Change>(&format!("{CHANGE_SELECT} WHERE c.id = $1 FOR UPDATE OF c"))
251            .bind(id)
252            .fetch_optional(&mut *conn)
253            .await?
254            .ok_or_else(|| AppError::not_found("Change not found."))?;
255    if !is_visible(conn, actor, id).await? {
256        return Err(AppError::not_found("Change not found."));
257    }
258    Ok(change)
259}
260
261/// The affected revisions of a change. Revisions of items the actor may not
262/// read are placeholder rows (`restricted`, no identity).
263pub async fn items(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Vec<ChangeItem>> {
264    let mut rows = items_unchecked(pool, change_id).await?;
265    for row in &mut rows {
266        if !actor.can_see_project(row.project_id) {
267            row.mask();
268        } else if row.successor_number.is_some() && !actor.can_see_project(row.successor_project_id)
269        {
270            row.successor_number = Some(access::RESTRICTED_LABEL.into());
271        }
272    }
273    // placeholders last: their position would bound the hidden number
274    rows.sort_by_key(|r| r.restricted);
275    Ok(rows)
276}
277
278pub(crate) async fn items_unchecked(pool: &PgPool, change_id: Uuid) -> AppResult<Vec<ChangeItem>> {
279    Ok(sqlx::query_as::<_, ChangeItem>(
280        "SELECT r.id AS revision_id, i.id AS item_id, i.number AS item_number, r.code, r.name, r.state,
281                ci.action, ssi.number AS successor_number, ssi.project_id AS successor_project_id,
282                co.display_name AS checked_out_by_name, i.project_id
283         FROM change_items ci
284         JOIN revisions r ON r.id = ci.revision_id
285         JOIN items i ON i.id = r.item_id
286         LEFT JOIN users co ON co.id = r.checked_out_by
287         LEFT JOIN items ssi ON ssi.id = ci.successor_item_id
288         WHERE ci.change_id = $1
289         ORDER BY i.number",
290    )
291    .bind(change_id)
292    .fetch_all(pool)
293    .await?)
294}
295
296/// The reviews of the current round. A pending reviewer who can no longer
297/// decide (deactivated, below the editor role, blind to an affected item) is
298/// flagged in `absent`.
299pub async fn reviews(pool: &PgPool, change_id: Uuid) -> AppResult<Vec<ChangeReview>> {
300    Ok(sqlx::query_as::<_, ChangeReview>(&format!(
301        "SELECT cr.reviewer_id, u.username AS reviewer_username, u.display_name AS reviewer_name,
302                cr.decision, cr.comment, cr.decided_at, cr.sign_off_method, cr.sign_off_meaning,
303                CASE WHEN cr.decision <> 'pending' THEN NULL
304                     WHEN NOT u.active THEN 'deactivated'
305                     WHEN u.role NOT IN ('editor', 'admin') THEN 'not_editor'
306                     WHEN EXISTS (SELECT 1 FROM change_items ci
307                                  JOIN revisions r ON r.id = ci.revision_id
308                                  JOIN items i ON i.id = r.item_id
309                                  WHERE ci.change_id = cr.change_id AND NOT {})
310                          THEN 'cannot_see_items'
311                END AS absent
312         FROM change_reviews cr JOIN users u ON u.id = cr.reviewer_id
313         WHERE cr.change_id = $1
314         ORDER BY u.display_name",
315        access::user_sees_project("u", "i.project_id")
316    ))
317    .bind(change_id)
318    .fetch_all(pool)
319    .await?)
320}
321
322/// The current review round (1 for a change that was never sent back).
323pub async fn review_round(pool: &PgPool, change_id: Uuid) -> AppResult<i32> {
324    Ok(
325        sqlx::query_scalar::<_, i32>("SELECT review_round FROM changes WHERE id = $1")
326            .bind(change_id)
327            .fetch_optional(pool)
328            .await?
329            .unwrap_or(1),
330    )
331}
332
333/// The reviews of the earlier rounds, newest round first.
334pub async fn earlier_rounds(pool: &PgPool, change_id: Uuid) -> AppResult<Vec<ChangeReviewRound>> {
335    Ok(sqlx::query_as::<_, ChangeReviewRound>(
336        "SELECT rr.round, rr.reviewer_id, u.username AS reviewer_username,
337                u.display_name AS reviewer_name, rr.decision, rr.comment, rr.decided_at, rr.closed_at,
338                rr.sign_off_method, rr.sign_off_meaning
339         FROM change_review_rounds rr JOIN users u ON u.id = rr.reviewer_id
340         WHERE rr.change_id = $1
341         ORDER BY rr.round DESC, u.display_name",
342    )
343    .bind(change_id)
344    .fetch_all(pool)
345    .await?)
346}
347
348/// Changes the actor may see that reference a revision, plus the number of
349/// those it may not see ("part of N change orders you cannot see").
350pub async fn for_revision(
351    pool: &PgPool,
352    actor: &Actor,
353    revision_id: Uuid,
354) -> AppResult<(Vec<Change>, i64)> {
355    let all = sqlx::query_as::<_, Change>(&format!(
356        "{CHANGE_SELECT} JOIN change_items ci ON ci.change_id = c.id
357         WHERE ci.revision_id = $1 ORDER BY c.created_at DESC"
358    ))
359    .bind(revision_id)
360    .fetch_all(pool)
361    .await?;
362    let visible = visible_ids(pool, actor, &all.iter().map(|c| c.id).collect::<Vec<_>>()).await?;
363    let hidden = all.iter().filter(|c| !visible.contains(&c.id)).count() as i64;
364    Ok((
365        all.into_iter()
366            .filter(|c| visible.contains(&c.id))
367            .collect(),
368        hidden,
369    ))
370}
371
372/// Of the given changes, those the actor may see.
373pub(crate) async fn visible_ids(
374    pool: &PgPool,
375    actor: &Actor,
376    ids: &[Uuid],
377) -> AppResult<Vec<Uuid>> {
378    if ids.is_empty() {
379        return Ok(Vec::new());
380    }
381    Ok(sqlx::query_scalar::<_, Uuid>(&format!(
382        "SELECT c.id FROM changes c WHERE c.id = ANY($1) AND {}",
383        access::change_visible("c", 2, 3)
384    ))
385    .bind(ids)
386    .bind(actor.id)
387    .bind(&actor.hidden_projects)
388    .fetch_all(pool)
389    .await?)
390}
391
392/// Changes in review that wait for a decision of the actor (only those it
393/// can still see completely).
394pub async fn pending_reviews(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Change>> {
395    Ok(sqlx::query_as::<_, Change>(&format!(
396        "{CHANGE_SELECT} JOIN change_reviews cr ON cr.change_id = c.id
397         WHERE c.status = 'in_review' AND cr.reviewer_id = $1 AND cr.decision = 'pending'
398           AND NOT EXISTS (SELECT 1 FROM change_items pci
399                           JOIN revisions pr ON pr.id = pci.revision_id
400                           JOIN items pi ON pi.id = pr.item_id
401                           WHERE pci.change_id = c.id AND NOT {})
402         ORDER BY c.submitted_at",
403        access::item_visible("pi", 2)
404    ))
405    .bind(actor.id)
406    .bind(&actor.hidden_projects)
407    .fetch_all(pool)
408    .await?)
409}
410
411/// Open changes authored by the actor.
412pub async fn open_by(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Change>> {
413    Ok(sqlx::query_as::<_, Change>(&format!(
414        "{CHANGE_SELECT} WHERE c.created_by = $1 AND c.status IN ('draft', 'in_review', 'rejected')
415         ORDER BY c.updated_at DESC"
416    ))
417    .bind(actor.id)
418    .fetch_all(pool)
419    .await?)
420}
421
422/// Draft changes the actor may see (targets for "add revision to change").
423pub async fn drafts(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Change>> {
424    Ok(sqlx::query_as::<_, Change>(&format!(
425        "{CHANGE_SELECT} WHERE c.status = 'draft' AND {} ORDER BY c.number DESC",
426        access::change_visible("c", 1, 2)
427    ))
428    .bind(actor.id)
429    .bind(&actor.hidden_projects)
430    .fetch_all(pool)
431    .await?)
432}
433
434/// SQL listing the users who changed the revisions of the change in `$1`:
435/// creators of the revisions, uploaders of their files and everybody with a
436/// logged edit (`reviewers::EDIT_ACTIONS`, bound as placeholder `actions`).
437/// A column `user_id`; the caller filters NULLs out.
438pub(crate) fn editors_sql(actions: usize) -> String {
439    format!(
440        "SELECT r.created_by AS user_id
441         FROM change_items ci JOIN revisions r ON r.id = ci.revision_id
442         WHERE ci.change_id = $1
443         UNION
444         SELECT f.uploaded_by
445         FROM change_items ci JOIN files f ON f.revision_id = ci.revision_id
446         WHERE ci.change_id = $1
447         UNION
448         SELECT a.user_id
449         FROM change_items ci
450         JOIN audit_log a ON a.entity_type = 'revision' AND a.entity_id = ci.revision_id
451         WHERE ci.change_id = $1 AND a.user_id IS NOT NULL AND a.action = ANY(${actions})"
452    )
453}
454
455/// Everybody who changed the affected revisions of a change order.
456pub(crate) async fn change_editors(
457    conn: &mut PgConnection,
458    change_id: Uuid,
459) -> AppResult<Vec<Uuid>> {
460    Ok(sqlx::query_scalar::<_, Uuid>(&editors_sql(2))
461        .bind(change_id)
462        .bind(crate::services::reviewers::EDIT_ACTIONS)
463        .fetch_all(&mut *conn)
464        .await?)
465}
466
467/// Audit actions on a change order that count as preparing it.
468pub(crate) const PREPARE_ACTIONS: &[&str] = &[
469    "change.add_item",
470    "change.remove_item",
471    "change.add_reviewer",
472    "change.remove_reviewer",
473    "change.replace_reviewer",
474    "change.update",
475    "change.submit",
476    "change.reopen",
477];
478
479/// Everybody who prepared or submitted a change order for its author: the
480/// recorded submitter, the extra user of the current call and every user with
481/// a logged preparing action on the change (an administrator acting as proxy).
482async fn change_preparers(
483    conn: &mut PgConnection,
484    change_id: Uuid,
485    extra: Option<Uuid>,
486) -> AppResult<Vec<Uuid>> {
487    let mut users = sqlx::query_scalar::<_, Uuid>(
488        "SELECT submitted_by FROM changes WHERE id = $1 AND submitted_by IS NOT NULL
489         UNION
490         SELECT user_id FROM audit_log
491         WHERE entity_type = 'change' AND entity_id = $1 AND user_id IS NOT NULL
492           AND action = ANY($2)",
493    )
494    .bind(change_id)
495    .bind(PREPARE_ACTIONS)
496    .fetch_all(&mut *conn)
497    .await?;
498    users.extend(extra);
499    Ok(users)
500}
501
502/// Separation of duties for one reviewer: not the author, not the person who
503/// prepared or submitted the change and nobody who changed the data. Checked
504/// when a reviewer is added, when the change is submitted and again when the
505/// reviewer decides, because people can edit while the change is a draft.
506/// With `author_only` (a change that was already in review when the rule
507/// arrived) only the author is refused.
508pub(crate) async fn review_conflict(
509    conn: &mut PgConnection,
510    change: &Change,
511    reviewer: Uuid,
512    acting: Option<Uuid>,
513    author_only: bool,
514) -> AppResult<Option<domain::ReviewConflict>> {
515    if author_only {
516        return Ok(domain::conflicting_reviewer(
517            reviewer,
518            change.created_by,
519            None,
520            &[],
521        ));
522    }
523    let editors = change_editors(conn, change.id).await?;
524    let preparers = change_preparers(conn, change.id, acting).await?;
525    Ok(domain::conflicting_reviewer(
526        reviewer,
527        change.created_by,
528        preparers.contains(&reviewer).then_some(reviewer),
529        &editors,
530    ))
531}
532
533fn ensure_owner(change: &Change, actor: &Actor) -> AppResult<()> {
534    actor.require(Role::Editor)?;
535    if change.created_by == actor.id || actor.is_admin() {
536        Ok(())
537    } else {
538        Err(AppError::Forbidden(format!(
539            "Only the author of {} or an administrator can do this.",
540            change.number
541        )))
542    }
543}
544
545/// Whether the actor may act for the author of an abandoned change (issue
546/// #173): the author is deactivated and the actor – a CAD editor – manages
547/// **every** project group of the change's items. A change without items, or
548/// with an item outside any group, has no such manager (administrators only).
549async fn stands_in_for_author(
550    conn: &mut PgConnection,
551    change: &Change,
552    actor: &Actor,
553) -> AppResult<bool> {
554    if !actor.can_edit() || change.created_by == actor.id {
555        return Ok(false);
556    }
557    let author_active = sqlx::query_scalar::<_, bool>("SELECT active FROM users WHERE id = $1")
558        .bind(change.created_by)
559        .fetch_optional(&mut *conn)
560        .await?
561        .unwrap_or(false);
562    if author_active {
563        return Ok(false);
564    }
565    let groups = sqlx::query_scalar::<_, Option<Uuid>>(
566        "SELECT DISTINCT i.project_id FROM change_items ci
567         JOIN revisions r ON r.id = ci.revision_id JOIN items i ON i.id = r.item_id
568         WHERE ci.change_id = $1",
569    )
570    .bind(change.id)
571    .fetch_all(&mut *conn)
572    .await?;
573    if groups.is_empty() {
574        return Ok(false);
575    }
576    for group in groups {
577        let Some(group) = group else {
578            return Ok(false);
579        };
580        if !projects::can_manage(conn, actor, group).await? {
581            return Ok(false);
582        }
583    }
584    Ok(true)
585}
586
587/// [`ensure_owner`], or a manager standing in for a deactivated author
588/// ([`stands_in_for_author`]): for withdrawing, reopening and cancelling.
589async fn ensure_owner_or_stand_in(
590    conn: &mut PgConnection,
591    change: &Change,
592    actor: &Actor,
593) -> AppResult<()> {
594    match ensure_owner(change, actor) {
595        Ok(()) => Ok(()),
596        Err(AppError::Forbidden(_)) if stands_in_for_author(conn, change, actor).await? => Ok(()),
597        Err(AppError::Forbidden(_)) => Err(AppError::Forbidden(format!(
598            "Only the author of {} or an administrator can do this (or, when the author is \
599             deactivated, a manager of every affected project group).",
600            change.number
601        ))),
602        Err(e) => Err(e),
603    }
604}
605
606/// For pages: whether the actor may withdraw, reopen, cancel or take over the
607/// change in place of its deactivated author.
608pub async fn may_stand_in(pool: &PgPool, actor: &Actor, change: &Change) -> AppResult<bool> {
609    let mut conn = pool.acquire().await?;
610    stands_in_for_author(&mut conn, change, actor).await
611}
612
613/// Takes over a draft or rejected change of a deactivated author: the actor
614/// becomes its author and can edit, submit or cancel it (issue #173). It
615/// frees the revisions an abandoned change holds without an administrator.
616///
617/// # Invariants
618///
619/// * **Permission:** CAD editor; the author is deactivated and the actor is
620///   an administrator or manages every affected project group (403); the
621///   change is a draft or rejected (409); the actor sees every item (403) and
622///   is not one of its reviewers – author and reviewer must differ (409).
623/// * **Locks:** the change row (`FOR UPDATE`).
624/// * **Audit:** `change.handover` (as the administrator's hand-over), with
625///   `taken_over: true`.
626/// * **Notifications / webhooks:** none.
627/// * **Data:** `created_by` becomes the actor; reviews and items stay. As
628///   author the actor can no longer review it (four-eyes principle).
629/// * **Atomic:** one transaction.
630pub async fn take_over(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
631    actor.require(Role::Editor)?;
632    let mut tx = pool.begin().await?;
633    let change = lock(&mut tx, actor, change_id).await?;
634    if change.created_by == actor.id {
635        return Err(AppError::Conflict(format!(
636            "You are already the author of {}.",
637            change.number
638        )));
639    }
640    let author_active = sqlx::query_scalar::<_, bool>("SELECT active FROM users WHERE id = $1")
641        .bind(change.created_by)
642        .fetch_optional(&mut *tx)
643        .await?
644        .unwrap_or(false);
645    let allowed = if actor.is_admin() {
646        !author_active
647    } else {
648        stands_in_for_author(&mut tx, &change, actor).await?
649    };
650    if !allowed {
651        return Err(AppError::Forbidden(format!(
652            "{} can be taken over only while its author is deactivated, by a manager of every \
653             affected project group or an administrator.",
654            change.number
655        )));
656    }
657    ensure_status(&change, &[ChangeStatus::Draft, ChangeStatus::Rejected])?;
658    ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
659    let reviewing = sqlx::query_scalar::<_, bool>(
660        "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2)",
661    )
662    .bind(change_id)
663    .bind(actor.id)
664    .fetch_one(&mut *tx)
665    .await?;
666    if reviewing {
667        return Err(AppError::Conflict(format!(
668            "You review {}; author and reviewer must differ. Remove yourself as reviewer first.",
669            change.number
670        )));
671    }
672    sqlx::query("UPDATE changes SET created_by = $2, updated_at = now() WHERE id = $1")
673        .bind(change_id)
674        .bind(actor.id)
675        .execute(&mut *tx)
676        .await?;
677    audit::record(
678        &mut tx,
679        Some(actor),
680        "change.handover",
681        "change",
682        Some(change_id),
683        format!(
684            "{} taken over from {} by {}",
685            change.number, change.created_by_name, actor.display_name
686        ),
687        json!({ "from": change.created_by, "to": actor.id, "taken_over": true }),
688    )
689    .await?;
690    tx.commit().await?;
691    get(pool, actor, change_id).await
692}
693
694fn ensure_status(change: &Change, allowed: &[ChangeStatus]) -> AppResult<ChangeStatus> {
695    let status = status_of(change);
696    if allowed.contains(&status) {
697        Ok(status)
698    } else {
699        Err(AppError::Conflict(format!(
700            "{} is {}; this action is not possible in that state.",
701            change.number,
702            status.label()
703        )))
704    }
705}
706
707async fn touch(conn: &mut PgConnection, change_id: Uuid) -> AppResult<()> {
708    sqlx::query("UPDATE changes SET updated_at = now() WHERE id = $1")
709        .bind(change_id)
710        .execute(&mut *conn)
711        .await?;
712    Ok(())
713}
714
715/// Resets all reviews to pending. When somebody had decided, the round is
716/// archived in `change_review_rounds` first and the next round starts.
717async fn reset_reviews(conn: &mut PgConnection, change_id: Uuid) -> AppResult<()> {
718    let decided = sqlx::query_scalar::<_, bool>(
719        "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND decision <> 'pending')",
720    )
721    .bind(change_id)
722    .fetch_one(&mut *conn)
723    .await?;
724    if decided {
725        sqlx::query(
726            "INSERT INTO change_review_rounds (change_id, round, reviewer_id, decision, comment,
727                                              decided_at, sign_off_method, sign_off_meaning)
728             SELECT cr.change_id, c.review_round, cr.reviewer_id, cr.decision, cr.comment,
729                    cr.decided_at, cr.sign_off_method, cr.sign_off_meaning
730             FROM change_reviews cr JOIN changes c ON c.id = cr.change_id
731             WHERE cr.change_id = $1
732             ON CONFLICT DO NOTHING",
733        )
734        .bind(change_id)
735        .execute(&mut *conn)
736        .await?;
737        sqlx::query("UPDATE changes SET review_round = review_round + 1 WHERE id = $1")
738            .bind(change_id)
739            .execute(&mut *conn)
740            .await?;
741    }
742    sqlx::query(
743        "UPDATE change_reviews SET decision = 'pending', comment = '', decided_at = NULL,
744                sign_off_method = NULL, sign_off_meaning = ''
745         WHERE change_id = $1",
746    )
747    .bind(change_id)
748    .execute(&mut *conn)
749    .await?;
750    Ok(())
751}
752
753/// Moves all revisions the change releases (not the action lines) from one state to another.
754async fn transition_items(
755    conn: &mut PgConnection,
756    change_id: Uuid,
757    to: LifecycleState,
758) -> AppResult<Vec<String>> {
759    let ids = sqlx::query_scalar::<_, Uuid>(
760        "SELECT revision_id FROM change_items
761         WHERE change_id = $1 AND action = 'release' ORDER BY revision_id",
762    )
763    .bind(change_id)
764    .fetch_all(&mut *conn)
765    .await?;
766    let mut labels = Vec::with_capacity(ids.len());
767    for id in ids {
768        let rev = revisions::lock(conn, id).await?;
769        revisions::transition(conn, &rev, to).await?;
770        labels.push(revisions::label(&rev));
771    }
772    Ok(labels)
773}
774
775/// The action lines of a change (obsolete and supersede): revision, action and the successor
776/// item of a supersede line.
777async fn action_lines(
778    conn: &mut PgConnection,
779    change_id: Uuid,
780) -> AppResult<Vec<(Uuid, LineAction, Option<Uuid>)>> {
781    let rows = sqlx::query_as::<_, (Uuid, String, Option<Uuid>)>(
782        "SELECT revision_id, action, successor_item_id FROM change_items
783         WHERE change_id = $1 AND action <> 'release' ORDER BY revision_id",
784    )
785    .bind(change_id)
786    .fetch_all(&mut *conn)
787    .await?;
788    Ok(rows
789        .into_iter()
790        .filter_map(|(rev, action, successor)| {
791            LineAction::parse(&action).map(|a| (rev, a, successor))
792        })
793        .collect())
794}
795
796/// Whether an action line can still be applied: the revision is released and a successor
797/// is still possible (no circle). Called when the line is added, on submit and on release.
798async fn check_action_line(
799    conn: &mut PgConnection,
800    rev: &crate::models::Revision,
801    action: LineAction,
802    successor: Option<Uuid>,
803) -> AppResult<()> {
804    if revisions::state_of(rev) != action.required_state() {
805        return Err(AppError::Conflict(format!(
806            "{} is {}; only released revisions can be obsoleted or superseded through a change.",
807            revisions::label(rev),
808            revisions::state_of(rev).label()
809        )));
810    }
811    if let Some(successor) = successor {
812        let item = items::get_unchecked(&mut *conn, rev.item_id).await?;
813        let successor = items::get_unchecked(&mut *conn, successor).await?;
814        interchange::ensure_no_circle(conn, &item, &successor).await?;
815    }
816    Ok(())
817}
818
819/// Applies the action lines of a released change in its transaction: each revision becomes
820/// obsolete; a supersede line also declares the item superseded (its watchers are told).
821/// Returns the labels of the obsoleted revisions. A line that cannot be applied any more
822/// (the revision is no longer released, the supersession would go in a circle) fails the
823/// whole release with a 409, so nothing is half done.
824async fn apply_action_lines(
825    conn: &mut PgConnection,
826    actor: &Actor,
827    change: &Change,
828) -> AppResult<Vec<String>> {
829    let lines = action_lines(conn, change.id).await?;
830    let mut labels = Vec::with_capacity(lines.len());
831    for (id, action, successor) in lines {
832        let rev = revisions::lock(conn, id).await?;
833        check_action_line(conn, &rev, action, successor).await?;
834        revisions::obsolete_in(conn, actor, &rev).await?;
835        if let Some(successor) = successor {
836            let item = items::get_unchecked(&mut *conn, rev.item_id).await?;
837            let successor = items::get_unchecked(&mut *conn, successor).await?;
838            let note = format!("Through {}", change.number);
839            interchange::supersede_in(conn, actor, &item, &successor, &note).await?;
840        }
841        labels.push(revisions::label(&rev));
842    }
843    Ok(labels)
844}
845
846/// "released A, B; obsoleted C" for the audit entry and the notification of a released change.
847fn release_summary(released: &[String], obsoleted: &[String]) -> String {
848    let mut parts = Vec::new();
849    if !released.is_empty() {
850        parts.push(format!("released {}", released.join(", ")));
851    }
852    if !obsoleted.is_empty() {
853        parts.push(format!("obsoleted {}", obsoleted.join(", ")));
854    }
855    parts.join("; ")
856}
857
858/// Reviewers of a change.
859async fn reviewer_ids(conn: &mut PgConnection, change_id: Uuid) -> AppResult<Vec<Uuid>> {
860    Ok(
861        sqlx::query_scalar::<_, Uuid>(
862            "SELECT reviewer_id FROM change_reviews WHERE change_id = $1",
863        )
864        .bind(change_id)
865        .fetch_all(&mut *conn)
866        .await?,
867    )
868}
869
870/// Sends a notification about a change to the given users.
871async fn tell(
872    conn: &mut PgConnection,
873    actor: &Actor,
874    change: &Change,
875    kind: NotificationKind,
876    recipients: &[Uuid],
877    title: String,
878    body: String,
879) -> AppResult<()> {
880    let notice = Notice::change(kind, change.id, &change.number, title, body);
881    notifications::notify(conn, Some(actor), recipients, &notice).await?;
882    Ok(())
883}
884
885/// [`tell`] with a body that names the change's items (`named`): recipients
886/// who may see the change but not all of them get `redacted_body`.
887#[allow(clippy::too_many_arguments)]
888async fn tell_naming(
889    conn: &mut PgConnection,
890    actor: &Actor,
891    change: &Change,
892    kind: NotificationKind,
893    recipients: &[Uuid],
894    title: String,
895    body: String,
896    named: &audit::Mentions,
897    redacted_body: String,
898) -> AppResult<()> {
899    let notice = Notice::change(kind, change.id, &change.number, title.clone(), body).naming(
900        named.clone(),
901        title,
902        redacted_body,
903    );
904    notifications::notify(conn, Some(actor), recipients, &notice).await?;
905    Ok(())
906}
907
908/// Tells the watchers of the released items (who are not already told about
909/// the change itself) that their item was released.
910async fn tell_item_watchers(
911    conn: &mut PgConnection,
912    actor: &Actor,
913    change: &Change,
914    revision_ids: &[Uuid],
915    already: &[Uuid],
916) -> AppResult<()> {
917    for id in revision_ids {
918        let rev = revisions::lock(conn, *id).await?;
919        let mut watchers = notifications::watchers(conn, "item", &[rev.item_id]).await?;
920        watchers.retain(|w| !already.contains(w));
921        let title = format!("{} was released", revisions::label(&rev));
922        // Watchers who may not see the change (it holds restricted items)
923        // learn only that the item was released.
924        let notice = Notice::revision(
925            NotificationKind::ItemUpdate,
926            &rev,
927            title.clone(),
928            format!(
929                "{} – released with {} ({}).",
930                rev.name, change.number, change.title
931            ),
932        )
933        .naming(
934            audit::Mentions::change(change.id),
935            title,
936            format!("{} – released.", rev.name),
937        );
938        notifications::notify(conn, Some(actor), &watchers, &notice).await?;
939    }
940    Ok(())
941}
942
943pub struct ChangeInput {
944    pub title: String,
945    pub description: String,
946    pub priority: Priority,
947}
948
949/// Creates a draft change.
950///
951/// # Invariants
952///
953/// * **Permission:** CAD editor.
954/// * **Locks:** the `change` counter row, held until commit, so concurrent creations get
955///   different numbers.
956/// * **Audit:** `change.create`.
957/// * **Notifications / webhooks:** none.
958/// * **Atomic:** one transaction; the number is stepped in it and is not used up when it fails.
959pub async fn create(pool: &PgPool, actor: &Actor, input: ChangeInput) -> AppResult<Change> {
960    let mut tx = pool.begin().await?;
961    let id = create_in(&mut tx, actor, input).await?;
962    tx.commit().await?;
963    get(pool, actor, id).await
964}
965
966/// Creates a draft change inside the caller's transaction (also used when an
967/// accepted change request creates its change order) and returns its id.
968pub(crate) async fn create_in(
969    tx: &mut PgConnection,
970    actor: &Actor,
971    input: ChangeInput,
972) -> AppResult<Uuid> {
973    actor.require(Role::Editor)?;
974    let title = domain::require_text("Title", &input.title, 200)?;
975    let description = domain::limit_text("Description", &input.description, 20_000)?;
976    let number = domain::format_change_number(next_counter(&mut *tx, "change").await?);
977    let id = Uuid::new_v4();
978    sqlx::query(
979        "INSERT INTO changes (id, number, title, description, priority, status, created_by)
980         VALUES ($1, $2, $3, $4, $5, 'draft', $6)",
981    )
982    .bind(id)
983    .bind(&number)
984    .bind(&title)
985    .bind(&description)
986    .bind(input.priority.as_str())
987    .bind(actor.id)
988    .execute(&mut *tx)
989    .await?;
990    audit::record(
991        &mut *tx,
992        Some(actor),
993        "change.create",
994        "change",
995        Some(id),
996        format!("Created change {number} – {title}"),
997        json!({ "priority": input.priority.as_str() }),
998    )
999    .await?;
1000    Ok(id)
1001}
1002
1003/// Changes title, description and priority of a change.
1004///
1005/// # Invariants
1006///
1007/// * **Permission:** CAD editor who is the author (or an administrator); the change is a draft
1008///   or rejected (409 otherwise); a change the actor cannot see answers "not found".
1009/// * **Locks:** the change row (`FOR UPDATE`).
1010/// * **Audit:** `change.update`.
1011/// * **Notifications / webhooks:** none.
1012/// * **Atomic:** one transaction.
1013pub async fn update(
1014    pool: &PgPool,
1015    actor: &Actor,
1016    id: Uuid,
1017    input: ChangeInput,
1018) -> AppResult<Change> {
1019    let title = domain::require_text("Title", &input.title, 200)?;
1020    let description = domain::limit_text("Description", &input.description, 20_000)?;
1021    let mut tx = pool.begin().await?;
1022    let change = lock(&mut tx, actor, id).await?;
1023    ensure_owner(&change, actor)?;
1024    ensure_status(&change, &[ChangeStatus::Draft, ChangeStatus::Rejected])?;
1025    sqlx::query(
1026        "UPDATE changes SET title = $2, description = $3, priority = $4, updated_at = now() WHERE id = $1",
1027    )
1028    .bind(id)
1029    .bind(&title)
1030    .bind(&description)
1031    .bind(input.priority.as_str())
1032    .execute(&mut *tx)
1033    .await?;
1034    audit::record(
1035        &mut tx,
1036        Some(actor),
1037        "change.update",
1038        "change",
1039        Some(id),
1040        format!("Updated change {}", change.number),
1041        json!({ "priority": input.priority.as_str() }),
1042    )
1043    .await?;
1044    tx.commit().await?;
1045    get(pool, actor, id).await
1046}
1047
1048/// Adds an in-work revision to a draft change.
1049///
1050/// # Invariants
1051///
1052/// * **Permission:** CAD editor who is the author (or an administrator); the change is a draft
1053///   (409); the revision is visible ("not found" otherwise) and in a group the actor may edit
1054///   (403), in work and in no other open change (draft, in review or rejected), and every
1055///   reviewer of the change can see it (409 each).
1056/// * **Locks:** the change row (`FOR UPDATE`), then the revision row (`revisions::lock`).
1057/// * **Audit:** `change.add_item`, only when the revision was not yet part of the change.
1058/// * **Notifications / webhooks:** none.
1059/// * **Atomic:** one transaction.
1060pub async fn add_item(
1061    pool: &PgPool,
1062    actor: &Actor,
1063    change_id: Uuid,
1064    revision_id: Uuid,
1065) -> AppResult<()> {
1066    let mut tx = pool.begin().await?;
1067    add_item_in(&mut tx, actor, change_id, revision_id).await?;
1068    tx.commit().await?;
1069    Ok(())
1070}
1071
1072/// [`add_item`] inside the caller's transaction (used when a change request
1073/// creates its change order). User errors are raised before anything is
1074/// written, so the caller may skip a revision and go on. Returns its label.
1075pub(crate) async fn add_item_in(
1076    tx: &mut PgConnection,
1077    actor: &Actor,
1078    change_id: Uuid,
1079    revision_id: Uuid,
1080) -> AppResult<String> {
1081    let change = lock(&mut *tx, actor, change_id).await?;
1082    ensure_owner(&change, actor)?;
1083    ensure_status(&change, &[ChangeStatus::Draft])?;
1084    add_item_tx(
1085        &mut *tx,
1086        actor,
1087        &change,
1088        revision_id,
1089        LineAction::Release,
1090        None,
1091    )
1092    .await?;
1093    let rev = revisions::lock(&mut *tx, revision_id).await?;
1094    Ok(revisions::label(&rev))
1095}
1096
1097/// Adds a revision to a locked draft change. Returns whether it was new.
1098async fn add_item_tx(
1099    conn: &mut PgConnection,
1100    actor: &Actor,
1101    change: &Change,
1102    revision_id: Uuid,
1103    action: LineAction,
1104    successor: Option<Uuid>,
1105) -> AppResult<bool> {
1106    let change_id = change.id;
1107    let rev = revisions::lock(conn, revision_id).await?;
1108    // Only members of the item's project group may put it up for release.
1109    revisions::ensure_project_access(&rev, actor)?;
1110    if action == LineAction::Release {
1111        if revisions::state_of(&rev) != LifecycleState::InWork {
1112            return Err(AppError::Conflict(format!(
1113                "Only in-work revisions can be added to a change; {} is {}.",
1114                revisions::label(&rev),
1115                revisions::state_of(&rev).label()
1116            )));
1117        }
1118    } else {
1119        check_action_line(conn, &rev, action, successor).await?;
1120    }
1121    // The other change is named only if the actor may see it (it can hold
1122    // restricted items of other groups).
1123    let other = sqlx::query_as::<_, (String, bool)>(&format!(
1124        "SELECT c.number, {} FROM change_items ci JOIN changes c ON c.id = ci.change_id
1125         WHERE ci.revision_id = $1 AND c.id <> $2 AND c.status IN ('draft', 'in_review', 'rejected')
1126         LIMIT 1",
1127        access::change_visible("c", 3, 4)
1128    ))
1129    .bind(revision_id)
1130    .bind(change_id)
1131    .bind(actor.id)
1132    .bind(&actor.hidden_projects)
1133    .fetch_optional(&mut *conn)
1134    .await?;
1135    if let Some((other, visible)) = other {
1136        return Err(AppError::Conflict(if visible {
1137            format!(
1138                "{} is already part of the open change {other}.",
1139                revisions::label(&rev)
1140            )
1141        } else {
1142            format!(
1143                "{} is already part of another open change.",
1144                revisions::label(&rev)
1145            )
1146        }));
1147    }
1148    // Every reviewer must be able to see the new item (the caller sees it).
1149    let blind = sqlx::query_scalar::<_, String>(&format!(
1150        "SELECT u.display_name FROM change_reviews cr JOIN users u ON u.id = cr.reviewer_id
1151         WHERE cr.change_id = $1 AND NOT {} LIMIT 1",
1152        access::user_sees_project("u", "$2::uuid")
1153    ))
1154    .bind(change_id)
1155    .bind(rev.project_id)
1156    .fetch_optional(&mut *conn)
1157    .await?;
1158    if let Some(name) = blind {
1159        return Err(AppError::Conflict(format!(
1160            "Reviewer {name} is not a member of group {}.",
1161            rev.project_code.as_deref().unwrap_or("?")
1162        )));
1163    }
1164    let inserted = sqlx::query(
1165        "INSERT INTO change_items (change_id, revision_id, action, successor_item_id)
1166         VALUES ($1, $2, $3, $4)
1167         ON CONFLICT (change_id, revision_id) DO UPDATE
1168         SET action = EXCLUDED.action, successor_item_id = EXCLUDED.successor_item_id
1169         WHERE change_items.action <> 'release'
1170           AND (change_items.action, change_items.successor_item_id)
1171               IS DISTINCT FROM (EXCLUDED.action, EXCLUDED.successor_item_id)",
1172    )
1173    .bind(change_id)
1174    .bind(revision_id)
1175    .bind(action.as_str())
1176    .bind(successor)
1177    .execute(&mut *conn)
1178    .await?
1179    .rows_affected();
1180    if inserted > 0 {
1181        touch(conn, change_id).await?;
1182        let verb = match action {
1183            LineAction::Release => "Added",
1184            LineAction::Obsolete => "Added an obsolete line for",
1185            LineAction::Supersede => "Added a supersede line for",
1186        };
1187        audit::record_related(
1188            conn,
1189            Some(actor),
1190            "change.add_item",
1191            "change",
1192            Some(change_id),
1193            Some(rev.item_id),
1194            format!("{verb} {} to {}", revisions::label(&rev), change.number),
1195            json!({ "revision_id": revision_id, "item_id": rev.item_id,
1196                    "action": action.as_str(), "successor_item_id": successor }),
1197        )
1198        .await?;
1199    }
1200    Ok(inserted > 0)
1201}
1202
1203/// Adds an action line to a draft change: the released revision is obsoleted
1204/// (`obsolete`) or obsoleted and its item declared superseded by `successor_number`
1205/// (`supersede`) when the change is released. Allowed in every group; groups with
1206/// `obsolete_requires_change` demand it.
1207///
1208/// # Invariants
1209///
1210/// * **Permission:** CAD editor who is the author (or an administrator); the change is a draft
1211///   (409); the revision is visible ("not found" otherwise) and in a group the actor may edit
1212///   (403), released (409) and in no other open change (409); every reviewer of the change can
1213///   see it (409 each). A supersede line needs a visible successor item that is not the item
1214///   itself (400) and does not close a circle (409); an obsolete line takes no successor (400).
1215/// * **Locks:** the change row (`FOR UPDATE`), the revision row, the table
1216///   `item_supersessions` for a supersede line.
1217/// * **Audit:** `change.add_item` with the action, only when the line is new or changed.
1218/// * **Notifications / webhooks:** none.
1219/// * **Atomic:** one transaction.
1220pub async fn add_action(
1221    pool: &PgPool,
1222    actor: &Actor,
1223    change_id: Uuid,
1224    revision_id: Uuid,
1225    action: LineAction,
1226    successor_number: Option<&str>,
1227) -> AppResult<()> {
1228    let number = successor_number.map(str::trim).filter(|s| !s.is_empty());
1229    let successor =
1230        match (action, number) {
1231            (LineAction::Release, _) => return Err(AppError::BadRequest(
1232                "Use add_item to release an in-work revision; actions are obsolete and supersede."
1233                    .into(),
1234            )),
1235            (LineAction::Obsolete, Some(_)) => {
1236                return Err(AppError::BadRequest(
1237                    "An obsolete line takes no successor; use supersede.".into(),
1238                ))
1239            }
1240            (LineAction::Obsolete, None) => None,
1241            (LineAction::Supersede, None) => {
1242                return Err(AppError::BadRequest(
1243                    "Name the successor item for a supersede line.".into(),
1244                ))
1245            }
1246            (LineAction::Supersede, Some(number)) => {
1247                Some(items::get_by_number(pool, actor, number).await?)
1248            }
1249        };
1250    let mut tx = pool.begin().await?;
1251    let change = lock(&mut tx, actor, change_id).await?;
1252    ensure_owner(&change, actor)?;
1253    ensure_status(&change, &[ChangeStatus::Draft])?;
1254    if let Some(successor) = &successor {
1255        let rev = revisions::lock(&mut tx, revision_id).await?;
1256        if rev.item_id == successor.id {
1257            return Err(AppError::BadRequest(
1258                "An item cannot supersede itself.".into(),
1259            ));
1260        }
1261    }
1262    add_item_tx(
1263        &mut tx,
1264        actor,
1265        &change,
1266        revision_id,
1267        action,
1268        successor.map(|s| s.id),
1269    )
1270    .await?;
1271    tx.commit().await?;
1272    Ok(())
1273}
1274
1275/// The release readiness of a change (see `services::readiness`).
1276pub async fn readiness(
1277    pool: &PgPool,
1278    actor: &Actor,
1279    change_id: Uuid,
1280) -> AppResult<readiness::Readiness> {
1281    let mut conn = pool.acquire().await?;
1282    Ok(readiness::check(&mut conn, change_id).await?.redact(actor))
1283}
1284
1285/// Impact analysis of a change the actor may see: the dependents of its
1286/// revisions (hidden ones dropped and counted).
1287pub async fn impact(
1288    pool: &PgPool,
1289    actor: &Actor,
1290    change_id: Uuid,
1291) -> AppResult<(Vec<impacts::Dependent>, i64)> {
1292    get(pool, actor, change_id).await?;
1293    let ids: Vec<Uuid> = items_unchecked(pool, change_id)
1294        .await?
1295        .iter()
1296        .map(|i| i.revision_id)
1297        .collect();
1298    impacts::analysis(pool, actor, &ids).await
1299}
1300
1301/// Upper bound for the rounds of [`add_dependencies`]: each round adds the
1302/// next level of unreleased dependencies.
1303const MAX_DEPENDENCY_ROUNDS: usize = 64;
1304
1305/// Adds the in-work revisions of all unreleased dependencies – and, level by
1306/// level, their own unreleased dependencies – to a draft change, in one
1307/// transaction. Returns the labels of the added revisions.
1308///
1309/// # Invariants
1310///
1311/// * **Permission:** as [`add_item`] for the change; a dependency in a group the actor may not
1312///   edit is skipped (it stays a blocker), every other refusal of [`add_item`] aborts all.
1313/// * **Locks:** the change row (`FOR UPDATE`), then each dependency revision
1314///   (`revisions::lock`).
1315/// * **Audit:** `change.add_item` per added revision.
1316/// * **Notifications / webhooks:** none.
1317/// * **Atomic:** one transaction; all dependencies are added or none.
1318pub async fn add_dependencies(
1319    pool: &PgPool,
1320    actor: &Actor,
1321    change_id: Uuid,
1322) -> AppResult<Vec<String>> {
1323    let mut tx = pool.begin().await?;
1324    let change = lock(&mut tx, actor, change_id).await?;
1325    ensure_owner(&change, actor)?;
1326    ensure_status(&change, &[ChangeStatus::Draft])?;
1327    let mut added = Vec::new();
1328    for _ in 0..MAX_DEPENDENCY_ROUNDS {
1329        let ids = readiness::check(&mut tx, change_id)
1330            .await?
1331            .addable_blockers();
1332        if ids.is_empty() {
1333            break;
1334        }
1335        let mut progress = false;
1336        for id in ids {
1337            // A dependency of another project group (e.g. a shared library
1338            // part) is left for its members; it stays a blocker, but does
1339            // not stop the others from being added.
1340            let rev = revisions::lock(&mut tx, id).await?;
1341            if revisions::ensure_project_access(&rev, actor).is_err() {
1342                continue;
1343            }
1344            if add_item_tx(&mut tx, actor, &change, id, LineAction::Release, None).await? {
1345                progress = true;
1346                added.push(revisions::label(&rev));
1347            }
1348        }
1349        if !progress {
1350            break;
1351        }
1352    }
1353    tx.commit().await?;
1354    Ok(added)
1355}
1356
1357/// Removes a revision from a draft change.
1358///
1359/// # Invariants
1360///
1361/// * **Permission:** CAD editor who is the author (or an administrator); the change is a draft
1362///   (409); the revision is part of the change (404 otherwise).
1363/// * **Locks:** the change row (`FOR UPDATE`), then the revision row (`revisions::lock`).
1364/// * **Audit:** `change.remove_item`.
1365/// * **Notifications / webhooks:** none.
1366/// * **Atomic:** one transaction.
1367pub async fn remove_item(
1368    pool: &PgPool,
1369    actor: &Actor,
1370    change_id: Uuid,
1371    revision_id: Uuid,
1372) -> AppResult<()> {
1373    let mut tx = pool.begin().await?;
1374    let change = lock(&mut tx, actor, change_id).await?;
1375    ensure_owner(&change, actor)?;
1376    ensure_status(&change, &[ChangeStatus::Draft])?;
1377    let removed = sqlx::query("DELETE FROM change_items WHERE change_id = $1 AND revision_id = $2")
1378        .bind(change_id)
1379        .bind(revision_id)
1380        .execute(&mut *tx)
1381        .await?
1382        .rows_affected();
1383    if removed == 0 {
1384        return Err(AppError::not_found(
1385            "The revision is not part of this change.",
1386        ));
1387    }
1388    let rev = revisions::lock(&mut tx, revision_id).await?;
1389    touch(&mut tx, change_id).await?;
1390    audit::record_related(
1391        &mut tx,
1392        Some(actor),
1393        "change.remove_item",
1394        "change",
1395        Some(change_id),
1396        Some(rev.item_id),
1397        format!("Removed {} from {}", revisions::label(&rev), change.number),
1398        json!({ "revision_id": revision_id, "item_id": rev.item_id }),
1399    )
1400    .await?;
1401    tx.commit().await?;
1402    Ok(())
1403}
1404
1405/// Assigns a reviewer. Authors cannot review their own change (four-eyes principle).
1406///
1407/// # Invariants
1408///
1409/// * **Permission:** CAD editor who is the author (or an administrator); the change is a draft
1410///   (409) and the actor sees every item of it (403); the reviewer is an active user with at
1411///   least the editor role (400), is not the author (400) and sees every item (409).
1412/// * **Locks:** the change row (`FOR UPDATE`).
1413/// * **Audit:** `change.add_reviewer`, only when the reviewer was not yet assigned.
1414/// * **Notifications / webhooks:** none.
1415/// * **Atomic:** one transaction.
1416pub async fn add_reviewer(
1417    pool: &PgPool,
1418    actor: &Actor,
1419    change_id: Uuid,
1420    username: &str,
1421) -> AppResult<()> {
1422    let reviewer = users::find_by_username(pool, username)
1423        .await?
1424        .ok_or_else(|| AppError::BadRequest(format!("Unknown user '{}'.", username.trim())))?;
1425    if !reviewer.active {
1426        return Err(AppError::BadRequest(format!(
1427            "{} is deactivated.",
1428            reviewer.display_name
1429        )));
1430    }
1431    if Role::parse(&reviewer.role).unwrap_or(Role::Viewer) < Role::Editor {
1432        return Err(AppError::BadRequest(format!(
1433            "{} has the viewer role and cannot review changes.",
1434            reviewer.display_name
1435        )));
1436    }
1437    let mut tx = pool.begin().await?;
1438    let change = lock(&mut tx, actor, change_id).await?;
1439    ensure_owner(&change, actor)?;
1440    ensure_status(&change, &[ChangeStatus::Draft])?;
1441    // The check below looks at every item; for items the actor cannot see
1442    // it would tell who is a member of their group.
1443    ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1444    if let Some(conflict) =
1445        review_conflict(&mut tx, &change, reviewer.id, Some(actor.id), false).await?
1446    {
1447        return Err(AppError::BadRequest(format!(
1448            "{} cannot review {}: they {} (four-eyes principle).",
1449            reviewer.display_name,
1450            change.number,
1451            conflict.describe()
1452        )));
1453    }
1454    // A reviewer must see everything they approve.
1455    let blind = sqlx::query_scalar::<_, bool>(&format!(
1456        "SELECT EXISTS(SELECT 1 FROM change_items ci
1457                       JOIN revisions r ON r.id = ci.revision_id
1458                       JOIN items i ON i.id = r.item_id
1459                       JOIN users u ON u.id = $2
1460                       WHERE ci.change_id = $1 AND NOT {})",
1461        access::user_sees_project("u", "i.project_id")
1462    ))
1463    .bind(change_id)
1464    .bind(reviewer.id)
1465    .fetch_one(&mut *tx)
1466    .await?;
1467    if blind {
1468        return Err(AppError::Conflict(format!(
1469            "{} cannot see every item of {} (not a member of a restricted project group).",
1470            reviewer.display_name, change.number
1471        )));
1472    }
1473    let inserted = sqlx::query(
1474        "INSERT INTO change_reviews (change_id, reviewer_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
1475    )
1476    .bind(change_id)
1477    .bind(reviewer.id)
1478    .execute(&mut *tx)
1479    .await?
1480    .rows_affected();
1481    if inserted > 0 {
1482        touch(&mut tx, change_id).await?;
1483        audit::record(
1484            &mut tx,
1485            Some(actor),
1486            "change.add_reviewer",
1487            "change",
1488            Some(change_id),
1489            format!(
1490                "Asked {} to review {}",
1491                reviewer.display_name, change.number
1492            ),
1493            json!({ "reviewer_id": reviewer.id }),
1494        )
1495        .await?;
1496    }
1497    tx.commit().await?;
1498    Ok(())
1499}
1500
1501/// Replaces a reviewer who has not decided yet while the change is in review
1502/// (typically one who is absent: deactivated, demoted or blind to an item).
1503/// The other decisions are kept.
1504///
1505/// # Invariants
1506///
1507/// * **Permission:** CAD editor who is the author (or an administrator) and sees every item
1508///   (403); the change is in review (409); `old` is a reviewer with a pending decision (404 /
1509///   409); the substitute is an active user with at least the editor role (400), not yet a
1510///   reviewer (409), conflict-free under the four-eyes rule (400) and sees every item (409).
1511/// * **Locks:** the change row (`FOR UPDATE`).
1512/// * **Audit:** `change.replace_reviewer`.
1513/// * **Notifications:** the substitute (review requested); webhooks: none.
1514/// * **Atomic:** one transaction.
1515pub async fn replace_reviewer(
1516    pool: &PgPool,
1517    actor: &Actor,
1518    change_id: Uuid,
1519    old_reviewer_id: Uuid,
1520    username: &str,
1521) -> AppResult<()> {
1522    let substitute = users::find_by_username(pool, username)
1523        .await?
1524        .ok_or_else(|| AppError::BadRequest(format!("Unknown user '{}'.", username.trim())))?;
1525    if !substitute.active {
1526        return Err(AppError::BadRequest(format!(
1527            "{} is deactivated.",
1528            substitute.display_name
1529        )));
1530    }
1531    if Role::parse(&substitute.role).unwrap_or(Role::Viewer) < Role::Editor {
1532        return Err(AppError::BadRequest(format!(
1533            "{} has the viewer role and cannot review changes.",
1534            substitute.display_name
1535        )));
1536    }
1537    let mut tx = pool.begin().await?;
1538    let change = lock(&mut tx, actor, change_id).await?;
1539    ensure_owner(&change, actor)?;
1540    ensure_status(&change, &[ChangeStatus::InReview])?;
1541    ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1542    let current = sqlx::query_as::<_, (String, String)>(
1543        "SELECT cr.decision, u.display_name FROM change_reviews cr
1544         JOIN users u ON u.id = cr.reviewer_id
1545         WHERE cr.change_id = $1 AND cr.reviewer_id = $2",
1546    )
1547    .bind(change_id)
1548    .bind(old_reviewer_id)
1549    .fetch_optional(&mut *tx)
1550    .await?
1551    .ok_or_else(|| AppError::not_found("This user is not a reviewer of the change."))?;
1552    if current.0 != Decision::Pending.as_str() {
1553        return Err(AppError::Conflict(format!(
1554            "{} has already decided; only a pending reviewer can be replaced.",
1555            current.1
1556        )));
1557    }
1558    let already = sqlx::query_scalar::<_, bool>(
1559        "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2)",
1560    )
1561    .bind(change_id)
1562    .bind(substitute.id)
1563    .fetch_one(&mut *tx)
1564    .await?;
1565    if already {
1566        return Err(AppError::Conflict(format!(
1567            "{} is already a reviewer of {}.",
1568            substitute.display_name, change.number
1569        )));
1570    }
1571    let four_eyes = sqlx::query_scalar::<_, bool>("SELECT four_eyes FROM changes WHERE id = $1")
1572        .bind(change_id)
1573        .fetch_one(&mut *tx)
1574        .await?;
1575    if let Some(conflict) =
1576        review_conflict(&mut tx, &change, substitute.id, Some(actor.id), !four_eyes).await?
1577    {
1578        return Err(AppError::BadRequest(format!(
1579            "{} cannot review {}: they {} (four-eyes principle).",
1580            substitute.display_name,
1581            change.number,
1582            conflict.describe()
1583        )));
1584    }
1585    let blind = sqlx::query_scalar::<_, bool>(&format!(
1586        "SELECT EXISTS(SELECT 1 FROM change_items ci
1587                       JOIN revisions r ON r.id = ci.revision_id
1588                       JOIN items i ON i.id = r.item_id
1589                       JOIN users u ON u.id = $2
1590                       WHERE ci.change_id = $1 AND NOT {})",
1591        access::user_sees_project("u", "i.project_id")
1592    ))
1593    .bind(change_id)
1594    .bind(substitute.id)
1595    .fetch_one(&mut *tx)
1596    .await?;
1597    if blind {
1598        return Err(AppError::Conflict(format!(
1599            "{} cannot see every item of {} (not a member of a restricted project group).",
1600            substitute.display_name, change.number
1601        )));
1602    }
1603    sqlx::query(
1604        "UPDATE change_reviews SET reviewer_id = $3 WHERE change_id = $1 AND reviewer_id = $2",
1605    )
1606    .bind(change_id)
1607    .bind(old_reviewer_id)
1608    .bind(substitute.id)
1609    .execute(&mut *tx)
1610    .await?;
1611    touch(&mut tx, change_id).await?;
1612    audit::record(
1613        &mut tx,
1614        Some(actor),
1615        "change.replace_reviewer",
1616        "change",
1617        Some(change_id),
1618        format!(
1619            "Replaced reviewer {} by {} on {}",
1620            current.1, substitute.display_name, change.number
1621        ),
1622        json!({ "old_reviewer_id": old_reviewer_id, "reviewer_id": substitute.id }),
1623    )
1624    .await?;
1625    tell(
1626        &mut tx,
1627        actor,
1628        &change,
1629        NotificationKind::ReviewRequested,
1630        &[substitute.id],
1631        format!("Review requested: {} – {}", change.number, change.title),
1632        format!("{} asks you to review this change.", actor.display_name),
1633    )
1634    .await?;
1635    tx.commit().await?;
1636    Ok(())
1637}
1638
1639/// Removes a reviewer from a draft change.
1640///
1641/// # Invariants
1642///
1643/// * **Permission:** CAD editor who is the author (or an administrator); the change is a draft
1644///   (409); the user is a reviewer of it (404 otherwise).
1645/// * **Locks:** the change row (`FOR UPDATE`).
1646/// * **Audit:** `change.remove_reviewer`.
1647/// * **Notifications / webhooks:** none.
1648/// * **Atomic:** one transaction.
1649pub async fn remove_reviewer(
1650    pool: &PgPool,
1651    actor: &Actor,
1652    change_id: Uuid,
1653    reviewer_id: Uuid,
1654) -> AppResult<()> {
1655    let mut tx = pool.begin().await?;
1656    let change = lock(&mut tx, actor, change_id).await?;
1657    ensure_owner(&change, actor)?;
1658    ensure_status(&change, &[ChangeStatus::Draft])?;
1659    let removed =
1660        sqlx::query("DELETE FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2")
1661            .bind(change_id)
1662            .bind(reviewer_id)
1663            .execute(&mut *tx)
1664            .await?
1665            .rows_affected();
1666    if removed == 0 {
1667        return Err(AppError::not_found(
1668            "This user is not a reviewer of the change.",
1669        ));
1670    }
1671    touch(&mut tx, change_id).await?;
1672    audit::record(
1673        &mut tx,
1674        Some(actor),
1675        "change.remove_reviewer",
1676        "change",
1677        Some(change_id),
1678        format!("Removed a reviewer from {}", change.number),
1679        json!({ "reviewer_id": reviewer_id }),
1680    )
1681    .await?;
1682    tx.commit().await?;
1683    Ok(())
1684}
1685
1686/// Submits a draft change for review and freezes its revisions.
1687///
1688/// # Invariants
1689///
1690/// * **Permission:** CAD editor who is the author (or an administrator); the change is a draft
1691///   (409) and the actor sees every item (403); it has an item and a reviewer, and no reviewer is deactivated (409); every
1692///   revision is in a group the actor may edit (403) and not checked out by someone else (409);
1693///   no dependency is unreleased unless it is in the change (409).
1694/// * **Locks:** the change row (`FOR UPDATE`), then every revision (`revisions::lock`) before
1695///   its state changes.
1696/// * **Review policy:** every affected project group's policy is met (409): enough
1697///   reviewers for the minimum approvals, the required people and a reviewer from each role group.
1698/// * **Audit:** `change.submit`.
1699/// * **Notifications:** the reviewers (review requested) and the other watchers of the change;
1700///   readers who may not see every item get a text without item names; never the actor.
1701/// * **Webhooks:** `change.submitted`.
1702/// * **Data:** the revisions go to in review (a check-out is cleared) and all reviews are reset
1703///   to pending.
1704/// * **Atomic:** one transaction.
1705pub async fn submit(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
1706    let mut tx = pool.begin().await?;
1707    let change = lock(&mut tx, actor, change_id).await?;
1708    ensure_owner(&change, actor)?;
1709    ensure_status(&change, &[ChangeStatus::Draft])?;
1710    ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1711    let item_ids = sqlx::query_scalar::<_, Uuid>(
1712        "SELECT revision_id FROM change_items WHERE change_id = $1 ORDER BY revision_id",
1713    )
1714    .bind(change_id)
1715    .fetch_all(&mut *tx)
1716    .await?;
1717    if item_ids.is_empty() {
1718        return Err(AppError::Conflict(
1719            "Add at least one revision before submitting the change.".into(),
1720        ));
1721    }
1722    let reviewer_count =
1723        sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM change_reviews WHERE change_id = $1")
1724            .bind(change_id)
1725            .fetch_one(&mut *tx)
1726            .await?;
1727    if reviewer_count == 0 {
1728        return Err(AppError::Conflict(
1729            "Assign at least one reviewer before submitting the change.".into(),
1730        ));
1731    }
1732    // Separation of duties again: people may have edited since they were added.
1733    let reviewers = sqlx::query_as::<_, (Uuid, String, bool)>(
1734        "SELECT cr.reviewer_id, u.display_name, u.active FROM change_reviews cr
1735         JOIN users u ON u.id = cr.reviewer_id WHERE cr.change_id = $1 ORDER BY u.username",
1736    )
1737    .bind(change_id)
1738    .fetch_all(&mut *tx)
1739    .await?;
1740    for (reviewer, name, active) in &reviewers {
1741        // A reviewer deactivated after being added could never decide, and
1742        // the change would stay in review for good (issue #173).
1743        if !active {
1744            return Err(AppError::Conflict(format!(
1745                "{name} is deactivated and cannot review {}. Replace them first: remove them as \
1746                 reviewer and add someone else.",
1747                change.number
1748            )));
1749        }
1750        if let Some(conflict) =
1751            review_conflict(&mut tx, &change, *reviewer, Some(actor.id), false).await?
1752        {
1753            return Err(AppError::Conflict(format!(
1754                "{name} cannot review {}: they {} (four-eyes principle). Remove them as reviewer first.",
1755                change.number,
1756                conflict.describe()
1757            )));
1758        }
1759    }
1760    // The review policy of every affected project group.
1761    let requirement = project_policy::review_requirement(&mut tx, Some(actor), change_id).await?;
1762    let reviewer_uuids: Vec<Uuid> = reviewers.iter().map(|(id, _, _)| *id).collect();
1763    // A required person who may not review this change (its author, an editor
1764    // of its data) can never be added: say so instead of "must be a reviewer".
1765    for (user, name) in &requirement.required_users {
1766        if reviewer_uuids.contains(user) {
1767            continue;
1768        }
1769        if let Some(conflict) =
1770            review_conflict(&mut tx, &change, *user, Some(actor.id), false).await?
1771        {
1772            return Err(AppError::Conflict(format!(
1773                "{name} is required by the review policy of {} but cannot review it: they {} (four-eyes principle). Change the policy or the change.",
1774                change.number,
1775                conflict.describe()
1776            )));
1777        }
1778    }
1779    let shortfalls = domain::policy_shortfalls(&reviewer_uuids, &requirement);
1780    if !shortfalls.is_empty() {
1781        return Err(AppError::Conflict(format!(
1782            "{} does not meet the review policy of its project groups: {}.",
1783            change.number,
1784            shortfalls.join("; ")
1785        )));
1786    }
1787    let action_lines = action_lines(&mut tx, change_id).await?;
1788    for (id, action, successor) in &action_lines {
1789        let rev = revisions::lock(&mut tx, *id).await?;
1790        revisions::ensure_project_access(&rev, actor)?;
1791        check_action_line(&mut tx, &rev, *action, *successor).await?;
1792    }
1793    for id in &item_ids {
1794        if action_lines.iter().any(|(a, _, _)| a == id) {
1795            continue;
1796        }
1797        let rev = revisions::lock(&mut tx, *id).await?;
1798        // Membership may have changed since the revision was added.
1799        revisions::ensure_project_access(&rev, actor)?;
1800        // An as-built revision needs its build record and a disposition
1801        // for every deviation before it goes into review.
1802        as_built::ensure_ready(&mut tx, &rev).await?;
1803        if let Some(owner) = rev.checked_out_by {
1804            if owner != actor.id {
1805                return Err(AppError::Conflict(format!(
1806                    "{} is checked out by {}; it must be checked in first.",
1807                    revisions::label(&rev),
1808                    rev.checked_out_by_name.as_deref().unwrap_or("another user")
1809                )));
1810            }
1811        }
1812    }
1813    let ready = readiness::check(&mut tx, change_id).await?.redact(actor);
1814    if !ready.is_ready() {
1815        return Err(AppError::Conflict(format!(
1816            "{} cannot be submitted: {} not released – {}. Add {} to this change or release {} first.",
1817            change.number,
1818            if ready.blockers.len() == 1 { "a dependency is" } else { "dependencies are" },
1819            ready.blocker_summary(),
1820            if ready.blockers.len() == 1 { "it" } else { "them" },
1821            if ready.blockers.len() == 1 { "it" } else { "them" },
1822        )));
1823    }
1824    let labels = transition_items(&mut tx, change_id, LifecycleState::InReview).await?;
1825    reset_reviews(&mut tx, change_id).await?;
1826    sqlx::query(
1827        "UPDATE changes SET status = 'in_review', submitted_at = now(), submitted_by = $2, four_eyes = true, updated_at = now() WHERE id = $1",
1828    )
1829    .bind(change_id)
1830    .bind(actor.id)
1831    .execute(&mut *tx)
1832    .await?;
1833    // The labels name every item of the change: readers who see the change
1834    // (author, reviewer, or later after items were removed) but not all of
1835    // its items get a redacted entry.
1836    let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
1837    audit::record_mentioning(
1838        &mut tx,
1839        Some(actor),
1840        "change.submit",
1841        "change",
1842        Some(change_id),
1843        &named,
1844        format!(
1845            "Submitted {} for review ({})",
1846            change.number,
1847            labels.join(", ")
1848        ),
1849        json!({ "revisions": labels }),
1850    )
1851    .await?;
1852    let reviewers = reviewer_ids(&mut tx, change_id).await?;
1853    tell_naming(
1854        &mut tx,
1855        actor,
1856        &change,
1857        NotificationKind::ReviewRequested,
1858        &reviewers,
1859        format!("Review requested: {} – {}", change.number, change.title),
1860        format!(
1861            "{} asks you to review {}.",
1862            actor.display_name,
1863            labels.join(", ")
1864        ),
1865        &named,
1866        format!("{} asks you to review this change.", actor.display_name),
1867    )
1868    .await?;
1869    let mut watchers = notifications::watchers(&mut tx, "change", &[change_id]).await?;
1870    watchers.retain(|w| !reviewers.contains(w));
1871    tell_naming(
1872        &mut tx,
1873        actor,
1874        &change,
1875        NotificationKind::ChangeUpdate,
1876        &watchers,
1877        format!("{} was submitted for review", change.number),
1878        format!("{} – {}", change.title, labels.join(", ")),
1879        &named,
1880        change.title.clone(),
1881    )
1882    .await?;
1883    if webhooks::wanted(&mut tx, &[WebhookEvent::ChangeSubmitted]).await? {
1884        let (revs, projects) = webhooks::change_revisions(&mut tx, change_id).await?;
1885        webhooks::emit(
1886            &mut tx,
1887            Some(actor),
1888            WebhookEvent::ChangeSubmitted,
1889            &projects,
1890            json!({ "change": webhooks::change_object(&change, ChangeStatus::InReview.as_str()),
1891                    "revisions": revs }),
1892        )
1893        .await?;
1894    }
1895    tx.commit().await?;
1896    get(pool, actor, change_id).await
1897}
1898
1899/// Records the decision of a reviewer and finishes the change when possible.
1900///
1901/// # Invariants
1902///
1903/// * **Permission:** CAD editor; the change is in review, the actor sees every
1904///   affected item and is one of its reviewers with a pending decision (409
1905///   when already decided); a rejection needs a comment; an approval needs
1906///   every "use as is" deviation confirmed where the group asks for it
1907///   (`as_built::ensure_concessions_confirmed`, 409).
1908/// * **Locks:** the change row (`FOR UPDATE`); on release or rejection every
1909///   affected revision (`revisions::lock`) before its state changes.
1910/// * **Audit:** `change.approve` / `change.reject`; when the change finishes,
1911///   `change.released` or `change.rejected`.
1912/// * **Notifications:** author, reviewers and watchers of the change; on
1913///   release also the watchers of the released items, and the dependents
1914///   flagged by `impacts::on_release`.
1915/// * **Webhooks:** `change.approved` / `change.rejected`; on release also
1916///   `revision.released` and `change.released`.
1917/// * **Release:** all revisions of the change or none, the impacts, the
1918///   implemented change requests (`requests::on_change_released`), the
1919///   notifications and the webhook deliveries in one transaction
1920///   (`tests/atomic_processes.rs`); a second approval at once gets a 409.
1921/// * **Sign-off:** when `reauth` asks for it (`TEAMCRAFT_APPROVAL_REAUTH`),
1922///   a decision signed off only by the web session is refused (403); the
1923///   method and the stated meaning are stored with the review.
1924pub async fn review(
1925    pool: &PgPool,
1926    actor: &Actor,
1927    change_id: Uuid,
1928    decision: Decision,
1929    comment: &str,
1930    sign_off: &SignOff,
1931    reauth: domain::ApprovalReauth,
1932) -> AppResult<Change> {
1933    actor.require(Role::Editor)?;
1934    let comment = domain::limit_text("Comment", comment, 4000)?;
1935    let meaning = domain::limit_text(
1936        "Meaning of the sign-off",
1937        &sign_off.meaning,
1938        domain::MAX_SIGN_OFF_MEANING,
1939    )?;
1940    if decision == Decision::Pending {
1941        return Err(AppError::BadRequest("Choose approve or reject.".into()));
1942    }
1943    if decision == Decision::Rejected && comment.is_empty() {
1944        return Err(AppError::BadRequest(
1945            "Please explain why you reject the change.".into(),
1946        ));
1947    }
1948    if reauth.requires(decision) && !sign_off.method.is_fresh() {
1949        return Err(AppError::Forbidden(
1950            "Confirm your decision with your password (or sign in again with single sign-on): \
1951             this server asks for a fresh sign-in for review decisions."
1952                .into(),
1953        ));
1954    }
1955    let mut tx = pool.begin().await?;
1956    let change = lock(&mut tx, actor, change_id).await?;
1957    ensure_status(&change, &[ChangeStatus::InReview])?;
1958    ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1959    let current = sqlx::query_scalar::<_, String>(
1960        "SELECT decision FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2",
1961    )
1962    .bind(change_id)
1963    .bind(actor.id)
1964    .fetch_optional(&mut *tx)
1965    .await?
1966    .ok_or_else(|| AppError::Forbidden(format!("You are not a reviewer of {}.", change.number)))?;
1967    let four_eyes = sqlx::query_scalar::<_, bool>("SELECT four_eyes FROM changes WHERE id = $1")
1968        .bind(change_id)
1969        .fetch_one(&mut *tx)
1970        .await?;
1971    if let Some(conflict) = review_conflict(&mut tx, &change, actor.id, None, !four_eyes).await? {
1972        return Err(AppError::Forbidden(format!(
1973            "You cannot decide on {}: you {} (four-eyes principle).",
1974            change.number,
1975            conflict.describe()
1976        )));
1977    }
1978    if current != Decision::Pending.as_str() {
1979        return Err(AppError::Conflict(
1980            "You have already decided on this change.".into(),
1981        ));
1982    }
1983    if decision == Decision::Approved {
1984        // groups with `concessions_need_review`: every "use as is" deviation
1985        // is confirmed on its own before an approval counts
1986        as_built::ensure_concessions_confirmed(&mut tx, change_id).await?;
1987    }
1988    sqlx::query(
1989        "UPDATE change_reviews SET decision = $3, comment = $4, decided_at = now(),
1990                sign_off_method = $5, sign_off_meaning = $6
1991         WHERE change_id = $1 AND reviewer_id = $2",
1992    )
1993    .bind(change_id)
1994    .bind(actor.id)
1995    .bind(decision.as_str())
1996    .bind(&comment)
1997    .bind(sign_off.method.as_str())
1998    .bind(&meaning)
1999    .execute(&mut *tx)
2000    .await?;
2001    audit::record(
2002        &mut tx,
2003        Some(actor),
2004        if decision == Decision::Approved {
2005            "change.approve"
2006        } else {
2007            "change.reject"
2008        },
2009        "change",
2010        Some(change_id),
2011        format!(
2012            "{} {}{}",
2013            if decision == Decision::Approved {
2014                "Approved"
2015            } else {
2016                "Rejected"
2017            },
2018            change.number,
2019            if comment.is_empty() {
2020                String::new()
2021            } else {
2022                format!(": {comment}")
2023            }
2024        ),
2025        json!({ "comment": comment, "sign_off": sign_off.method.as_str(), "meaning": meaning }),
2026    )
2027    .await?;
2028
2029    let votes: Vec<(Uuid, Decision)> = sqlx::query_as::<_, (Uuid, String)>(
2030        "SELECT reviewer_id, decision FROM change_reviews WHERE change_id = $1",
2031    )
2032    .bind(change_id)
2033    .fetch_all(&mut *tx)
2034    .await?
2035    .into_iter()
2036    .filter_map(|(u, d)| Decision::parse(&d).map(|d| (u, d)))
2037    .collect();
2038    let people = notifications::change_people(&mut tx, change_id).await?;
2039    let requirement = project_policy::review_requirement(&mut tx, None, change_id).await?;
2040    // Reviewers are fixed after submit; only what they can still meet counts.
2041    let voters: Vec<Uuid> = votes.iter().map(|(u, _)| *u).collect();
2042    let requirement = domain::attainable(&requirement, &voters);
2043    let outcome = domain::review_outcome_with(&votes, &requirement);
2044    match outcome {
2045        ReviewOutcome::Pending => {
2046            touch(&mut tx, change_id).await?;
2047            tell(
2048                &mut tx,
2049                actor,
2050                &change,
2051                NotificationKind::ChangeUpdate,
2052                &people,
2053                format!(
2054                    "{} {} {}",
2055                    actor.display_name,
2056                    if decision == Decision::Approved {
2057                        "approved"
2058                    } else {
2059                        "rejected"
2060                    },
2061                    change.number
2062                ),
2063                if comment.is_empty() {
2064                    format!("{} – waiting for the other reviewers.", change.title)
2065                } else {
2066                    format!("{} – {comment}", change.title)
2067                },
2068            )
2069            .await?;
2070        }
2071        ReviewOutcome::Rejected => {
2072            let labels = transition_items(&mut tx, change_id, LifecycleState::InWork).await?;
2073            sqlx::query("UPDATE changes SET status = 'rejected', updated_at = now() WHERE id = $1")
2074                .bind(change_id)
2075                .execute(&mut *tx)
2076                .await?;
2077            let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
2078            audit::record_mentioning(
2079                &mut tx,
2080                Some(actor),
2081                "change.rejected",
2082                "change",
2083                Some(change_id),
2084                &named,
2085                format!(
2086                    "{} was rejected; {} returned to In Work",
2087                    change.number,
2088                    labels.join(", ")
2089                ),
2090                json!({ "revisions": labels }),
2091            )
2092            .await?;
2093            tell_naming(
2094                &mut tx,
2095                actor,
2096                &change,
2097                NotificationKind::ChangeUpdate,
2098                &people,
2099                format!("{} was rejected by {}", change.number, actor.display_name),
2100                format!("{comment}\n{} returned to In Work.", labels.join(", ")),
2101                &named,
2102                format!("{comment}\nIts revisions returned to In Work."),
2103            )
2104            .await?;
2105        }
2106        ReviewOutcome::Approved => {
2107            let labels = transition_items(&mut tx, change_id, LifecycleState::Released).await?;
2108            // Freeze the released configuration and flag the dependents.
2109            let released = sqlx::query_scalar::<_, Uuid>(
2110                "SELECT revision_id FROM change_items WHERE change_id = $1 AND action = 'release'",
2111            )
2112            .bind(change_id)
2113            .fetch_all(&mut *tx)
2114            .await?;
2115            impacts::on_release(&mut tx, actor, Some(change_id), &released).await?;
2116            sqlx::query(
2117                "UPDATE changes SET status = 'released', closed_at = now(), updated_at = now() WHERE id = $1",
2118            )
2119            .bind(change_id)
2120            .execute(&mut *tx)
2121            .await?;
2122            let obsoleted = apply_action_lines(&mut tx, actor, &change).await?;
2123            let summary = release_summary(&labels, &obsoleted);
2124            let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
2125            audit::record_mentioning(
2126                &mut tx,
2127                Some(actor),
2128                "change.released",
2129                "change",
2130                Some(change_id),
2131                &named,
2132                format!("{} was approved; {summary}", change.number),
2133                json!({ "revisions": labels, "obsoleted": obsoleted }),
2134            )
2135            .await?;
2136            tell_naming(
2137                &mut tx,
2138                actor,
2139                &change,
2140                NotificationKind::ChangeUpdate,
2141                &people,
2142                format!("{} was released", change.number),
2143                format!(
2144                    "{} – {} (last approval by {}).",
2145                    change.title, summary, actor.display_name
2146                ),
2147                &named,
2148                format!(
2149                    "{} – released (last approval by {}).",
2150                    change.title, actor.display_name
2151                ),
2152            )
2153            .await?;
2154            tell_item_watchers(&mut tx, actor, &change, &released, &people).await?;
2155            // The accepted change requests this change implements are done.
2156            requests::on_change_released(&mut tx, actor, &change).await?;
2157        }
2158    }
2159    emit_review_events(&mut tx, actor, &change, decision, &comment, outcome).await?;
2160    tx.commit().await?;
2161    get(pool, actor, change_id).await
2162}
2163
2164/// Webhook events of a review decision: `change.approved` or
2165/// `change.rejected`, and on the last approval `revision.released` for each
2166/// revision and `change.released`.
2167async fn emit_review_events(
2168    conn: &mut PgConnection,
2169    actor: &Actor,
2170    change: &Change,
2171    decision: Decision,
2172    comment: &str,
2173    outcome: ReviewOutcome,
2174) -> AppResult<()> {
2175    let status = match outcome {
2176        ReviewOutcome::Pending => ChangeStatus::InReview,
2177        ReviewOutcome::Rejected => ChangeStatus::Rejected,
2178        ReviewOutcome::Approved => ChangeStatus::Released,
2179    }
2180    .as_str();
2181    let event = if decision == Decision::Approved {
2182        WebhookEvent::ChangeApproved
2183    } else {
2184        WebhookEvent::ChangeRejected
2185    };
2186    let mut events = vec![event];
2187    if outcome == ReviewOutcome::Approved {
2188        events.extend([WebhookEvent::RevisionReleased, WebhookEvent::ChangeReleased]);
2189    }
2190    if !webhooks::wanted(conn, &events).await? {
2191        return Ok(());
2192    }
2193    let change_json = webhooks::change_object(change, status);
2194    let (revs, projects) = webhooks::change_revisions(conn, change.id).await?;
2195    let review = json!({
2196        "reviewer": actor.username,
2197        "decision": decision.as_str(),
2198        "comment": comment,
2199        "change_status": status,
2200    });
2201    webhooks::emit(
2202        conn,
2203        Some(actor),
2204        event,
2205        &projects,
2206        json!({ "change": change_json, "review": review, "revisions": revs }),
2207    )
2208    .await?;
2209    if outcome == ReviewOutcome::Approved {
2210        let each = revs
2211            .iter()
2212            .zip(&projects)
2213            // obsolete and supersede lines have their own `revision.obsoleted` event
2214            .filter(|(rev, _)| rev["action"] == LineAction::Release.as_str())
2215            .map(|(rev, project)| (*project, json!({ "revision": rev, "change": change_json })))
2216            .collect();
2217        webhooks::emit_each(
2218            conn,
2219            Some(actor),
2220            WebhookEvent::RevisionReleased,
2221            each,
2222            &projects,
2223        )
2224        .await?;
2225        webhooks::emit(
2226            conn,
2227            Some(actor),
2228            WebhookEvent::ChangeReleased,
2229            &projects,
2230            json!({ "change": change_json, "revisions": revs }),
2231        )
2232        .await?;
2233    }
2234    Ok(())
2235}
2236
2237/// Pulls a change back from review into draft.
2238///
2239/// # Invariants
2240///
2241/// * **Permission:** CAD editor who is the author (or an administrator, or a manager of every affected group while the author is deactivated); the change is in review
2242///   (409).
2243/// * **Locks:** the change row (`FOR UPDATE`), then every revision (`revisions::lock`) before
2244///   its state changes.
2245/// * **Audit:** `change.withdraw`.
2246/// * **Notifications:** author, reviewers and watchers of the change, except the actor.
2247/// * **Webhooks:** none.
2248/// * **Data:** the revisions go back to in work and all reviews are reset to pending.
2249/// * **Atomic:** one transaction.
2250pub async fn withdraw(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
2251    let mut tx = pool.begin().await?;
2252    let change = lock(&mut tx, actor, change_id).await?;
2253    ensure_owner_or_stand_in(&mut tx, &change, actor).await?;
2254    ensure_status(&change, &[ChangeStatus::InReview])?;
2255    let labels = transition_items(&mut tx, change_id, LifecycleState::InWork).await?;
2256    reset_reviews(&mut tx, change_id).await?;
2257    sqlx::query(
2258        "UPDATE changes SET status = 'draft', submitted_at = NULL, updated_at = now() WHERE id = $1",
2259    )
2260    .bind(change_id)
2261    .execute(&mut *tx)
2262    .await?;
2263    let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
2264    audit::record_mentioning(
2265        &mut tx,
2266        Some(actor),
2267        "change.withdraw",
2268        "change",
2269        Some(change_id),
2270        &named,
2271        format!("Withdrew {} from review", change.number),
2272        json!({ "revisions": labels }),
2273    )
2274    .await?;
2275    let people = notifications::change_people(&mut tx, change_id).await?;
2276    tell(
2277        &mut tx,
2278        actor,
2279        &change,
2280        NotificationKind::ChangeUpdate,
2281        &people,
2282        format!("{} was withdrawn from review", change.number),
2283        format!(
2284            "{} – {} took it back; no review is needed for now.",
2285            change.title, actor.display_name
2286        ),
2287    )
2288    .await?;
2289    tx.commit().await?;
2290    get(pool, actor, change_id).await
2291}
2292
2293/// Turns a rejected change back into a draft so it can be fixed and resubmitted.
2294///
2295/// # Invariants
2296///
2297/// * **Permission:** CAD editor who is the author (or an administrator, or a manager of every affected group while the author is deactivated); the change is rejected
2298///   (409) and the actor sees every item (403).
2299/// * **Locks:** the change row (`FOR UPDATE`).
2300/// * **Audit:** `change.reopen`.
2301/// * **Notifications / webhooks:** none.
2302/// * **Data:** all reviews are reset to pending; the revisions are not touched.
2303/// * **Atomic:** one transaction.
2304pub async fn reopen(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
2305    let mut tx = pool.begin().await?;
2306    let change = lock(&mut tx, actor, change_id).await?;
2307    ensure_owner_or_stand_in(&mut tx, &change, actor).await?;
2308    ensure_status(&change, &[ChangeStatus::Rejected])?;
2309    ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
2310    reset_reviews(&mut tx, change_id).await?;
2311    sqlx::query(
2312        "UPDATE changes SET status = 'draft', submitted_at = NULL, updated_at = now() WHERE id = $1",
2313    )
2314    .bind(change_id)
2315    .execute(&mut *tx)
2316    .await?;
2317    audit::record(
2318        &mut tx,
2319        Some(actor),
2320        "change.reopen",
2321        "change",
2322        Some(change_id),
2323        format!("Reopened {} as draft", change.number),
2324        json!({}),
2325    )
2326    .await?;
2327    tx.commit().await?;
2328    get(pool, actor, change_id).await
2329}
2330
2331/// Cancels a draft or rejected change.
2332///
2333/// # Invariants
2334///
2335/// * **Permission:** CAD editor who is the author (or an administrator, or a manager of every affected group while the author is deactivated); the change is a draft
2336///   or rejected (409).
2337/// * **Locks:** the change row (`FOR UPDATE`).
2338/// * **Audit:** `change.cancel`.
2339/// * **Notifications:** author, reviewers and watchers of the change, except the actor.
2340/// * **Webhooks:** none.
2341/// * **Atomic:** one transaction.
2342pub async fn cancel(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
2343    let mut tx = pool.begin().await?;
2344    let change = lock(&mut tx, actor, change_id).await?;
2345    ensure_owner_or_stand_in(&mut tx, &change, actor).await?;
2346    ensure_status(&change, &[ChangeStatus::Draft, ChangeStatus::Rejected])?;
2347    sqlx::query(
2348        "UPDATE changes SET status = 'cancelled', closed_at = now(), updated_at = now() WHERE id = $1",
2349    )
2350    .bind(change_id)
2351    .execute(&mut *tx)
2352    .await?;
2353    audit::record(
2354        &mut tx,
2355        Some(actor),
2356        "change.cancel",
2357        "change",
2358        Some(change_id),
2359        format!("Cancelled {}", change.number),
2360        json!({}),
2361    )
2362    .await?;
2363    let people = notifications::change_people(&mut tx, change_id).await?;
2364    tell(
2365        &mut tx,
2366        actor,
2367        &change,
2368        NotificationKind::ChangeUpdate,
2369        &people,
2370        format!("{} was cancelled", change.number),
2371        format!("{} – cancelled by {}.", change.title, actor.display_name),
2372    )
2373    .await?;
2374    tx.commit().await?;
2375    get(pool, actor, change_id).await
2376}
2377
2378#[cfg(test)]
2379mod tests {
2380    use super::release_summary;
2381
2382    #[test]
2383    fn the_release_summary_names_what_was_released_and_obsoleted() {
2384        let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
2385        assert_eq!(
2386            release_summary(&s(&["A/A", "B/A"]), &[]),
2387            "released A/A, B/A"
2388        );
2389        assert_eq!(release_summary(&[], &s(&["C/B"])), "obsoleted C/B");
2390        assert_eq!(
2391            release_summary(&s(&["A/A"]), &s(&["C/B", "D/A"])),
2392            "released A/A; obsoleted C/B, D/A"
2393        );
2394        assert_eq!(release_summary(&[], &[]), "");
2395    }
2396}