forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
49.3 KiB
1//! Notifications. The inbox at `GET /notifications` (Unread by default, `?all=1` for everything,
2//! grouped by repo, 50 rows a page with a `?before=` cursor) and its routes: open a row, mark a
3//! row or a whole repo or inbox read, toggle the subscription on an issue or pull, and set the
4//! repo watch level. Rows for repos the viewer can no longer read are hidden and marked read.
5//! The sidebar box and the watch form are shared with the issue, pull and repo header views.
6
7use std::collections::HashMap;
8
9use forge_core::authz::{Action, authorize};
10use forge_core::discussions::{DiscussionNotification, DiscussionReason};
11use forge_core::store::advisories::AdvisoryNotice;
12use forge_core::store::transfers::PendingTransfer;
13use forge_core::{Notification, Reason, Role, SubState, User, WatchLevel};
14use serde::Deserialize;
15use topcoat::Result;
16use topcoat::context::{Cx, app_context};
17use topcoat::router::content::Form;
18use topcoat::router::error::{SeeOther, bad_request, not_found, see_other};
19use topcoat::router::{page, path_param, query_params, route};
20use topcoat::view::{View, component, view};
21
22use crate::auth::{percent_encode, require_csrf, require_user};
23use crate::i18n::misc::{self as g, K};
24use crate::i18n::{self, Key, Lang};
25use crate::pages::issue_parts::{parse_number, stamp_unix};
26use crate::pages::repo_ctx::{RepoCtx, load_repo};
27use crate::pages::repo_transfer::transfer_list_for;
28use crate::pages::star::return_path;
29use crate::state::AppState;
30use crate::views::{csrf_field, display_name, iso_time, now_unix, relative_time, repo_href, shell};
31
32/// Rows per inbox page.
33const PAGE_SIZE: u32 = 50;
34
35path_param!(owner);
36path_param!(repo);
37path_param!(number);
38path_param!(note);
39path_param!(discussion_id);
40path_param!(advisory_repo_id);
41
42#[query_params(error = bad_request)]
43struct InboxQuery {
44 all: Option<String>,
45 before: Option<String>,
46}
47
48#[query_params(error = bad_request)]
49struct RepoQuery {
50 repo: Option<String>,
51}
52
53#[derive(Deserialize)]
54struct CsrfForm {
55 #[serde(default)]
56 csrf: String,
57}
58
59#[derive(Deserialize)]
60struct SubscriptionForm {
61 #[serde(default)]
62 csrf: String,
63 #[serde(default)]
64 action: String,
65}
66
67#[derive(Deserialize)]
68struct WatchForm {
69 #[serde(default)]
70 csrf: String,
71 #[serde(default)]
72 level: String,
73}
74
75/// One inbox row, with its strings ready for the view.
76pub(crate) struct RowView {
77 item_class: &'static str,
78 icon_class: &'static str,
79 kind_label: &'static str,
80 open_href: String,
81 read_action: String,
82 title_line: String,
83 reason: &'static str,
84 activity: String,
85 iso: String,
86 absolute: String,
87 relative: String,
88 unread: bool,
89}
90
91/// The rows of one repo, in the order they first appear in the inbox.
92pub(crate) struct RepoGroup {
93 heading: String,
94 href: String,
95 mark_action: String,
96 rows: Vec<RowView>,
97}
98
99/// One inbox entry before it becomes a row: an issue or pull notification, a discussion one, or
100/// the security alerts of a repo.
101enum Entry {
102 Issue(Notification),
103 Discussion(DiscussionNotification),
104 Advisory(AdvisoryNotice),
105}
106
107impl Entry {
108 fn repo_id(&self) -> &str {
109 match self {
110 Entry::Issue(note) => &note.repo_id,
111 Entry::Discussion(note) => &note.repo_id,
112 Entry::Advisory(note) => &note.repo_id,
113 }
114 }
115
116 fn repo_owner(&self) -> &str {
117 match self {
118 Entry::Issue(note) => &note.repo_owner,
119 Entry::Discussion(note) => &note.repo_owner,
120 Entry::Advisory(note) => &note.repo_owner,
121 }
122 }
123
124 fn repo_name(&self) -> &str {
125 match self {
126 Entry::Issue(note) => &note.repo_name,
127 Entry::Discussion(note) => &note.repo_name,
128 Entry::Advisory(note) => &note.repo_name,
129 }
130 }
131
132 fn updated_at(&self) -> &str {
133 match self {
134 Entry::Issue(note) => &note.updated_at,
135 Entry::Discussion(note) => &note.updated_at,
136 Entry::Advisory(note) => &note.updated_at,
137 }
138 }
139}
140
141/// The row for an issue or pull inbox entry.
142fn issue_row(lang: Lang, note: &Notification, tz: &str, now: i64) -> RowView {
143 let (kind_label, icon_class) = match (note.is_pull, note.issue_state.as_str()) {
144 (false, "open") => (g::t(lang, K::KindOpenIssue), "notif-icon issue-open"),
145 (false, _) => (g::t(lang, K::KindClosedIssue), "notif-icon issue-closed"),
146 (true, "open") => (g::t(lang, K::KindOpenPull), "notif-icon pull-open"),
147 (true, _) => (g::t(lang, K::KindClosedPull), "notif-icon pull-closed"),
148 };
149 let actor = note
150 .last_actor_name
151 .as_deref()
152 .map(|name| display_name(lang, name));
153 let unix = stamp_unix(&note.updated_at);
154 RowView {
155 item_class: if note.unread {
156 "notif-row unread"
157 } else {
158 "notif-row"
159 },
160 icon_class,
161 kind_label,
162 open_href: format!("/notifications/{}/open", percent_encode(&note.id)),
163 read_action: format!("/notifications/{}/read", percent_encode(&note.id)),
164 title_line: format!(
165 "{}/{} #{} {}",
166 note.repo_owner, note.repo_name, note.number, note.title
167 ),
168 reason: reason_label(lang, note.reason),
169 activity: g::activity_line(lang, activity_verb(&note.last_activity), actor.as_deref()),
170 iso: iso_time(unix),
171 absolute: forge_core::tz::format_in_tz(unix, tz),
172 relative: relative_time(lang, unix, now),
173 unread: note.unread,
174 }
175}
176
177/// The row for a discussion inbox entry: a mention, or a reply to the viewer's discussion.
178fn discussion_row(lang: Lang, note: &DiscussionNotification, tz: &str, now: i64) -> RowView {
179 let (reason, activity) = match note.reason {
180 DiscussionReason::Mention => (g::t(lang, K::ReasonMentioned), K::VerbMentionedYou),
181 DiscussionReason::Reply => (g::t(lang, K::ReasonReplied), K::VerbDiscussionReply),
182 };
183 let actor = note
184 .last_actor_name
185 .as_deref()
186 .map(|name| display_name(lang, name));
187 let unix = stamp_unix(&note.updated_at);
188 RowView {
189 item_class: if note.unread {
190 "notif-row unread"
191 } else {
192 "notif-row"
193 },
194 icon_class: "notif-icon discussion",
195 kind_label: g::t(lang, K::KindDiscussion),
196 open_href: format!(
197 "/notifications/discussions/{}/open",
198 percent_encode(&note.discussion_id)
199 ),
200 read_action: format!(
201 "/notifications/discussions/{}/read",
202 percent_encode(&note.discussion_id)
203 ),
204 title_line: format!(
205 "{}/{} #{} {}",
206 note.repo_owner, note.repo_name, note.number, note.title
207 ),
208 reason,
209 activity: g::activity_line(lang, activity, actor.as_deref()),
210 iso: iso_time(unix),
211 absolute: forge_core::tz::format_in_tz(unix, tz),
212 relative: relative_time(lang, unix, now),
213 unread: note.unread,
214 }
215}
216
217/// The row for a repo's security alerts: how many alerts a scan opened since the viewer last read
218/// the row. It links to the repo's security page through the open route.
219fn advisory_row(lang: Lang, note: &AdvisoryNotice, tz: &str, now: i64) -> RowView {
220 let unix = stamp_unix(&note.updated_at);
221 let count = note.new_alerts;
222 RowView {
223 item_class: if note.unread {
224 "notif-row unread"
225 } else {
226 "notif-row"
227 },
228 icon_class: "notif-icon advisory",
229 kind_label: g::t(lang, K::KindSecurityAlerts),
230 open_href: format!(
231 "/notifications/advisories/{}/open",
232 percent_encode(&note.repo_id)
233 ),
234 read_action: format!(
235 "/notifications/advisories/{}/read",
236 percent_encode(&note.repo_id)
237 ),
238 title_line: format!("{}/{}", note.repo_owner, note.repo_name),
239 reason: g::t(lang, K::ReasonSecurity),
240 activity: g::new_alerts(lang, count),
241 iso: iso_time(unix),
242 absolute: forge_core::tz::format_in_tz(unix, tz),
243 relative: relative_time(lang, unix, now),
244 unread: note.unread,
245 }
246}
247
248#[page("/notifications")]
249async fn inbox(cx: &Cx) -> Result<impl View> {
250 let viewer = require_user(cx).await?;
251 let state = app_context::<AppState>(cx);
252 let query = query_params::<InboxQuery>(cx)?;
253 let all = query.all.as_deref() == Some("1");
254 let before = query.before.clone().filter(|s| !s.is_empty());
255 let user = &viewer.0;
256 let lang = i18n::lang(cx, Some(user));
257 let tz = user.timezone.clone();
258 let now = now_unix();
259
260 let rows = state
261 .store
262 .list_notifications(&user.id, !all, before.as_deref(), PAGE_SIZE)
263 .await?;
264 let discussion_rows = state
265 .store
266 .list_discussion_notifications(&user.id, !all, before.as_deref(), PAGE_SIZE)
267 .await?;
268 let advisory_rows = state
269 .store
270 .list_advisory_notifications(&user.id, !all, before.as_deref(), PAGE_SIZE)
271 .await?;
272 // Every list is newest first and holds at most one page, so the top page of the merge is
273 // complete. A `more` link is due when any list was full or the merge has rows past the page.
274 let lists_full = rows.len() == PAGE_SIZE as usize
275 || discussion_rows.len() == PAGE_SIZE as usize
276 || advisory_rows.len() == PAGE_SIZE as usize;
277 let mut merged: Vec<Entry> = rows
278 .into_iter()
279 .map(Entry::Issue)
280 .chain(discussion_rows.into_iter().map(Entry::Discussion))
281 .chain(advisory_rows.into_iter().map(Entry::Advisory))
282 .collect();
283 merged.sort_by(|a, b| b.updated_at().cmp(a.updated_at()));
284 let more_rows = lists_full || merged.len() > PAGE_SIZE as usize;
285 merged.truncate(PAGE_SIZE as usize);
286 let more = if more_rows {
287 merged.last().map(|last| {
288 let mut href = format!(
289 "/notifications?before={}",
290 percent_encode(last.updated_at())
291 );
292 if all {
293 href.push_str("&all=1");
294 }
295 href
296 })
297 } else {
298 None
299 };
300
301 let mut readable: HashMap<String, bool> = HashMap::new();
302 let mut hidden = Vec::new();
303 let mut groups: Vec<RepoGroup> = Vec::new();
304 let mut index: HashMap<String, usize> = HashMap::new();
305 for entry in merged {
306 let repo_id = entry.repo_id().to_owned();
307 let visible = match readable.get(&repo_id) {
308 Some(visible) => *visible,
309 None => {
310 let visible = can_read(state, user, &repo_id).await?;
311 readable.insert(repo_id.clone(), visible);
312 visible
313 }
314 };
315 // Security alerts are for the repo's managers only, the same rule as the security page.
316 let visible = visible
317 && (!matches!(entry, Entry::Advisory(_))
318 || can_manage_alerts(state, user, &repo_id).await?);
319 if !visible {
320 hidden.push(entry);
321 continue;
322 }
323 let slot = match index.get(&repo_id) {
324 Some(slot) => *slot,
325 None => {
326 let heading = format!("{}/{}", entry.repo_owner(), entry.repo_name());
327 groups.push(RepoGroup {
328 href: repo_href(entry.repo_owner(), entry.repo_name(), "", ""),
329 mark_action: format!(
330 "/notifications/mark_all_read?repo={}",
331 percent_encode(&repo_id)
332 ),
333 heading,
334 rows: Vec::new(),
335 });
336 index.insert(repo_id.clone(), groups.len() - 1);
337 groups.len() - 1
338 }
339 };
340 let row = match entry {
341 Entry::Issue(note) => issue_row(lang, &note, &tz, now),
342 Entry::Discussion(note) => discussion_row(lang, &note, &tz, now),
343 Entry::Advisory(note) => advisory_row(lang, &note, &tz, now),
344 };
345 groups[slot].rows.push(row);
346 }
347 // Rows for repos the viewer can no longer read are marked read, so they stop counting.
348 for entry in &hidden {
349 match entry {
350 Entry::Issue(note) => state.store.mark_read(&user.id, &note.id).await?,
351 Entry::Discussion(note) => {
352 state
353 .store
354 .mark_discussion_notification_read(&user.id, &note.discussion_id)
355 .await?;
356 }
357 Entry::Advisory(note) => {
358 state
359 .store
360 .mark_advisory_notification_read(&user.id, &note.repo_id)
361 .await?;
362 }
363 }
364 }
365
366 let unread_count = state.store.unread_count(&user.id).await?;
367 let transfers = state.store.pending_transfers_for_user(&user.id).await?;
368 let csrf = viewer.1.csrf_token.clone();
369 Ok(view! {
370 shell(title: i18n::t(lang, Key::Notifications), viewer: Some(&viewer),
371 inbox_list(lang: lang, all: all, unread_count: unread_count, groups: groups, more: more, csrf: csrf, transfers: transfers)
372 )
373 })
374}
375
376/// Opens one inbox row: marks it read and sends the viewer to the issue or pull. Another user's
377/// row, or a repo the viewer can no longer read, is 404.
378#[route(GET "/notifications/{note}/open")]
379async fn open_notification(cx: &Cx) -> Result<SeeOther> {
380 let (user, _) = require_user(cx).await?;
381 let state = app_context::<AppState>(cx);
382 let id = path_param::<Note>(cx);
383 let Some(target) = state.store.notification_target(&user.id, id).await? else {
384 return Err(not_found().into());
385 };
386 state.store.mark_read(&user.id, id).await?;
387 if !can_read(state, &user, &target.repo_id).await? {
388 return Err(not_found().into());
389 }
390 let section = if target.is_pull { "pulls" } else { "issues" };
391 let href = repo_href(
392 &target.owner,
393 &target.name,
394 section,
395 &target.number.to_string(),
396 );
397 Ok(see_other(href))
398}
399
400/// Opens one discussion inbox row: marks it read and sends the viewer to the discussion. Another
401/// user's row, or a repo the viewer can no longer read, is 404.
402#[route(GET "/notifications/discussions/{discussion_id}/open")]
403async fn open_discussion_notification(cx: &Cx) -> Result<SeeOther> {
404 let (user, _) = require_user(cx).await?;
405 let state = app_context::<AppState>(cx);
406 let id = path_param::<DiscussionId>(cx);
407 let Some(note) = state.store.discussion_notification(&user.id, id).await? else {
408 return Err(not_found().into());
409 };
410 state
411 .store
412 .mark_discussion_notification_read(&user.id, id)
413 .await?;
414 if !can_read(state, &user, &note.repo_id).await? {
415 return Err(not_found().into());
416 }
417 let href = repo_href(
418 &note.repo_owner,
419 &note.repo_name,
420 "discussions",
421 &note.number.to_string(),
422 );
423 Ok(see_other(href))
424}
425
426/// Marks one discussion inbox row read, then returns to the page the viewer came from.
427#[route(POST "/notifications/discussions/{discussion_id}/read")]
428async fn read_discussion_notification(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
429 let (user, _) = require_csrf(cx, &form.csrf).await?;
430 let state = app_context::<AppState>(cx);
431 let id = path_param::<DiscussionId>(cx);
432 state
433 .store
434 .mark_discussion_notification_read(&user.id, id)
435 .await?;
436 Ok(see_other(back(cx)))
437}
438
439/// Opens a repo's security alerts row: marks it read and sends the viewer to the repo's security
440/// page. Another user's row, or a repo whose alerts the viewer may not manage, is 404.
441#[route(GET "/notifications/advisories/{advisory_repo_id}/open")]
442async fn open_advisory_notification(cx: &Cx) -> Result<SeeOther> {
443 let (user, _) = require_user(cx).await?;
444 let state = app_context::<AppState>(cx);
445 let repo_id = path_param::<AdvisoryRepoId>(cx);
446 let Some(notice) = state.store.advisory_notification(&user.id, repo_id).await? else {
447 return Err(not_found().into());
448 };
449 state
450 .store
451 .mark_advisory_notification_read(&user.id, repo_id)
452 .await?;
453 if !can_manage_alerts(state, &user, repo_id).await? {
454 return Err(not_found().into());
455 }
456 Ok(see_other(repo_href(
457 &notice.repo_owner,
458 &notice.repo_name,
459 "security",
460 "",
461 )))
462}
463
464/// Marks one repo's security alerts row read, then returns to the page the viewer came from.
465#[route(POST "/notifications/advisories/{advisory_repo_id}/read")]
466async fn read_advisory_notification(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
467 let (user, _) = require_csrf(cx, &form.csrf).await?;
468 let state = app_context::<AppState>(cx);
469 let repo_id = path_param::<AdvisoryRepoId>(cx);
470 state
471 .store
472 .mark_advisory_notification_read(&user.id, repo_id)
473 .await?;
474 Ok(see_other(back(cx)))
475}
476
477/// Marks one inbox row read, then returns to the page the viewer came from.
478#[route(POST "/notifications/{note}/read")]
479async fn read_notification(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
480 let (user, _) = require_csrf(cx, &form.csrf).await?;
481 let state = app_context::<AppState>(cx);
482 let id = path_param::<Note>(cx);
483 state.store.mark_read(&user.id, id).await?;
484 Ok(see_other(back(cx)))
485}
486
487/// Marks the viewer's unread rows read: in one repo with `?repo=<id>`, otherwise everywhere.
488#[route(POST "/notifications/mark_all_read")]
489async fn mark_all_read(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
490 let (user, _) = require_csrf(cx, &form.csrf).await?;
491 let state = app_context::<AppState>(cx);
492 let query = query_params::<RepoQuery>(cx)?;
493 let repo_id = query.repo.as_deref().filter(|s| !s.is_empty());
494 state.store.mark_all_read(&user.id, repo_id).await?;
495 state
496 .store
497 .mark_all_discussion_notifications_read(&user.id, repo_id)
498 .await?;
499 state
500 .store
501 .mark_all_advisory_notifications_read(&user.id, repo_id)
502 .await?;
503 Ok(see_other(back(cx)))
504}
505
506/// Subscribes or unsubscribes the viewer from an issue.
507#[route(POST "/{owner}/{repo}/issues/{number}/subscription")]
508async fn issue_subscription(cx: &Cx, Form(form): Form<SubscriptionForm>) -> Result<SeeOther> {
509 set_subscription(cx, false, &form).await
510}
511
512/// Subscribes or unsubscribes the viewer from a pull request.
513#[route(POST "/{owner}/{repo}/pulls/{number}/subscription")]
514async fn pull_subscription(cx: &Cx, Form(form): Form<SubscriptionForm>) -> Result<SeeOther> {
515 set_subscription(cx, true, &form).await
516}
517
518async fn set_subscription(cx: &Cx, pull: bool, form: &SubscriptionForm) -> Result<SeeOther> {
519 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
520 let (user, _) = require_csrf(cx, &form.csrf).await?;
521 let state = app_context::<AppState>(cx);
522 let number = parse_number(path_param::<Number>(cx))?;
523 let issue = if pull {
524 state
525 .store
526 .pull_by_number(&ctx.repo.id, number)
527 .await?
528 .map(|pr| pr.issue)
529 } else {
530 state.store.issue_by_number(&ctx.repo.id, number).await?
531 };
532 let Some(issue) = issue else {
533 return Err(not_found().into());
534 };
535 // Keep the reason the thread already has; a new subscription is for an explicit choice.
536 let reason = state
537 .store
538 .subscription(&user.id, &issue.id)
539 .await?
540 .map_or(Reason::Subscribed, |(_, reason)| reason);
541 match form.action.as_str() {
542 "subscribe" => {
543 state
544 .store
545 .subscribe(&user.id, &issue.id, SubState::Subscribed, reason)
546 .await?
547 }
548 "unsubscribe" => {
549 state
550 .store
551 .subscribe(&user.id, &issue.id, SubState::Ignored, reason)
552 .await?
553 }
554 _ => return Err(bad_request("unknown subscription action").into()),
555 }
556 let section = if pull { "pulls" } else { "issues" };
557 let href = repo_href(
558 &ctx.repo.owner_name,
559 &ctx.repo.name,
560 section,
561 &number.to_string(),
562 );
563 Ok(see_other(href))
564}
565
566/// Sets the viewer's watch level on a repo. The viewer must be able to read the repo.
567#[route(POST "/{owner}/{repo}/watch")]
568async fn set_watch(cx: &Cx, Form(form): Form<WatchForm>) -> Result<SeeOther> {
569 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
570 let (user, _) = require_csrf(cx, &form.csrf).await?;
571 let level = match form.level.as_str() {
572 "participating" => WatchLevel::Participating,
573 "watching" => WatchLevel::Watching,
574 "ignoring" => WatchLevel::Ignoring,
575 _ => return Err(bad_request("unknown watch level").into()),
576 };
577 let state = app_context::<AppState>(cx);
578 state.store.set_watch(&user.id, &ctx.repo.id, level).await?;
579 Ok(see_other(repo_href(
580 &ctx.repo.owner_name,
581 &ctx.repo.name,
582 "",
583 "",
584 )))
585}
586
587/// Whether the viewer may manage the repo's security alerts: Write on the repo, the same rule as
588/// the security page. Rows for a repo the viewer may not manage are hidden from the inbox.
589async fn can_manage_alerts(state: &AppState, user: &User, repo_id: &str) -> Result<bool> {
590 let Some(repo) = state.store.repo_by_id(repo_id).await? else {
591 return Ok(false);
592 };
593 let role: Option<Role> = state.store.collaborator_role(&repo.id, &user.id).await?;
594 let org_role = state
595 .store
596 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
597 .await?;
598 Ok(authorize(Some(user), &repo, role, org_role, Action::Write).is_ok())
599}
600
601/// Whether the viewer may read the repo right now, by their current collaborator role.
602pub(crate) async fn can_read(state: &AppState, user: &User, repo_id: &str) -> Result<bool> {
603 let Some(repo) = state.store.repo_by_id(repo_id).await? else {
604 return Ok(false);
605 };
606 let role: Option<Role> = state.store.collaborator_role(&repo.id, &user.id).await?;
607 let org_role = state
608 .store
609 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
610 .await?;
611 Ok(authorize(Some(user), &repo, role, org_role, Action::Read).is_ok())
612}
613
614/// The sidebar box of an issue or pull for a signed-in viewer. Viewing the thread marks the
615/// viewer's inbox row for it read. `base` is the item's URL. `None` for anonymous viewers.
616pub(crate) async fn notify_box_for(
617 state: &AppState,
618 ctx: &RepoCtx,
619 lang: Lang,
620 issue_id: &str,
621 base: &str,
622) -> Result<Option<NotifyBox>> {
623 let Some((user, session)) = &ctx.viewer else {
624 return Ok(None);
625 };
626 state.store.mark_issue_read(&user.id, issue_id).await?;
627 let level = state.store.watch_level(&user.id, &ctx.repo.id).await?;
628 let subscription = state.store.subscription(&user.id, issue_id).await?;
629 let (subscribed, reason) = if level == WatchLevel::Ignoring {
630 (false, g::t(lang, K::NotReceivingRepo).to_owned())
631 } else {
632 match subscription {
633 Some((SubState::Ignored, _)) => (false, g::t(lang, K::NotReceivingThread).to_owned()),
634 Some((SubState::Subscribed, reason)) => (true, because(lang, reason)),
635 None if level == WatchLevel::Watching => {
636 (true, g::receiving_because(lang, K::WhyWatching))
637 }
638 None => (false, g::t(lang, K::NotReceivingThread).to_owned()),
639 }
640 };
641 Ok(Some(NotifyBox {
642 action: format!("{base}/subscription"),
643 csrf: session.csrf_token.clone(),
644 subscribed,
645 reason,
646 }))
647}
648
649/// The notification sidebar box data: where the form posts, the session CSRF token, whether the
650/// viewer is receiving notifications, and the sentence explaining why.
651pub(crate) struct NotifyBox {
652 pub(crate) action: String,
653 pub(crate) csrf: String,
654 pub(crate) subscribed: bool,
655 pub(crate) reason: String,
656}
657
658/// "You're receiving notifications because ..." for a stored subscription reason.
659fn because(lang: Lang, reason: Reason) -> String {
660 let why = match reason {
661 Reason::Mention => K::WhyMentioned,
662 Reason::Assign => K::WhyAssigned,
663 Reason::ReviewRequested => K::WhyReviewRequested,
664 Reason::Author => K::WhyAuthor,
665 Reason::Comment => K::WhyCommented,
666 Reason::StateChange | Reason::Subscribed => K::WhySubscribed,
667 Reason::Watching => K::WhyWatching,
668 };
669 g::receiving_because(lang, why)
670}
671
672/// The human label of a notification reason.
673fn reason_label(lang: Lang, reason: Reason) -> &'static str {
674 match reason {
675 Reason::Mention => g::t(lang, K::ReasonMentioned),
676 Reason::Assign => g::t(lang, K::ReasonAssigned),
677 Reason::ReviewRequested => g::t(lang, K::ReasonReviewRequested),
678 Reason::Author => g::t(lang, K::ReasonAuthor),
679 Reason::Comment => g::t(lang, K::ReasonCommented),
680 Reason::StateChange => g::t(lang, K::ReasonStateChange),
681 Reason::Subscribed => g::t(lang, K::ReasonSubscribed),
682 Reason::Watching => g::t(lang, K::ReasonWatching),
683 }
684}
685
686/// The verb shown for the last activity on a thread.
687fn activity_verb(activity: &str) -> K {
688 match activity {
689 "opened" => K::VerbOpened,
690 "comment" => K::VerbCommented,
691 "closed" => K::VerbClosed,
692 "reopened" => K::VerbReopened,
693 "merged" => K::VerbMerged,
694 "review" => K::VerbReviewed,
695 "assigned" => K::VerbAssigned,
696 "mentioned" => K::VerbMentionedYou,
697 "pushed" => K::VerbPushedTo,
698 _ => K::VerbUpdated,
699 }
700}
701
702/// Where the POST forms return: the Referer when it is a same-site path, else the inbox.
703fn back(cx: &Cx) -> String {
704 return_path(cx).unwrap_or_else(|| "/notifications".to_owned())
705}
706
707/// The inbox body: heading, mark-all button, tabs, the repo groups and the older-rows link.
708#[component]
709async fn inbox_list(
710 lang: Lang,
711 all: bool,
712 unread_count: u64,
713 groups: Vec<RepoGroup>,
714 more: Option<String>,
715 csrf: String,
716 transfers: Vec<PendingTransfer>,
717) -> Result<impl View> {
718 let empty = groups.is_empty();
719 let unread_class = if all { "" } else { "active" };
720 let all_class = if all { "active" } else { "" };
721 let unread_text = format!("{} ({unread_count})", g::t(lang, K::Unread));
722 Ok(view! {
723 <section class="page-head">
724 <h1>(i18n::t(lang, Key::Notifications))</h1>
725 if !empty {
726 <form method="post" action="/notifications/mark_all_read" class="notif-inline-form">
727 csrf_field(token: csrf.as_str())
728 <button type="submit" class="button">(g::t(lang, K::MarkAllAsRead))</button>
729 </form>
730 }
731 </section>
732 transfer_list_for(lang: lang, transfers: transfers, csrf: csrf.clone())
733 <nav class="notif-tabs" aria-label=(g::t(lang, K::NotificationFilter))>
734 <a href="/notifications" class=(unread_class)>(unread_text.as_str())</a>
735 <a href="/notifications?all=1" class=(all_class)>(g::t(lang, K::AllTab))</a>
736 </nav>
737 if empty {
738 <p class="muted">
739 if all {
740 (g::t(lang, K::NoNotificationsYet))
741 } else {
742 (g::t(lang, K::NoUnreadNotifications))
743 }
744 </p>
745 }
746 for group in groups.into_iter() {
747 <section class="card notif-group">
748 <header class="notif-group-head">
749 <a class="notif-repo" href=(group.href.as_str())>(group.heading.as_str())</a>
750 <form method="post" action=(group.mark_action.as_str()) class="notif-inline-form">
751 csrf_field(token: csrf.as_str())
752 <button type="submit" class="link-button">(g::t(lang, K::MarkAllRead))</button>
753 </form>
754 </header>
755 <ul class="notif-list">
756 for row in group.rows.into_iter() {
757 <li class=(row.item_class)>
758 <span class=(row.icon_class) aria-hidden="true"></span>
759 <span class="notif-sr">(row.kind_label)</span>
760 <a class="notif-title" href=(row.open_href.as_str())>(row.title_line.as_str())</a>
761 <span class="badge">(row.reason)</span>
762 <span class="muted small">(row.activity.as_str())</span>
763 <time class="muted small" datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
764 if row.unread {
765 <form method="post" action=(row.read_action.as_str()) class="notif-inline-form">
766 csrf_field(token: csrf.as_str())
767 <button type="submit" class="link-button">(g::t(lang, K::MarkRead))</button>
768 </form>
769 }
770 </li>
771 }
772 </ul>
773 </section>
774 }
775 if let Some(href) = more {
776 <p class="notif-more"><a class="button" href=(href.as_str())>(g::t(lang, K::Older))</a></p>
777 }
778 })
779}
780
781/// The Notifications box in the issue and pull sidebar: the reason and a Subscribe or
782/// Unsubscribe button.
783#[component]
784pub(crate) async fn notify_box(
785 lang: Lang,
786 action: String,
787 csrf: String,
788 subscribed: bool,
789 reason: String,
790) -> Result<impl View> {
791 let (label, value_text) = if subscribed {
792 (g::t(lang, K::Unsubscribe), "unsubscribe")
793 } else {
794 (g::t(lang, K::Subscribe), "subscribe")
795 };
796 Ok(view! {
797 <section class="card notif-box">
798 <h2>(i18n::t(lang, Key::Notifications))</h2>
799 <p class="muted small">(reason.as_str())</p>
800 <form method="post" action=(action.as_str()) class="notif-inline-form">
801 csrf_field(token: csrf.as_str())
802 <button type="submit" name="action" value=(value_text)>(label)</button>
803 </form>
804 </section>
805 })
806}
807
808/// The Watch control in the repo header: a level select and a submit button, plus the count.
809#[component]
810pub(crate) async fn watch_form(
811 lang: Lang,
812 action: String,
813 csrf: String,
814 level: &'static str,
815 watchers: u64,
816) -> Result<impl View> {
817 let count = g::watchers_count(lang, watchers);
818 Ok(view! {
819 <form method="post" action=(action.as_str()) class="watch-form">
820 csrf_field(token: csrf.as_str())
821 <label class="watch-label">
822 <span class="sr-only">(g::t(lang, K::NotificationLevel))</span>
823 <select name="level">
824 if level == "participating" {
825 <option value="participating" selected="selected">(g::t(lang, K::OptParticipating))</option>
826 } else {
827 <option value="participating">(g::t(lang, K::OptParticipating))</option>
828 }
829 if level == "watching" {
830 <option value="watching" selected="selected">(g::t(lang, K::OptWatching))</option>
831 } else {
832 <option value="watching">(g::t(lang, K::OptWatching))</option>
833 }
834 if level == "ignoring" {
835 <option value="ignoring" selected="selected">(g::t(lang, K::OptIgnoring))</option>
836 } else {
837 <option value="ignoring">(g::t(lang, K::OptIgnoring))</option>
838 }
839 </select>
840 </label>
841 <button type="submit" class="button">(i18n::t(lang, Key::Save))</button>
842 <span class="muted small">(count.as_str())</span>
843 </form>
844 })
845}
846
847#[cfg(test)]
848mod tests {
849 use forge_core::{Issue, Reason, Role, Store, SubState, Visibility, WatchLevel};
850 use topcoat::router::request::Request;
851 use topcoat::router::{Body, Router, to_bytes};
852
853 use crate::auth::SESSION_COOKIE;
854
855 const CSRF: &str = "notif-test-csrf";
856
857 struct Site {
858 router: Router,
859 store: Store,
860 _dir: tempfile::TempDir,
861 }
862
863 struct Reply {
864 status: u16,
865 location: Option<String>,
866 body: String,
867 }
868
869 async fn site() -> Site {
870 let dir = tempfile::tempdir().expect("tempdir");
871 let state = crate::test_support::test_state(dir.path()).await;
872 let store = state.store.clone();
873 let router = crate::test_support::app_router(state);
874 Site {
875 router,
876 store,
877 _dir: dir,
878 }
879 }
880
881 /// Signs `user_id` in with `token` and returns the cookie header value.
882 async fn sign_in(store: &Store, user_id: &str, token: &str) -> String {
883 crate::test_support::create_session(store, user_id, token, CSRF).await;
884 format!("__Host-{SESSION_COOKIE}={token}")
885 }
886
887 async fn call(
888 site: &Site,
889 method: &str,
890 uri: &str,
891 cookie: Option<&str>,
892 form: Option<String>,
893 ) -> Reply {
894 let mut builder = Request::builder().method(method).uri(uri);
895 if let Some(cookie) = cookie {
896 builder = builder.header("cookie", cookie);
897 }
898 let body = match form {
899 Some(form) => {
900 builder = builder.header("content-type", "application/x-www-form-urlencoded");
901 Body::from(form)
902 }
903 None => Body::empty(),
904 };
905 let res = site
906 .router
907 .handle(builder.body(body).expect("request"))
908 .await;
909 let status = res.status().as_u16();
910 let location = res
911 .headers()
912 .get("location")
913 .and_then(|v| v.to_str().ok())
914 .map(str::to_owned);
915 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
916 Reply {
917 status,
918 location,
919 body: String::from_utf8_lossy(&bytes).into_owned(),
920 }
921 }
922
923 async fn get(site: &Site, uri: &str, cookie: Option<&str>) -> Reply {
924 call(site, "GET", uri, cookie, None).await
925 }
926
927 async fn post(site: &Site, uri: &str, cookie: &str, extra: &str) -> Reply {
928 call(
929 site,
930 "POST",
931 uri,
932 Some(cookie),
933 Some(format!("csrf={CSRF}{extra}")),
934 )
935 .await
936 }
937
938 /// Alice owns `demo` with one issue (#1), and Bob has a notification row for it.
939 struct World {
940 alice: forge_core::User,
941 bob: forge_core::User,
942 repo: forge_core::Repo,
943 issue: Issue,
944 }
945
946 async fn world(site: &Site, visibility: Visibility) -> World {
947 let store = &site.store;
948 let alice = store
949 .create_user("alice", None, "x", false)
950 .await
951 .expect("alice");
952 let bob = store
953 .create_user("bob", None, "x", false)
954 .await
955 .expect("bob");
956 let repo = store
957 .create_repo(&alice.id, "demo", "", visibility, "main")
958 .await
959 .expect("repo");
960 let issue = store
961 .create_issue(&repo.id, &alice.id, "First", "body")
962 .await
963 .expect("issue");
964 World {
965 alice,
966 bob,
967 repo,
968 issue,
969 }
970 }
971
972 async fn notify(site: &Site, w: &World, reason: Reason) {
973 site.store
974 .upsert_notification(
975 &w.bob.id,
976 &w.repo.id,
977 &w.issue.id,
978 reason,
979 "comment",
980 Some(&w.alice.id),
981 )
982 .await
983 .expect("notification");
984 }
985
986 async fn first_note_id(site: &Site, user_id: &str) -> String {
987 site.store
988 .list_notifications(user_id, false, None, 50)
989 .await
990 .expect("list")
991 .first()
992 .expect("one notification")
993 .id
994 .clone()
995 }
996
997 #[tokio::test]
998 async fn inbox_lists_unread_and_all_shows_read() {
999 let site = site().await;
1000 let w = world(&site, Visibility::Public).await;
1001 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1002 notify(&site, &w, Reason::Mention).await;
1003 let id = first_note_id(&site, &w.bob.id).await;
1004 site.store.mark_read(&w.bob.id, &id).await.expect("read");
1005
1006 let unread = get(&site, "/notifications", Some(&cookie)).await;
1007 assert_eq!(unread.status, 200);
1008 assert!(!unread.body.contains("alice/demo #1 First"));
1009 assert!(unread.body.contains("You have no unread notifications."));
1010
1011 let all = get(&site, "/notifications?all=1", Some(&cookie)).await;
1012 assert_eq!(all.status, 200);
1013 assert!(all.body.contains("alice/demo #1 First"));
1014 assert!(all.body.contains("mentioned"));
1015 }
1016
1017 #[tokio::test]
1018 async fn unread_inbox_lists_unread_rows_grouped_by_repo() {
1019 let site = site().await;
1020 let w = world(&site, Visibility::Public).await;
1021 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1022 notify(&site, &w, Reason::Assign).await;
1023
1024 let page = get(&site, "/notifications", Some(&cookie)).await;
1025 assert_eq!(page.status, 200);
1026 assert!(page.body.contains("alice/demo #1 First"));
1027 assert!(page.body.contains("assigned"));
1028 assert!(page.body.contains("/notifications/mark_all_read?repo="));
1029 assert!(page.body.contains("Mark all as read"));
1030 }
1031
1032 #[tokio::test]
1033 async fn open_marks_read_and_redirects_to_the_issue() {
1034 let site = site().await;
1035 let w = world(&site, Visibility::Public).await;
1036 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1037 notify(&site, &w, Reason::Comment).await;
1038 let id = first_note_id(&site, &w.bob.id).await;
1039
1040 let res = get(&site, &format!("/notifications/{id}/open"), Some(&cookie)).await;
1041 assert_eq!(res.status, 303);
1042 assert_eq!(res.location.as_deref(), Some("/alice/demo/issues/1"));
1043 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1044 }
1045
1046 #[tokio::test]
1047 async fn another_users_notification_is_404() {
1048 let site = site().await;
1049 let w = world(&site, Visibility::Public).await;
1050 let alice_cookie = sign_in(&site.store, &w.alice.id, "alice-token").await;
1051 notify(&site, &w, Reason::Comment).await;
1052 let id = first_note_id(&site, &w.bob.id).await;
1053
1054 let res = get(
1055 &site,
1056 &format!("/notifications/{id}/open"),
1057 Some(&alice_cookie),
1058 )
1059 .await;
1060 assert_eq!(res.status, 404);
1061 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 1);
1062
1063 let read = post(
1064 &site,
1065 &format!("/notifications/{id}/read"),
1066 &alice_cookie,
1067 "",
1068 )
1069 .await;
1070 assert_eq!(read.status, 303);
1071 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 1);
1072 }
1073
1074 #[tokio::test]
1075 async fn mark_all_read_clears_the_inbox() {
1076 let site = site().await;
1077 let w = world(&site, Visibility::Public).await;
1078 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1079 notify(&site, &w, Reason::Comment).await;
1080
1081 let res = post(&site, "/notifications/mark_all_read", &cookie, "").await;
1082 assert_eq!(res.status, 303);
1083 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1084 }
1085
1086 #[tokio::test]
1087 async fn header_bell_shows_the_unread_count() {
1088 let site = site().await;
1089 let w = world(&site, Visibility::Public).await;
1090 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1091
1092 let none = get(&site, "/", Some(&cookie)).await;
1093 assert!(none.body.contains("href=\"/notifications\""));
1094 assert!(!none.body.contains("bell-count"));
1095
1096 notify(&site, &w, Reason::Mention).await;
1097 let one = get(&site, "/", Some(&cookie)).await;
1098 assert!(one.body.contains("<span class=\"bell-count\">1</span>"));
1099 }
1100
1101 #[tokio::test]
1102 async fn subscription_toggle_on_an_issue() {
1103 let site = site().await;
1104 let w = world(&site, Visibility::Public).await;
1105 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1106
1107 let res = post(
1108 &site,
1109 "/alice/demo/issues/1/subscription",
1110 &cookie,
1111 "&action=subscribe",
1112 )
1113 .await;
1114 assert_eq!(res.status, 303);
1115 assert_eq!(res.location.as_deref(), Some("/alice/demo/issues/1"));
1116 let sub = site
1117 .store
1118 .subscription(&w.bob.id, &w.issue.id)
1119 .await
1120 .expect("sub");
1121 assert_eq!(sub, Some((SubState::Subscribed, Reason::Subscribed)));
1122
1123 let page = get(&site, "/alice/demo/issues/1", Some(&cookie)).await;
1124 assert!(page.body.contains("because you subscribed to this."));
1125 assert!(page.body.contains("value=\"unsubscribe\""));
1126
1127 post(
1128 &site,
1129 "/alice/demo/issues/1/subscription",
1130 &cookie,
1131 "&action=unsubscribe",
1132 )
1133 .await;
1134 let sub = site
1135 .store
1136 .subscription(&w.bob.id, &w.issue.id)
1137 .await
1138 .expect("sub");
1139 assert_eq!(sub, Some((SubState::Ignored, Reason::Subscribed)));
1140 }
1141
1142 #[tokio::test]
1143 async fn pull_subscription_404_for_unknown_number() {
1144 let site = site().await;
1145 let w = world(&site, Visibility::Public).await;
1146 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1147 let res = post(
1148 &site,
1149 "/alice/demo/pulls/1/subscription",
1150 &cookie,
1151 "&action=subscribe",
1152 )
1153 .await;
1154 assert_eq!(res.status, 404);
1155 }
1156
1157 #[tokio::test]
1158 async fn watch_form_sets_the_level() {
1159 let site = site().await;
1160 let w = world(&site, Visibility::Public).await;
1161 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1162
1163 let res = post(&site, "/alice/demo/watch", &cookie, "&level=watching").await;
1164 assert_eq!(res.status, 303);
1165 assert_eq!(
1166 site.store
1167 .watch_level(&w.bob.id, &w.repo.id)
1168 .await
1169 .expect("level"),
1170 WatchLevel::Watching
1171 );
1172
1173 let header = get(&site, "/alice/demo/issues/1", Some(&cookie)).await;
1174 assert!(header.body.contains("action=\"/alice/demo/watch\""));
1175 assert!(
1176 header
1177 .body
1178 .contains("<option value=\"watching\" selected=\"selected\">")
1179 );
1180 assert!(header.body.contains("1 watching"));
1181
1182 let bad = post(&site, "/alice/demo/watch", &cookie, "&level=loud").await;
1183 assert_eq!(bad.status, 400);
1184 }
1185
1186 #[tokio::test]
1187 async fn anonymous_viewer_is_redirected_to_login() {
1188 let site = site().await;
1189 let _w = world(&site, Visibility::Public).await;
1190 let res = get(&site, "/notifications", None).await;
1191 assert_eq!(res.status, 303);
1192 assert!(res.location.unwrap_or_default().starts_with("/login"));
1193 }
1194
1195 #[tokio::test]
1196 async fn row_for_a_repo_the_viewer_lost_access_to_is_hidden() {
1197 let site = site().await;
1198 let w = world(&site, Visibility::Private).await;
1199 let cookie = sign_in(&site.store, &w.bob.id, "bob-token").await;
1200 site.store
1201 .set_collaborator(&w.repo.id, &w.bob.id, Role::Read)
1202 .await
1203 .expect("collaborator");
1204 notify(&site, &w, Reason::Mention).await;
1205 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 1);
1206
1207 site.store
1208 .remove_collaborator(&w.repo.id, &w.bob.id)
1209 .await
1210 .expect("remove");
1211 let page = get(&site, "/notifications?all=1", Some(&cookie)).await;
1212 assert_eq!(page.status, 200);
1213 assert!(!page.body.contains("alice/demo #1 First"));
1214 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1215 }
1216
1217 #[tokio::test]
1218 async fn discussion_reply_shows_in_inbox_and_open_marks_read() {
1219 let site = site().await;
1220 let w = world(&site, Visibility::Public).await;
1221 // The world's issue takes number 1, so the discussion is #2.
1222 let discussion = site
1223 .store
1224 .create_discussion(
1225 &w.repo.id,
1226 &w.alice.id,
1227 forge_core::discussions::DiscussionCategory::General,
1228 "Ideas",
1229 "",
1230 )
1231 .await
1232 .expect("discussion");
1233 site.store
1234 .add_discussion_comment(&discussion.id, &w.bob.id, None, "+1", false)
1235 .await
1236 .expect("reply");
1237 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1238 let bob = sign_in(&site.store, &w.bob.id, "bob-token").await;
1239 let open = format!("/notifications/discussions/{}/open", discussion.id);
1240
1241 let page = get(&site, "/notifications", Some(&alice)).await;
1242 assert_eq!(page.status, 200);
1243 assert!(page.body.contains("Ideas"));
1244 assert!(page.body.contains(&open));
1245 assert_eq!(
1246 site.store.unread_count(&w.alice.id).await.expect("count"),
1247 1
1248 );
1249
1250 // Bob has no row for the discussion, so he cannot open it.
1251 let other = get(&site, &open, Some(&bob)).await;
1252 assert_eq!(other.status, 404);
1253
1254 let res = get(&site, &open, Some(&alice)).await;
1255 assert_eq!(res.status, 303);
1256 assert_eq!(res.location.as_deref(), Some("/alice/demo/discussions/2"));
1257 assert_eq!(
1258 site.store.unread_count(&w.alice.id).await.expect("count"),
1259 0
1260 );
1261 }
1262
1263 #[tokio::test]
1264 async fn discussion_rows_are_marked_read_by_mark_all() {
1265 let site = site().await;
1266 let w = world(&site, Visibility::Public).await;
1267 let discussion = site
1268 .store
1269 .create_discussion(
1270 &w.repo.id,
1271 &w.alice.id,
1272 forge_core::discussions::DiscussionCategory::General,
1273 "Ideas",
1274 "",
1275 )
1276 .await
1277 .expect("discussion");
1278 site.store
1279 .add_discussion_comment(&discussion.id, &w.bob.id, None, "+1", false)
1280 .await
1281 .expect("reply");
1282 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1283 assert_eq!(
1284 site.store.unread_count(&w.alice.id).await.expect("count"),
1285 1
1286 );
1287
1288 let res = post(&site, "/notifications/mark_all_read", &alice, "").await;
1289 assert_eq!(res.status, 303);
1290 assert_eq!(
1291 site.store.unread_count(&w.alice.id).await.expect("count"),
1292 0
1293 );
1294 }
1295
1296 #[tokio::test]
1297 async fn advisory_alerts_show_for_managers_and_open_marks_read() {
1298 let site = site().await;
1299 let w = world(&site, Visibility::Public).await;
1300 // Alice owns the repo, so she manages its alerts. Bob is a reader with a stale row.
1301 site.store
1302 .notify_new_alerts(&w.alice.id, &w.repo.id, 2)
1303 .await
1304 .expect("alerts");
1305 site.store
1306 .notify_new_alerts(&w.bob.id, &w.repo.id, 1)
1307 .await
1308 .expect("alerts");
1309 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1310 let bob = sign_in(&site.store, &w.bob.id, "bob-token").await;
1311 let open = format!("/notifications/advisories/{}/open", w.repo.id);
1312
1313 let page = get(&site, "/notifications", Some(&alice)).await;
1314 assert_eq!(page.status, 200);
1315 assert!(page.body.contains("Security alerts"));
1316 assert!(page.body.contains("2 new alerts"));
1317 assert!(page.body.contains(&open));
1318 assert_eq!(
1319 site.store.unread_count(&w.alice.id).await.expect("count"),
1320 1
1321 );
1322
1323 // Bob cannot manage the alerts: his row is hidden and marked read, as lost-access rows are.
1324 let bob_page = get(&site, "/notifications", Some(&bob)).await;
1325 assert_eq!(bob_page.status, 200);
1326 assert!(!bob_page.body.contains(&open));
1327 assert_eq!(site.store.unread_count(&w.bob.id).await.expect("count"), 0);
1328 let bob_open = get(&site, &open, Some(&bob)).await;
1329 assert_eq!(bob_open.status, 404);
1330
1331 let res = get(&site, &open, Some(&alice)).await;
1332 assert_eq!(res.status, 303);
1333 assert_eq!(res.location.as_deref(), Some("/alice/demo/security"));
1334 assert_eq!(
1335 site.store.unread_count(&w.alice.id).await.expect("count"),
1336 0
1337 );
1338 }
1339
1340 #[tokio::test]
1341 async fn advisory_rows_are_marked_read_by_mark_read_and_mark_all() {
1342 let site = site().await;
1343 let w = world(&site, Visibility::Public).await;
1344 let alice = sign_in(&site.store, &w.alice.id, "alice-token").await;
1345 let read = format!("/notifications/advisories/{}/read", w.repo.id);
1346
1347 site.store
1348 .notify_new_alerts(&w.alice.id, &w.repo.id, 1)
1349 .await
1350 .expect("alerts");
1351 assert_eq!(
1352 site.store.unread_count(&w.alice.id).await.expect("count"),
1353 1
1354 );
1355 let res = post(&site, &read, &alice, "").await;
1356 assert_eq!(res.status, 303);
1357 assert_eq!(
1358 site.store.unread_count(&w.alice.id).await.expect("count"),
1359 0
1360 );
1361
1362 site.store
1363 .notify_new_alerts(&w.alice.id, &w.repo.id, 3)
1364 .await
1365 .expect("alerts");
1366 assert_eq!(
1367 site.store.unread_count(&w.alice.id).await.expect("count"),
1368 1
1369 );
1370 let res = post(&site, "/notifications/mark_all_read", &alice, "").await;
1371 assert_eq!(res.status, 303);
1372 assert_eq!(
1373 site.store.unread_count(&w.alice.id).await.expect("count"),
1374 0
1375 );
1376 }
1377}