1use 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
31pub const MAX_PER_KIND: i64 = 1000;
33
34#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
36pub struct HandoverEntry {
37 pub kind: &'static str,
39 pub number: String,
41 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 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
85async 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
109async 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
126async 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
142async 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
157async 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
180async 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
203pub 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
275pub 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
293pub 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
325pub(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 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 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 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 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 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}