1use serde::Serialize;
27use serde_json::json;
28use sqlx::{PgConnection, PgPool};
29use uuid::Uuid;
30
31use crate::{
32 auth::Actor,
33 domain::{self, NotificationKind},
34 error::{AppError, AppResult},
35 models::Revision,
36 services::{access, audit, audit::Mentions},
37};
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Subject {
44 Item(Uuid),
46 Change(Uuid),
47 Request(Uuid),
48 Personal,
50}
51
52#[derive(Clone, Debug)]
54pub struct Notice {
55 pub kind: NotificationKind,
56 pub title: String,
57 pub body: String,
58 pub link: String,
60 pub subject_type: &'static str,
62 pub subject_ref: String,
64 pub subject: Subject,
66 pub redacted: Option<(Mentions, String, String)>,
70}
71
72impl Notice {
73 pub fn change(
74 kind: NotificationKind,
75 change_id: Uuid,
76 number: &str,
77 title: String,
78 body: String,
79 ) -> Notice {
80 Notice {
81 kind,
82 title,
83 body,
84 link: format!("/changes/{change_id}"),
85 subject_type: "change",
86 subject_ref: number.to_string(),
87 subject: Subject::Change(change_id),
88 redacted: None,
89 }
90 }
91
92 pub fn request(
93 kind: NotificationKind,
94 request_id: Uuid,
95 number: &str,
96 title: String,
97 body: String,
98 ) -> Notice {
99 Notice {
100 kind,
101 title,
102 body,
103 link: format!("/requests/{request_id}"),
104 subject_type: "change_request",
105 subject_ref: number.to_string(),
106 subject: Subject::Request(request_id),
107 redacted: None,
108 }
109 }
110
111 pub fn revision(kind: NotificationKind, rev: &Revision, title: String, body: String) -> Notice {
112 Notice::revision_ref(
113 kind,
114 rev.id,
115 rev.item_id,
116 &rev.item_number,
117 &rev.code,
118 title,
119 body,
120 )
121 }
122
123 pub fn revision_ref(
124 kind: NotificationKind,
125 revision_id: Uuid,
126 item_id: Uuid,
127 item_number: &str,
128 code: &str,
129 title: String,
130 body: String,
131 ) -> Notice {
132 Notice {
133 kind,
134 title,
135 body,
136 link: format!("/revisions/{revision_id}"),
137 subject_type: "revision",
138 subject_ref: format!("{item_number}/{code}"),
139 subject: Subject::Item(item_id),
140 redacted: None,
141 }
142 }
143
144 pub fn redacted_for(self, item_id: Uuid, title: String, body: String) -> Notice {
147 self.naming(Mentions::items([item_id]), title, body)
148 }
149
150 pub fn naming(mut self, mentions: Mentions, title: String, body: String) -> Notice {
153 if !mentions.is_empty() {
154 self.redacted = Some((mentions, title, body));
155 }
156 self
157 }
158}
159
160fn subject_kind(subject: Subject) -> Option<&'static str> {
161 match subject {
162 Subject::Item(_) => Some("item"),
163 Subject::Change(_) => Some("change"),
164 Subject::Request(_) => Some("request"),
165 Subject::Personal => None,
166 }
167}
168
169pub(crate) fn notification_visible(n: &str, actor: &str, hidden: &str) -> String {
178 format!(
179 "(NOT ({n}.mentioned_projects && {hidden})
180 AND ({n}.subject_kind IS DISTINCT FROM 'item' OR NOT EXISTS (
181 SELECT 1 FROM items ni WHERE ni.id = {n}.subject_id AND NOT {item_subject}))
182 AND ({n}.subject_kind IS DISTINCT FROM 'change' OR NOT EXISTS (
183 SELECT 1 FROM changes nc WHERE nc.id = {n}.subject_id AND NOT {change_subject}))
184 AND ({n}.subject_kind IS DISTINCT FROM 'request' OR NOT EXISTS (
185 SELECT 1 FROM change_requests nq WHERE nq.id = {n}.subject_id AND NOT {request}))
186 AND NOT EXISTS (SELECT 1 FROM items nmi WHERE nmi.id = ANY({n}.mentioned_items)
187 AND NOT {item_mention})
188 AND NOT EXISTS (SELECT 1 FROM changes nmc WHERE nmc.id = ANY({n}.mentioned_changes)
189 AND NOT {change_mention}))",
190 item_subject = access::project_visible_in("ni.project_id", hidden),
191 change_subject = access::change_visible_for("nc", actor, hidden),
192 request = access::request_visible_for("nq", actor, hidden),
193 item_mention = access::project_visible_in("nmi.project_id", hidden),
194 change_mention = access::change_visible_for("nmc", actor, hidden),
195 )
196}
197
198pub fn restricted_now_sql(n: &str) -> String {
203 format!(
204 "({n}.confidential OR {})",
205 touches_group_sql(n, "cp.visibility = 'restricted'")
206 )
207}
208
209pub fn generic_text_sql(n: &str) -> String {
213 touches_group_sql(
214 n,
215 "cp.visibility = 'restricted' AND EXISTS (SELECT 1 FROM project_policies cpp
216 WHERE cpp.project_id = cp.id AND NOT cpp.notification_content)",
217 )
218}
219
220fn touches_group_sql(n: &str, restricted: &str) -> String {
223 format!(
224 "(FALSE
225 OR EXISTS (SELECT 1 FROM projects cp WHERE cp.id = ANY({n}.mentioned_projects) AND {restricted})
226 OR EXISTS (SELECT 1 FROM items ci JOIN projects cp ON cp.id = ci.project_id
227 WHERE {restricted}
228 AND (ci.id = ANY({n}.mentioned_items)
229 OR ({n}.subject_kind = 'item' AND ci.id = {n}.subject_id)))
230 OR EXISTS (SELECT 1 FROM change_items cci JOIN revisions cr ON cr.id = cci.revision_id
231 JOIN items ci ON ci.id = cr.item_id JOIN projects cp ON cp.id = ci.project_id
232 WHERE {restricted}
233 AND (cci.change_id = ANY({n}.mentioned_changes)
234 OR ({n}.subject_kind = 'change' AND cci.change_id = {n}.subject_id)))
235 OR EXISTS (SELECT 1 FROM change_request_items cri JOIN items ci ON ci.id = cri.item_id
236 JOIN projects cp ON cp.id = ci.project_id
237 WHERE {restricted} AND {n}.subject_kind = 'request'
238 AND cri.request_id = {n}.subject_id))"
239 )
240}
241
242fn visible_to(actor_n: usize, hidden_n: usize) -> String {
245 notification_visible(
246 "notifications",
247 &format!("${actor_n}"),
248 &format!("${hidden_n}"),
249 )
250}
251
252fn recipient_sees(subject: Subject) -> String {
255 let hidden = access::hidden_of_user("u");
256 match subject {
257 Subject::Item(_) => access::user_sees_project(
258 "u",
259 "(SELECT si.project_id FROM items si WHERE si.id = $10)",
260 ),
261 Subject::Change(_) => format!(
262 "EXISTS (SELECT 1 FROM changes sc WHERE sc.id = $10 AND {})",
263 access::change_visible_for("sc", "u.id", &hidden)
264 ),
265 Subject::Request(_) => format!(
266 "EXISTS (SELECT 1 FROM change_requests sq WHERE sq.id = $10 AND {})",
267 access::request_visible_for("sq", "u.id", &hidden)
268 ),
269 Subject::Personal => "($10::uuid IS NULL OR TRUE)".into(),
270 }
271}
272
273async fn is_confidential(conn: &mut PgConnection, notice: &Notice) -> AppResult<bool> {
276 let restricted = "p.visibility = 'restricted'";
277 let sql = match notice.subject {
278 Subject::Item(_) => format!(
279 "SELECT EXISTS(SELECT 1 FROM items i JOIN projects p ON p.id = i.project_id
280 WHERE i.id = $1 AND {restricted})"
281 ),
282 Subject::Change(_) => format!(
283 "SELECT EXISTS(SELECT 1 FROM change_items ci JOIN revisions r ON r.id = ci.revision_id
284 JOIN items i ON i.id = r.item_id JOIN projects p ON p.id = i.project_id
285 WHERE ci.change_id = $1 AND {restricted})"
286 ),
287 Subject::Request(_) => format!(
288 "SELECT EXISTS(SELECT 1 FROM change_request_items ri JOIN items i ON i.id = ri.item_id
289 JOIN projects p ON p.id = i.project_id
290 WHERE ri.request_id = $1 AND {restricted})"
291 ),
292 Subject::Personal => return Ok(false),
293 };
294 let mut confidential = sqlx::query_scalar::<_, bool>(&sql)
295 .bind(subject_id(notice.subject))
296 .fetch_one(&mut *conn)
297 .await?;
298 if let Some((mentions, _, _)) = ¬ice.redacted {
299 confidential |= sqlx::query_scalar::<_, bool>(&format!(
300 "SELECT EXISTS(SELECT 1 FROM items i JOIN projects p ON p.id = i.project_id
301 WHERE i.id = ANY($1) AND {restricted})
302 OR EXISTS(SELECT 1 FROM change_items ci JOIN revisions r ON r.id = ci.revision_id
303 JOIN items i ON i.id = r.item_id JOIN projects p ON p.id = i.project_id
304 WHERE ci.change_id = ANY($2) AND {restricted})"
305 ))
306 .bind(&mentions.items)
307 .bind(&mentions.changes)
308 .fetch_one(&mut *conn)
309 .await?;
310 }
311 Ok(confidential)
312}
313
314fn subject_id(subject: Subject) -> Option<Uuid> {
315 match subject {
316 Subject::Item(id) | Subject::Change(id) | Subject::Request(id) => Some(id),
317 Subject::Personal => None,
318 }
319}
320
321pub(crate) async fn notify(
327 conn: &mut PgConnection,
328 actor: Option<&Actor>,
329 recipients: &[Uuid],
330 notice: &Notice,
331) -> AppResult<u64> {
332 if recipients.is_empty() {
333 return Ok(0);
334 }
335 let confidential = is_confidential(conn, notice).await?;
336 let sees = recipient_sees(notice.subject);
337 let none = Mentions::default();
340 let mentions = notice.redacted.as_ref().map(|(m, _, _)| m).unwrap_or(&none);
341 let variants: Vec<(String, &str, &str, &Mentions)> = match ¬ice.redacted {
342 None => vec![("TRUE".into(), ¬ice.title, ¬ice.body, &none)],
343 Some((_, title, body)) => {
344 let sees_all = format!(
345 "(NOT EXISTS (SELECT 1 FROM items ri WHERE ri.id = ANY($12) AND NOT {item})
346 AND NOT EXISTS (SELECT 1 FROM changes rc WHERE rc.id = ANY($13) AND NOT {change}))",
347 item = access::user_sees_project("u", "ri.project_id"),
348 change = access::change_visible_for("rc", "u.id", &access::hidden_of_user("u")),
349 );
350 vec![
351 (sees_all.clone(), ¬ice.title, ¬ice.body, mentions),
352 (
353 format!("NOT {sees_all}"),
354 title.as_str(),
355 body.as_str(),
356 &none,
357 ),
358 ]
359 }
360 };
361 let mut created = 0;
362 for (condition, title, body, stored) in variants {
363 created += sqlx::query(&format!(
364 "INSERT INTO notifications (id, user_id, kind, title, body, link, subject_type, subject_ref,
365 actor_name, email_state, chat_state, confidential,
366 subject_kind, subject_id, mentioned_items, mentioned_changes,
367 mentioned_projects)
368 SELECT gen_random_uuid(), u.id, $2, $3, $4, $5, $6, $7, $8,
369 CASE WHEN u.notify_email AND COALESCE(u.email, '') <> '' THEN 'pending' ELSE 'none' END,
370 CASE WHEN u.notify_mattermost THEN 'pending' ELSE 'none' END,
371 $11, $16, $10, $14, $15,
372 ARRAY(SELECT DISTINCT pi.project_id FROM items pi
373 WHERE pi.project_id IS NOT NULL
374 AND (pi.id = ANY($14) OR ($16 = 'item' AND pi.id = $10)))
375 FROM users u
376 WHERE u.id = ANY($1) AND u.active AND u.id <> $9
377 AND ($2 = 'test' OR NOT ($2 = ANY(u.notify_muted)))
378 AND {sees}
379 AND cardinality($12::uuid[]) >= 0 AND cardinality($13::uuid[]) >= 0
380 AND {condition}"
381 ))
382 .bind(recipients)
383 .bind(notice.kind.as_str())
384 .bind(domain::truncate_chars(title, 300))
385 .bind(domain::truncate_chars(body, 4000))
386 .bind(¬ice.link)
387 .bind(notice.subject_type)
388 .bind(¬ice.subject_ref)
389 .bind(actor.map(|a| a.display_name.as_str()).unwrap_or("TeamCraft"))
390 .bind(actor.map(|a| a.id).unwrap_or(Uuid::nil()))
391 .bind(subject_id(notice.subject))
392 .bind(confidential)
393 .bind(&mentions.items)
394 .bind(&mentions.changes)
395 .bind(&stored.items)
396 .bind(&stored.changes)
397 .bind(subject_kind(notice.subject))
398 .execute(&mut *conn)
399 .await?
400 .rows_affected();
401 }
402 Ok(created)
403}
404
405pub(crate) async fn watchers(
407 conn: &mut PgConnection,
408 entity_type: &str,
409 ids: &[Uuid],
410) -> AppResult<Vec<Uuid>> {
411 Ok(sqlx::query_scalar::<_, Uuid>(
412 "SELECT DISTINCT user_id FROM watches WHERE entity_type = $1 AND entity_id = ANY($2)",
413 )
414 .bind(entity_type)
415 .bind(ids)
416 .fetch_all(&mut *conn)
417 .await?)
418}
419
420pub(crate) async fn change_people(
422 conn: &mut PgConnection,
423 change_id: Uuid,
424) -> AppResult<Vec<Uuid>> {
425 Ok(sqlx::query_scalar::<_, Uuid>(
426 "SELECT created_by FROM changes WHERE id = $1
427 UNION SELECT reviewer_id FROM change_reviews WHERE change_id = $1
428 UNION SELECT user_id FROM watches WHERE entity_type = 'change' AND entity_id = $1",
429 )
430 .bind(change_id)
431 .fetch_all(&mut *conn)
432 .await?)
433}
434
435pub(crate) async fn add_watch(
437 conn: &mut PgConnection,
438 user_id: Uuid,
439 entity_type: &str,
440 entity_id: Uuid,
441) -> AppResult<()> {
442 sqlx::query(
443 "INSERT INTO watches (user_id, entity_type, entity_id) VALUES ($1, $2, $3)
444 ON CONFLICT DO NOTHING",
445 )
446 .bind(user_id)
447 .bind(entity_type)
448 .bind(entity_id)
449 .execute(&mut *conn)
450 .await?;
451 Ok(())
452}
453
454#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
459pub struct Notification {
460 pub id: Uuid,
461 pub kind: String,
462 pub title: String,
463 pub body: String,
464 pub link: String,
465 pub subject_type: String,
466 pub subject_ref: String,
467 pub actor_name: String,
468 pub created_at: chrono::DateTime<chrono::Utc>,
469 pub read_at: Option<chrono::DateTime<chrono::Utc>>,
470 pub email_state: String,
472 pub chat_state: String,
473 pub chat_error: String,
476}
477
478const NOTIFICATION_COLUMNS: &str =
479 "id, kind, title, body, link, subject_type, subject_ref, actor_name,
480 created_at, read_at, email_state, chat_state,
481 CASE WHEN chat_state = 'failed'
482 THEN COALESCE(substring(delivery_error from 'Mattermost: (.*)$'), '')
483 ELSE '' END AS chat_error";
484
485pub async fn list(
487 pool: &PgPool,
488 actor: &Actor,
489 unread_only: bool,
490 limit: i64,
491) -> AppResult<Vec<Notification>> {
492 Ok(sqlx::query_as::<_, Notification>(&format!(
493 "SELECT {NOTIFICATION_COLUMNS} FROM notifications
494 WHERE user_id = $1 AND (NOT $2 OR read_at IS NULL) AND {}
495 ORDER BY created_at DESC LIMIT $3",
496 visible_to(1, 4)
497 ))
498 .bind(actor.id)
499 .bind(unread_only)
500 .bind(limit.clamp(1, 500))
501 .bind(&actor.hidden_projects)
502 .fetch_all(pool)
503 .await?)
504}
505
506pub async fn unread_count(pool: &PgPool, actor: &Actor) -> AppResult<i64> {
509 Ok(sqlx::query_scalar::<_, i64>(&format!(
510 "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL AND {}",
511 visible_to(1, 2)
512 ))
513 .bind(actor.id)
514 .bind(&actor.hidden_projects)
515 .fetch_one(pool)
516 .await?)
517}
518
519pub async fn mark_read(pool: &PgPool, actor: &Actor, ids: Option<&[Uuid]>) -> AppResult<i64> {
522 sqlx::query(
523 "UPDATE notifications SET read_at = now()
524 WHERE user_id = $1 AND read_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2))",
525 )
526 .bind(actor.id)
527 .bind(ids)
528 .execute(pool)
529 .await?;
530 unread_count(pool, actor).await
531}
532
533pub async fn open(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Notification> {
535 sqlx::query_as::<_, Notification>(&format!(
536 "UPDATE notifications SET read_at = COALESCE(read_at, now())
537 WHERE id = $1 AND user_id = $2 AND {} RETURNING {NOTIFICATION_COLUMNS}",
538 visible_to(2, 3)
539 ))
540 .bind(id)
541 .bind(actor.id)
542 .bind(&actor.hidden_projects)
543 .fetch_optional(pool)
544 .await?
545 .ok_or_else(|| AppError::not_found("Notification not found."))
546}
547
548#[derive(Clone, Debug, Serialize)]
553pub struct Settings {
554 pub email: bool,
555 pub email_address: Option<String>,
557 pub mattermost: bool,
558 pub mattermost_username: Option<String>,
560 pub muted: Vec<String>,
562}
563
564pub async fn settings(pool: &PgPool, user_id: Uuid) -> AppResult<Settings> {
565 #[derive(sqlx::FromRow)]
566 struct Row {
567 notify_email: bool,
568 email: Option<String>,
569 notify_mattermost: bool,
570 mattermost_username: Option<String>,
571 notify_muted: Vec<String>,
572 }
573 let row = sqlx::query_as::<_, Row>(
574 "SELECT notify_email, email, notify_mattermost, mattermost_username, notify_muted
575 FROM users WHERE id = $1",
576 )
577 .bind(user_id)
578 .fetch_optional(pool)
579 .await?
580 .ok_or_else(|| AppError::not_found("User not found."))?;
581 Ok(Settings {
582 email: row.notify_email,
583 email_address: row.email.filter(|e| !e.is_empty()),
584 mattermost: row.notify_mattermost,
585 mattermost_username: row.mattermost_username,
586 muted: row.notify_muted,
587 })
588}
589
590#[derive(Clone, Debug)]
591pub struct SettingsUpdate {
592 pub email: bool,
593 pub mattermost: bool,
594 pub mattermost_username: Option<String>,
595 pub muted: Vec<String>,
596}
597
598fn clean_mattermost_username(raw: Option<&str>) -> AppResult<Option<String>> {
600 let Some(name) = raw
601 .map(|n| n.trim().trim_start_matches('@').to_lowercase())
602 .filter(|n| !n.is_empty())
603 else {
604 return Ok(None);
605 };
606 if name.len() > 64
607 || !name
608 .chars()
609 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
610 {
611 return Err(AppError::BadRequest(format!(
612 "'{name}' is not a valid Mattermost username."
613 )));
614 }
615 Ok(Some(name))
616}
617
618pub async fn update_settings(
631 pool: &PgPool,
632 actor: &Actor,
633 upd: SettingsUpdate,
634) -> AppResult<Settings> {
635 let mattermost_username = clean_mattermost_username(upd.mattermost_username.as_deref())?;
636 let mut muted = Vec::new();
637 for kind in &upd.muted {
638 match domain::NotificationKind::parse(kind) {
639 Some(k) if NotificationKind::MUTABLE.contains(&k) => {
640 if !muted.contains(&k.as_str().to_string()) {
641 muted.push(k.as_str().to_string());
642 }
643 }
644 _ => {
645 return Err(AppError::BadRequest(format!(
646 "Unknown notification kind '{kind}'."
647 )))
648 }
649 }
650 }
651 let mut tx = pool.begin().await?;
652 sqlx::query(
653 "UPDATE users SET notify_email = $2, notify_mattermost = $3, mattermost_username = $4,
654 notify_muted = $5
655 WHERE id = $1",
656 )
657 .bind(actor.id)
658 .bind(upd.email)
659 .bind(upd.mattermost)
660 .bind(&mattermost_username)
661 .bind(&muted)
662 .execute(&mut *tx)
663 .await?;
664 audit::record(
665 &mut tx,
666 Some(actor),
667 "user.notifications",
668 "user",
669 Some(actor.id),
670 format!(
671 "{} changed the notification settings (e-mail {}, Mattermost {}, muted: {})",
672 actor.username,
673 if upd.email { "on" } else { "off" },
674 if upd.mattermost { "on" } else { "off" },
675 if muted.is_empty() {
676 "nothing".to_string()
677 } else {
678 muted.join(", ")
679 }
680 ),
681 json!({ "email": upd.email, "mattermost": upd.mattermost, "muted": muted }),
682 )
683 .await?;
684 tx.commit().await?;
685 settings(pool, actor.id).await
686}
687
688pub async fn send_test(pool: &PgPool, actor: &Actor) -> AppResult<()> {
700 let mut tx = pool.begin().await?;
701 let notice = Notice {
702 kind: NotificationKind::Test,
703 title: "Test notification".into(),
704 body: format!(
705 "Hello {}, this is a test message from TeamCraft. If it arrives by e-mail or in \
706 Mattermost, these channels work.",
707 actor.display_name
708 ),
709 link: "/notifications".into(),
710 subject_type: "",
711 subject_ref: String::new(),
712 subject: Subject::Personal,
713 redacted: None,
714 };
715 notify(&mut tx, None, &[actor.id], ¬ice).await?;
717 tx.commit().await?;
718 Ok(())
719}
720
721pub async fn set_watch(
728 pool: &PgPool,
729 actor: &Actor,
730 entity_type: &str,
731 entity_id: Uuid,
732 watch: bool,
733) -> AppResult<bool> {
734 let mut conn = pool.acquire().await?;
735 let exists = match entity_type {
737 "item" => {
738 sqlx::query_scalar::<_, bool>(&format!(
739 "SELECT EXISTS(SELECT 1 FROM items i WHERE i.id = $1 AND {})",
740 access::item_visible("i", 2)
741 ))
742 .bind(entity_id)
743 .bind(&actor.hidden_projects)
744 .fetch_one(&mut *conn)
745 .await?
746 }
747 "change" => crate::services::changes::is_visible(&mut conn, actor, entity_id).await?,
748 "change_request" => {
749 crate::services::requests::is_visible(&mut conn, actor, entity_id).await?
750 }
751 _ => false,
752 };
753 if !exists {
754 return Err(AppError::not_found(format!("No such {entity_type}.")));
755 }
756 if watch {
757 add_watch(&mut conn, actor.id, entity_type, entity_id).await?;
758 } else {
759 sqlx::query(
760 "DELETE FROM watches WHERE user_id = $1 AND entity_type = $2 AND entity_id = $3",
761 )
762 .bind(actor.id)
763 .bind(entity_type)
764 .bind(entity_id)
765 .execute(&mut *conn)
766 .await?;
767 }
768 Ok(watch)
769}
770
771pub async fn is_watching(
772 pool: &PgPool,
773 user_id: Uuid,
774 entity_type: &str,
775 entity_id: Uuid,
776) -> AppResult<bool> {
777 Ok(sqlx::query_scalar::<_, bool>(
778 "SELECT EXISTS(SELECT 1 FROM watches WHERE user_id = $1 AND entity_type = $2 AND entity_id = $3)",
779 )
780 .bind(user_id)
781 .bind(entity_type)
782 .bind(entity_id)
783 .fetch_one(pool)
784 .await?)
785}
786
787#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
789pub struct Watched {
790 pub entity_type: String,
791 pub entity_id: Uuid,
792 pub number: String,
794 pub name: String,
795 pub state: String,
796 pub link: String,
798 pub revision_code: Option<String>,
800}
801
802pub async fn watched(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Watched>> {
805 Ok(sqlx::query_as::<_, Watched>(&format!(
806 "SELECT 'item' AS entity_type, i.id AS entity_id, i.number, lr.name, lr.state,
807 '/revisions/' || lr.id AS link, lr.code AS revision_code
808 FROM watches w JOIN items i ON i.id = w.entity_id
809 CROSS JOIN LATERAL (SELECT r.id, r.code, r.name, r.state FROM revisions r
810 WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1) lr
811 WHERE w.user_id = $1 AND w.entity_type = 'item' AND {item}
812 UNION ALL
813 SELECT 'change', c.id, c.number, c.title, c.status, '/changes/' || c.id, NULL
814 FROM watches w JOIN changes c ON c.id = w.entity_id
815 WHERE w.user_id = $1 AND w.entity_type = 'change' AND {change}
816 UNION ALL
817 SELECT 'change_request', cr.id, cr.number, cr.title, cr.status, '/requests/' || cr.id, NULL
818 FROM watches w JOIN change_requests cr ON cr.id = w.entity_id
819 WHERE w.user_id = $1 AND w.entity_type = 'change_request' AND {request}
820 ORDER BY 1, 3",
821 item = access::item_visible("i", 2),
822 change = access::change_visible("c", 1, 2),
823 request = access::request_visible("cr", 1, 2),
824 ))
825 .bind(actor.id)
826 .bind(&actor.hidden_projects)
827 .fetch_all(pool)
828 .await?)
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 #[test]
836 fn mattermost_usernames() {
837 assert_eq!(
838 clean_mattermost_username(Some(" @Erika.Muster ")).unwrap(),
839 Some("erika.muster".into())
840 );
841 assert_eq!(clean_mattermost_username(Some(" ")).unwrap(), None);
842 assert_eq!(clean_mattermost_username(None).unwrap(), None);
843 assert!(clean_mattermost_username(Some("a b")).is_err());
844 assert!(clean_mattermost_username(Some(&"x".repeat(65))).is_err());
845 }
846
847 #[test]
848 fn notices_link_to_their_subject() {
849 let id = Uuid::nil();
850 let n = Notice::change(
851 NotificationKind::ReviewRequested,
852 id,
853 "CHG-000004",
854 "t".into(),
855 "b".into(),
856 );
857 assert_eq!(n.link, format!("/changes/{id}"));
858 assert_eq!(
859 (n.subject_type, n.subject_ref.as_str()),
860 ("change", "CHG-000004")
861 );
862 let n = Notice::revision_ref(
863 NotificationKind::Impact,
864 id,
865 id,
866 "ASM-000001",
867 "B",
868 "t".into(),
869 "b".into(),
870 );
871 assert_eq!(n.subject_ref, "ASM-000001/B");
872 assert_eq!(n.link, format!("/revisions/{id}"));
873 let n = Notice::request(
874 NotificationKind::ChangeRequest,
875 id,
876 "ECR-000002",
877 "t".into(),
878 "b".into(),
879 );
880 assert_eq!(n.link, format!("/requests/{id}"));
881 assert_eq!(
882 (n.subject_type, n.subject_ref.as_str()),
883 ("change_request", "ECR-000002")
884 );
885 }
886}