2524340759
- app.md - backend/dist/assets/index-D_Kep7Fb.js - backend/dist/index.html - backend/handlers.go - backend/main.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/src/components/CardChatPanel.tsx - frontend/src/components/LoginPage.tsx - frontend/src/types.ts - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
329 lines
9.2 KiB
Go
329 lines
9.2 KiB
Go
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
|
||
}
|