buhapp-backend/internal/chat/repo.go
ga 778a2da4ef Sprint 5.1: JSON snake_case tags + lowercased fields
- All response types now use lowercase json tags (id, name, photo_url, etc.)
- email/phone/birthdate/last_seen_at/photo_url use omitempty
- audit.Metadata type []byte (was map[string]any)
- fixes register/login inconsistency where login returned ID/Name (CamelCase)
  while register returned id/name (lowercase)
2026-08-20 16:29:02 +00:00

281 lines
7.9 KiB
Go
Raw Permalink 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"`
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 — нормализуем 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 `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()
}
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
}