buhapp-backend/internal/chat/repo.go
ga 0f9e0ce596 Sprint 7: race conditions + read_at + slog
1. chat.EnsureChat: ON CONFLICT DO UPDATE (race-safe)
2. chat.Send: tx.Begin/Commit (atomic INSERT message + UPDATE last_msg_at)
3. chat.MarkRead: добавил member-check (NOT a member -> 403)
4. chat.Message: +ReadAt field
5. migrations/0006: read_at column on messages
6. locations.Nearby: bounding-box prefilter + CTE (haversine только для отфильтрованных)
7. audit.Log: real metadata JSON passthrough (no more 'null' TODO)
8. main.go: slog JSON logger (был stdlib log)
9. WS: 'message_read' event от MarkRead
10. WS push: добавлены read/read_at/edited в message payload
11. Bumped v0.6.0
2026-08-20 21:04:56 +00:00

307 lines
9.0 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 `json:"id"`
UserA uuid.UUID `json:"user_a"`
UserB uuid.UUID `json:"user_b"`
CreatedAt time.Time `json:"created_at"`
LastMsgAt time.Time `json:"last_msg_at"`
}
type Message struct {
ID uuid.UUID `json:"id"`
ChatID uuid.UUID `json:"chat_id"`
SenderID uuid.UUID `json:"sender_id"`
Body string `json:"body"`
PhotoURL string `json:"photo_url,omitempty"`
Read bool `json:"read"`
ReadAt *time.Time `json:"read_at,omitempty"`
Edited bool `json:"edited"`
Deleted bool `json:"deleted"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Repo struct {
pool *pgxpool.Pool
}
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
// EnsureChat — race-safe: ON CONFLICT DO NOTHING + RETURNING.
// Нормализуем 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, `
INSERT INTO chats (user_a, user_b) VALUES ($1, $2)
ON CONFLICT (user_a, user_b) DO UPDATE
SET last_msg_at = chats.last_msg_at -- noop, чтобы вернулось
RETURNING id, user_a, user_b, created_at, last_msg_at`,
a, b,
).Scan(&c.ID, &c.UserA, &c.UserB, &c.CreatedAt, &c.LastMsgAt)
if err != nil {
return nil, err
}
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 `json:"chat"`
OtherID uuid.UUID `json:"other_id"`
OtherName string `json:"other_name"`
OtherPhoto string `json:"other_photo,omitempty"`
LastMessage string `json:"last_message"`
LastMsgAt time.Time `json:"last_msg_at"`
UnreadCount int `json:"unread_count"`
}
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()
}
// Send — атомарно: INSERT message + UPDATE chat.last_msg_at в одной транзакции
func (r *Repo) Send(ctx context.Context, chatID, senderID uuid.UUID, body, photoURL string) (*Message, error) {
m := &Message{}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
err = tx.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, read_at, edited, deleted, created_at, updated_at`,
chatID, senderID, body, photoURL,
).Scan(&m.ID, &m.ChatID, &m.SenderID, &m.Body, &m.PhotoURL,
&m.Read, &m.ReadAt, &m.Edited, &m.Deleted, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `UPDATE chats SET last_msg_at = NOW() WHERE id=$1`, chatID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
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, read_at, 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.ReadAt, &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, read_at, 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.ReadAt, &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
}
// MarkRead — атомарно с проверкой членства. Возвращает chat_id для WS broadcast.
func (r *Repo) MarkRead(ctx context.Context, chatID, meID uuid.UUID) error {
// member-check + update в одном UPDATE с подзапросом
res, err := r.pool.Exec(ctx, `
UPDATE messages SET read=TRUE, read_at=NOW()
WHERE chat_id=$1 AND sender_id != $2 AND read=FALSE
AND EXISTS (SELECT 1 FROM chats c
WHERE c.id=$1 AND (c.user_a=$2 OR c.user_b=$2))`,
chatID, meID,
)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
// не ошибка, просто либо нет сообщений, либо юзер не в чате
// проверим явно membership для возврата ошибки
var isMember bool
if err := r.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM chats WHERE id=$1 AND (user_a=$2 OR user_b=$2))`,
chatID, meID,
).Scan(&isMember); err != nil {
return err
}
if !isMember {
return errors.New("not a member of this chat")
}
}
return nil
}
// 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
}