Files
spsays-bff/internal/repository/chat_repo.go
T
2026-09-02 23:00:55 +05:30

1534 lines
56 KiB
Go

package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
// ErrNotParticipant is the authorization failure for every chat read and
// write: if you are not in the thread, it does not exist for you.
ErrNotParticipant = errors.New("not a participant in this conversation")
ErrConversationGone = errors.New("conversation not found")
// ErrReplyTargetNotHere guards against replying to a message from another
// thread. The foreign key only proves the target exists somewhere, so
// without this check a client could quote a conversation it was never in.
ErrReplyTargetNotHere = errors.New("cannot reply to a message from another conversation")
ErrAttachmentGone = errors.New("attachment not found")
ErrMessageNotHere = errors.New("message is not in this conversation")
ErrUserGone = errors.New("user not found")
// ErrStickerNotOwned covers both "no such sticker" and "not yours" on
// purpose: the catalog shouldn't be enumerable by probing keys.
ErrStickerNotOwned = errors.New("sticker not available")
)
type ChatRepo struct{ pg *Postgres }
func NewChatRepo(pg *Postgres) *ChatRepo { return &ChatRepo{pg: pg} }
// dmKey builds the sorted pair key that makes "one DM thread per pair" a
// database guarantee rather than a service-layer race.
func dmKey(a, b string) string {
if a < b {
return a + ":" + b
}
return b + ":" + a
}
// EnsureDM returns the DM between two users, creating it if absent.
//
// Idempotent under concurrency: two people tapping "message" simultaneously
// race on the partial unique index, and the loser falls through to the SELECT
// rather than erroring or creating a second thread.
func (r *ChatRepo) EnsureDM(ctx context.Context, userA, userB string) (*model.Conversation, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin dm tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
key := dmKey(userA, userB)
var c model.Conversation
err = tx.QueryRow(ctx, `
INSERT INTO conversations (kind, dm_key, created_by)
VALUES ('dm', $1, $2)
ON CONFLICT (dm_key) WHERE kind = 'dm' DO NOTHING
RETURNING id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at`,
key, userA,
).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
// Already existed — read it back.
err = tx.QueryRow(ctx, `
SELECT id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at
FROM conversations WHERE dm_key = $1 AND kind = 'dm'`, key,
).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt)
}
if err != nil {
return nil, fmt.Errorf("ensure dm: %w", err)
}
// Participants are inserted unconditionally: a thread created moments ago
// by the other party may not list us yet.
if _, err := tx.Exec(ctx, `
INSERT INTO conversation_participants (conversation_id, user_id)
VALUES ($1, $2), ($1, $3)
ON CONFLICT (conversation_id, user_id) DO NOTHING`,
c.ID, userA, userB,
); err != nil {
return nil, fmt.Errorf("ensure dm participants: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit dm: %w", err)
}
return &c, nil
}
// CreateGroup opens a group thread with creator as owner and members joined.
func (r *ChatRepo) CreateGroup(ctx context.Context, creatorID, title string, memberIDs []string) (*model.Conversation, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin group tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
var c model.Conversation
if err := tx.QueryRow(ctx, `
INSERT INTO conversations (kind, title, created_by)
VALUES ('group', $1, $2)
RETURNING id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at`,
title, creatorID,
).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("create group: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO conversation_participants (conversation_id, user_id, role)
VALUES ($1, $2, 'owner') ON CONFLICT DO NOTHING`, c.ID, creatorID,
); err != nil {
return nil, fmt.Errorf("add group owner: %w", err)
}
for _, m := range memberIDs {
if m == creatorID {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO conversation_participants (conversation_id, user_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, c.ID, m,
); err != nil {
return nil, fmt.Errorf("add group member: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit group: %w", err)
}
return &c, nil
}
// EnsureAIThread returns the caller's Kautilya thread, creating it on first
// use. One participant; the assistant has no user row.
func (r *ChatRepo) EnsureAIThread(ctx context.Context, userID string) (*model.Conversation, error) {
var c model.Conversation
err := r.pg.Pool.QueryRow(ctx, `
SELECT c.id, c.kind, c.title, c.avatar_key, c.send_policy, c.last_seq, c.last_message_at, c.created_at
FROM conversations c
JOIN conversation_participants p ON p.conversation_id = c.id AND p.user_id = $1
WHERE c.kind = 'ai' ORDER BY c.created_at LIMIT 1`, userID,
).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt)
if err == nil {
return &c, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("find ai thread: %w", err)
}
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin ai tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if err := tx.QueryRow(ctx, `
INSERT INTO conversations (kind, created_by) VALUES ('ai', $1)
RETURNING id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at`, userID,
).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("create ai thread: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`, c.ID, userID,
); err != nil {
return nil, fmt.Errorf("add ai participant: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit ai thread: %w", err)
}
return &c, nil
}
// Conversation loads a thread by id, without any membership check — callers
// pair it with Participant when the caller's access matters.
func (r *ChatRepo) Conversation(ctx context.Context, id string) (*model.Conversation, error) {
var c model.Conversation
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at
FROM conversations WHERE id = $1`, id,
).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrConversationGone
}
if err != nil {
return nil, fmt.Errorf("load conversation: %w", err)
}
return &c, nil
}
// Participant loads the caller's membership row — the authorization primitive
// behind every other chat call and the realtime channel rule.
func (r *ChatRepo) Participant(ctx context.Context, conversationID, userID string) (*model.Participant, error) {
var p model.Participant
err := r.pg.Pool.QueryRow(ctx, `
SELECT conversation_id, user_id, role, last_read_seq, last_delivered_seq,
muted_until, joined_at, left_at
FROM conversation_participants
WHERE conversation_id = $1 AND user_id = $2 AND left_at IS NULL`,
conversationID, userID,
).Scan(&p.ConversationID, &p.UserID, &p.Role, &p.LastReadSeq, &p.LastDeliveredSeq,
&p.MutedUntil, &p.JoinedAt, &p.LeftAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotParticipant
}
if err != nil {
return nil, fmt.Errorf("load participant: %w", err)
}
return &p, nil
}
// Participants lists everyone still in the thread with the details needed to
// render a name and avatar beside their messages.
func (r *ChatRepo) Participants(ctx context.Context, conversationID string) ([]model.ChatPeer, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url, p.role, p.joined_at
FROM conversation_participants p
JOIN users u ON u.id = p.user_id
WHERE p.conversation_id = $1 AND p.left_at IS NULL
-- Owner first, then admins, then members by seniority: the order the
-- member list is read in.
ORDER BY CASE p.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,
p.joined_at`, conversationID)
if err != nil {
return nil, fmt.Errorf("query participants: %w", err)
}
defer rows.Close()
people := []model.ChatPeer{}
for rows.Next() {
var c model.ChatPeer
if err := rows.Scan(&c.ID, &c.Handle, &c.Name, &c.AvatarURL, &c.Role, &c.JoinedAt); err != nil {
return nil, fmt.Errorf("scan participant: %w", err)
}
people = append(people, c)
}
return people, rows.Err()
}
// ParticipantIDs lists everyone still in the thread — the fan-out list for
// realtime publishes and push.
func (r *ChatRepo) ParticipantIDs(ctx context.Context, conversationID string) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND left_at IS NULL`, conversationID)
if err != nil {
return nil, fmt.Errorf("participant ids: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan participant id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// Inbox lists the caller's threads of the given kinds, most recent first.
//
// The last-message line comes from a LATERAL on (conversation_id, seq DESC)
// rather than a denormalized column, so an edit or delete is reflected without
// a second write path that could drift. Messages the caller deleted for
// themselves are skipped, so their inbox preview matches their thread.
func (r *ChatRepo) Inbox(ctx context.Context, userID string, kinds []string, limit int) ([]model.InboxItem, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT c.id, c.kind, c.title, c.avatar_key, c.send_policy, c.last_seq, c.last_message_at, c.created_at,
GREATEST(c.last_seq - p.last_read_seq, 0) AS unread,
p.muted_until,
m.seq, m.sender_id, m.kind, m.body, m.deleted_at,
COALESCE(su.name, ''),
peer.id, peer.handle, peer.name, peer.avatar_url
FROM conversation_participants p
JOIN conversations c ON c.id = p.conversation_id
LEFT JOIN LATERAL (
SELECT msg.seq, msg.sender_id, msg.kind, msg.body, msg.deleted_at
FROM messages msg
WHERE msg.conversation_id = c.id
AND NOT EXISTS (SELECT 1 FROM message_hides h
WHERE h.message_id = msg.id AND h.user_id = $1)
ORDER BY msg.seq DESC LIMIT 1
) m ON TRUE
LEFT JOIN users su ON su.id = m.sender_id
LEFT JOIN LATERAL (
SELECT u2.id, u2.handle, u2.name, u2.avatar_url
FROM conversation_participants p2
JOIN users u2 ON u2.id = p2.user_id
WHERE p2.conversation_id = c.id AND p2.user_id <> $1
LIMIT 1
) peer ON c.kind = 'dm'
WHERE p.user_id = $1 AND p.left_at IS NULL AND c.kind = ANY($2)
ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC
LIMIT $3`, userID, kinds, limit)
if err != nil {
return nil, fmt.Errorf("query inbox: %w", err)
}
defer rows.Close()
items := []model.InboxItem{}
for rows.Next() {
var it model.InboxItem
var seq *int64
var senderID, msgKind, body *string
var deletedAt *time.Time
var senderName string
var peerID, peerHandle, peerName, peerAvatar *string
if err := rows.Scan(
&it.ID, &it.Kind, &it.Title, &it.AvatarKey, &it.SendPolicy, &it.LastSeq, &it.LastMessageAt, &it.CreatedAt,
&it.Unread, &it.MutedUntil,
&seq, &senderID, &msgKind, &body, &deletedAt,
&senderName,
&peerID, &peerHandle, &peerName, &peerAvatar,
); err != nil {
return nil, fmt.Errorf("scan inbox row: %w", err)
}
if seq != nil {
it.LastMessage = &model.MessagePreview{
Seq: *seq, SenderID: senderID, SenderName: senderName,
Kind: derefOr(msgKind, model.MessageText), Body: derefOr(body, ""),
DeletedAt: deletedAt,
}
}
if peerID != nil {
it.Peer = &model.ChatPeer{
ID: *peerID, Handle: derefOr(peerHandle, ""),
Name: derefOr(peerName, ""), AvatarURL: peerAvatar,
}
}
items = append(items, it)
}
return items, rows.Err()
}
// Messages returns one page of history, newest first. beforeSeq == 0 starts at
// the newest message; otherwise it is an exclusive upper bound, which makes
// paging stable even as new messages arrive at the top.
// messageSelectFrom is the canonical read shape for a message, including the
// quoted-reply preview. Shared by the paged thread read and the single-message
// read so the two can never drift into returning different shapes.
const messageSelectFrom = `
SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities,
m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at,
rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '')
FROM messages m
LEFT JOIN LATERAL (
SELECT r.id, r.seq, r.sender_id, r.body, r.deleted_at, ru.name AS sender_name
FROM messages r
LEFT JOIN users ru ON ru.id = r.sender_id
-- The conversation guard is repeated here on purpose: even a row
-- written before the write-side check existed cannot leak another
-- thread's text through a quote.
WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id
) rep ON TRUE
`
// Message reads one message of a conversation, in the same shape as the paged
// read. Hidden (deleted-for-me) messages are excluded, so a refresh can't
// resurrect something the caller removed from their own view.
func (r *ChatRepo) Message(ctx context.Context, conversationID, messageID, userID string) (*model.Message, error) {
msgs, err := r.scanMessages(ctx, messageSelectFrom+`
WHERE m.conversation_id = $1
AND m.id = $2
AND NOT EXISTS (SELECT 1 FROM message_hides h
WHERE h.message_id = m.id AND h.user_id = $3)`,
conversationID, messageID, userID)
if err != nil {
return nil, err
}
if len(msgs) == 0 {
return nil, ErrMessageNotHere
}
return &msgs[0], nil
}
func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string, beforeSeq int64, limit int) ([]model.Message, error) {
return r.scanMessages(ctx, messageSelectFrom+`
WHERE m.conversation_id = $1
AND ($2 = 0 OR m.seq < $2)
AND NOT EXISTS (SELECT 1 FROM message_hides h
WHERE h.message_id = m.id AND h.user_id = $3)
ORDER BY m.seq DESC
LIMIT $4`, conversationID, beforeSeq, userID, limit)
}
// scanMessages runs a query built on messageSelectFrom and decodes its rows.
// Shared so the paged read and the single-message read decode identically.
func (r *ChatRepo) scanMessages(ctx context.Context, query string, args ...any) ([]model.Message, error) {
rows, err := r.pg.Pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query messages: %w", err)
}
defer rows.Close()
msgs := []model.Message{}
for rows.Next() {
var m model.Message
var entities []byte
var repID *string
var repSeq *int64
var repSender *string
var repBody *string
var repDeleted *time.Time
var repSenderName string
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind, &m.Body,
&entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID,
&m.EditedAt, &m.DeletedAt, &m.CreatedAt,
&repID, &repSeq, &repSender, &repBody, &repDeleted, &repSenderName); err != nil {
return nil, fmt.Errorf("scan message: %w", err)
}
m.Entities = json.RawMessage(entities)
if repID != nil {
m.ReplyTo = &model.ReplyPreview{
ID: *repID, Seq: derefInt64(repSeq), SenderID: repSender,
SenderName: repSenderName, Body: derefOr(repBody, ""),
Deleted: repDeleted != nil,
}
}
msgs = append(msgs, m)
}
return msgs, rows.Err()
}
// AppendParams carries everything needed to persist one outgoing message.
type AppendParams struct {
ConversationID string
SenderID *string
Kind string
Body string
Entities json.RawMessage
ClientMsgID *string
ReplyToID *string
ForwardedFromID *string
}
// Append persists a message and assigns its sequence number.
//
// The conversation row is locked FOR UPDATE first. That serialises sends within
// a thread — which is what makes `seq` gapless — and it also means the
// duplicate check below runs under the lock, so a retried send returns the
// original message instead of consuming a sequence number and leaving a hole.
func (r *ChatRepo) Append(ctx context.Context, p AppendParams) (*model.Message, bool, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, false, fmt.Errorf("begin append tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
var lastSeq int64
if err := tx.QueryRow(ctx,
`SELECT last_seq FROM conversations WHERE id = $1 FOR UPDATE`, p.ConversationID,
).Scan(&lastSeq); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, ErrConversationGone
}
return nil, false, fmt.Errorf("lock conversation: %w", err)
}
if p.ClientMsgID != nil {
existing, err := scanMessage(tx.QueryRow(ctx, `
SELECT id, conversation_id, seq, sender_id, kind, body, entities,
client_msg_id, reply_to_id, forwarded_from_id, edited_at, deleted_at, created_at
FROM messages WHERE conversation_id = $1 AND client_msg_id = $2`,
p.ConversationID, *p.ClientMsgID))
if err == nil {
return existing, false, tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, false, fmt.Errorf("dedup lookup: %w", err)
}
}
// Checked under the conversation lock we already hold.
if p.ReplyToID != nil {
var sameThread bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND conversation_id = $2)`,
*p.ReplyToID, p.ConversationID,
).Scan(&sameThread); err != nil {
return nil, false, fmt.Errorf("validate reply target: %w", err)
}
if !sameThread {
return nil, false, ErrReplyTargetNotHere
}
}
entities := p.Entities
if len(entities) == 0 {
entities = json.RawMessage(`[]`)
}
seq := lastSeq + 1
m, err := scanMessage(tx.QueryRow(ctx, `
INSERT INTO messages (conversation_id, seq, sender_id, kind, body, entities,
client_msg_id, reply_to_id, forwarded_from_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, conversation_id, seq, sender_id, kind, body, entities,
client_msg_id, reply_to_id, forwarded_from_id, edited_at, deleted_at, created_at`,
p.ConversationID, seq, p.SenderID, p.Kind, p.Body, entities,
p.ClientMsgID, p.ReplyToID, p.ForwardedFromID))
if err != nil {
return nil, false, fmt.Errorf("insert message: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE conversations SET last_seq = $2, last_message_at = now(), updated_at = now()
WHERE id = $1`, p.ConversationID, seq); err != nil {
return nil, false, fmt.Errorf("bump conversation: %w", err)
}
// The sender has trivially seen their own message; advancing their cursors
// here keeps their unread count at zero without a follow-up write.
if p.SenderID != nil {
if _, err := tx.Exec(ctx, `
UPDATE conversation_participants
SET last_read_seq = GREATEST(last_read_seq, $3),
last_delivered_seq = GREATEST(last_delivered_seq, $3)
WHERE conversation_id = $1 AND user_id = $2`,
p.ConversationID, *p.SenderID, seq); err != nil {
return nil, false, fmt.Errorf("advance sender cursor: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, false, fmt.Errorf("commit append: %w", err)
}
return m, true, nil
}
// MarkRead advances the caller's read cursor. GREATEST means an out-of-order
// or replayed request can never move a cursor backwards and resurrect unreads.
// Reading implies delivery, so both cursors move together.
func (r *ChatRepo) MarkRead(ctx context.Context, conversationID, userID string, seq int64) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE conversation_participants
SET last_read_seq = GREATEST(last_read_seq, $3),
last_delivered_seq = GREATEST(last_delivered_seq, $3)
WHERE conversation_id = $1 AND user_id = $2`, conversationID, userID, seq)
if err != nil {
return fmt.Errorf("mark read: %w", err)
}
return nil
}
// MarkDelivered advances only the delivery cursor — the client received the
// message but the user has not opened the thread.
func (r *ChatRepo) MarkDelivered(ctx context.Context, conversationID, userID string, seq int64) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE conversation_participants
SET last_delivered_seq = GREATEST(last_delivered_seq, $3)
WHERE conversation_id = $1 AND user_id = $2`, conversationID, userID, seq)
if err != nil {
return fmt.Errorf("mark delivered: %w", err)
}
return nil
}
// AddAttachmentParams describes one attachment to persist.
type AddAttachmentParams struct {
MessageID string
ConversationID string
Ord int
Kind string
R2Key *string
Mime *string
SizeBytes *int64
Width *int
Height *int
DurationMs *int
Blurhash *string
FileName *string
URL *string
Title *string
Description *string
SiteName *string
ImageURL *string
// Shared entity: the durable reference plus a snapshot used only as a
// fallback. Live data is re-resolved per viewer at read time.
EntityType *string
EntityID *string
Snapshot []byte
}
// AddAttachment persists one attachment. conversation_id is denormalized here
// so the chat profile's media and links tabs are a single index scan.
func (r *ChatRepo) AddAttachment(ctx context.Context, p AddAttachmentParams) (*model.Attachment, error) {
var a model.Attachment
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO message_attachments
(message_id, conversation_id, ord, kind, r2_key, mime, size_bytes,
width, height, duration_ms, blurhash, file_name,
url, title, description, site_name, image_url,
entity_type, entity_id, snapshot)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
RETURNING id, message_id, ord, kind, mime, size_bytes, width, height,
duration_ms, blurhash, file_name, url, title, description,
site_name, image_url, entity_type, entity_id, snapshot, created_at`,
p.MessageID, p.ConversationID, p.Ord, p.Kind, p.R2Key, p.Mime, p.SizeBytes,
p.Width, p.Height, p.DurationMs, p.Blurhash, p.FileName,
p.URL, p.Title, p.Description, p.SiteName, p.ImageURL,
p.EntityType, p.EntityID, p.Snapshot,
).Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width,
&a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, &a.Title,
&a.Description, &a.SiteName, &a.ImageURL,
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt)
if err != nil {
return nil, fmt.Errorf("add attachment: %w", err)
}
return &a, nil
}
// AttachmentsFor loads the attachments of the given messages, keyed by message.
func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map[string][]model.Attachment, error) {
out := map[string][]model.Attachment{}
if len(messageIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, message_id, ord, kind, mime, size_bytes, width, height,
duration_ms, blurhash, file_name, url, title, description,
site_name, image_url, entity_type, entity_id, snapshot, created_at
FROM message_attachments
WHERE message_id = ANY($1)
ORDER BY message_id, ord`, messageIDs)
if err != nil {
return nil, fmt.Errorf("query attachments: %w", err)
}
defer rows.Close()
for rows.Next() {
var a model.Attachment
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
&a.Title, &a.Description, &a.SiteName, &a.ImageURL,
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("scan attachment: %w", err)
}
out[a.MessageID] = append(out[a.MessageID], a)
}
return out, rows.Err()
}
// AttachmentLocation returns an attachment's storage key and the conversation
// it belongs to, so a download can be authorized before a URL is minted.
func (r *ChatRepo) AttachmentLocation(ctx context.Context, attachmentID string) (conversationID, r2Key string, err error) {
var key *string
err = r.pg.Pool.QueryRow(ctx, `
SELECT conversation_id, r2_key FROM message_attachments WHERE id = $1`,
attachmentID).Scan(&conversationID, &key)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", ErrAttachmentGone
}
if err != nil {
return "", "", fmt.Errorf("attachment location: %w", err)
}
if key == nil || *key == "" {
return "", "", ErrAttachmentGone
}
return conversationID, *key, nil
}
// GalleryKindsMedia / GalleryKindsLink name the two chat-profile tabs.
var (
GalleryKindsMedia = []string{model.AttachmentImage, model.AttachmentVideo, model.AttachmentAudio, model.AttachmentFile}
GalleryKindsLink = []string{model.AttachmentLink}
)
// Gallery lists a thread's attachments of the given kinds, newest first.
//
// Served by message_attachments_gallery_idx, which is why conversation_id is
// denormalized onto attachments: this is one index range scan rather than a
// join back through messages.
//
// Attachments of messages the caller deleted for themselves are excluded, so
// the gallery matches the thread they actually see.
func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, kinds []string, before *time.Time, limit int) ([]model.Attachment, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT a.id, a.message_id, a.ord, a.kind, a.mime, a.size_bytes, a.width,
a.height, a.duration_ms, a.blurhash, a.file_name, a.url, a.title,
a.description, a.site_name, a.image_url,
a.entity_type, a.entity_id, a.snapshot, a.created_at
FROM message_attachments a
JOIN messages m ON m.id = a.message_id
WHERE a.conversation_id = $1
AND a.kind = ANY($2)
AND m.deleted_at IS NULL
AND ($3::timestamptz IS NULL OR a.created_at < $3)
AND NOT EXISTS (SELECT 1 FROM message_hides h
WHERE h.message_id = a.message_id AND h.user_id = $4)
ORDER BY a.created_at DESC
LIMIT $5`, conversationID, kinds, before, userID, limit)
if err != nil {
return nil, fmt.Errorf("query gallery: %w", err)
}
defer rows.Close()
out := []model.Attachment{}
for rows.Next() {
var a model.Attachment
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
&a.Title, &a.Description, &a.SiteName, &a.ImageURL,
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("scan gallery row: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
// SetStar stars or unstars a message for one user.
//
// The message must belong to the conversation: the caller's membership is
// checked against the thread, so accepting a message id from elsewhere would
// let them star something they cannot see.
func (r *ChatRepo) SetStar(ctx context.Context, conversationID, messageID, userID string, starred bool) error {
if !starred {
_, err := r.pg.Pool.Exec(ctx,
`DELETE FROM message_stars WHERE message_id = $1 AND user_id = $2`, messageID, userID)
if err != nil {
return fmt.Errorf("unstar: %w", err)
}
return nil
}
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO message_stars (message_id, user_id, conversation_id)
SELECT $1, $2, $3
WHERE EXISTS (SELECT 1 FROM messages WHERE id = $1 AND conversation_id = $3)
ON CONFLICT (message_id, user_id) DO NOTHING`, messageID, userID, conversationID)
if err != nil {
return fmt.Errorf("star: %w", err)
}
// No row inserted and no conflict means the message isn't in this thread.
if tag.RowsAffected() == 0 {
var exists bool
if err := r.pg.Pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM message_stars WHERE message_id=$1 AND user_id=$2)`,
messageID, userID).Scan(&exists); err != nil {
return fmt.Errorf("star check: %w", err)
}
if !exists {
return ErrMessageNotHere
}
}
return nil
}
// Starred lists the caller's starred messages in a thread, newest first.
func (r *ChatRepo) Starred(ctx context.Context, conversationID, userID string, limit int) ([]model.Message, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities,
m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at,
m.deleted_at, m.created_at
FROM message_stars st
JOIN messages m ON m.id = st.message_id
WHERE st.user_id = $1 AND st.conversation_id = $2 AND m.deleted_at IS NULL
ORDER BY st.created_at DESC
LIMIT $3`, userID, conversationID, limit)
if err != nil {
return nil, fmt.Errorf("query starred: %w", err)
}
defer rows.Close()
msgs := []model.Message{}
for rows.Next() {
var m model.Message
var entities []byte
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind,
&m.Body, &entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID,
&m.EditedAt, &m.DeletedAt, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("scan starred: %w", err)
}
m.Entities = json.RawMessage(entities)
m.Attachments = []model.Attachment{}
msgs = append(msgs, m)
}
return msgs, rows.Err()
}
// StarredIDs reports which of the given messages the caller has starred, so a
// thread can show the star on each bubble.
func (r *ChatRepo) StarredIDs(ctx context.Context, userID string, messageIDs []string) (map[string]bool, error) {
out := map[string]bool{}
if len(messageIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx,
`SELECT message_id FROM message_stars WHERE user_id = $1 AND message_id = ANY($2)`,
userID, messageIDs)
if err != nil {
return nil, fmt.Errorf("query stars: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan star: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// FollowEdges returns the accepted-follow state between two users in both
// directions, plus when the first of them was accepted.
func (r *ChatRepo) FollowEdges(ctx context.Context, a, b string) (aFollowsB, bFollowsA bool, since *time.Time, err error) {
rows, qErr := r.pg.Pool.Query(ctx, `
SELECT follower_id, status, COALESCE(responded_at, created_at)
FROM follows
WHERE (follower_id = $1 AND following_id = $2)
OR (follower_id = $2 AND following_id = $1)`, a, b)
if qErr != nil {
return false, false, nil, fmt.Errorf("follow edges: %w", qErr)
}
defer rows.Close()
for rows.Next() {
var follower, status string
var at time.Time
if err := rows.Scan(&follower, &status, &at); err != nil {
return false, false, nil, fmt.Errorf("scan follow edge: %w", err)
}
if status != "accepted" {
continue // a pending request is not a friendship
}
if follower == a {
aFollowsB = true
} else {
bFollowsA = true
}
// "Friends since" is the earliest accepted edge either way.
if since == nil || at.Before(*since) {
at := at
since = &at
}
}
return aFollowsB, bFollowsA, since, rows.Err()
}
// Settings returns a user's chat preferences, falling back to the defaults
// when they have never changed anything.
func (r *ChatRepo) Settings(ctx context.Context, userID string) (model.ChatSettings, error) {
out := model.DefaultChatSettings()
var gender *string
err := r.pg.Pool.QueryRow(ctx, `
SELECT COALESCE(s.read_receipts, TRUE),
COALESCE(s.who_can_message, 'anyone'),
COALESCE(s.min_account_age_months, 3),
COALESCE(s.train_kautilya, TRUE),
u.gender
FROM users u
LEFT JOIN chat_settings s ON s.user_id = u.id
WHERE u.id = $1`, userID,
).Scan(&out.ReadReceipts, &out.WhoCanMessage, &out.MinAccountAgeMonths,
&out.TrainKautilya, &gender)
if errors.Is(err, pgx.ErrNoRows) {
return out, ErrUserGone
}
if err != nil {
return out, fmt.Errorf("chat settings: %w", err)
}
out.GenderSet = gender != nil && *gender != ""
return out, nil
}
// SaveSettings upserts a user's chat preferences.
func (r *ChatRepo) SaveSettings(ctx context.Context, userID string, s model.ChatSettings) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO chat_settings
(user_id, read_receipts, who_can_message, min_account_age_months, train_kautilya)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
read_receipts = EXCLUDED.read_receipts,
who_can_message = EXCLUDED.who_can_message,
min_account_age_months = EXCLUDED.min_account_age_months,
train_kautilya = EXCLUDED.train_kautilya,
updated_at = now()`,
userID, s.ReadReceipts, s.WhoCanMessage, s.MinAccountAgeMonths, s.TrainKautilya)
if err != nil {
return fmt.Errorf("save chat settings: %w", err)
}
return nil
}
// CanStartConversation applies the recipient's "who can message you" rule.
//
// Only gates STARTING a thread. An existing conversation keeps working, so
// tightening the setting never strands people mid-exchange.
func (r *ChatRepo) CanStartConversation(ctx context.Context, senderID, recipientID string) (bool, error) {
settings, err := r.Settings(ctx, recipientID)
if err != nil {
return false, err
}
switch settings.WhoCanMessage {
case model.WhoAnyone:
return true, nil
case model.WhoFriends:
// An accepted follow in EITHER direction counts as a connection.
following, followedBy, _, err := r.FollowEdges(ctx, senderID, recipientID)
if err != nil {
return false, err
}
return following || followedBy, nil
case model.WhoAccountAge:
var oldEnough bool
if err := r.pg.Pool.QueryRow(ctx, `
SELECT created_at <= now() - make_interval(months => $2)
FROM users WHERE id = $1`, senderID, settings.MinAccountAgeMonths,
).Scan(&oldEnough); err != nil {
return false, fmt.Errorf("account age: %w", err)
}
return oldEnough, nil
case model.WhoSameGender:
// Unusable without the recipient's own gender; treated as "anyone"
// rather than locking them out of their own inbox.
if !settings.GenderSet {
return true, nil
}
var same bool
if err := r.pg.Pool.QueryRow(ctx, `
SELECT s.gender IS NOT NULL AND s.gender = r.gender
FROM users s, users r WHERE s.id = $1 AND r.id = $2`,
senderID, recipientID).Scan(&same); err != nil {
return false, fmt.Errorf("gender match: %w", err)
}
return same, nil
}
return true, nil
}
// ReadReceiptsEnabled reports which of the given users allow read receipts.
func (r *ChatRepo) ReadReceiptsEnabled(ctx context.Context, userIDs []string) (map[string]bool, error) {
out := map[string]bool{}
if len(userIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, COALESCE(s.read_receipts, TRUE)
FROM users u LEFT JOIN chat_settings s ON s.user_id = u.id
WHERE u.id = ANY($1)`, userIDs)
if err != nil {
return nil, fmt.Errorf("read receipt settings: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
var on bool
if err := rows.Scan(&id, &on); err != nil {
return nil, fmt.Errorf("scan receipt setting: %w", err)
}
out[id] = on
}
return out, rows.Err()
}
// UserIDsByHandles resolves handles to ids, skipping any that don't exist.
//
// Member operations take handles because that is what the UI has: search
// results and follow lists identify people by handle, and handles are what the
// rest of the API keys on (/u/{handle}/block, /u/{handle}/followers).
func (r *ChatRepo) UserIDsByHandles(ctx context.Context, handles []string) ([]string, error) {
if len(handles) == 0 {
return nil, nil
}
rows, err := r.pg.Pool.Query(ctx,
// Banned users are skipped: they cannot be added to a group.
`SELECT id FROM users WHERE handle = ANY($1) AND banned_at IS NULL`, handles)
if err != nil {
return nil, fmt.Errorf("resolve handles: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan handle id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// AddParticipants adds members to a group, reviving anyone who had left.
func (r *ChatRepo) AddParticipants(ctx context.Context, conversationID string, userIDs []string) error {
for _, id := range userIDs {
if _, err := r.pg.Pool.Exec(ctx, `
INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at)
VALUES ($1, $2, 'member', now())
ON CONFLICT (conversation_id, user_id)
-- Re-adding someone who left clears left_at and restarts their
-- tenure; it must not resurrect an old role.
DO UPDATE SET left_at = NULL, role = 'member', joined_at = now()
WHERE conversation_participants.left_at IS NOT NULL`,
conversationID, id); err != nil {
return fmt.Errorf("add participant: %w", err)
}
}
return nil
}
// RemoveParticipant marks someone as having left. The row stays so their past
// messages still resolve to a member when history is rendered.
func (r *ChatRepo) RemoveParticipant(ctx context.Context, conversationID, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE conversation_participants SET left_at = now()
WHERE conversation_id = $1 AND user_id = $2 AND left_at IS NULL`,
conversationID, userID)
if err != nil {
return fmt.Errorf("remove participant: %w", err)
}
return nil
}
// SetRole changes a participant's role.
func (r *ChatRepo) SetRole(ctx context.Context, conversationID, userID, role string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE conversation_participants SET role = $3
WHERE conversation_id = $1 AND user_id = $2 AND left_at IS NULL`,
conversationID, userID, role)
if err != nil {
return fmt.Errorf("set role: %w", err)
}
return nil
}
// UpdateGroup changes a group's title and/or send policy. Nil leaves a field
// untouched.
func (r *ChatRepo) UpdateGroup(ctx context.Context, conversationID string, title, sendPolicy, avatarKey *string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE conversations
SET title = COALESCE($2, title),
send_policy = COALESCE($3, send_policy),
avatar_key = COALESCE($4, avatar_key),
updated_at = now()
WHERE id = $1`, conversationID, title, sendPolicy, avatarKey)
if err != nil {
return fmt.Errorf("update group: %w", err)
}
return nil
}
// SuccessorOwner picks who inherits a group when its owner leaves: the
// longest-serving remaining admin, else the longest-serving member. Empty when
// nobody is left.
//
// Without this a group whose owner walks away would have no one able to
// administer it.
func (r *ChatRepo) SuccessorOwner(ctx context.Context, conversationID, leavingUserID string) (string, error) {
var id string
err := r.pg.Pool.QueryRow(ctx, `
SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id <> $2 AND left_at IS NULL
ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, joined_at
LIMIT 1`, conversationID, leavingUserID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("successor owner: %w", err)
}
return id, nil
}
// MessageConversation returns the thread a message belongs to, plus its
// sender — enough to authorize a delete, a forward or a report.
func (r *ChatRepo) MessageConversation(ctx context.Context, messageID string) (conversationID string, senderID *string, err error) {
err = r.pg.Pool.QueryRow(ctx,
`SELECT conversation_id, sender_id FROM messages WHERE id = $1`, messageID,
).Scan(&conversationID, &senderID)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil, ErrMessageNotHere
}
if err != nil {
return "", nil, fmt.Errorf("message conversation: %w", err)
}
return conversationID, senderID, nil
}
// MessageByID loads a single message. Callers authorize separately — this
// only fetches.
func (r *ChatRepo) MessageByID(ctx context.Context, messageID string) (*model.Message, error) {
m, err := scanMessage(r.pg.Pool.QueryRow(ctx, `
SELECT id, conversation_id, seq, sender_id, kind, body, entities,
client_msg_id, reply_to_id, forwarded_from_id, edited_at,
deleted_at, created_at
FROM messages WHERE id = $1`, messageID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrMessageNotHere
}
if err != nil {
return nil, fmt.Errorf("message by id: %w", err)
}
return m, nil
}
// HideMessage removes a message from one user's view only. Others keep seeing
// it — this is "delete for me".
func (r *ChatRepo) HideMessage(ctx context.Context, messageID, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO message_hides (message_id, user_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, messageID, userID)
if err != nil {
return fmt.Errorf("hide message: %w", err)
}
return nil
}
// DeleteMessage tombstones a message for everyone.
//
// The row survives on purpose: `seq` must stay gapless for the client's
// catch-up cursor, and other participants need something to replace the bubble
// with rather than a hole. Body and entities are cleared so the text is
// actually gone, and attachments go with it.
func (r *ChatRepo) DeleteMessage(ctx context.Context, messageID, byUserID string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin delete tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `
UPDATE messages
SET deleted_at = now(), deleted_by = $2, body = '', entities = '[]'::jsonb
WHERE id = $1`, messageID, byUserID); err != nil {
return fmt.Errorf("delete message: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM message_attachments WHERE message_id = $1`, messageID); err != nil {
return fmt.Errorf("delete message attachments: %w", err)
}
return tx.Commit(ctx)
}
// MessageWithContext returns a message and the few around it — the snapshot a
// report captures so moderators never need to read the live thread.
func (r *ChatRepo) MessageWithContext(ctx context.Context, messageID string, around int) ([]model.Message, error) {
rows, err := r.pg.Pool.Query(ctx, `
WITH target AS (
SELECT conversation_id, seq FROM messages WHERE id = $1
)
SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities,
m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at,
m.deleted_at, m.created_at
FROM messages m, target t
WHERE m.conversation_id = t.conversation_id
AND m.seq BETWEEN t.seq - $2 AND t.seq + $2
ORDER BY m.seq`, messageID, around)
if err != nil {
return nil, fmt.Errorf("message context: %w", err)
}
defer rows.Close()
msgs := []model.Message{}
for rows.Next() {
var m model.Message
var entities []byte
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind,
&m.Body, &entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID,
&m.EditedAt, &m.DeletedAt, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("scan context message: %w", err)
}
m.Entities = json.RawMessage(entities)
msgs = append(msgs, m)
}
return msgs, rows.Err()
}
// ReportMessage records a report with its evidence copied in.
func (r *ChatRepo) ReportMessage(ctx context.Context, reporterID, messageID, conversationID string, reportedUserID *string, category, reason string, snapshot []byte) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO message_reports
(reporter_id, message_id, conversation_id, reported_user_id,
category, reason, snapshot)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
reporterID, messageID, conversationID, reportedUserID, category, reason, snapshot)
if err != nil {
return fmt.Errorf("report message: %w", err)
}
return nil
}
// SetReaction records one person's emoji on a message, replacing whatever
// they chose before. Returns the message's conversation so the caller can
// authorize and publish without a second query.
func (r *ChatRepo) SetReaction(ctx context.Context, messageID, userID, emoji string) (conversationID string, err error) {
err = r.pg.Pool.QueryRow(ctx, `
INSERT INTO message_reactions (message_id, user_id, conversation_id, emoji)
SELECT $1, $2, m.conversation_id, $3
FROM messages m
WHERE m.id = $1 AND m.deleted_at IS NULL
ON CONFLICT (message_id, user_id)
DO UPDATE SET emoji = EXCLUDED.emoji, updated_at = now()
RETURNING conversation_id`, messageID, userID, emoji).Scan(&conversationID)
if errors.Is(err, pgx.ErrNoRows) {
// No row inserted means no such message, or it was deleted — you
// cannot react to something that isn't there.
return "", ErrMessageNotHere
}
if err != nil {
return "", fmt.Errorf("set reaction: %w", err)
}
return conversationID, nil
}
// ClearReaction removes this user's reaction. Returns the conversation so the
// caller can publish the change; ErrMessageNotHere when there was none.
func (r *ChatRepo) ClearReaction(ctx context.Context, messageID, userID string) (conversationID string, err error) {
err = r.pg.Pool.QueryRow(ctx, `
DELETE FROM message_reactions
WHERE message_id = $1 AND user_id = $2
RETURNING conversation_id`, messageID, userID).Scan(&conversationID)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrMessageNotHere
}
if err != nil {
return "", fmt.Errorf("clear reaction: %w", err)
}
return conversationID, nil
}
// Stickers lists everything a user may send: rewards they earned through
// sādhana, plus stickers they saved from a chat.
//
// One UNION rather than two calls and a merge in Go — "may I send this?" and
// "what can I send?" then have exactly one answer, and it stays correct if a
// user both earns and is sent the same sticker.
func (r *ChatRepo) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT sr.key, sr.name, sr.image_url, bool_and(src.saved) AS saved
FROM (
SELECT reward_id, FALSE AS saved FROM sadhna_reward_grants WHERE user_id = $1
UNION ALL
SELECT reward_id, TRUE AS saved FROM saved_stickers WHERE user_id = $1
) src
JOIN sadhna_rewards sr ON sr.id = src.reward_id
WHERE sr.active
GROUP BY sr.key, sr.name, sr.image_url, sr.sort
ORDER BY sr.sort, sr.name`, userID)
if err != nil {
return nil, fmt.Errorf("stickers: %w", err)
}
defer rows.Close()
out := []model.Sticker{}
for rows.Next() {
var s model.Sticker
if err := rows.Scan(&s.Key, &s.Name, &s.ImageURL, &s.Saved); err != nil {
return nil, fmt.Errorf("scan sticker: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// Sticker looks up one piece of artwork a user is entitled to send.
// ErrStickerNotOwned when they have neither earned nor saved it.
func (r *ChatRepo) Sticker(ctx context.Context, userID, key string) (*model.Sticker, error) {
var s model.Sticker
err := r.pg.Pool.QueryRow(ctx, `
SELECT sr.key, sr.name, sr.image_url
FROM sadhna_rewards sr
WHERE sr.key = $2 AND sr.active
AND (EXISTS (SELECT 1 FROM sadhna_reward_grants g
WHERE g.user_id = $1 AND g.reward_id = sr.id)
OR EXISTS (SELECT 1 FROM saved_stickers ss
WHERE ss.user_id = $1 AND ss.reward_id = sr.id))`,
userID, key).Scan(&s.Key, &s.Name, &s.ImageURL)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrStickerNotOwned
}
if err != nil {
return nil, fmt.Errorf("sticker: %w", err)
}
return &s, nil
}
// SaveSticker keeps a sticker someone sent. Idempotent.
//
// Only stickers that actually appear on a message in a thread the caller is
// in can be saved — otherwise knowing a key would be enough to collect the
// whole catalog without earning any of it.
func (r *ChatRepo) SaveSticker(ctx context.Context, userID, key, fromMessageID string) error {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO saved_stickers (user_id, reward_id, saved_from)
SELECT $1, sr.id, m.sender_id
FROM message_attachments a
JOIN messages m ON m.id = a.message_id
JOIN conversation_participants p
ON p.conversation_id = m.conversation_id AND p.user_id = $1 AND p.left_at IS NULL
JOIN sadhna_rewards sr ON sr.key = a.entity_id AND sr.active
WHERE a.message_id = $3 AND a.kind = 'sticker' AND a.entity_id = $2
ON CONFLICT (user_id, reward_id) DO NOTHING`,
userID, key, fromMessageID)
if err != nil {
return fmt.Errorf("save sticker: %w", err)
}
// Zero rows means the sticker isn't on that message, or the caller isn't
// in the thread. Both are "you can't have this", not "already saved" —
// a repeat save conflicts instead, which also reports zero.
if tag.RowsAffected() == 0 {
var exists bool
if err := r.pg.Pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM saved_stickers ss
JOIN sadhna_rewards sr ON sr.id = ss.reward_id
WHERE ss.user_id = $1 AND sr.key = $2)`,
userID, key).Scan(&exists); err != nil {
return fmt.Errorf("save sticker check: %w", err)
}
if !exists {
return ErrStickerNotOwned
}
}
return nil
}
// CopyAttachments duplicates one message's attachments onto another.
//
// Forwarding needs this: a quote, a sticker, a photo or a shared post lives in
// an attachment row, not in the body, so copying only body+entities forwards
// an empty bubble.
//
// conversation_id is rewritten to the destination, which is what keeps access
// correct: reads authorize against the attachment's OWN conversation, so the
// copy is readable by the new thread's participants and the original stays
// scoped to the old one. Media rows point at the same stored object rather
// than duplicating it.
//
// Entity rows carry only their reference forward; visibility is re-resolved
// per viewer on every read, so forwarding a post cannot leak one.
func (r *ChatRepo) CopyAttachments(ctx context.Context, fromMessageID, toMessageID, toConversationID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO message_attachments
(message_id, conversation_id, ord, kind, r2_key, mime, size_bytes,
width, height, duration_ms, blurhash, file_name,
url, title, description, site_name, image_url,
entity_type, entity_id, snapshot)
SELECT $2, $3, ord, kind, r2_key, mime, size_bytes,
width, height, duration_ms, blurhash, file_name,
url, title, description, site_name, image_url,
entity_type, entity_id, snapshot
FROM message_attachments
WHERE message_id = $1
ORDER BY ord`, fromMessageID, toMessageID, toConversationID)
if err != nil {
return fmt.Errorf("copy attachments: %w", err)
}
return nil
}
// ReactionDetails lists everyone who reacted to one message, newest first.
//
// Separate from ReactionsFor: the grouped counts are what a thread renders,
// and sending the full membership of every reaction on every page would grow
// with the room. This is only read when someone opens the list.
func (r *ChatRepo) ReactionDetails(ctx context.Context, messageID string) ([]model.ReactionDetail, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url, mr.emoji, mr.updated_at
FROM message_reactions mr
JOIN users u ON u.id = mr.user_id
WHERE mr.message_id = $1
ORDER BY mr.updated_at DESC`, messageID)
if err != nil {
return nil, fmt.Errorf("reaction details: %w", err)
}
defer rows.Close()
out := []model.ReactionDetail{}
for rows.Next() {
var d model.ReactionDetail
if err := rows.Scan(&d.User.ID, &d.User.Handle, &d.User.Name,
&d.User.AvatarURL, &d.Emoji, &d.CreatedAt); err != nil {
return nil, fmt.Errorf("scan reaction detail: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// ReactionsFor groups reactions for a page of messages in one query.
//
// Grouped in SQL rather than in Go: the counts are what the UI draws, and
// sending one row per person would grow with the room.
func (r *ChatRepo) ReactionsFor(ctx context.Context, messageIDs []string, viewerID string) (map[string][]model.Reaction, error) {
out := map[string][]model.Reaction{}
if len(messageIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT message_id, emoji, count(*),
bool_or(user_id = $2) AS mine
FROM message_reactions
WHERE message_id = ANY($1)
GROUP BY message_id, emoji
-- Most-used first, then stable by emoji so the chip order doesn't
-- shuffle between reloads.
ORDER BY message_id, count(*) DESC, emoji`, messageIDs, viewerID)
if err != nil {
return nil, fmt.Errorf("reactions for: %w", err)
}
defer rows.Close()
for rows.Next() {
var messageID string
var re model.Reaction
if err := rows.Scan(&messageID, &re.Emoji, &re.Count, &re.Mine); err != nil {
return nil, fmt.Errorf("scan reaction: %w", err)
}
out[messageID] = append(out[messageID], re)
}
return out, rows.Err()
}
// PushCandidate is a participant plus the state that decides whether a new
// message should reach them out-of-band.
type PushCandidate struct {
UserID string
MutedUntil *time.Time
LastReadSeq int64
}
// PushCandidates lists everyone still in the thread except the sender.
func (r *ChatRepo) PushCandidates(ctx context.Context, conversationID, senderID string) ([]PushCandidate, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT user_id, muted_until, last_read_seq
FROM conversation_participants
WHERE conversation_id = $1 AND user_id <> $2 AND left_at IS NULL`,
conversationID, senderID)
if err != nil {
return nil, fmt.Errorf("push candidates: %w", err)
}
defer rows.Close()
out := []PushCandidate{}
for rows.Next() {
var c PushCandidate
if err := rows.Scan(&c.UserID, &c.MutedUntil, &c.LastReadSeq); err != nil {
return nil, fmt.Errorf("scan push candidate: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// UnreadConversations counts the caller's threads holding unread messages.
//
// Counts THREADS, not messages: the badge answers "is anything waiting for
// me?", and one chatty group shouldn't dominate it.
//
// Muted threads are excluded. Unread still accrues on them and the inbox still
// shows it — mute silences the alert, and a badge is an alert.
func (r *ChatRepo) UnreadConversations(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*)
FROM conversation_participants p
JOIN conversations c ON c.id = p.conversation_id
WHERE p.user_id = $1
AND p.left_at IS NULL
AND c.last_seq > p.last_read_seq
AND (p.muted_until IS NULL OR p.muted_until <= now())`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count unread conversations: %w", err)
}
return n, nil
}
// SetMute sets or clears the caller's mute on a thread. A nil until unmutes.
//
// Mute is per participant, not per conversation: muting a group silences it for
// you alone.
func (r *ChatRepo) SetMute(ctx context.Context, conversationID, userID string, until *time.Time) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE conversation_participants SET muted_until = $3
WHERE conversation_id = $1 AND user_id = $2`, conversationID, userID, until)
if err != nil {
return fmt.Errorf("set mute: %w", err)
}
return nil
}
// Receipts returns the delivery/read cursors across everyone EXCEPT the
// caller, so the caller can draw ticks on their own messages. MIN is the
// deliberate choice: in a group, blue ticks mean everyone has read it.
func (r *ChatRepo) Receipts(ctx context.Context, conversationID, excludeUserID string) (model.Receipts, error) {
var rc model.Receipts
err := r.pg.Pool.QueryRow(ctx, `
SELECT COALESCE(MIN(last_delivered_seq), 0), COALESCE(MIN(last_read_seq), 0)
FROM conversation_participants
WHERE conversation_id = $1 AND user_id <> $2 AND left_at IS NULL`,
conversationID, excludeUserID).Scan(&rc.DeliveredSeq, &rc.ReadSeq)
if err != nil {
return rc, fmt.Errorf("receipts: %w", err)
}
return rc, nil
}
func scanMessage(row pgx.Row) (*model.Message, error) {
var m model.Message
var entities []byte
if err := row.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind, &m.Body,
&entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID,
&m.EditedAt, &m.DeletedAt, &m.CreatedAt); err != nil {
return nil, err
}
m.Entities = json.RawMessage(entities)
return &m, nil
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func derefOr(s *string, fallback string) string {
if s == nil {
return fallback
}
return *s
}