- 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
281 lines
7.5 KiB
Go
281 lines
7.5 KiB
Go
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
|
||
}
|