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:
2026-05-20 18:17:04 +02:00
parent 1923fd31a4
commit 2524340759
20 changed files with 2165 additions and 236 deletions
+328
View File
@@ -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
}