Skip to main content

teamcraft/services/
notifications.rs

1//! Notifications and watch lists.
2//!
3//! Services create notifications inside the transaction of the event that
4//! causes them (a change is submitted, a reviewer decides, a dependent is
5//! flagged, …), so they exist exactly when the event happened. Each user sees
6//! them in the web UI (bell and page), through the API and in the desktop
7//! app; e-mail and Mattermost delivery read the same rows as an outbox
8//! (`crate::delivery`). Users mute kinds and switch channels off on their
9//! account page. The actor of an event is never notified about it.
10//!
11//! Who is notified:
12//!
13//! | Event                                   | Recipients                                         |
14//! |-----------------------------------------|----------------------------------------------------|
15//! | change submitted                        | reviewers (review request), watchers of the change |
16//! | reviewer decides, change released / rejected / withdrawn / cancelled | author, reviewers, watchers of the change |
17//! | revision released, revised, made obsolete, checked in | watchers of the item                   |
18//! | dependent flagged (change propagation)  | creator of the dependent, author of its latest revision, its watchers |
19//! | check-out released by somebody else     | the holder of the lock                             |
20//! | change request raised                   | CAD editors of the affected project groups, watchers of the affected items |
21//! | change request triaged, assigned, commented, decided, implemented | requester, assignee, commenters, watchers of the request |
22//! | periodic digest of the requests waiting for triage (`request_digests`) | CAD editors of the project group, e-mail only |
23//!
24//! Creating an item makes its creator a watcher.
25
26use 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/// What a notification is about. Recipients who may not read the subject
40/// are left out (`notify`), so a notification never carries data of a
41/// restricted project group to outsiders.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Subject {
44    /// An item (or one of its revisions).
45    Item(Uuid),
46    Change(Uuid),
47    Request(Uuid),
48    /// About the recipient's own account (test notifications).
49    Personal,
50}
51
52/// The content of a notification.
53#[derive(Clone, Debug)]
54pub struct Notice {
55    pub kind: NotificationKind,
56    pub title: String,
57    pub body: String,
58    /// Path in the web UI.
59    pub link: String,
60    /// `change`, `change_request` or `revision` (for the desktop app), or empty.
61    pub subject_type: &'static str,
62    /// Change or request number, or `<item number>/<revision code>`.
63    pub subject_ref: String,
64    /// What it is about – decides who may receive it.
65    pub subject: Subject,
66    /// Other items and change orders the text names (the source of an
67    /// impact, the revisions of a change, the change that released an item)
68    /// and the text for recipients who may not see all of them.
69    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    /// For recipients who may not see `item_id` (e.g. the source of an
145    /// impact), use this title and body instead.
146    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    /// The text names `mentions`; recipients who may not see all of them get
151    /// this title and body instead. Empty mentions change nothing.
152    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
169/// SQL guard: the notification aliased `n` may still be read by the user
170/// `actor` (an SQL expression) whose hidden groups are `hidden`: its subject
171/// and everything its text names are visible, and none of the groups those
172/// items were in when it was written is hidden (`mentioned_projects`: this
173/// also covers items deleted since). Rows written before the subject was
174/// stored got it from their link in migration 0027 (and the objects their
175/// text names from its numbers in 0028); personal ones (and
176/// old rows whose subject is gone) have none.
177pub(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
198/// SQL expression: the notification aliased `n` is confidential *now* – the
199/// stored flag, or its subject or something its text names is in a
200/// restricted group at delivery time (the group may have been restricted
201/// after the row was queued).
202pub fn restricted_now_sql(n: &str) -> String {
203    format!(
204        "({n}.confidential OR {})",
205        touches_group_sql(n, "cp.visibility = 'restricted'")
206    )
207}
208
209/// SQL expression: the notification aliased `n` is about a group that
210/// switched `notification_content` off (need-to-know, #151): its e-mails and
211/// chat posts carry only a generic text and the link, also in direct messages.
212pub 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
220/// SQL: the subject of notification `n`, or something its text names, is in a
221/// project group `cp` that satisfies `restricted`.
222fn 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
242/// [`notification_visible`] for the actor (`$actor_n` = id, `$hidden_n` =
243/// hidden groups).
244fn 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
252/// The recipient-side SQL condition for a subject: the user aliased `u`
253/// may read it. `$10` is the subject id.
254fn 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
273/// Whether the subject touches a restricted project group (then chat
274/// channels get only a generic text).
275async 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, _, _)) = &notice.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
321/// Creates the notification for every recipient (each once, active users
322/// only, not the actor, not for users who muted the kind, and only for
323/// users who may read the subject – evaluated per recipient). E-mail and
324/// Mattermost delivery are queued according to the user's settings.
325/// Returns the number of notifications created.
326pub(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    // Recipients who may not see everything the text names get the redacted
338    // text. The full text stores what it names, so the list re-checks it.
339    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 &notice.redacted {
342        None => vec![("TRUE".into(), &notice.title, &notice.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(), &notice.title, &notice.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(&notice.link)
387        .bind(notice.subject_type)
388        .bind(&notice.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
405/// Users who watch any of the given entities.
406pub(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
420/// Author, reviewers and watchers of a change.
421pub(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
435/// Makes `user_id` watch an entity (no-op if it already does).
436pub(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// ---------------------------------------------------------------------------
455// Reading
456// ---------------------------------------------------------------------------
457
458#[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    /// `none`, `pending`, `sent`, `failed` or `skipped`.
471    pub email_state: String,
472    pub chat_state: String,
473    /// Why the Mattermost delivery failed (empty unless `chat_state` is `failed`),
474    /// e.g. *Mattermost user "xy" not found*; shown to the recipient.
475    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
485/// The newest notifications of a user.
486pub 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
506/// Unread notifications the actor may still read (a group restricted
507/// since, or a lost membership, hides the older ones).
508pub 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
519/// Marks the given notifications (all if `None`) of the actor as read and
520/// returns the remaining unread count.
521pub 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
533/// Marks one notification of the actor as read and returns it (for its link).
534pub 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// ---------------------------------------------------------------------------
549// Settings
550// ---------------------------------------------------------------------------
551
552#[derive(Clone, Debug, Serialize)]
553pub struct Settings {
554    pub email: bool,
555    /// Where e-mails go (from the user account / the IdP).
556    pub email_address: Option<String>,
557    pub mattermost: bool,
558    /// Mattermost username if it differs from the TeamCraft username.
559    pub mattermost_username: Option<String>,
560    /// Muted kinds.
561    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
598/// Mattermost usernames: 1–64 characters `a-z 0-9 . _ -`, without a leading `@`.
599fn 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
618/// Saves the actor's own notification settings (channels, Mattermost name,
619/// muted kinds).
620///
621/// # Invariants
622///
623/// * **Permission:** any signed-in actor; only the actor's own row changes.
624///   An invalid Mattermost username or a kind that cannot be muted is
625///   refused (400).
626/// * **Locks:** none.
627/// * **Audit:** `user.notifications`.
628/// * **Notifications / webhooks:** none.
629/// * **Atomic:** one transaction.
630pub 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
688/// Sends a test notification to the actor (checks e-mail and Mattermost).
689///
690/// # Invariants
691///
692/// * **Permission:** any signed-in actor; the actor is the only recipient.
693/// * **Locks:** none.
694/// * **Data:** the test kind is delivered even if the actor muted kinds;
695///   e-mail and Mattermost delivery are queued per the actor's settings.
696/// * **Audit:** none.
697/// * **Webhooks:** none.
698/// * **Atomic:** one transaction.
699pub 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    // The actor is the recipient here, so it is not excluded as the sender.
716    notify(&mut tx, None, &[actor.id], &notice).await?;
717    tx.commit().await?;
718    Ok(())
719}
720
721// ---------------------------------------------------------------------------
722// Watch lists
723// ---------------------------------------------------------------------------
724
725/// Starts (`watch = true`) or stops watching an item or a change. Returns
726/// whether the actor watches it afterwards.
727pub 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    // Hidden objects give the same "not found" as missing ones.
736    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/// What a user watches, for the notifications page and the API.
788#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
789pub struct Watched {
790    pub entity_type: String,
791    pub entity_id: Uuid,
792    /// Item number or change number.
793    pub number: String,
794    pub name: String,
795    pub state: String,
796    /// Page in the web UI.
797    pub link: String,
798    /// Latest revision code (items).
799    pub revision_code: Option<String>,
800}
801
802/// What the actor watches – only what it may still read (the watches of
803/// objects it lost access to stay and come back with the access).
804pub 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}