package forge import ( "fmt" "net/http" "strconv" "strings" "time" ) const inboxSchema = ` create table if not exists notifications ( id integer primary key, user integer not null references users(id) on delete cascade, repo integer not null references repos(id) on delete cascade, kind text not null, number integer not null, title text not null, actor integer not null references users(id) on delete cascade, verb text not null, created integer not null, unread integer not null default 1, unique(user, repo, kind, number) ); ` type Notification struct { ID int64 Repo Repo Kind string Number int64 Title string Actor string Verb string Created time.Time Unread bool } func (n Notification) Link() string { return fmt.Sprintf("/%s/%ss/%d", n.Repo.Path(), n.Kind, n.Number) } func (s *Store) Watchers(repo Repo, kind string, item, author int64) []int64 { ids := []int64{author} if s.IsOrg(repo.Owner.ID) { for _, m := range s.Members(repo.Owner.ID) { ids = append(ids, m.ID) } } else { ids = append(ids, repo.Owner.ID) } q := `select author from comments where pull = ?1 union select author from reviews where pull = ?1 union select author from prreviews where pull = ?1` if kind == "issue" { q = `select author from issue_comments where issue = ?1` } rows, err := s.db.Query(q, item) if err != nil { return ids } defer rows.Close() for rows.Next() { var id int64 if rows.Scan(&id) == nil { ids = append(ids, id) } } return ids } func (s *Store) Notify(users []int64, actor User, repo Repo, kind string, number int64, title, verb string) { seen := map[int64]bool{actor.ID: true} now := time.Now().Unix() for _, id := range users { if seen[id] { continue } seen[id] = true if repo.Private && !s.CanRead(repo, &User{ID: id}) { continue } s.db.Exec(`insert into notifications(user, repo, kind, number, title, actor, verb, created) values (?, ?, ?, ?, ?, ?, ?, ?) on conflict(user, repo, kind, number) do update set title = excluded.title, actor = excluded.actor, verb = excluded.verb, created = excluded.created, unread = 1`, id, repo.ID, kind, number, title, actor.ID, verb, now) } } func (s *Store) Notifications(user int64, limit int) []Notification { rows, err := s.db.Query(`select n.id, n.kind, n.number, n.title, a.name, n.verb, n.created, n.unread, r.id, r.name, r.private, o.id, o.name from notifications n join users a on a.id = n.actor join repos r on r.id = n.repo join users o on o.id = r.owner where n.user = ? order by n.unread desc, n.created desc limit ?`, user, limit) if err != nil { return nil } var all []Notification for rows.Next() { var n Notification var created, private int64 if rows.Scan(&n.ID, &n.Kind, &n.Number, &n.Title, &n.Actor, &n.Verb, &created, &n.Unread, &n.Repo.ID, &n.Repo.Name, &private, &n.Repo.Owner.ID, &n.Repo.Owner.Name) == nil { n.Created = time.Unix(created, 0) n.Repo.Private = private == 1 all = append(all, n) } } rows.Close() shown := all[:0] for _, n := range all { if !n.Repo.Private || s.CanRead(n.Repo, &User{ID: user}) { shown = append(shown, n) } } return shown } func (s *Store) ReadThread(user, repo int64, kind string, number int64) { var unread bool s.db.QueryRow(`select 1 from notifications where user = ? and repo = ? and kind = ? and number = ? and unread = 1`, user, repo, kind, number).Scan(&unread) if !unread { return } s.db.Exec(`update notifications set unread = 0 where user = ? and repo = ? and kind = ? and number = ? and unread = 1`, user, repo, kind, number) } func (s *Server) inbox(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } if r.Method == "POST" { s.store.db.Exec(`update notifications set unread = 0 where user = ?`, u.ID) http.Redirect(w, r, "/notifications", http.StatusSeeOther) return } s.store.db.Exec(`delete from notifications where user = ? and unread = 0 and created < ?`, u.ID, time.Now().AddDate(0, 0, -90).Unix()) s.render(w, r, "notifications", page{Title: "Notifications", User: u, Data: map[string]any{"Items": s.store.Notifications(u.ID, 100)}}) } func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) var n Notification var repo int64 if s.store.db.QueryRow(`select repo, kind, number from notifications where id = ? and user = ?`, id, u.ID).Scan(&repo, &n.Kind, &n.Number) != nil { http.Redirect(w, r, "/notifications", http.StatusSeeOther) return } n.Repo, _ = s.store.RepoByID(repo) if !s.store.CanRead(n.Repo, u) { http.Redirect(w, r, "/notifications", http.StatusSeeOther) return } s.store.ReadThread(u.ID, repo, n.Kind, n.Number) http.Redirect(w, r, n.Link(), http.StatusSeeOther) } func shortVerb(verb string) string { if strings.HasPrefix(verb, "reviewed ") { return "reviewed" } return verb }