buhapp-backend/internal/handlers/chat.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

383 lines
12 KiB
Go

package handlers
import (
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/buhapp/backend/internal/audit"
"github.com/buhapp/backend/internal/chat"
"github.com/buhapp/backend/internal/ws"
)
type ChatHandlers struct {
Chat *chat.Repo
Audit *audit.Repo
Hub *ws.Hub
}
func NewChatHandlers(c *chat.Repo, a *audit.Repo, hub *ws.Hub) *ChatHandlers {
return &ChatHandlers{Chat: c, Audit: a, Hub: hub}
}
type ensureChatRequest struct {
OtherID string `json:"other_id"`
}
func (h *ChatHandlers) EnsureChat(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req ensureChatRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
other, err := uuid.Parse(req.OtherID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad other_id"})
}
if me == other {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "cannot chat with yourself"})
}
blocked, err := h.Chat.IsBlockedEither(c.UserContext(), me, other)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if blocked {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "user blocked"})
}
chatObj, err := h.Chat.EnsureChat(c.UserContext(), me, other)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(chatObj)
}
func (h *ChatHandlers) ListChats(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
items, err := h.Chat.ListChats(c.UserContext(), me, limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
// mark online
for i := range items {
items[i].OtherID = items[i].OtherID // already set
// online info
}
return c.JSON(fiber.Map{
"chats": items,
"count": len(items),
})
}
type sendMessageRequest struct {
Body string `json:"body"`
PhotoURL string `json:"photo_url"`
}
func (h *ChatHandlers) SendMessage(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
chatID, err := uuid.Parse(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad chat id"})
}
var req sendMessageRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
body := strings.TrimSpace(req.Body)
photo := strings.TrimSpace(req.PhotoURL)
if body == "" && photo == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body or photo_url required"})
}
if len(body) > 4000 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body too long (max 4000)"})
}
// Проверим что я участник чата
ch, err := h.Chat.GetByID(c.UserContext(), chatID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if ch == nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "chat not found"})
}
if ch.UserA != me && ch.UserB != me {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "not a chat member"})
}
// Проверим блок
other := ch.UserA
if other == me {
other = ch.UserB
}
blocked, _ := h.Chat.IsBlockedEither(c.UserContext(), me, other)
if blocked {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "user blocked"})
}
m, err := h.Chat.Send(c.UserContext(), chatID, me, body, photo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
_ = h.Audit.Log(c.UserContext(), &audit.Event{
UserID: &me, Action: "message.send",
TargetType: "chat", TargetID: chatID.String(),
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
// push to recipient via WebSocket
h.Hub.SendTo(other, ws.Outgoing{
Type: "message",
Payload: fiber.Map{
"id": m.ID,
"chat_id": m.ChatID,
"sender_id": m.SenderID,
"body": m.Body,
"photo_url": m.PhotoURL,
"read": m.Read,
"read_at": m.ReadAt,
"edited": m.Edited,
"created_at": m.CreatedAt,
},
Time: time.Now(),
})
// echo to sender too
h.Hub.SendTo(me, ws.Outgoing{
Type: "message",
Payload: fiber.Map{
"id": m.ID,
"chat_id": m.ChatID,
"sender_id": m.SenderID,
"body": m.Body,
"photo_url": m.PhotoURL,
"read": m.Read,
"read_at": m.ReadAt,
"edited": m.Edited,
"created_at": m.CreatedAt,
},
Time: time.Now(),
})
return c.Status(fiber.StatusCreated).JSON(m)
}
func (h *ChatHandlers) ListMessages(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
chatID, err := uuid.Parse(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad chat id"})
}
ch, err := h.Chat.GetByID(c.UserContext(), chatID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if ch == nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "chat not found"})
}
if ch.UserA != me && ch.UserB != me {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "not a chat member"})
}
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
msgs, err := h.Chat.ListMessages(c.UserContext(), chatID, limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(fiber.Map{"messages": msgs, "count": len(msgs)})
}
type editRequest struct {
Body string `json:"body"`
}
func (h *ChatHandlers) EditMessage(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
msgID, err := uuid.Parse(c.Params("msgId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad msg id"})
}
var req editRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
body := strings.TrimSpace(req.Body)
if body == "" || len(body) > 4000 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body required, max 4000"})
}
if err := h.Chat.EditMessage(c.UserContext(), msgID, me, body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
_ = h.Audit.Log(c.UserContext(), &audit.Event{
UserID: &me, Action: "message.edit", TargetType: "message", TargetID: msgID.String(),
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
// Push WS — обоим участникам чата
m, err := h.Chat.GetMessage(c.UserContext(), msgID)
if err == nil && m != nil {
ch, _ := h.Chat.GetByID(c.UserContext(), m.ChatID)
if ch != nil {
other := ch.UserA
if other == me {
other = ch.UserB
}
for _, uid := range []uuid.UUID{me, other} {
h.Hub.SendTo(uid, ws.Outgoing{
Type: "message_edited",
Payload: fiber.Map{
"id": m.ID,
"chat_id": m.ChatID,
"body": m.Body,
"edited": m.Edited,
"updated_at": m.UpdatedAt,
},
Time: time.Now(),
})
}
}
}
return c.JSON(fiber.Map{"ok": true, "edited": true})
}
func (h *ChatHandlers) DeleteMessage(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
msgID, err := uuid.Parse(c.Params("msgId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad msg id"})
}
if err := h.Chat.DeleteMessage(c.UserContext(), msgID, me); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func (h *ChatHandlers) MarkRead(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
chatID, err := uuid.Parse(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad chat id"})
}
if err := h.Chat.MarkRead(c.UserContext(), chatID, me); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
// оповестим отправителя через WS что сообщения прочитаны
chat, _ := h.Chat.GetByID(c.UserContext(), chatID)
if chat != nil {
other := chat.UserA
if other == me {
other = chat.UserB
}
h.Hub.SendTo(other, ws.Outgoing{
Type: "message_read",
Payload: fiber.Map{
"chat_id": chatID,
"reader": me,
"read_at": time.Now(),
},
Time: time.Now(),
})
}
return c.JSON(fiber.Map{"ok": true})
}
type blockRequest struct {
UserID string `json:"user_id"`
}
func (h *ChatHandlers) Block(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req blockRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
other, err := uuid.Parse(req.UserID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad user_id"})
}
if err := h.Chat.Block(c.UserContext(), me, other); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
_ = h.Audit.Log(c.UserContext(), &audit.Event{
UserID: &me, Action: "user.block", TargetType: "user", TargetID: other.String(),
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
return c.JSON(fiber.Map{"ok": true})
}
func (h *ChatHandlers) Unblock(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
other, err := uuid.Parse(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad id"})
}
if err := h.Chat.Unblock(c.UserContext(), me, other); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
return c.JSON(fiber.Map{"ok": true})
}
type reportRequest struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Reason string `json:"reason"`
}
func (h *ChatHandlers) Report(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req reportRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
if req.TargetType != "user" && req.TargetType != "message" && req.TargetType != "chat" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bad target_type"})
}
if req.TargetID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "target_id required"})
}
if err := h.Chat.AddReport(c.UserContext(), &chat.Report{
ReporterID: me, TargetType: req.TargetType, TargetID: req.TargetID, Reason: req.Reason,
}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
_ = h.Audit.Log(c.UserContext(), &audit.Event{
UserID: &me, Action: "report.create", TargetType: req.TargetType, TargetID: req.TargetID,
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
return c.JSON(fiber.Map{"ok": true})
}