buhapp-backend/internal/chat/repo.go
ga 3fe6930af2 Chat edit with WS notification
- PUT /messages/:msgId now returns {ok, edited:true}
- WS pushes 'message_edited' event to both chat members
- Mobile UI: show 'ред.' marker when edited=true
- Audit log: message.edit action
- Repo: GetMessage helper for fetching after edit
2026-08-20 15:52:57 +00:00

281 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package chat
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Chat struct {
ID uuid.UUID
UserA uuid.UUID
UserB uuid.UUID
CreatedAt time.Time
LastMsgAt time.Time
}
type Message struct {
ID uuid.UUID
ChatID uuid.UUID
SenderID uuid.UUID
Body string
PhotoURL string
Read bool
Edited bool
Deleted bool
CreatedAt time.Time
UpdatedAt time.Time
}
type Repo struct {
pool *pgxpool.Pool
}
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
// EnsureChat — нормализуем user_a < user_b, чтобы избежать дублей
func (r *Repo) EnsureChat(ctx context.Context, me, other uuid.UUID) (*Chat, error) {
a, b := me, other
if a.String() > b.String() {
a, b = b, a
}
// ищем существующий
c := &Chat{}
err := r.pool.QueryRow(ctx, `
SELECT id, user_a, user_b, created_at, last_msg_at
FROM chats WHERE user_a=$1 AND user_b=$2`, a, b,
).Scan(&c.ID, &c.UserA, &c.UserB, &c.CreatedAt, &c.LastMsgAt)
if err == nil {
return c, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
// создаём
err = r.pool.QueryRow(ctx, `
INSERT INTO chats (user_a, user_b) VALUES ($1, $2)
RETURNING id, created_at, last_msg_at`,
a, b,
).Scan(&c.ID, &c.CreatedAt, &c.LastMsgAt)
if err != nil {
return nil, err
}
c.UserA, c.UserB = a, b
return c, nil
}
func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*Chat, error) {
c := &Chat{}
err := r.pool.QueryRow(ctx, `
SELECT id, user_a, user_b, created_at, last_msg_at
FROM chats WHERE id=$1`, id,
).Scan(&c.ID, &c.UserA, &c.UserB, &c.CreatedAt, &c.LastMsgAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
// ListChats — список чатов пользователя с инфой по последнему сообщению
type ChatListItem struct {
Chat Chat
OtherID uuid.UUID
OtherName string
OtherPhoto string
LastMessage string
LastMsgAt time.Time
UnreadCount int
}
func (r *Repo) ListChats(ctx context.Context, me uuid.UUID, limit, offset int) ([]ChatListItem, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.user_a, c.user_b, c.created_at, c.last_msg_at,
CASE WHEN c.user_a = $1 THEN c.user_b ELSE c.user_a END AS other_id,
u.name,
u.photo_url,
(SELECT COALESCE(body, '') FROM messages m
WHERE m.chat_id = c.id AND m.deleted = FALSE
ORDER BY m.created_at DESC LIMIT 1) AS last_body,
(SELECT COUNT(*) FROM messages m
WHERE m.chat_id = c.id AND m.read = FALSE
AND m.sender_id != $1) AS unread
FROM chats c
JOIN users u ON u.id = CASE WHEN c.user_a = $1 THEN c.user_b ELSE c.user_a END
WHERE c.user_a = $1 OR c.user_b = $1
ORDER BY c.last_msg_at DESC
LIMIT $2 OFFSET $3`,
me, limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatListItem
for rows.Next() {
var c ChatListItem
var photo *string
if err := rows.Scan(&c.Chat.ID, &c.Chat.UserA, &c.Chat.UserB, &c.Chat.CreatedAt, &c.Chat.LastMsgAt,
&c.OtherID, &c.OtherName, &photo, &c.LastMessage, &c.UnreadCount); err != nil {
return nil, err
}
if photo != nil {
c.OtherPhoto = *photo
}
c.LastMsgAt = c.Chat.LastMsgAt
out = append(out, c)
}
return out, rows.Err()
}
func (r *Repo) Send(ctx context.Context, chatID, senderID uuid.UUID, body, photoURL string) (*Message, error) {
m := &Message{}
err := r.pool.QueryRow(ctx, `
INSERT INTO messages (chat_id, sender_id, body, photo_url)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''))
RETURNING id, chat_id, sender_id, COALESCE(body, ''), COALESCE(photo_url, ''),
read, edited, deleted, created_at, updated_at`,
chatID, senderID, body, photoURL,
).Scan(&m.ID, &m.ChatID, &m.SenderID, &m.Body, &m.PhotoURL,
&m.Read, &m.Edited, &m.Deleted, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return nil, err
}
_, _ = r.pool.Exec(ctx, `UPDATE chats SET last_msg_at = NOW() WHERE id=$1`, chatID)
return m, nil
}
func (r *Repo) ListMessages(ctx context.Context, chatID uuid.UUID, limit, offset int) ([]Message, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.pool.Query(ctx, `
SELECT id, chat_id, sender_id, COALESCE(body, ''), COALESCE(photo_url, ''),
read, edited, deleted, created_at, updated_at
FROM messages
WHERE chat_id=$1
ORDER BY created_at ASC
LIMIT $2 OFFSET $3`, chatID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Message
for rows.Next() {
var m Message
if err := rows.Scan(&m.ID, &m.ChatID, &m.SenderID, &m.Body, &m.PhotoURL,
&m.Read, &m.Edited, &m.Deleted, &m.CreatedAt, &m.UpdatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (r *Repo) EditMessage(ctx context.Context, msgID, senderID uuid.UUID, newBody string) error {
res, err := r.pool.Exec(ctx, `
UPDATE messages SET body=$1, edited=TRUE, updated_at=NOW()
WHERE id=$2 AND sender_id=$3 AND deleted=FALSE`,
newBody, msgID, senderID,
)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return errors.New("message not found or not yours")
}
return nil
}
func (r *Repo) GetMessage(ctx context.Context, msgID uuid.UUID) (*Message, error) {
m := &Message{}
err := r.pool.QueryRow(ctx, `
SELECT id, chat_id, sender_id, COALESCE(body, ''), COALESCE(photo_url, ''),
read, edited, deleted, created_at, updated_at
FROM messages WHERE id=$1`, msgID,
).Scan(&m.ID, &m.ChatID, &m.SenderID, &m.Body, &m.PhotoURL,
&m.Read, &m.Edited, &m.Deleted, &m.CreatedAt, &m.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return m, nil
}
func (r *Repo) DeleteMessage(ctx context.Context, msgID, senderID uuid.UUID) error {
res, err := r.pool.Exec(ctx, `
UPDATE messages SET deleted=TRUE, body=NULL, photo_url=NULL, updated_at=NOW()
WHERE id=$1 AND sender_id=$2`,
msgID, senderID,
)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return errors.New("message not found or not yours")
}
return nil
}
func (r *Repo) MarkRead(ctx context.Context, chatID, meID uuid.UUID) error {
_, err := r.pool.Exec(ctx, `
UPDATE messages SET read=TRUE
WHERE chat_id=$1 AND sender_id != $2 AND read=FALSE`,
chatID, meID,
)
return err
}
// Block / Unblock
func (r *Repo) Block(ctx context.Context, me, other uuid.UUID) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO blocks (blocker_id, blocked_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, me, other)
return err
}
func (r *Repo) Unblock(ctx context.Context, me, other uuid.UUID) error {
_, err := r.pool.Exec(ctx, `DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2`, me, other)
return err
}
func (r *Repo) IsBlockedEither(ctx context.Context, a, b uuid.UUID) (bool, error) {
var blocked bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM blocks WHERE
(blocker_id=$1 AND blocked_id=$2) OR (blocker_id=$2 AND blocked_id=$1)
)`, a, b).Scan(&blocked)
return blocked, err
}
// Report
type Report struct {
ReporterID uuid.UUID
TargetType string
TargetID string
Reason string
}
func (r *Repo) AddReport(ctx context.Context, rep *Report) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO reports (reporter_id, target_type, target_id, reason)
VALUES ($1, $2, $3, NULLIF($4, ''))`,
rep.ReporterID, rep.TargetType, rep.TargetID, rep.Reason)
return err
}