package handler import ( "context" "encoding/json" "errors" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/response" ) // ChatHandler exposes the chat inbox, threads and messages. // // Every route is session-authenticated and every read re-checks membership in // the service, so a conversation id is not a capability: knowing one gets you // nothing if you were never in the thread (or have left it). type ChatHandler struct { svc *service.ChatService } func NewChatHandler(svc *service.ChatService) *ChatHandler { return &ChatHandler{svc: svc} } // writeChatError maps service errors onto status codes. Not-a-participant is a // 404, not a 403: confirming a thread exists to someone outside it leaks that // two people are talking. func writeChatError(w http.ResponseWriter, err error) { switch { case errors.Is(err, repository.ErrNotParticipant), errors.Is(err, repository.ErrConversationGone): response.Error(w, http.StatusNotFound, "conversation not found") case errors.Is(err, service.ErrAvatarUnset): response.Error(w, http.StatusNotFound, err.Error()) case errors.Is(err, service.ErrChatBadEntity): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, repository.ErrStickerNotOwned): response.Error(w, http.StatusForbidden, "that sticker isn't in your collection") case errors.Is(err, service.ErrChatBadEmoji): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBadQuote): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBadAvatarKey): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, repository.ErrMessageNotHere): response.Error(w, http.StatusNotFound, "message not found") case errors.Is(err, repository.ErrAttachmentGone): response.Error(w, http.StatusNotFound, "attachment not found") case errors.Is(err, service.ErrChatMediaOff): response.Error(w, http.StatusServiceUnavailable, err.Error()) case errors.Is(err, service.ErrChatBadMedia): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatNotAdmin), errors.Is(err, service.ErrChatOwnerImmutable), errors.Is(err, service.ErrChatAdminsOnly): response.Error(w, http.StatusForbidden, err.Error()) case errors.Is(err, service.ErrChatNotGroup), errors.Is(err, service.ErrChatBadRole), errors.Is(err, service.ErrChatBadPolicy): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatNotSender): response.Error(w, http.StatusForbidden, err.Error()) case errors.Is(err, service.ErrMessageDeleted), errors.Is(err, service.ErrChatBadReport): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatNotAccepting): response.Error(w, http.StatusForbidden, err.Error()) case errors.Is(err, service.ErrChatBadWhoCanMessage), errors.Is(err, service.ErrChatBadAccountAge), errors.Is(err, service.ErrChatGenderUnset): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBlocked): response.Error(w, http.StatusForbidden, err.Error()) case errors.Is(err, service.ErrChatReadOnly): response.Error(w, http.StatusConflict, "Kautilya is not available yet") case errors.Is(err, service.ErrChatEmptyBody), errors.Is(err, service.ErrChatBodyTooLong), errors.Is(err, service.ErrChatBadMuteDuration), errors.Is(err, repository.ErrReplyTargetNotHere): response.Error(w, http.StatusBadRequest, err.Error()) default: response.Error(w, http.StatusInternalServerError, "chat request failed") } } // Inbox — GET /chat/conversations?tab=chats|kautilya func (h *ChatHandler) Inbox(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) tab := r.URL.Query().Get("tab") items, err := h.svc.Inbox(r.Context(), userID, tab, atoiOr(r.URL.Query().Get("limit"), 0)) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, items) } type openDMReq struct { // Handle, not id: profiles are addressed by handle everywhere else, and // the public profile payload carries no user id. Handle string `json:"handle"` } // Unread — GET /chat/unread. Drives the nav badge, so it stays a single // count rather than anything the caller has to aggregate. func (h *ChatHandler) Unread(w http.ResponseWriter, r *http.Request) { n, err := h.svc.UnreadCount(r.Context(), middleware.UserID(r.Context())) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, map[string]int{"count": n}) } // OpenDM — POST /chat/conversations/dm. Idempotent: returns the existing // thread when there is one, so the client can call it every time it opens a // profile without checking first. func (h *ChatHandler) OpenDM(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) var req openDMReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" { response.Error(w, http.StatusBadRequest, "handle is required") return } conv, err := h.svc.OpenDMByHandle(r.Context(), userID, req.Handle) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, conv) } type createGroupReq struct { Title string `json:"title"` MemberHandles []string `json:"member_handles"` } // CreateGroup — POST /chat/conversations/group func (h *ChatHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) var req createGroupReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } conv, err := h.svc.CreateGroup(r.Context(), userID, req.Title, req.MemberHandles) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusCreated, conv) } // OpenAIThread — POST /chat/conversations/kautilya func (h *ChatHandler) OpenAIThread(w http.ResponseWriter, r *http.Request) { conv, err := h.svc.OpenAIThread(r.Context(), middleware.UserID(r.Context())) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, conv) } // Detail — GET /chat/conversations/{id} func (h *ChatHandler) Detail(w http.ResponseWriter, r *http.Request) { detail, err := h.svc.Detail(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, detail) } // Messages — GET /chat/conversations/{id}/messages?before_seq=&limit= // // before_seq is an exclusive upper bound rather than an offset, so paging stays // stable while new messages keep arriving at the top. func (h *ChatHandler) Messages(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) convID := chi.URLParam(r, "id") msgs, err := h.svc.Messages(r.Context(), userID, convID, int64(atoiOr(r.URL.Query().Get("before_seq"), 0)), atoiOr(r.URL.Query().Get("limit"), 0)) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, msgs) } type mediaReq struct { Key string `json:"key"` Mime string `json:"mime"` SizeBytes *int64 `json:"size_bytes"` Width *int `json:"width"` Height *int `json:"height"` DurationMs *int `json:"duration_ms"` Blurhash *string `json:"blurhash"` FileName *string `json:"file_name"` } type sendReq struct { Body string `json:"body"` ClientMsgID *string `json:"client_msg_id"` // preview_url is the link the sender chose to keep a card for; omitted // when they dismissed it. Only the URL — never the card's contents. PreviewURL *string `json:"preview_url"` Attachments []mediaReq `json:"attachments"` // quote is a passage shared from the library. `path` must be app-relative; // the service refuses anything that could navigate off-site. Quote *quoteReq `json:"quote"` // sticker_key sends reward artwork. Ownership is checked server-side. StickerKey *string `json:"sticker_key"` // entity shares something else in the app — a post — as a card. Only the // reference; the BFF resolves the contents and re-checks visibility. Entity *entityReq `json:"entity"` ReplyToID *string `json:"reply_to_id"` // forward_from_id is deliberately NOT accepted yet: forwarding lands with // its own "can the caller see the source?" check in a later phase, and // accepting an unvalidated message reference in the meantime would let a // client point at any message in the system. } // Send — POST /chat/conversations/{id}/messages func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) convID := chi.URLParam(r, "id") var req sendReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } media := make([]service.MediaAttachment, 0, len(req.Attachments)) for _, a := range req.Attachments { media = append(media, service.MediaAttachment{ Key: a.Key, Mime: a.Mime, SizeBytes: a.SizeBytes, Width: a.Width, Height: a.Height, DurationMs: a.DurationMs, Blurhash: a.Blurhash, FileName: a.FileName, }) } var quote *service.QuoteAttachment if req.Quote != nil { quote = &service.QuoteAttachment{ Path: req.Quote.Path, Title: req.Quote.Title, Text: req.Quote.Text, } } var entity *service.EntityAttachment if req.Entity != nil { entity = &service.EntityAttachment{Type: req.Entity.Type, ID: req.Entity.ID} } msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{ Body: req.Body, ClientMsgID: req.ClientMsgID, ReplyToID: req.ReplyToID, PreviewURL: req.PreviewURL, Quote: quote, StickerKey: req.StickerKey, Entity: entity, Attachments: media, }) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusCreated, msg) } // Settings — GET /chat/settings func (h *ChatHandler) Settings(w http.ResponseWriter, r *http.Request) { settings, err := h.svc.Settings(r.Context(), middleware.UserID(r.Context())) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, settings) } type chatSettingsReq struct { ReadReceipts *bool `json:"read_receipts"` WhoCanMessage *string `json:"who_can_message"` MinAccountAgeMonths *int `json:"min_account_age_months"` TrainKautilya *bool `json:"train_kautilya"` } // UpdateSettings — PATCH /chat/settings // // Fields are optional: anything omitted keeps its current value, so a toggle // doesn't have to send the whole object back. func (h *ChatHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) current, err := h.svc.Settings(r.Context(), userID) if err != nil { writeChatError(w, err) return } var req chatSettingsReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if req.ReadReceipts != nil { current.ReadReceipts = *req.ReadReceipts } if req.WhoCanMessage != nil { current.WhoCanMessage = *req.WhoCanMessage } if req.MinAccountAgeMonths != nil { current.MinAccountAgeMonths = *req.MinAccountAgeMonths } if req.TrainKautilya != nil { current.TrainKautilya = *req.TrainKautilya } saved, err := h.svc.SaveSettings(r.Context(), userID, current) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, saved) } type linkPreviewReq struct { URL string `json:"url"` } // LinkPreview — POST /chat/link-preview // // The composer calls this as a link is typed so the sender can see the card // and dismiss it before sending. Only the URL travels in either direction's // trust boundary: the BFF fetches the metadata itself. func (h *ChatHandler) LinkPreview(w http.ResponseWriter, r *http.Request) { var req linkPreviewReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" { response.Error(w, http.StatusBadRequest, "url is required") return } preview, err := h.svc.LinkPreview(r.Context(), req.URL) if err != nil { // No card is a normal outcome, not a server fault. response.Error(w, http.StatusUnprocessableEntity, "no preview available") return } response.JSON(w, http.StatusOK, preview) } type chatPresignReq struct { ContentType string `json:"content_type"` } // PresignAttachment — POST /chat/conversations/{id}/attachments/presign func (h *ChatHandler) PresignAttachment(w http.ResponseWriter, r *http.Request) { var req chatPresignReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } upload, key, err := h.svc.PresignAttachment(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType)) if err != nil { writeChatError(w, err) return } // The key comes back so the client can name it when it sends the message; // there is no public URL, by design. response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key}) } // Attachment — GET /chat/attachments/{attachmentID} // // A stable URL that redirects to a short-lived signed one. Membership is // re-checked on every fetch, so access revoked after a link was shared stops // working — which a signed URL baked into the message payload could not do. func (h *ChatHandler) Attachment(w http.ResponseWriter, r *http.Request) { url, err := h.svc.AttachmentURL(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "attachmentID")) if err != nil { writeChatError(w, err) return } // Private and short-lived: never let a shared cache hold onto it. w.Header().Set("Cache-Control", "private, no-store") http.Redirect(w, r, url, http.StatusFound) } // PresignGroupAvatar — POST /chat/conversations/{id}/avatar/presign func (h *ChatHandler) PresignGroupAvatar(w http.ResponseWriter, r *http.Request) { var req chatPresignReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } upload, key, err := h.svc.PresignGroupAvatar(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType)) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key}) } // GroupAvatar — GET /chat/conversations/{id}/avatar // // Stable URL that redirects to a short-lived signed one, re-checking // membership each time, exactly as message attachments do. func (h *ChatHandler) GroupAvatar(w http.ResponseWriter, r *http.Request) { url, err := h.svc.GroupAvatarURL(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")) if err != nil { writeChatError(w, err) return } w.Header().Set("Cache-Control", "private, no-store") http.Redirect(w, r, url, http.StatusFound) } // Gallery — GET /chat/conversations/{id}/gallery?tab=media|links&before= func (h *ChatHandler) Gallery(w http.ResponseWriter, r *http.Request) { var before *time.Time if raw := r.URL.Query().Get("before"); raw != "" { if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { before = &t } } items, err := h.svc.Gallery(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), r.URL.Query().Get("tab"), before, atoiOr(r.URL.Query().Get("limit"), 0)) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, items) } // Starred — GET /chat/conversations/{id}/starred func (h *ChatHandler) Starred(w http.ResponseWriter, r *http.Request) { msgs, err := h.svc.Starred(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), atoiOr(r.URL.Query().Get("limit"), 0)) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, msgs) } // Star / Unstar — POST|DELETE /chat/conversations/{id}/messages/{messageID}/star func (h *ChatHandler) Star(w http.ResponseWriter, r *http.Request) { h.setStar(w, r, true) } func (h *ChatHandler) Unstar(w http.ResponseWriter, r *http.Request) { h.setStar(w, r, false) } func (h *ChatHandler) setStar(w http.ResponseWriter, r *http.Request, starred bool) { err := h.svc.SetStar(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), chi.URLParam(r, "messageID"), starred) if err != nil { writeChatError(w, err) return } response.NoContent(w) } // DeleteMessage — DELETE /chat/conversations/{id}/messages/{messageID}?scope=me|everyone func (h *ChatHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) { scope := r.URL.Query().Get("scope") if scope == "" { scope = service.DeleteForMe // the safer default } err := h.svc.DeleteMessage(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "messageID"), scope) if err != nil { writeChatError(w, err) return } response.NoContent(w) } type forwardReq struct { MessageID string `json:"message_id"` } // Forward — POST /chat/conversations/{id}/forward // // The path names the DESTINATION; the body names the message being copied in. func (h *ChatHandler) Forward(w http.ResponseWriter, r *http.Request) { var req forwardReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" { response.Error(w, http.StatusBadRequest, "message_id is required") return } msg, err := h.svc.ForwardMessage(r.Context(), middleware.UserID(r.Context()), req.MessageID, chi.URLParam(r, "id")) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusCreated, msg) } type chatReportReq struct { Category string `json:"category"` Reason string `json:"reason"` } // ReportMessage — POST /chat/conversations/{id}/messages/{messageID}/report func (h *ChatHandler) ReportMessage(w http.ResponseWriter, r *http.Request) { var req chatReportReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } err := h.svc.ReportMessage(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "messageID"), req.Category, req.Reason) if err != nil { writeChatError(w, err) return } response.NoContent(w) } type membersReq struct { // Handles, not ids: search results and follow lists identify people by // handle, and it matches /u/{handle}/block elsewhere in the API. MemberHandles []string `json:"member_handles"` } // AddMembers — POST /chat/conversations/{id}/members func (h *ChatHandler) AddMembers(w http.ResponseWriter, r *http.Request) { var req membersReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if err := h.svc.AddMembers(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.MemberHandles); err != nil { writeChatError(w, err) return } response.NoContent(w) } // RemoveMember — DELETE /chat/conversations/{id}/members/{handle} // // Passing your own handle is how you leave. func (h *ChatHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { if err := h.svc.RemoveMember(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), chi.URLParam(r, "handle")); err != nil { writeChatError(w, err) return } response.NoContent(w) } type roleReq struct { Role string `json:"role"` } // SetMemberRole — PATCH /chat/conversations/{id}/members/{userID} func (h *ChatHandler) SetMemberRole(w http.ResponseWriter, r *http.Request) { var req roleReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if err := h.svc.SetMemberRole(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), chi.URLParam(r, "handle"), req.Role); err != nil { writeChatError(w, err) return } response.NoContent(w) } type updateGroupReq struct { Title *string `json:"title"` SendPolicy *string `json:"send_policy"` // AvatarKey comes from PresignGroupAvatar; the service checks it belongs // to this group before storing it. AvatarKey *string `json:"avatar_key"` } // UpdateGroup — PATCH /chat/conversations/{id} func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) { var req updateGroupReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if err := h.svc.UpdateGroup(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Title, req.SendPolicy, req.AvatarKey); err != nil { writeChatError(w, err) return } response.NoContent(w) } type entityReq struct { Type string `json:"type"` ID string `json:"id"` } type quoteReq struct { Path string `json:"path"` Title string `json:"title"` Text string `json:"text"` } type receiptReq struct { Seq int64 `json:"seq"` } // MarkRead — POST /chat/conversations/{id}/read func (h *ChatHandler) MarkRead(w http.ResponseWriter, r *http.Request) { h.receipt(w, r, h.svc.MarkRead) } // MarkDelivered — POST /chat/conversations/{id}/delivered func (h *ChatHandler) MarkDelivered(w http.ResponseWriter, r *http.Request) { h.receipt(w, r, h.svc.MarkDelivered) } func (h *ChatHandler) receipt(w http.ResponseWriter, r *http.Request, apply func(ctx context.Context, userID, convID string, seq int64) error) { var req receiptReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if err := apply(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Seq); err != nil { writeChatError(w, err) return } response.NoContent(w) } type muteReq struct { // Duration is one of 8h, 1w, always, off. Duration string `json:"duration"` } // SetMute — POST /chat/conversations/{id}/mute // // Muting silences alerts for the caller only; the thread keeps receiving // messages and its unread count keeps climbing. func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) { var req muteReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } until, err := h.svc.SetMute(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Duration) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, map[string]any{"muted_until": until}) } // Message — GET /chat/conversations/{id}/messages/{messageID} func (h *ChatHandler) Message(w http.ResponseWriter, r *http.Request) { msg, err := h.svc.Message(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), chi.URLParam(r, "messageID")) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, msg) } // MessageReactions — GET /chat/conversations/{id}/messages/{messageID}/reactions // // Only read when someone opens the list; the thread itself renders from the // grouped counts that ride along with each message. func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) { list, err := h.svc.MessageReactions(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "messageID")) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, list) } // Stickers — GET /chat/stickers. What the caller may send. func (h *ChatHandler) Stickers(w http.ResponseWriter, r *http.Request) { list, err := h.svc.Stickers(r.Context(), middleware.UserID(r.Context())) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, list) } type saveStickerReq struct { Key string `json:"key"` } // SaveSticker — POST /chat/conversations/{id}/messages/{messageID}/sticker // // Keyed to the message it was sent on, not just the sticker key: that is what // proves the caller was actually sent it rather than guessing at the catalog. func (h *ChatHandler) SaveSticker(w http.ResponseWriter, r *http.Request) { var req saveStickerReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if err := h.svc.SaveSticker(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "messageID"), req.Key); err != nil { writeChatError(w, err) return } response.NoContent(w) } // Typing — POST /chat/conversations/{id}/typing // // Fire-and-forget: nothing is stored and the response carries nothing, because // a typing indicator is stale within seconds of being sent. func (h *ChatHandler) Typing(w http.ResponseWriter, r *http.Request) { if err := h.svc.Typing(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil { writeChatError(w, err) return } response.NoContent(w) } type reactReq struct { Emoji string `json:"emoji"` } // React — PUT /chat/conversations/{id}/messages/{messageID}/reaction. // Replaces any previous choice. func (h *ChatHandler) React(w http.ResponseWriter, r *http.Request) { var req reactReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } // The message id is authoritative: React re-derives the conversation from // it and authorizes there, so the id in the path can't be used to react // into a thread the caller isn't in. if err := h.svc.React(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "messageID"), req.Emoji); err != nil { writeChatError(w, err) return } response.NoContent(w) } // Unreact — DELETE /chat/conversations/{id}/messages/{messageID}/reaction. // Idempotent. func (h *ChatHandler) Unreact(w http.ResponseWriter, r *http.Request) { if err := h.svc.Unreact(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "messageID")); err != nil { writeChatError(w, err) return } response.NoContent(w) } // Receipts — GET /chat/conversations/{id}/receipts func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) { rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")) if err != nil { writeChatError(w, err) return } response.JSON(w, http.StatusOK, rc) } func atoiOr(s string, fallback int) int { if s == "" { return fallback } n, err := strconv.Atoi(s) if err != nil || n < 0 { return fallback } return n }