big update: added reactions, groups, media share
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 21:59:48 +03:00
parent ae24bdd0e0
commit 9d03ea4c51
41 changed files with 2548 additions and 172 deletions
+8 -8
View File
@@ -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 {
+121
View File
@@ -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
}
+71 -1
View File
@@ -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)
}
}
+62
View File
@@ -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
}
+12 -11
View File
@@ -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;
+71
View File
@@ -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
}
+10
View File
@@ -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