1use 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
38pub const MAX_LIMIT: usize = 20;
40pub const DEFAULT_LIMIT: usize = 5;
42pub const EDIT_DAYS: i32 = 90;
44const MAX_REASONS: usize = 4;
46const MAX_ITEMS: i64 = 200;
48const MAX_EARLIER_CHANGES: i64 = 50;
49const MAX_DEPENDENTS: i64 = 200;
50const MAX_ROWS: i64 = 5000;
51const MAX_PER_SOURCE: i64 = 6;
54
55pub(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 pub cannot_review: bool,
85 pub reasons: Vec<String>,
87}
88
89#[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 pub kind: String,
98 pub subject: String,
100 pub via: String,
102 pub detail: String,
104 pub n: i64,
105 pub editor: bool,
107}
108
109const LATEST: &str = "pr.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = pr.item_id)
111 AND pr.state <> 'obsolete'";
112
113pub 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 {
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
271fn 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
317fn cap(kind: &str) -> i64 {
319 match kind {
320 "edit" => 15,
321 "review" => 12,
322 "dependent" => 6,
323 "manager" => 6,
324 _ => 2,
325 }
326}
327
328pub(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 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 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}