627 lines
21 KiB
Go
627 lines
21 KiB
Go
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.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"`
|
|
}
|
|
|
|
// 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"`
|
|
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,
|
|
})
|
|
}
|
|
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
|
|
Body: req.Body,
|
|
ClientMsgID: req.ClientMsgID,
|
|
ReplyToID: req.ReplyToID,
|
|
PreviewURL: req.PreviewURL,
|
|
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 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})
|
|
}
|
|
|
|
// 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
|
|
}
|