Skip to main content

teamcraft/services/
reviewers.rs

1//! Suggested reviewers for a change order: who knows the affected items?
2//!
3//! Deterministic and explainable – no model, only the history TeamCraft
4//! already keeps. Every candidate collects points from these sources, each
5//! with a reason that is shown as is:
6//!
7//! | Source                                                            | Points            |
8//! |-------------------------------------------------------------------|-------------------|
9//! | edits of an affected item in the last 90 days (audit log)         | 3 + one per edit  |
10//! | decision on an earlier change order with the same items           | 4 (pending: 2)    |
11//! | owner (creator, author of the latest revision) of a dependent     | 2                 |
12//! | manager of the project group of an affected item                  | 3                 |
13//! | requester of a change request this change implements              | 1                 |
14//!
15//! Each source is capped per candidate so that one busy source cannot drown
16//! the others. Candidates are active CAD editors and admins (as
17//! `changes::add_reviewer` requires), never the author of the change and
18//! never someone who is already a reviewer. People who changed the affected
19//! revisions know the item best but may not review it (four-eyes): they are
20//! listed after the real candidates with `cannot_review`, as information. Ties are broken by
21//! name, so the same data always gives the same order.
22//!
23//! The whole lookup is one statement whose parts are bounded (affected
24//! items, earlier changes, dependents, result rows).
25
26use std::collections::BTreeMap;
27
28use serde::Serialize;
29use sqlx::PgPool;
30use uuid::Uuid;
31
32use crate::{
33    auth::Actor,
34    error::AppResult,
35    services::{access, changes},
36};
37
38/// Most suggestions in one answer.
39pub const MAX_LIMIT: usize = 20;
40/// Suggestions shown next to the reviewer picker.
41pub const DEFAULT_LIMIT: usize = 5;
42/// Look-back for edits.
43pub const EDIT_DAYS: i32 = 90;
44/// Reasons kept per suggestion.
45const MAX_REASONS: usize = 4;
46/// Bounds of the parts of the query.
47const MAX_ITEMS: i64 = 200;
48const MAX_EARLIER_CHANGES: i64 = 50;
49const MAX_DEPENDENTS: i64 = 200;
50const MAX_ROWS: i64 = 5000;
51/// Rows per user and source that can still add points: the smallest
52/// weight of a source divides its cap at most this often (review: 12 / 2).
53const MAX_PER_SOURCE: i64 = 6;
54
55/// Audit actions on a revision that count as editing it.
56pub(crate) const EDIT_ACTIONS: &[&str] = &[
57    "revision.checkin",
58    "revision.update",
59    "revision.revise",
60    "file.upload",
61    "file.version",
62    "file.restore",
63    "file.delete",
64    "file.folder",
65    "file.retention",
66    "bom.add",
67    "bom.update",
68    "bom.remove",
69    "reference.add",
70    "reference.remove",
71    "import.bom",
72    "bom.replace",
73];
74
75#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
76pub struct Suggestion {
77    pub user_id: Uuid,
78    pub username: String,
79    pub display_name: String,
80    pub role: String,
81    pub score: i64,
82    /// Knows the item but changed it, so the four-eyes principle forbids
83    /// the review: shown as information, not offered as a reviewer.
84    pub cannot_review: bool,
85    /// Most important first, e.g. "edited PRT-000005 3 times in the last 90 days".
86    pub reasons: Vec<String>,
87}
88
89/// One piece of evidence for one user (a row of the query).
90#[derive(Clone, Debug, sqlx::FromRow)]
91pub(crate) struct Signal {
92    pub user_id: Uuid,
93    pub username: String,
94    pub display_name: String,
95    pub role: String,
96    /// `edit`, `review`, `dependent`, `manager` or `request`.
97    pub kind: String,
98    /// Item, change, project group or request number.
99    pub subject: String,
100    /// For `dependent`: the affected item it depends on.
101    pub via: String,
102    /// For `review`: the decision; for `dependent`: the relation.
103    pub detail: String,
104    pub n: i64,
105    /// Changed the affected revisions: knows the item but may not review it.
106    pub editor: bool,
107}
108
109/// Latest, not obsolete revision of an item (SQL condition on `pr`).
110const LATEST: &str = "pr.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = pr.item_id)
111                      AND pr.state <> 'obsolete'";
112
113/// Ranked reviewer suggestions for a change order (at most `limit`, 1–20).
114/// Read-only; it never assigns anybody.
115pub async fn suggest(
116    pool: &PgPool,
117    actor: &Actor,
118    change_id: Uuid,
119    limit: usize,
120) -> AppResult<Vec<Suggestion>> {
121    let change = changes::get(pool, actor, change_id).await?;
122    // Who may review depends on every item; with items the actor cannot see
123    // the list would tell who belongs to their groups (and adding a
124    // reviewer is refused anyway, see `changes::add_reviewer`).
125    {
126        let mut conn = pool.acquire().await?;
127        if !changes::sees_all_items(&mut conn, actor, change_id).await? {
128            return Ok(Vec::new());
129        }
130    }
131    let sql = format!(
132        "WITH ci AS MATERIALIZED (
133             SELECT r.id AS revision_id, r.item_id, i.number, i.project_id
134             FROM change_items ci
135             JOIN revisions r ON r.id = ci.revision_id
136             JOIN items i ON i.id = r.item_id
137             WHERE ci.change_id = $1 AND {vis_i}
138             ORDER BY i.number LIMIT $3
139         ),
140         ci_revs AS MATERIALIZED (
141             SELECT r.id, ci.number FROM revisions r JOIN ci ON ci.item_id = r.item_id
142         ),
143         earlier AS (
144             SELECT DISTINCT c.id, c.number, c.created_at
145             FROM change_items oci
146             JOIN ci_revs cr ON cr.id = oci.revision_id
147             JOIN changes c ON c.id = oci.change_id
148             WHERE c.id <> $1 AND c.status <> 'cancelled' AND {vis_c}
149             ORDER BY c.created_at DESC LIMIT $4
150         ),
151         dep_edges AS (
152             SELECT pr.item_id, ci.number AS via, 'bom'::text AS relation, 1 AS rank
153             FROM bom_lines bl
154             JOIN ci ON ci.item_id = bl.child_item_id
155             JOIN revisions pr ON pr.id = bl.parent_revision_id
156             WHERE {LATEST}
157             UNION ALL
158             SELECT pr.item_id, ci.number, ir.kind, 2
159             FROM item_references ir
160             JOIN ci ON ci.item_id = ir.to_item_id
161             JOIN revisions pr ON pr.id = ir.from_revision_id
162             WHERE {LATEST}
163             UNION ALL
164             SELECT pr.item_id, ci.number, 'file'::text, 3
165             FROM ci
166             JOIN files sf ON sf.revision_id = ci.revision_id
167             JOIN file_references fr ON lower(fr.name) = lower(sf.filename)
168             JOIN file_versions dv ON dv.id = fr.file_version_id
169             JOIN files df ON df.id = dv.file_id AND dv.version = df.current_version
170             JOIN revisions pr ON pr.id = df.revision_id
171             WHERE {LATEST}
172         ),
173         deps_bounded AS (
174             SELECT * FROM (
175                 SELECT DISTINCT ON (e.item_id) e.item_id, di.number, e.via, e.relation,
176                        di.created_by
177                 FROM dep_edges e
178                 JOIN items di ON di.id = e.item_id
179                 WHERE NOT EXISTS (SELECT 1 FROM ci WHERE ci.item_id = e.item_id) AND {vis_di}
180                 ORDER BY e.item_id, e.rank, e.via
181             ) d
182             ORDER BY d.number LIMIT $5
183         ),
184         -- the latest revision's author only for the bounded dependents
185         deps AS (
186             SELECT d.*, lr.created_by AS revision_by
187             FROM deps_bounded d
188             CROSS JOIN LATERAL (SELECT x.created_by FROM revisions x WHERE x.item_id = d.item_id
189                                 ORDER BY x.seq DESC LIMIT 1) lr
190         ),
191         signals AS (
192             SELECT a.user_id, 'edit'::text AS kind, cr.number AS subject, ''::text AS via,
193                    ''::text AS detail, COUNT(*)::bigint AS n
194             FROM audit_log a
195             JOIN ci_revs cr ON a.entity_type = 'revision' AND a.entity_id = cr.id
196             WHERE a.user_id IS NOT NULL AND a.action = ANY($6)
197               AND a.at > now() - make_interval(days => $7)
198             GROUP BY a.user_id, cr.number
199             UNION ALL
200             SELECT rv.reviewer_id, 'review'::text, e.number, ''::text, rv.decision, 1::bigint
201             FROM earlier e JOIN change_reviews rv ON rv.change_id = e.id
202             UNION ALL
203             SELECT DISTINCT o.user_id, 'dependent'::text, d.number, d.via, d.relation, 1::bigint
204             FROM deps d CROSS JOIN LATERAL (VALUES (d.created_by), (d.revision_by)) o(user_id)
205             UNION ALL
206             SELECT DISTINCT pm.user_id, 'manager'::text, p.code, ''::text, ''::text, 1::bigint
207             FROM project_members pm JOIN projects p ON p.id = pm.project_id
208             WHERE pm.manager AND pm.project_id IN (SELECT project_id FROM ci)
209             UNION ALL
210             SELECT rq.requested_by, 'request'::text, rq.number, ''::text, ''::text, 1::bigint
211             FROM change_request_changes crc JOIN change_requests rq ON rq.id = crc.request_id
212             WHERE crc.change_id = $1 AND {vis_rq}
213         ),
214         -- points as in weigh(); only the strongest rows per user and source
215         -- can count (cap()), and a cut by $8 drops the weakest rows, not
216         -- the users late in the alphabet
217         eligible AS (
218             SELECT s.*, u.username, u.display_name, u.role,
219                    u.id IN (SELECT e.user_id FROM ({editors}) e WHERE e.user_id IS NOT NULL) AS editor,
220                    CASE s.kind WHEN 'edit' THEN 3 + LEAST(s.n, 7)
221                                WHEN 'review' THEN CASE WHEN s.detail IN ('approved', 'rejected')
222                                                        THEN 4 ELSE 2 END
223                                WHEN 'dependent' THEN 2
224                                WHEN 'manager' THEN 3
225                                ELSE 1 END AS points
226             FROM signals s JOIN users u ON u.id = s.user_id
227             WHERE u.active AND u.role IN ('editor', 'admin') AND u.id <> $2
228               AND ($12::uuid IS NULL OR u.id <> $12)
229               AND NOT EXISTS (SELECT 1 FROM change_reviews x
230                               WHERE x.change_id = $1 AND x.reviewer_id = u.id)
231               AND NOT EXISTS (SELECT 1 FROM change_items sci
232                               JOIN revisions sr ON sr.id = sci.revision_id
233                               JOIN items si ON si.id = sr.item_id
234                               WHERE sci.change_id = $1 AND NOT {sees})
235         ),
236         ranked AS (
237             SELECT e.*, ROW_NUMBER() OVER (PARTITION BY e.user_id, e.kind
238                                            ORDER BY e.points DESC, e.subject, e.via) AS pos
239             FROM eligible e
240         )
241         SELECT user_id, username, display_name, role, editor, kind, subject, via, detail, n
242         FROM ranked
243         WHERE pos <= $9
244         ORDER BY points DESC, username, kind, subject, via
245         LIMIT $8",
246        vis_i = access::item_visible("i", 10),
247        vis_di = access::item_visible("di", 10),
248        vis_c = access::change_visible("c", 11, 10),
249        vis_rq = access::request_visible("rq", 11, 10),
250        sees = access::user_sees_project("u", "si.project_id"),
251        editors = changes::editors_sql(6),
252    );
253    let signals = sqlx::query_as::<_, Signal>(&sql)
254        .bind(change_id)
255        .bind(change.created_by)
256        .bind(MAX_ITEMS)
257        .bind(MAX_EARLIER_CHANGES)
258        .bind(MAX_DEPENDENTS)
259        .bind(EDIT_ACTIONS)
260        .bind(EDIT_DAYS)
261        .bind(MAX_ROWS)
262        .bind(MAX_PER_SOURCE)
263        .bind(&actor.hidden_projects)
264        .bind(actor.id)
265        .bind((actor.is_admin() && change.created_by != actor.id).then_some(actor.id))
266        .fetch_all(pool)
267        .await?;
268    Ok(rank(signals, limit))
269}
270
271/// Points and reason of one signal.
272fn weigh(s: &Signal) -> Option<(&'static str, i64, String)> {
273    let n = s.n.max(1);
274    Some(match s.kind.as_str() {
275        "edit" => (
276            "edit",
277            3 + n.min(7),
278            if n == 1 {
279                format!("edited {} once in the last {EDIT_DAYS} days", s.subject)
280            } else {
281                format!(
282                    "edited {} {n} times in the last {EDIT_DAYS} days",
283                    s.subject
284                )
285            },
286        ),
287        "review" => match s.detail.as_str() {
288            "approved" | "rejected" => {
289                ("review", 4, format!("reviewed {} (same items)", s.subject))
290            }
291            _ => (
292                "review",
293                2,
294                format!("asked to review {} (same items)", s.subject),
295            ),
296        },
297        "dependent" => {
298            let how = match s.detail.as_str() {
299                "bom" => format!("which uses {}", s.via),
300                "drawing" => format!("a drawing of {}", s.via),
301                "specification" => format!("a specification for {}", s.via),
302                "file" => format!("whose CAD files reference {}", s.via),
303                _ => format!("which references {}", s.via),
304            };
305            ("dependent", 2, format!("owns {}, {how}", s.subject))
306        }
307        "manager" => ("manager", 3, format!("manager of {}", s.subject)),
308        "request" => (
309            "request",
310            1,
311            format!("raised {}, implemented by this change", s.subject),
312        ),
313        _ => return None,
314    })
315}
316
317/// Most points one source gives a candidate.
318fn cap(kind: &str) -> i64 {
319    match kind {
320        "edit" => 15,
321        "review" => 12,
322        "dependent" => 6,
323        "manager" => 6,
324        _ => 2,
325    }
326}
327
328/// Aggregates the signals per user and ranks them: score, then name.
329pub(crate) fn rank(signals: Vec<Signal>, limit: usize) -> Vec<Suggestion> {
330    struct Acc {
331        s: Suggestion,
332        per_kind: BTreeMap<&'static str, i64>,
333        reasons: Vec<(i64, String)>,
334    }
335    let mut by_user: BTreeMap<Uuid, Acc> = BTreeMap::new();
336    for sig in signals {
337        let Some((kind, points, reason)) = weigh(&sig) else {
338            continue;
339        };
340        let acc = by_user.entry(sig.user_id).or_insert_with(|| Acc {
341            s: Suggestion {
342                user_id: sig.user_id,
343                username: sig.username.clone(),
344                display_name: sig.display_name.clone(),
345                role: sig.role.clone(),
346                score: 0,
347                cannot_review: false,
348                reasons: Vec::new(),
349            },
350            per_kind: BTreeMap::new(),
351            reasons: Vec::new(),
352        });
353        acc.s.cannot_review |= sig.editor;
354        if acc.reasons.iter().any(|(_, r)| *r == reason) {
355            continue;
356        }
357        let used = acc.per_kind.entry(kind).or_insert(0);
358        let add = points.min(cap(kind) - *used).max(0);
359        *used += add;
360        acc.s.score += add;
361        acc.reasons.push((points, reason));
362    }
363    let mut out: Vec<Suggestion> = by_user
364        .into_values()
365        .filter(|a| a.s.score > 0)
366        .map(|mut a| {
367            a.reasons
368                .sort_by(|x, y| y.0.cmp(&x.0).then_with(|| x.1.cmp(&y.1)));
369            a.s.reasons = a
370                .reasons
371                .into_iter()
372                .take(MAX_REASONS)
373                .map(|(_, r)| r)
374                .collect();
375            a.s
376        })
377        .collect();
378    out.sort_by(|a, b| {
379        a.cannot_review
380            .cmp(&b.cannot_review)
381            .then_with(|| b.score.cmp(&a.score))
382            .then_with(|| a.display_name.cmp(&b.display_name))
383            .then_with(|| a.username.cmp(&b.username))
384    });
385    out.truncate(limit.clamp(1, MAX_LIMIT));
386    out
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn sig(user: u128, name: &str, kind: &str, subject: &str, detail: &str, n: i64) -> Signal {
394        Signal {
395            user_id: Uuid::from_u128(user),
396            username: name.to_lowercase(),
397            display_name: name.into(),
398            role: "editor".into(),
399            kind: kind.into(),
400            subject: subject.into(),
401            via: "PRT-000001".into(),
402            detail: detail.into(),
403            n,
404            editor: false,
405        }
406    }
407
408    #[test]
409    fn ranks_by_score_then_name_with_reasons() {
410        let out = rank(
411            vec![
412                sig(1, "Max", "manager", "ROVER", "", 1),
413                sig(2, "Erika", "edit", "PRT-000005", "", 3),
414                sig(2, "Erika", "review", "ECO-000012", "approved", 1),
415                sig(3, "Anna", "manager", "ROVER", "", 1),
416                sig(4, "Bob", "dependent", "ASM-000003", "bom", 1),
417                sig(4, "Bob", "dependent", "ASM-000003", "bom", 1),
418                sig(5, "Zed", "unknown", "X", "", 1),
419            ],
420            5,
421        );
422        let names: Vec<&str> = out.iter().map(|s| s.display_name.as_str()).collect();
423        assert_eq!(names, ["Erika", "Anna", "Max", "Bob"]);
424        assert_eq!(out[0].score, 6 + 4);
425        assert_eq!(
426            out[0].reasons,
427            [
428                "edited PRT-000005 3 times in the last 90 days",
429                "reviewed ECO-000012 (same items)"
430            ]
431        );
432        // the duplicate row counts once
433        assert_eq!(out[3].score, 2);
434        assert_eq!(out[3].reasons, ["owns ASM-000003, which uses PRT-000001"]);
435        assert_eq!(rank(Vec::new(), 5), Vec::new());
436    }
437
438    #[test]
439    fn editors_are_listed_last_and_flagged() {
440        let mut editor = sig(1, "Anna", "edit", "PRT-000001", "", 5);
441        editor.editor = true;
442        let out = rank(vec![editor, sig(2, "Zed", "manager", "ROVER", "", 1)], 5);
443        let names: Vec<(&str, bool)> = out
444            .iter()
445            .map(|s| (s.display_name.as_str(), s.cannot_review))
446            .collect();
447        assert_eq!(names, [("Zed", false), ("Anna", true)]);
448        // the limit cuts the editors first
449        let mut editor = sig(1, "Anna", "edit", "PRT-000001", "", 5);
450        editor.editor = true;
451        let out = rank(vec![editor, sig(2, "Zed", "manager", "ROVER", "", 1)], 1);
452        assert_eq!(out[0].display_name, "Zed");
453    }
454
455    #[test]
456    fn sources_are_capped_and_limit_is_bounded() {
457        let mut signals: Vec<Signal> = (0..50)
458            .map(|i| sig(1, "Busy", "dependent", &format!("ASM-{i:06}"), "bom", 1))
459            .collect();
460        signals.push(sig(1, "Busy", "edit", "PRT-000001", "", 1));
461        signals.push(sig(2, "Calm", "edit", "PRT-000001", "", 1_000_000));
462        let out = rank(signals, 0);
463        assert_eq!(out.len(), 1, "limit 0 means one");
464        assert_eq!(out[0].display_name, "Busy");
465        assert_eq!(out[0].score, 6 + 4);
466        assert_eq!(out[0].reasons.len(), MAX_REASONS);
467        assert_eq!(
468            out[0].reasons[0],
469            "edited PRT-000001 once in the last 90 days"
470        );
471        let out = rank(
472            vec![sig(2, "Calm", "edit", "PRT-000001", "", 1_000_000)],
473            99,
474        );
475        assert_eq!(out[0].score, 10);
476        let many: Vec<Signal> = (0..40)
477            .map(|i| sig(i, &format!("U{i:02}"), "manager", "ROVER", "", 1))
478            .collect();
479        assert_eq!(rank(many, 1000).len(), MAX_LIMIT);
480    }
481}