chore: auto-commit (21 archivos)
- 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>
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user