big update: added reactions, groups, media share
This commit is contained in:
@@ -14,13 +14,13 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
||||
// Check existing DM
|
||||
var conv Conversation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.id, c.type, c.created_at
|
||||
SELECT c.id, c.type, c.name, c.avatar, c.created_at
|
||||
FROM conversations c
|
||||
JOIN conversation_members m1 ON m1.conversation_id=c.id AND m1.user_id=$1
|
||||
JOIN conversation_members m2 ON m2.conversation_id=c.id AND m2.user_id=$2
|
||||
WHERE c.type='dm'
|
||||
LIMIT 1
|
||||
`, userA, userB).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
`, userA, userB).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err == nil {
|
||||
return &conv, nil
|
||||
}
|
||||
@@ -42,8 +42,8 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO conversations(type) VALUES('dm')
|
||||
RETURNING id, type, created_at
|
||||
`).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
RETURNING id, type, name, avatar, created_at
|
||||
`).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,8 +68,8 @@ func (s *Store) CreateDM(ctx context.Context, userA, userB int64) (*Conversation
|
||||
func (s *Store) GetConversationByID(ctx context.Context, id int64) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, type, created_at FROM conversations WHERE id=$1
|
||||
`, id).Scan(&conv.ID, &conv.Type, &conv.CreatedAt)
|
||||
SELECT id, type, name, avatar, created_at FROM conversations WHERE id=$1
|
||||
`, id).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -118,7 +118,7 @@ func (s *Store) ConversationIDsForUser(ctx context.Context, userID int64) ([]int
|
||||
// ListConversationsForUser returns summaries of conversations for a user.
|
||||
func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]ConversationSummary, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.type, c.created_at,
|
||||
SELECT c.id, c.type, c.name, c.avatar, c.created_at,
|
||||
m.id, m.conversation_id, m.sender_id, m.body, m.created_at, m.edited_at, m.deleted_at,
|
||||
uc.unread_count
|
||||
FROM conversation_members cm
|
||||
@@ -156,7 +156,7 @@ func (s *Store) ListConversationsForUser(ctx context.Context, userID int64) ([]C
|
||||
msgDeleted *time.Time
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&cs.ID, &cs.Type, &cs.CreatedAt,
|
||||
&cs.ID, &cs.Type, &cs.Name, &cs.Avatar, &cs.CreatedAt,
|
||||
&msgID, &msgConvID, &msgSender, &msgBody, &msgCreated, &msgEdited, &msgDeleted,
|
||||
&cs.UnreadCount,
|
||||
); err != nil {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CreateGroup creates a group conversation with the creator as owner and
|
||||
// the given members added, returning the created conversation.
|
||||
func (s *Store) CreateGroup(ctx context.Context, creatorID int64, name string, memberIDs []int64) (*Conversation, error) {
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var conv Conversation
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO conversations(type, name) VALUES('group', $1)
|
||||
RETURNING id, type, name, avatar, created_at
|
||||
`, name).Scan(&conv.ID, &conv.Type, &conv.Name, &conv.Avatar, &conv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_members(conversation_id, user_id, role)
|
||||
VALUES($1, $2, 'owner')
|
||||
`, conv.ID, creatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[int64]struct{}{creatorID: {}}
|
||||
for _, uid := range memberIDs {
|
||||
if _, ok := seen[uid]; ok {
|
||||
continue
|
||||
}
|
||||
seen[uid] = struct{}{}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_members(conversation_id, user_id, role)
|
||||
VALUES($1, $2, 'member')
|
||||
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||
`, conv.ID, uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
// AddMember adds a user to a conversation; it is a no-op if already a member.
|
||||
func (s *Store) AddMember(ctx context.Context, conversationID, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO conversation_members(conversation_id, user_id, role)
|
||||
VALUES($1, $2, 'member')
|
||||
ON CONFLICT (conversation_id, user_id) DO NOTHING
|
||||
`, conversationID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMember removes a user from a conversation; it is a no-op if absent.
|
||||
func (s *Store) RemoveMember(ctx context.Context, conversationID, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM conversation_members WHERE conversation_id=$1 AND user_id=$2
|
||||
`, conversationID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MemberRole returns the role of a user in a conversation, or ErrNotFound.
|
||||
func (s *Store) MemberRole(ctx context.Context, conversationID, userID int64) (string, error) {
|
||||
var role string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT role FROM conversation_members WHERE conversation_id=$1 AND user_id=$2
|
||||
`, conversationID, userID).Scan(&role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// ListMembers returns the members of a conversation with their profiles.
|
||||
func (s *Store) ListMembers(ctx context.Context, conversationID int64) ([]MemberInfo, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT u.id, u.username, u.display_name, u.avatar, u.created_at, cm.role, cm.joined_at
|
||||
FROM conversation_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.conversation_id = $1
|
||||
ORDER BY cm.joined_at, u.id
|
||||
`, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []MemberInfo
|
||||
for rows.Next() {
|
||||
var m MemberInfo
|
||||
if err := rows.Scan(
|
||||
&m.User.ID, &m.User.Username, &m.User.DisplayName, &m.User.Avatar, &m.User.CreatedAt,
|
||||
&m.Role, &m.JoinedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func TestIntegration(t *testing.T) {
|
||||
t.Fatalf("ListConversationsForUser: %v, %v", sums, err)
|
||||
}
|
||||
|
||||
msg, err := db.InsertMessage(ctx, conv.ID, u1.ID, "hello")
|
||||
msg, err := db.InsertMessage(ctx, conv.ID, u1.ID, "hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InsertMessage: %v", err)
|
||||
}
|
||||
@@ -111,4 +111,74 @@ func TestIntegration(t *testing.T) {
|
||||
if err := db.UpdateLastRead(ctx, conv.ID, u2.ID); err != nil {
|
||||
t.Fatalf("UpdateLastRead: %v", err)
|
||||
}
|
||||
|
||||
// Groups
|
||||
u3, err := db.CreateUser(ctx, "user3", "User Three", "hash3")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u3: %v", err)
|
||||
}
|
||||
group, err := db.CreateGroup(ctx, u1.ID, "Family", []int64{u2.ID, u1.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
if group.Type != "group" || group.Name != "Family" {
|
||||
t.Fatalf("CreateGroup result = %+v", group)
|
||||
}
|
||||
members, err := db.ListMembers(ctx, group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMembers: %v", err)
|
||||
}
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("ListMembers len = %d, want 2", len(members))
|
||||
}
|
||||
role, err := db.MemberRole(ctx, group.ID, u1.ID)
|
||||
if err != nil || role != "owner" {
|
||||
t.Fatalf("MemberRole u1 = %q, %v; want owner", role, err)
|
||||
}
|
||||
if err := db.AddMember(ctx, group.ID, u3.ID); err != nil {
|
||||
t.Fatalf("AddMember: %v", err)
|
||||
}
|
||||
if err := db.AddMember(ctx, group.ID, u3.ID); err != nil {
|
||||
t.Fatalf("AddMember idempotent: %v", err)
|
||||
}
|
||||
members, _ = db.ListMembers(ctx, group.ID)
|
||||
if len(members) != 3 {
|
||||
t.Fatalf("ListMembers after add = %d, want 3", len(members))
|
||||
}
|
||||
if err := db.RemoveMember(ctx, group.ID, u3.ID); err != nil {
|
||||
t.Fatalf("RemoveMember: %v", err)
|
||||
}
|
||||
members, _ = db.ListMembers(ctx, group.ID)
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("ListMembers after remove = %d, want 2", len(members))
|
||||
}
|
||||
|
||||
// Reactions
|
||||
if err := db.AddReaction(ctx, msg.ID, u2.ID, "👍"); err != nil {
|
||||
t.Fatalf("AddReaction: %v", err)
|
||||
}
|
||||
if err := db.AddReaction(ctx, msg.ID, u2.ID, "👍"); err != nil {
|
||||
t.Fatalf("AddReaction idempotent: %v", err)
|
||||
}
|
||||
if err := db.AddReaction(ctx, msg.ID, u1.ID, "❤️"); err != nil {
|
||||
t.Fatalf("AddReaction u1: %v", err)
|
||||
}
|
||||
reactions, err := db.ReactionsForMessages(ctx, []int64{msg.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ReactionsForMessages: %v", err)
|
||||
}
|
||||
if len(reactions) != 2 {
|
||||
t.Fatalf("ReactionsForMessages len = %d, want 2", len(reactions))
|
||||
}
|
||||
if err := db.RemoveReaction(ctx, msg.ID, u2.ID, "👍"); err != nil {
|
||||
t.Fatalf("RemoveReaction: %v", err)
|
||||
}
|
||||
reactions, _ = db.ReactionsForMessages(ctx, []int64{msg.ID})
|
||||
if len(reactions) != 1 {
|
||||
t.Fatalf("ReactionsForMessages after remove = %d, want 1", len(reactions))
|
||||
}
|
||||
belongs, err := db.MessageBelongsToConversation(ctx, conv.ID, msg.ID)
|
||||
if err != nil || !belongs {
|
||||
t.Fatalf("MessageBelongsToConversation = %v, %v; want true", belongs, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Media stores metadata of an uploaded file. The file bytes live in the
|
||||
// media storage backend (see internal/media), referenced by StorageKey.
|
||||
type Media struct {
|
||||
ID int64
|
||||
UploaderID int64
|
||||
Filename string
|
||||
ContentType string
|
||||
Size int64
|
||||
StorageKey string
|
||||
ThumbKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateMedia inserts a media record and returns it with its ID.
|
||||
func (s *Store) CreateMedia(ctx context.Context, m Media) (*Media, error) {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO media(uploader_id, filename, content_type, size, storage_key, thumb_key)
|
||||
VALUES($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, m.UploaderID, m.Filename, m.ContentType, m.Size, m.StorageKey, nullableString(m.ThumbKey)).Scan(&m.ID, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GetMediaByID retrieves a media record or returns ErrNotFound.
|
||||
func (s *Store) GetMediaByID(ctx context.Context, id int64) (*Media, error) {
|
||||
var m Media
|
||||
var thumbKey *string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, uploader_id, filename, content_type, size, storage_key, thumb_key, created_at
|
||||
FROM media WHERE id=$1
|
||||
`, id).Scan(&m.ID, &m.UploaderID, &m.Filename, &m.ContentType, &m.Size, &m.StorageKey, &thumbKey, &m.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if thumbKey != nil {
|
||||
m.ThumbKey = *thumbKey
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func nullableString(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
)
|
||||
|
||||
// InsertMessage creates a new message in a conversation and returns it.
|
||||
func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int64, body string) (*Message, error) {
|
||||
// mediaID, when non-nil, attaches a previously uploaded media object.
|
||||
func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int64, body string, mediaID *int64) (*Message, error) {
|
||||
var m Message
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO messages(conversation_id, sender_id, body)
|
||||
VALUES($1, $2, $3)
|
||||
RETURNING id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
`, conversationID, senderID, body).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
INSERT INTO messages(conversation_id, sender_id, body, media_id)
|
||||
VALUES($1, $2, $3, $4)
|
||||
RETURNING id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
`, conversationID, senderID, body, mediaID).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -28,10 +29,10 @@ func (s *Store) InsertMessage(ctx context.Context, conversationID, senderID int6
|
||||
func (s *Store) GetMessageByID(ctx context.Context, id int64) (*Message, error) {
|
||||
var m Message
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
FROM messages WHERE id=$1
|
||||
`, id).Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -50,14 +51,14 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i
|
||||
var err error
|
||||
if before > 0 {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
FROM messages
|
||||
WHERE conversation_id=$1 AND id<$2
|
||||
ORDER BY id DESC LIMIT $3
|
||||
`, conversationID, before, limit)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, conversation_id, sender_id, body, created_at, edited_at, deleted_at
|
||||
SELECT id, conversation_id, sender_id, body, media_id, created_at, edited_at, deleted_at
|
||||
FROM messages
|
||||
WHERE conversation_id=$1
|
||||
ORDER BY id DESC LIMIT $2
|
||||
@@ -72,7 +73,7 @@ func (s *Store) ListMessages(ctx context.Context, conversationID int64, before i
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body,
|
||||
&m.ID, &m.ConversationID, &m.SenderID, &m.Body, &m.MediaID,
|
||||
&m.CreatedAt, &m.EditedAt, &m.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 0002_groups.sql
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE conversation_members ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'member';
|
||||
CREATE INDEX IF NOT EXISTS idx_members_conversation ON conversation_members(conversation_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 0003_reactions.sql
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_message ON message_reactions(message_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0004_media.sql
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uploader_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size BIGINT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
thumb_key TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_uploader ON media(uploader_id);
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS media_id BIGINT REFERENCES media(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Reaction represents a single emoji reaction on a message.
|
||||
type Reaction struct {
|
||||
MessageID int64
|
||||
UserID int64
|
||||
Emoji string
|
||||
}
|
||||
|
||||
// AddReaction adds a reaction to a message; it is a no-op if it already exists.
|
||||
func (s *Store) AddReaction(ctx context.Context, messageID, userID int64, emoji string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO message_reactions(message_id, user_id, emoji)
|
||||
VALUES($1, $2, $3)
|
||||
ON CONFLICT (message_id, user_id, emoji) DO NOTHING
|
||||
`, messageID, userID, emoji)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveReaction removes a reaction from a message; it is a no-op if absent.
|
||||
func (s *Store) RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM message_reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3
|
||||
`, messageID, userID, emoji)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReactionsForMessages returns all reactions for the given message IDs.
|
||||
func (s *Store) ReactionsForMessages(ctx context.Context, messageIDs []int64) ([]Reaction, error) {
|
||||
if len(messageIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT message_id, user_id, emoji
|
||||
FROM message_reactions
|
||||
WHERE message_id = ANY($1)
|
||||
ORDER BY message_id, created_at
|
||||
`, messageIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reactions []Reaction
|
||||
for rows.Next() {
|
||||
var r Reaction
|
||||
if err := rows.Scan(&r.MessageID, &r.UserID, &r.Emoji); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reactions = append(reactions, r)
|
||||
}
|
||||
return reactions, nil
|
||||
}
|
||||
|
||||
// MessageBelongsToConversation reports whether a message belongs to a conversation.
|
||||
func (s *Store) MessageBelongsToConversation(ctx context.Context, conversationID, messageID int64) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM messages WHERE id=$1 AND conversation_id=$2
|
||||
)
|
||||
`, messageID, conversationID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
@@ -42,9 +42,18 @@ type Session struct {
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
Type string
|
||||
Name string
|
||||
Avatar string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MemberInfo describes a conversation participant together with their profile.
|
||||
type MemberInfo struct {
|
||||
User User
|
||||
Role string
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
// ConversationSummary provides a conversation with its last message and unread count.
|
||||
type ConversationSummary struct {
|
||||
Conversation
|
||||
@@ -58,6 +67,7 @@ type Message struct {
|
||||
ConversationID int64
|
||||
SenderID int64
|
||||
Body string
|
||||
MediaID *int64
|
||||
CreatedAt time.Time
|
||||
EditedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
|
||||
Reference in New Issue
Block a user