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 }