package main import ( "database/sql" "fmt" "regexp" "strings" "time" ) // Notification kinds, ordered by priority (highest first). When a single // message triggers multiple kinds for one user, the highest-priority kind // is the one persisted. const ( NotifKindMention = "mention" NotifKindAssignedChat = "assigned_chat" NotifKindReply = "reply" ) func notifKindPriority(k string) int { switch k { case NotifKindMention: return 3 case NotifKindAssignedChat: return 2 case NotifKindReply: return 1 } return 0 } type Notification struct { ID string `json:"id"` UserID string `json:"user_id"` CardID string `json:"card_id"` MessageID string `json:"message_id"` Kind string `json:"kind"` ActorID string `json:"actor_id"` CreatedAt string `json:"created_at"` ReadAt *string `json:"read_at"` CardTitle string `json:"card_title"` CardSeqNum int `json:"card_seq_num"` ActorName string `json:"actor_name"` Snippet string `json:"snippet"` } type CardMention struct { ID string `json:"id"` CardID string `json:"card_id"` MessageID string `json:"message_id"` UserID string `json:"user_id"` CreatedAt string `json:"created_at"` } var mentionRe = regexp.MustCompile(`(?i)@([a-z0-9][a-z0-9_.-]{0,63})`) // extractMentions returns the set of @usernames referenced in body, lowercased. // The leading '@' is not included. Each username is returned at most once. func extractMentions(body string) []string { matches := mentionRe.FindAllStringSubmatch(body, -1) if len(matches) == 0 { return nil } seen := map[string]struct{}{} out := make([]string, 0, len(matches)) for _, m := range matches { u := strings.ToLower(m[1]) if _, ok := seen[u]; ok { continue } seen[u] = struct{}{} out = append(out, u) } return out } // CreateCardMessageAndNotify wraps CreateCardMessage with mention parsing, // notification fan-out and pub/sub publication. The returned slice contains // the user_ids that received a notification (useful for tests). func (db *DB) CreateCardMessageAndNotify(cardID, authorID, body string, hub *EventHub) (*CardMessage, []Notification, []CardMention, error) { msg, err := db.CreateCardMessage(cardID, authorID, body) if err != nil { return nil, nil, nil, err } mentions, err := db.resolveAndStoreMentions(cardID, msg.ID, body) if err != nil { return msg, nil, nil, err } notifs, err := db.fanoutNotifications(cardID, msg, authorID, mentions) if err != nil { return msg, nil, mentions, err } if hub != nil { hub.PublishJSON("message.created", cardID, "", msg) for _, n := range notifs { hub.PublishJSON("notification.created", cardID, n.UserID, n) } } return msg, notifs, mentions, nil } // resolveAndStoreMentions parses @usernames from body, resolves them to // existing user_ids (silently ignoring unknowns) and persists the matches // in card_mentions. func (db *DB) resolveAndStoreMentions(cardID, messageID, body string) ([]CardMention, error) { usernames := extractMentions(body) if len(usernames) == 0 { return nil, nil } placeholders := strings.Repeat("?,", len(usernames)) placeholders = placeholders[:len(placeholders)-1] args := make([]interface{}, 0, len(usernames)) for _, u := range usernames { args = append(args, u) } rows, err := db.conn.Query( fmt.Sprintf(`SELECT id, username FROM users WHERE username IN (%s)`, placeholders), args..., ) if err != nil { return nil, err } defer rows.Close() resolved := map[string]string{} for rows.Next() { var id, uname string if err := rows.Scan(&id, &uname); err != nil { return nil, err } resolved[uname] = id } if err := rows.Err(); err != nil { return nil, err } if len(resolved) == 0 { return nil, nil } now := time.Now().UTC().Format(time.RFC3339) out := make([]CardMention, 0, len(resolved)) for _, userID := range resolved { m := CardMention{ID: newID(), CardID: cardID, MessageID: messageID, UserID: userID, CreatedAt: now} if _, err := db.conn.Exec( `INSERT INTO card_mentions (id, card_id, message_id, user_id, created_at) VALUES (?, ?, ?, ?, ?)`, m.ID, m.CardID, m.MessageID, m.UserID, m.CreatedAt, ); err != nil { return out, err } out = append(out, m) } return out, nil } // fanoutNotifications computes the recipient set for a new message and // inserts one notification row per recipient with the highest-priority kind. // // Recipients = {assignee_id of card} ∪ {previous authors of card_messages // on this card} ∪ {users mentioned in this message} \ {author}. // // Kind precedence: mention > assigned_chat > reply. func (db *DB) fanoutNotifications(cardID string, msg *CardMessage, authorID string, mentions []CardMention) ([]Notification, error) { recipients := map[string]string{} // userID -> kind upgrade := func(userID, kind string) { if userID == "" || userID == authorID { return } existing, ok := recipients[userID] if !ok || notifKindPriority(kind) > notifKindPriority(existing) { recipients[userID] = kind } } // Previous authors on this card. rows, err := db.conn.Query( `SELECT DISTINCT author_id FROM card_messages WHERE card_id = ? AND author_id IS NOT NULL AND author_id != '' AND id != ?`, cardID, msg.ID, ) if err != nil { return nil, err } for rows.Next() { var uid sql.NullString if err := rows.Scan(&uid); err != nil { rows.Close() return nil, err } if uid.Valid { upgrade(uid.String, NotifKindReply) } } rows.Close() // Assignee. var assignee sql.NullString if err := db.conn.QueryRow(`SELECT assignee_id FROM cards WHERE id = ?`, cardID).Scan(&assignee); err != nil && err != sql.ErrNoRows { return nil, err } if assignee.Valid { upgrade(assignee.String, NotifKindAssignedChat) } // Mentions (highest priority). for _, m := range mentions { upgrade(m.UserID, NotifKindMention) } if len(recipients) == 0 { return nil, nil } now := time.Now().UTC().Format(time.RFC3339) out := make([]Notification, 0, len(recipients)) // Snippet for hydrated notif payload. snippet := msg.Body if len(snippet) > 140 { snippet = snippet[:140] + "…" } var cardTitle string var cardSeq int _ = db.conn.QueryRow(`SELECT title, seq_num FROM cards WHERE id = ?`, cardID).Scan(&cardTitle, &cardSeq) var actorName string _ = db.conn.QueryRow(`SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = ?`, authorID).Scan(&actorName) for userID, kind := range recipients { n := Notification{ ID: newID(), UserID: userID, CardID: cardID, MessageID: msg.ID, Kind: kind, ActorID: authorID, CreatedAt: now, CardTitle: cardTitle, CardSeqNum: cardSeq, ActorName: actorName, Snippet: snippet, } if _, err := db.conn.Exec( `INSERT INTO notifications (id, user_id, card_id, message_id, kind, actor_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, n.ID, n.UserID, n.CardID, n.MessageID, n.Kind, n.ActorID, n.CreatedAt, ); err != nil { return out, err } out = append(out, n) } return out, nil } // ListNotifications returns notifications for userID. If onlyUnread is true, // already-read entries are skipped. Limit defaults to 50 when <= 0. func (db *DB) ListNotifications(userID string, onlyUnread bool, limit int) ([]Notification, error) { if limit <= 0 { limit = 50 } q := `SELECT n.id, n.user_id, n.card_id, n.message_id, n.kind, n.actor_id, n.created_at, n.read_at, COALESCE(c.title, ''), COALESCE(c.seq_num, 0), COALESCE(NULLIF(u.display_name, ''), u.username, ''), COALESCE(m.body, '') FROM notifications n LEFT JOIN cards c ON c.id = n.card_id LEFT JOIN users u ON u.id = n.actor_id LEFT JOIN card_messages m ON m.id = n.message_id WHERE n.user_id = ?` if onlyUnread { q += ` AND n.read_at IS NULL` } q += ` ORDER BY n.created_at DESC LIMIT ?` rows, err := db.conn.Query(q, userID, limit) if err != nil { return nil, err } defer rows.Close() out := []Notification{} for rows.Next() { var n Notification var readAt sql.NullString var body string if err := rows.Scan(&n.ID, &n.UserID, &n.CardID, &n.MessageID, &n.Kind, &n.ActorID, &n.CreatedAt, &readAt, &n.CardTitle, &n.CardSeqNum, &n.ActorName, &body); err != nil { return nil, err } if readAt.Valid { s := readAt.String n.ReadAt = &s } if len(body) > 140 { n.Snippet = body[:140] + "…" } else { n.Snippet = body } out = append(out, n) } return out, rows.Err() } func (db *DB) CountUnreadNotifications(userID string) (int, error) { var n int err := db.conn.QueryRow( `SELECT COUNT(*) FROM notifications WHERE user_id = ? AND read_at IS NULL`, userID, ).Scan(&n) return n, err } func (db *DB) MarkNotificationRead(userID, notifID string) error { now := time.Now().UTC().Format(time.RFC3339) res, err := db.conn.Exec( `UPDATE notifications SET read_at = ? WHERE id = ? AND user_id = ? AND read_at IS NULL`, now, notifID, userID, ) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { // Not an error: idempotent. return nil } return nil } func (db *DB) MarkAllNotificationsRead(userID string) (int, error) { now := time.Now().UTC().Format(time.RFC3339) res, err := db.conn.Exec( `UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL`, now, userID, ) if err != nil { return 0, err } n, _ := res.RowsAffected() return int(n), nil }