Skip to main content

teamcraft/services/
ownership.rs

1//! Item owners and the hand-over of a leaving user's work (issue #152, step 7).
2//!
3//! * [`set_item_owner`]: the owner of an item (default: its creator) can be
4//!   changed by the managers of the item's project group and by
5//!   administrators. The new owner must be an active CAD editor or
6//!   administrator who may read the item.
7//! * [`hand_over`]: an administrator moves everything a user holds to
8//!   another user in **one transaction**: item ownerships, draft (and
9//!   rejected) change orders they authored, open change requests assigned to
10//!   them and their pending reviews. The result lists what was moved and
11//!   what was skipped with the reason (for example a review the new person
12//!   may not do under the four-eyes principle). Every object gets its own
13//!   audit entry.
14//!
15//! The lists are bounded ([`MAX_PER_KIND`] per kind and run); `truncated`
16//! says that another run is needed.
17
18use serde::Serialize;
19use serde_json::json;
20use sqlx::{PgConnection, PgPool};
21use uuid::Uuid;
22
23use crate::{
24    auth::Actor,
25    domain::Role,
26    error::{AppError, AppResult},
27    models::Item,
28    services::{access, audit, changes, items, projects},
29};
30
31/// Most objects of one kind that one hand-over moves.
32pub const MAX_PER_KIND: i64 = 1000;
33
34/// One object that was moved (or skipped).
35#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
36pub struct HandoverEntry {
37    /// `item`, `change`, `request` or `review`.
38    pub kind: &'static str,
39    /// Item, change or request number.
40    pub number: String,
41    /// Why it was skipped; empty for moved objects.
42    pub reason: String,
43}
44
45#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
46pub struct HandoverReport {
47    pub from_name: String,
48    pub to_name: String,
49    pub moved: Vec<HandoverEntry>,
50    pub skipped: Vec<HandoverEntry>,
51    /// A kind reached [`MAX_PER_KIND`]: run the hand-over again.
52    pub truncated: bool,
53}
54
55impl HandoverReport {
56    pub fn count(&self, kind: &str) -> usize {
57        self.moved.iter().filter(|e| e.kind == kind).count()
58    }
59
60    fn moved(&mut self, kind: &'static str, number: &str) {
61        self.moved.push(HandoverEntry {
62            kind,
63            number: number.to_string(),
64            reason: String::new(),
65        });
66    }
67
68    fn skipped(&mut self, kind: &'static str, number: &str, reason: impl Into<String>) {
69        self.skipped.push(HandoverEntry {
70            kind,
71            number: number.to_string(),
72            reason: reason.into(),
73        });
74    }
75}
76
77#[derive(sqlx::FromRow)]
78struct Recipient {
79    id: Uuid,
80    display_name: String,
81    role: String,
82    active: bool,
83}
84
85/// The receiving user: active, CAD editor or administrator.
86async fn recipient(conn: &mut PgConnection, id: Uuid) -> AppResult<Recipient> {
87    let user = sqlx::query_as::<_, Recipient>(
88        "SELECT id, display_name, role, active FROM users WHERE id = $1 FOR SHARE",
89    )
90    .bind(id)
91    .fetch_optional(&mut *conn)
92    .await?
93    .ok_or_else(|| AppError::BadRequest("Unknown user.".into()))?;
94    if !user.active {
95        return Err(AppError::BadRequest(format!(
96            "{} is deactivated.",
97            user.display_name
98        )));
99    }
100    if Role::parse(&user.role).unwrap_or(Role::Viewer) < Role::Editor {
101        return Err(AppError::BadRequest(format!(
102            "{} has the viewer role and cannot own items or review changes.",
103            user.display_name
104        )));
105    }
106    Ok(user)
107}
108
109/// Whether `user` may read an item of `project_id` (SQL, same rule as the
110/// notification recipients).
111async fn user_sees(
112    conn: &mut PgConnection,
113    user: Uuid,
114    project_id: Option<Uuid>,
115) -> AppResult<bool> {
116    Ok(sqlx::query_scalar::<_, bool>(&format!(
117        "SELECT {} FROM users u, (SELECT $2::uuid AS project_id) i WHERE u.id = $1",
118        access::user_sees_project("u", "i.project_id")
119    ))
120    .bind(user)
121    .bind(project_id)
122    .fetch_one(&mut *conn)
123    .await?)
124}
125
126/// Whether `user` is blind to at least one affected item of the change.
127async fn blind_to_change(conn: &mut PgConnection, user: Uuid, change: Uuid) -> AppResult<bool> {
128    Ok(sqlx::query_scalar::<_, bool>(&format!(
129        "SELECT EXISTS(SELECT 1 FROM change_items ci
130                       JOIN revisions r ON r.id = ci.revision_id
131                       JOIN items i ON i.id = r.item_id
132                       JOIN users u ON u.id = $2
133                       WHERE ci.change_id = $1 AND NOT {})",
134        access::user_sees_project("u", "i.project_id")
135    ))
136    .bind(change)
137    .bind(user)
138    .fetch_one(&mut *conn)
139    .await?)
140}
141
142/// Whether `user` is blind to at least one affected item of the request.
143async fn blind_to_request(conn: &mut PgConnection, user: Uuid, request: Uuid) -> AppResult<bool> {
144    Ok(sqlx::query_scalar::<_, bool>(&format!(
145        "SELECT EXISTS(SELECT 1 FROM change_request_items ri
146                       JOIN items i ON i.id = ri.item_id
147                       JOIN users u ON u.id = $2
148                       WHERE ri.request_id = $1 AND NOT {})",
149        access::user_sees_project("u", "i.project_id")
150    ))
151    .bind(request)
152    .bind(user)
153    .fetch_one(&mut *conn)
154    .await?)
155}
156
157/// Number of a change as the acting administrator may see it (need-to-know).
158async fn change_label(
159    conn: &mut PgConnection,
160    actor: &Actor,
161    id: Uuid,
162    number: &str,
163) -> AppResult<String> {
164    let visible = sqlx::query_scalar::<_, bool>(&format!(
165        "SELECT {} FROM changes c WHERE c.id = $1",
166        access::change_visible("c", 2, 3)
167    ))
168    .bind(id)
169    .bind(actor.id)
170    .bind(&actor.hidden_projects)
171    .fetch_one(&mut *conn)
172    .await?;
173    Ok(if visible {
174        number.to_string()
175    } else {
176        access::RESTRICTED_LABEL.to_string()
177    })
178}
179
180/// Number of a change request as the acting administrator may see it.
181async fn request_label(
182    conn: &mut PgConnection,
183    actor: &Actor,
184    id: Uuid,
185    number: &str,
186) -> AppResult<String> {
187    let visible = sqlx::query_scalar::<_, bool>(&format!(
188        "SELECT {} FROM change_requests r WHERE r.id = $1",
189        access::request_visible("r", 2, 3)
190    ))
191    .bind(id)
192    .bind(actor.id)
193    .bind(&actor.hidden_projects)
194    .fetch_one(&mut *conn)
195    .await?;
196    Ok(if visible {
197        number.to_string()
198    } else {
199        access::RESTRICTED_LABEL.to_string()
200    })
201}
202
203/// Changes the owner of an item.
204///
205/// # Invariants
206///
207/// * **Permission:** administrator, or a manager of the item's project group
208///   (403); the actor must be able to read the item (404). Items without a
209///   group are for administrators only.
210/// * **Data:** the new owner is active, a CAD editor or administrator and
211///   may read the item (400).
212/// * **Locks:** the item row (`FOR UPDATE`).
213/// * **Audit:** `item.owner`, related to the item.
214/// * **Notifications / webhooks:** none.
215/// * **Atomic:** one transaction.
216pub async fn set_item_owner(
217    pool: &PgPool,
218    actor: &Actor,
219    item_id: Uuid,
220    new_owner: Uuid,
221) -> AppResult<Item> {
222    actor.require(Role::Editor)?;
223    let mut tx = pool.begin().await?;
224    let current = sqlx::query_as::<_, Item>(&format!(
225        "{} WHERE i.id = $1 FOR UPDATE OF i",
226        items::ITEM_SELECT
227    ))
228    .bind(item_id)
229    .fetch_optional(&mut *tx)
230    .await?
231    .ok_or_else(|| AppError::not_found("Item not found."))?;
232    access::ensure_visible(actor, current.project_id, access::not_found_item())?;
233    let may = match current.project_id {
234        Some(p) => projects::can_manage(&mut tx, actor, p).await?,
235        None => actor.is_admin(),
236    };
237    if !may {
238        return Err(AppError::Forbidden(format!(
239            "Only the managers of the project group (and administrators) can change the owner of {}.",
240            current.number
241        )));
242    }
243    let owner = recipient(&mut tx, new_owner).await?;
244    if !user_sees(&mut tx, owner.id, current.project_id).await? {
245        return Err(AppError::BadRequest(format!(
246            "{} cannot read {} (not a member of its restricted project group).",
247            owner.display_name, current.number
248        )));
249    }
250    if owner.id != current.owner_id {
251        sqlx::query("UPDATE items SET owner_id = $2 WHERE id = $1")
252            .bind(item_id)
253            .bind(owner.id)
254            .execute(&mut *tx)
255            .await?;
256        audit::record_related(
257            &mut tx,
258            Some(actor),
259            "item.owner",
260            "item",
261            Some(item_id),
262            Some(item_id),
263            format!(
264                "Owner of {} changed from {} to {}",
265                current.number, current.owner_name, owner.display_name
266            ),
267            json!({ "from": current.owner_id, "to": owner.id }),
268        )
269        .await?;
270    }
271    tx.commit().await?;
272    items::get(pool, actor, item_id).await
273}
274
275/// Users who can become the owner of an item of `project_id`: active CAD
276/// editors and administrators who may read it, by name.
277pub async fn owner_candidates(
278    pool: &PgPool,
279    project_id: Option<Uuid>,
280) -> AppResult<Vec<crate::models::User>> {
281    Ok(sqlx::query_as::<_, crate::models::User>(&format!(
282        "SELECT {} FROM users u, (SELECT $1::uuid AS project_id) i
283         WHERE u.active AND u.role IN ('editor', 'admin') AND {}
284         ORDER BY u.display_name, u.username",
285        crate::models::USER_COLUMNS,
286        access::user_sees_project("u", "i.project_id")
287    ))
288    .bind(project_id)
289    .fetch_all(pool)
290    .await?)
291}
292
293/// Moves the work of `from` to `to`.
294///
295/// # Invariants
296///
297/// * **Permission:** administrator (403). `from` and `to` differ; `to` is an
298///   active CAD editor or administrator (400). `from` may be deactivated
299///   (the usual case).
300/// * **Locks:** both user rows, then every moved item, change and request
301///   (`FOR UPDATE`).
302/// * **Moves:** items owned by `from`; changes authored by `from` that are
303///   drafts or rejected; change requests assigned to `from` that are not
304///   closed; pending reviews of `from` on changes in draft or in review.
305///   Objects `to` may not own or review (cannot read them, already a
306///   reviewer, or four-eyes conflict) stay with `from` and are listed as
307///   skipped with the reason.
308/// * **Audit:** one entry per moved object (`item.owner`, `change.handover`,
309///   `change_request.handover`, `change.review_handover`) and one
310///   `user.handover` with the counts.
311/// * **Notifications / webhooks:** none.
312/// * **Atomic:** one transaction.
313pub async fn hand_over(
314    pool: &PgPool,
315    actor: &Actor,
316    from: Uuid,
317    to: Uuid,
318) -> AppResult<HandoverReport> {
319    let mut tx = pool.begin().await?;
320    let report = hand_over_in(&mut tx, actor, from, to).await?;
321    tx.commit().await?;
322    Ok(report)
323}
324
325/// [`hand_over`] inside the caller's transaction (the offboarding of a
326/// leaver runs it together with the deactivation, see
327/// `services::offboarding::deactivate`). Same invariants; the caller commits.
328pub(crate) async fn hand_over_in(
329    tx: &mut PgConnection,
330    actor: &Actor,
331    from: Uuid,
332    to: Uuid,
333) -> AppResult<HandoverReport> {
334    actor.require(Role::Admin)?;
335    if from == to {
336        return Err(AppError::BadRequest(
337            "Choose a different user to hand over to.".into(),
338        ));
339    }
340    let from_name =
341        sqlx::query_scalar::<_, String>("SELECT display_name FROM users WHERE id = $1 FOR UPDATE")
342            .bind(from)
343            .fetch_optional(&mut *tx)
344            .await?
345            .ok_or_else(|| AppError::not_found("User not found."))?;
346    let target = recipient(&mut *tx, to).await?;
347    let mut report = HandoverReport {
348        from_name,
349        to_name: target.display_name.clone(),
350        ..HandoverReport::default()
351    };
352    let details = json!({ "from": from, "to": to });
353
354    // 1. item ownerships
355    let owned = sqlx::query_as::<_, (Uuid, String, Option<Uuid>)>(
356        "SELECT id, number, project_id FROM items WHERE owner_id = $1
357         ORDER BY number LIMIT $2 FOR UPDATE",
358    )
359    .bind(from)
360    .bind(MAX_PER_KIND)
361    .fetch_all(&mut *tx)
362    .await?;
363    report.truncated |= owned.len() as i64 >= MAX_PER_KIND;
364    for (id, number, project_id) in owned {
365        let shown = if actor.can_see_project(project_id) {
366            number.clone()
367        } else {
368            access::RESTRICTED_LABEL.to_string()
369        };
370        if !user_sees(&mut *tx, to, project_id).await? {
371            report.skipped(
372                "item",
373                &shown,
374                format!(
375                    "{} cannot read it (restricted project group)",
376                    target.display_name
377                ),
378            );
379            continue;
380        }
381        sqlx::query("UPDATE items SET owner_id = $2 WHERE id = $1")
382            .bind(id)
383            .bind(to)
384            .execute(&mut *tx)
385            .await?;
386        audit::record_related(
387            &mut *tx,
388            Some(actor),
389            "item.owner",
390            "item",
391            Some(id),
392            Some(id),
393            format!(
394                "Owner of {number} handed over from {} to {}",
395                report.from_name, report.to_name
396            ),
397            details.clone(),
398        )
399        .await?;
400        report.moved("item", &shown);
401    }
402
403    // 2. draft and rejected changes of the author
404    let authored = sqlx::query_as::<_, (Uuid, String)>(
405        "SELECT id, number FROM changes
406         WHERE created_by = $1 AND status IN ('draft', 'rejected')
407         ORDER BY number LIMIT $2 FOR UPDATE",
408    )
409    .bind(from)
410    .bind(MAX_PER_KIND)
411    .fetch_all(&mut *tx)
412    .await?;
413    report.truncated |= authored.len() as i64 >= MAX_PER_KIND;
414    for (id, number) in authored {
415        let shown = change_label(&mut *tx, actor, id, &number).await?;
416        let reviewing = sqlx::query_scalar::<_, bool>(
417            "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2)",
418        )
419        .bind(id)
420        .bind(to)
421        .fetch_one(&mut *tx)
422        .await?;
423        if reviewing {
424            report.skipped(
425                "change",
426                &shown,
427                format!(
428                    "{} is a reviewer of it (author and reviewer must differ)",
429                    target.display_name
430                ),
431            );
432            continue;
433        }
434        if blind_to_change(&mut *tx, to, id).await? {
435            report.skipped(
436                "change",
437                &shown,
438                format!("{} cannot read all its items", target.display_name),
439            );
440            continue;
441        }
442        sqlx::query("UPDATE changes SET created_by = $2, updated_at = now() WHERE id = $1")
443            .bind(id)
444            .bind(to)
445            .execute(&mut *tx)
446            .await?;
447        audit::record(
448            &mut *tx,
449            Some(actor),
450            "change.handover",
451            "change",
452            Some(id),
453            format!(
454                "{number} handed over from {} to {}",
455                report.from_name, report.to_name
456            ),
457            details.clone(),
458        )
459        .await?;
460        report.moved("change", &shown);
461    }
462
463    // 3. open change requests
464    let assigned = sqlx::query_as::<_, (Uuid, String)>(
465        "SELECT id, number FROM change_requests
466         WHERE assignee = $1 AND status IN ('new', 'in_triage', 'accepted', 'deferred')
467         ORDER BY number LIMIT $2 FOR UPDATE",
468    )
469    .bind(from)
470    .bind(MAX_PER_KIND)
471    .fetch_all(&mut *tx)
472    .await?;
473    report.truncated |= assigned.len() as i64 >= MAX_PER_KIND;
474    for (id, number) in assigned {
475        let shown = request_label(&mut *tx, actor, id, &number).await?;
476        if blind_to_request(&mut *tx, to, id).await? {
477            report.skipped(
478                "request",
479                &shown,
480                format!("{} cannot read all its items", target.display_name),
481            );
482            continue;
483        }
484        sqlx::query("UPDATE change_requests SET assignee = $2, updated_at = now() WHERE id = $1")
485            .bind(id)
486            .bind(to)
487            .execute(&mut *tx)
488            .await?;
489        audit::record(
490            &mut *tx,
491            Some(actor),
492            "change_request.handover",
493            "change_request",
494            Some(id),
495            format!(
496                "{number} handed over from {} to {}",
497                report.from_name, report.to_name
498            ),
499            details.clone(),
500        )
501        .await?;
502        report.moved("request", &shown);
503    }
504
505    // 4. pending reviews
506    let reviews = sqlx::query_as::<_, (Uuid,)>(
507        "SELECT c.id FROM change_reviews rv JOIN changes c ON c.id = rv.change_id
508         WHERE rv.reviewer_id = $1 AND rv.decision = 'pending'
509           AND c.status IN ('draft', 'in_review')
510         ORDER BY c.number LIMIT $2",
511    )
512    .bind(from)
513    .bind(MAX_PER_KIND)
514    .fetch_all(&mut *tx)
515    .await?;
516    report.truncated |= reviews.len() as i64 >= MAX_PER_KIND;
517    for (change_id,) in reviews {
518        let change = sqlx::query_as::<_, crate::models::Change>(&format!(
519            "{} WHERE c.id = $1 FOR UPDATE OF c",
520            changes::CHANGE_SELECT
521        ))
522        .bind(change_id)
523        .fetch_one(&mut *tx)
524        .await?;
525        if !matches!(change.status.as_str(), "draft" | "in_review") {
526            continue;
527        }
528        let shown = change_label(&mut *tx, actor, change_id, &change.number).await?;
529        let already = sqlx::query_scalar::<_, bool>(
530            "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2)",
531        )
532        .bind(change_id)
533        .bind(to)
534        .fetch_one(&mut *tx)
535        .await?;
536        if already {
537            report.skipped(
538                "review",
539                &shown,
540                format!("{} is already a reviewer", target.display_name),
541            );
542            continue;
543        }
544        if let Some(conflict) = changes::review_conflict(&mut *tx, &change, to, None, false).await?
545        {
546            report.skipped(
547                "review",
548                &shown,
549                format!(
550                    "{} {} (four-eyes principle)",
551                    target.display_name,
552                    conflict.describe()
553                ),
554            );
555            continue;
556        }
557        if blind_to_change(&mut *tx, to, change_id).await? {
558            report.skipped(
559                "review",
560                &shown,
561                format!("{} cannot read all its items", target.display_name),
562            );
563            continue;
564        }
565        let moved = sqlx::query(
566            "UPDATE change_reviews SET reviewer_id = $3
567             WHERE change_id = $1 AND reviewer_id = $2 AND decision = 'pending'",
568        )
569        .bind(change_id)
570        .bind(from)
571        .bind(to)
572        .execute(&mut *tx)
573        .await?
574        .rows_affected();
575        if moved == 0 {
576            // decided or removed since the first look: nothing moved
577            continue;
578        }
579        sqlx::query("UPDATE changes SET updated_at = now() WHERE id = $1")
580            .bind(change_id)
581            .execute(&mut *tx)
582            .await?;
583        audit::record(
584            &mut *tx,
585            Some(actor),
586            "change.review_handover",
587            "change",
588            Some(change_id),
589            format!(
590                "Review of {} handed over from {} to {}",
591                change.number, report.from_name, report.to_name
592            ),
593            details.clone(),
594        )
595        .await?;
596        report.moved("review", &shown);
597    }
598
599    audit::record(
600        &mut *tx,
601        Some(actor),
602        "user.handover",
603        "user",
604        Some(from),
605        format!(
606            "Handed over the work of {} to {}: {} items, {} changes, {} requests, {} reviews ({} skipped)",
607            report.from_name,
608            report.to_name,
609            report.count("item"),
610            report.count("change"),
611            report.count("request"),
612            report.count("review"),
613            report.skipped.len()
614        ),
615        json!({
616            "from": from,
617            "to": to,
618            "items": report.count("item"),
619            "changes": report.count("change"),
620            "requests": report.count("request"),
621            "reviews": report.count("review"),
622            "skipped": report.skipped.len(),
623            "truncated": report.truncated,
624        }),
625    )
626    .await?;
627    Ok(report)
628}