feat: add attachment persistence and link on chat_send (High #3)

- Add attachment_queries.go with GetAttachmentByID, LinkAttachmentsToMessage,
  and GetAttachmentsByMessageIDs
- Wire attachment linking in handleChatSend with ATTACH_FILES permission check
- Include linked attachments in chat_message WS broadcast payload
- Wire attachment batch-fetch into GetMessagesForAPI for REST responses
- Add attachments table to channel handler test schema
This commit is contained in:
jevb
2026-03-16 17:00:09 +01:00
parent b2bfe5593c
commit 53d78feef6
4 changed files with 146 additions and 2 deletions
+9
View File
@@ -109,6 +109,15 @@ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
stored_as TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
+97
View File
@@ -0,0 +1,97 @@
package db
import (
"fmt"
"strings"
)
// Attachment represents a row in the attachments table.
type Attachment struct {
ID string
MessageID *int64
Filename string
StoredAs string
MimeType string
Size int64
UploadedAt string
}
// GetAttachmentByID returns the attachment with the given ID, or nil if not found.
func (d *DB) GetAttachmentByID(id string) (*Attachment, error) {
row := d.sqlDB.QueryRow(
`SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at
FROM attachments WHERE id = ?`, id,
)
a := &Attachment{}
err := row.Scan(&a.ID, &a.MessageID, &a.Filename, &a.StoredAs, &a.MimeType, &a.Size, &a.UploadedAt)
if err != nil {
return nil, fmt.Errorf("GetAttachmentByID: %w", err)
}
return a, nil
}
// LinkAttachmentsToMessage sets message_id on attachments that are currently
// unlinked (message_id IS NULL). Returns the number of rows updated.
// Uses WHERE message_id IS NULL to prevent double-linking in a race.
func (d *DB) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) {
if len(attachmentIDs) == 0 {
return 0, nil
}
placeholders := make([]string, len(attachmentIDs))
args := make([]any, 0, len(attachmentIDs)+1)
args = append(args, messageID)
for i, id := range attachmentIDs {
placeholders[i] = "?"
args = append(args, id)
}
query := fmt.Sprintf(
`UPDATE attachments SET message_id = ? WHERE id IN (%s) AND message_id IS NULL`,
strings.Join(placeholders, ","),
)
res, err := d.sqlDB.Exec(query, args...)
if err != nil {
return 0, fmt.Errorf("LinkAttachmentsToMessage: %w", err)
}
return res.RowsAffected()
}
// GetAttachmentsByMessageIDs returns attachments grouped by message ID.
func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentInfo, error) {
if len(msgIDs) == 0 {
return map[int64][]AttachmentInfo{}, nil
}
placeholders := make([]string, len(msgIDs))
args := make([]any, len(msgIDs))
for i, id := range msgIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(
`SELECT id, message_id, filename, size, mime_type
FROM attachments WHERE message_id IN (%s)`,
strings.Join(placeholders, ","),
)
rows, err := d.sqlDB.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("GetAttachmentsByMessageIDs: %w", err)
}
defer rows.Close()
result := make(map[int64][]AttachmentInfo)
for rows.Next() {
var id string
var msgID int64
var ai AttachmentInfo
if scanErr := rows.Scan(&id, &msgID, &ai.Filename, &ai.Size, &ai.Mime); scanErr != nil {
return nil, fmt.Errorf("GetAttachmentsByMessageIDs scan: %w", scanErr)
}
ai.ID = id
ai.URL = "/api/files/" + id
result[msgID] = append(result[msgID], ai)
}
return result, nil
}
+11
View File
@@ -305,6 +305,17 @@ func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUse
}
}
// Batch-fetch attachments for all message IDs.
attMap, err := d.GetAttachmentsByMessageIDs(msgIDs)
if err != nil {
return nil, fmt.Errorf("GetMessagesForAPI attachments: %w", err)
}
for i := range msgs {
if a, ok := attMap[msgs[i].ID]; ok {
msgs[i].Attachments = a
}
}
return msgs, nil
}
+29 -2
View File
@@ -171,7 +171,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
return
}
// Persist.
// Persist message.
msgID, err := h.db.CreateMessage(channelID, c.userID, content, p.ReplyTo)
if err != nil {
slog.Error("ws handleChatSend CreateMessage", "err", err)
@@ -179,6 +179,33 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
return
}
// Link attachments if provided.
var attachments []map[string]any
if len(p.Attachments) > 0 {
if !h.hasChannelPerm(c, channelID, permissions.AttachFiles) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing ATTACH_FILES permission"))
return
}
linked, linkErr := h.db.LinkAttachmentsToMessage(msgID, p.Attachments)
if linkErr != nil {
slog.Error("ws handleChatSend LinkAttachments", "err", linkErr)
}
if linked > 0 {
attMap, attErr := h.db.GetAttachmentsByMessageIDs([]int64{msgID})
if attErr == nil {
for _, ai := range attMap[msgID] {
attachments = append(attachments, map[string]any{
"id": ai.ID,
"filename": ai.Filename,
"size": ai.Size,
"mime": ai.Mime,
"url": ai.URL,
})
}
}
}
}
// Retrieve to get timestamp.
msg, err := h.db.GetMessage(msgID)
if err != nil || msg == nil {
@@ -200,7 +227,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp))
// Broadcast to channel.
broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, content, msg.Timestamp, p.ReplyTo, nil)
broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, content, msg.Timestamp, p.ReplyTo, attachments)
h.BroadcastToChannel(channelID, broadcast)
}