2026-10-07 19:40:26 +03:30
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"path/filepath"
|
2026-10-10 16:42:35 +03:30
|
|
|
"sort"
|
2026-10-07 19:40:26 +03:30
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"pargar/backend/internal/models"
|
|
|
|
|
"pargar/backend/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const maxUploadSize = 25 << 20 // 25 MB
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
actor := currentUser(r)
|
2026-10-10 16:42:35 +03:30
|
|
|
page := parsePageParams(r)
|
|
|
|
|
if page.PageSize < 20 {
|
|
|
|
|
page.PageSize = 20
|
|
|
|
|
page.Offset = (page.Page - 1) * page.PageSize
|
|
|
|
|
}
|
|
|
|
|
roles := []string{models.RoleMentor.String(), models.RoleAdmin.String()}
|
2026-10-07 19:40:26 +03:30
|
|
|
if actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin {
|
2026-10-10 16:42:35 +03:30
|
|
|
roles = []string{models.RoleStudent.String(), models.RoleMentor.String(), models.RoleAdmin.String()}
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
people, total, err := s.store.ListActiveByRoles(r.Context(), roles, page.PageSize, page.Offset)
|
2026-10-07 19:40:26 +03:30
|
|
|
if err != nil {
|
2026-10-10 16:42:35 +03:30
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری افراد ممکن نشد")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
ids := make([]int64, 0, len(people))
|
|
|
|
|
for _, m := range people {
|
|
|
|
|
if m.ID != actor.ID {
|
|
|
|
|
ids = append(ids, m.ID)
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
}
|
|
|
|
|
seen, _ := s.store.LastSeenByIDs(r.Context(), ids)
|
|
|
|
|
safe := make([]map[string]any, 0, len(people))
|
|
|
|
|
for _, m := range people {
|
|
|
|
|
if m.ID == actor.ID {
|
2026-10-07 19:40:26 +03:30
|
|
|
continue
|
|
|
|
|
}
|
2026-10-09 19:05:59 +03:30
|
|
|
s.signUserMedia(&m)
|
2026-10-10 16:42:35 +03:30
|
|
|
item := map[string]any{
|
2026-10-07 19:40:26 +03:30
|
|
|
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
|
|
|
|
|
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
|
2026-10-10 16:42:35 +03:30
|
|
|
"online": s.hub.Online(m.ID),
|
|
|
|
|
}
|
|
|
|
|
if ts, ok := seen[m.ID]; ok && !ts.IsZero() {
|
|
|
|
|
item["lastSeen"] = ts.UTC()
|
|
|
|
|
}
|
|
|
|
|
safe = append(safe, item)
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"mentors": safe,
|
|
|
|
|
"total": total,
|
|
|
|
|
"page": page.Page,
|
|
|
|
|
"pageSize": page.PageSize,
|
|
|
|
|
})
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type conversation struct {
|
2026-10-10 16:42:35 +03:30
|
|
|
Partner map[string]any `json:"partner"`
|
|
|
|
|
LastMessage *models.ChatMessage `json:"lastMessage,omitempty"`
|
|
|
|
|
UnreadCount int `json:"unreadCount"`
|
|
|
|
|
MessageCount int `json:"messageCount"`
|
|
|
|
|
PinnedRank *int `json:"pinnedRank"`
|
|
|
|
|
Muted bool `json:"muted"`
|
|
|
|
|
Archived bool `json:"archived"`
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
rows, err := s.store.Pool().Query(r.Context(), `
|
|
|
|
|
SELECT DISTINCT partner, u2.id, u2.name, u2.email, u2.role,
|
|
|
|
|
u2.avatar_variant, u2.avatar_palette, u2.avatar_photo
|
|
|
|
|
FROM (
|
|
|
|
|
SELECT user_id AS partner FROM chat_messages WHERE mentor_id=$1
|
|
|
|
|
UNION
|
|
|
|
|
SELECT mentor_id FROM chat_messages WHERE user_id=$1
|
|
|
|
|
) p
|
|
|
|
|
JOIN users u2 ON u2.id = p.partner
|
|
|
|
|
ORDER BY u2.id`, u.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری گفتگوها ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
type pmeta struct {
|
|
|
|
|
ID int64
|
|
|
|
|
Name string
|
|
|
|
|
Email string
|
|
|
|
|
Role models.Role
|
|
|
|
|
AvatarVariant string
|
|
|
|
|
AvatarPalette string
|
|
|
|
|
AvatarPhoto string
|
|
|
|
|
}
|
|
|
|
|
var partners []pmeta
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var p pmeta
|
|
|
|
|
if err := rows.Scan(&p.ID, &p.ID, &p.Name, &p.Email, &p.Role,
|
|
|
|
|
&p.AvatarVariant, &p.AvatarPalette, &p.AvatarPhoto); err != nil {
|
|
|
|
|
rows.Close()
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری گفتگوها ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
partners = append(partners, p)
|
|
|
|
|
}
|
|
|
|
|
rows.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری گفتگوها ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 16:42:35 +03:30
|
|
|
showArchived := r.URL.Query().Get("archived") == "1"
|
|
|
|
|
partnerIDs := make([]int64, 0, len(partners))
|
|
|
|
|
for _, p := range partners {
|
|
|
|
|
if p.ID != u.ID {
|
|
|
|
|
partnerIDs = append(partnerIDs, p.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
seen, _ := s.store.LastSeenByIDs(r.Context(), partnerIDs)
|
2026-10-07 19:40:26 +03:30
|
|
|
out := make([]conversation, 0, len(partners))
|
|
|
|
|
for _, p := range partners {
|
|
|
|
|
if !s.canChatWith(r.Context(), u, p.ID) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
pref := s.store.ChatPref(r.Context(), u.ID, p.ID)
|
|
|
|
|
if pref.Archived != showArchived {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
msgs, err := s.store.ListChatMessages(r.Context(), u.ID, p.ID, 0, 100)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var last *models.ChatMessage
|
|
|
|
|
unread := 0
|
|
|
|
|
for i := range msgs {
|
|
|
|
|
m := msgs[i]
|
|
|
|
|
last = &m
|
|
|
|
|
if m.ReadAt == nil && m.SenderRole != u.Role {
|
|
|
|
|
unread++
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-09 19:05:59 +03:30
|
|
|
photo := p.AvatarPhoto
|
|
|
|
|
if photo != "" {
|
|
|
|
|
tmp := &models.User{ID: p.ID, AvatarPhoto: photo}
|
|
|
|
|
s.signUserMedia(tmp)
|
|
|
|
|
photo = tmp.AvatarPhoto
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
name := p.Name
|
|
|
|
|
saved := p.ID == u.ID
|
|
|
|
|
if saved {
|
|
|
|
|
name = "پیامهای ذخیرهشده"
|
|
|
|
|
}
|
|
|
|
|
partner := map[string]any{
|
|
|
|
|
"id": p.ID, "name": name, "email": p.Email, "role": p.Role,
|
|
|
|
|
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
|
|
|
|
|
"online": !saved && s.hub.Online(p.ID), "saved": saved,
|
|
|
|
|
}
|
|
|
|
|
if !saved {
|
|
|
|
|
if ts, ok := seen[p.ID]; ok && !ts.IsZero() {
|
|
|
|
|
partner["lastSeen"] = ts.UTC()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
conv := conversation{
|
2026-10-10 16:42:35 +03:30
|
|
|
Partner: partner,
|
|
|
|
|
UnreadCount: unread,
|
|
|
|
|
MessageCount: len(msgs),
|
|
|
|
|
PinnedRank: pref.PinnedRank,
|
|
|
|
|
Muted: pref.Muted,
|
|
|
|
|
Archived: pref.Archived,
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
if last != nil {
|
|
|
|
|
conv.LastMessage = last
|
|
|
|
|
}
|
|
|
|
|
out = append(out, conv)
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
if !showArchived {
|
|
|
|
|
hasSelf := false
|
|
|
|
|
for _, c := range out {
|
|
|
|
|
if id, _ := c.Partner["id"].(int64); id == u.ID {
|
|
|
|
|
hasSelf = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !hasSelf {
|
|
|
|
|
out = append(out, conversation{
|
|
|
|
|
Partner: map[string]any{
|
|
|
|
|
"id": u.ID, "name": "پیامهای ذخیرهشده", "email": u.Email, "role": u.Role,
|
|
|
|
|
"avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
|
|
|
|
|
"online": false, "saved": true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
if out == nil {
|
|
|
|
|
out = []conversation{}
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
|
|
|
pi, pj := out[i].PinnedRank, out[j].PinnedRank
|
|
|
|
|
if (pi != nil) != (pj != nil) {
|
|
|
|
|
return pi != nil
|
|
|
|
|
}
|
|
|
|
|
if pi != nil && pj != nil && *pi != *pj {
|
|
|
|
|
return *pi < *pj
|
|
|
|
|
}
|
|
|
|
|
ti := time.Time{}
|
|
|
|
|
tj := time.Time{}
|
|
|
|
|
if out[i].LastMessage != nil {
|
|
|
|
|
ti = out[i].LastMessage.CreatedAt
|
|
|
|
|
}
|
|
|
|
|
if out[j].LastMessage != nil {
|
|
|
|
|
tj = out[j].LastMessage.CreatedAt
|
|
|
|
|
}
|
|
|
|
|
return ti.After(tj)
|
|
|
|
|
})
|
|
|
|
|
total := len(out)
|
|
|
|
|
slice := out
|
|
|
|
|
page := pageParams{Page: 1, PageSize: total}
|
|
|
|
|
if r.URL.Query().Get("page") != "" || r.URL.Query().Get("pageSize") != "" {
|
|
|
|
|
page = parsePageParams(r)
|
|
|
|
|
start := page.Offset
|
|
|
|
|
if start > total {
|
|
|
|
|
start = total
|
|
|
|
|
}
|
|
|
|
|
end := start + page.PageSize
|
|
|
|
|
if end > total {
|
|
|
|
|
end = total
|
|
|
|
|
}
|
|
|
|
|
slice = out[start:end]
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"conversations": slice,
|
|
|
|
|
"total": total,
|
|
|
|
|
"page": page.Page,
|
|
|
|
|
"pageSize": page.PageSize,
|
|
|
|
|
"mutedAll": s.store.ChatMutedAll(r.Context(), u.ID),
|
|
|
|
|
})
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
partner := routeID(r, "partner")
|
|
|
|
|
if !s.canChatWith(r.Context(), u, partner) {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-09 19:05:59 +03:30
|
|
|
_ = s.markIncomingChatRead(r.Context(), u, partner)
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
|
|
|
if raw := r.URL.Query().Get("around"); raw != "" {
|
|
|
|
|
aroundID, err := strconv.ParseInt(raw, 10, 64)
|
|
|
|
|
if err != nil || aroundID <= 0 {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "شناسهٔ پیام نامعتبر است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ok, err := s.store.ChatMessageInPair(r.Context(), u.ID, partner, aroundID)
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const pageSize = 60
|
|
|
|
|
msgs, hasMoreBefore, hasMoreAfter, err := s.store.ListChatMessagesAround(r.Context(), u.ID, partner, aroundID, pageSize)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری پیامها ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
|
|
|
|
|
s.resignChatMessages(msgs)
|
|
|
|
|
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
|
|
|
|
|
if pinned == nil {
|
|
|
|
|
pinned = []models.ChatMessage{}
|
|
|
|
|
}
|
|
|
|
|
_ = s.store.FillReactions(r.Context(), u.ID, pinned)
|
|
|
|
|
s.resignChatMessages(pinned)
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"messages": msgs,
|
|
|
|
|
"hasMore": hasMoreBefore,
|
|
|
|
|
"hasMoreBefore": hasMoreBefore,
|
|
|
|
|
"hasMoreAfter": hasMoreAfter,
|
|
|
|
|
"around": aroundID,
|
|
|
|
|
"pinned": pinned,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
before := int64(0)
|
|
|
|
|
if raw := r.URL.Query().Get("before"); raw != "" {
|
|
|
|
|
v, err := strconv.ParseInt(raw, 10, 64)
|
|
|
|
|
if err == nil && v > 0 {
|
|
|
|
|
before = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const pageSize = 60
|
|
|
|
|
msgs, err := s.store.ListChatMessages(r.Context(), u.ID, partner, before, pageSize+1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری پیامها ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hasMore := int64(len(msgs)) > pageSize
|
|
|
|
|
nextBefore := int64(0)
|
|
|
|
|
if hasMore {
|
|
|
|
|
msgs = msgs[1:]
|
|
|
|
|
if len(msgs) > 0 {
|
|
|
|
|
nextBefore = msgs[0].ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
|
|
|
|
|
s.resignChatMessages(msgs)
|
|
|
|
|
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
|
|
|
|
|
if pinned == nil {
|
|
|
|
|
pinned = []models.ChatMessage{}
|
|
|
|
|
}
|
|
|
|
|
_ = s.store.FillReactions(r.Context(), u.ID, pinned)
|
|
|
|
|
s.resignChatMessages(pinned)
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"messages": msgs, "hasMore": hasMore, "nextBefore": nextBefore, "pinned": pinned,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
partnerID := routeID(r, "partner")
|
|
|
|
|
if !s.canChatWith(r.Context(), u, partnerID) {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var req struct {
|
2026-10-10 16:42:35 +03:30
|
|
|
Body string `json:"body"`
|
|
|
|
|
ReplyTo *int64 `json:"replyTo"`
|
|
|
|
|
Attachment *models.Attachment `json:"attachment,omitempty"`
|
|
|
|
|
Attachments []models.Attachment `json:"attachments,omitempty"`
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
if err := bodyJSON(r, &req); err != nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.Body = strings.TrimSpace(req.Body)
|
2026-10-10 16:42:35 +03:30
|
|
|
atts, attErr := prepareChatAttachments(u.ID, req.Attachment, req.Attachments)
|
|
|
|
|
if attErr != "" {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, attErr)
|
|
|
|
|
return
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
if req.Body == "" && len(atts) == 0 {
|
2026-10-07 19:40:26 +03:30
|
|
|
writeErr(w, http.StatusBadRequest, "پیام باید شامل متن یا فایل باشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if len(req.Body) > 4000 {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
partner, err := s.store.GetUserByID(r.Context(), partnerID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusNotFound, "طرف گفتگو پیدا نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
studentID := u.ID
|
|
|
|
|
mentorID := partner.ID
|
|
|
|
|
if u.Role == models.RoleMentor || u.Role == models.RoleAdmin {
|
|
|
|
|
studentID = partner.ID
|
|
|
|
|
mentorID = u.ID
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 16:42:35 +03:30
|
|
|
msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, atts)
|
2026-10-07 19:40:26 +03:30
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "ارسال پیام ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.resignChatMessage(msg)
|
|
|
|
|
|
|
|
|
|
// live push chat_message to both participants
|
|
|
|
|
s.hub.Push(u.ID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID})
|
|
|
|
|
s.hub.Push(partnerID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID})
|
2026-10-10 16:42:35 +03:30
|
|
|
s.noteBotReply(r, partnerID, req.Body)
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
|
|
|
// notify the recipient
|
|
|
|
|
if u.Role == models.RoleMentor || u.Role == models.RoleAdmin {
|
|
|
|
|
_ = s.notify.Notify(context.Background(), studentID, "mentor", "mentor_reply",
|
|
|
|
|
"پاسخ منتور", u.Name+" برای شما پیام فرستاد.", "/unwrap?tab=chats&with="+itoa(u.ID), nil)
|
|
|
|
|
} else {
|
|
|
|
|
_ = s.notify.Notify(context.Background(), mentorID, "mentor", "student_message",
|
|
|
|
|
"پیام از "+u.Name, "یک هنرجو به کمک شما نیاز دارد.", "/unwrap?tab=chats&with="+itoa(u.ID), nil)
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
partner := routeID(r, "partner")
|
|
|
|
|
if !s.canChatWith(r.Context(), u, partner) {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
msgID := routeID(r, "id")
|
|
|
|
|
var req struct {
|
|
|
|
|
Emoji string `json:"emoji"`
|
|
|
|
|
}
|
|
|
|
|
if err := bodyJSON(r, &req); err != nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.Emoji = strings.TrimSpace(req.Emoji)
|
|
|
|
|
if len(req.Emoji) > 16 {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "شکلک نامعتبر است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ok, err := s.store.MessageInConversation(r.Context(), msgID, u.ID, partner)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری پیام ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !ok {
|
|
|
|
|
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
emoji, reactions, err := s.store.ReactToChatMessage(r.Context(), msgID, u.ID, req.Emoji)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ واکنش ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
event := map[string]any{"type": "chat_reaction", "messageId": msgID, "from": u.ID, "emoji": emoji}
|
|
|
|
|
s.hub.Push(u.ID, event)
|
|
|
|
|
s.hub.Push(partner, event)
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 19:05:59 +03:30
|
|
|
func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
partner := routeID(r, "partner")
|
|
|
|
|
if !s.canChatWith(r.Context(), u, partner) {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
msgID := routeID(r, "id")
|
|
|
|
|
var req struct {
|
2026-10-10 16:42:35 +03:30
|
|
|
Body string `json:"body"`
|
|
|
|
|
Attachment *models.Attachment `json:"attachment,omitempty"`
|
|
|
|
|
AddAttachments []models.Attachment `json:"addAttachments,omitempty"`
|
|
|
|
|
RemoveIndexes []int `json:"removeIndexes,omitempty"`
|
|
|
|
|
Replacements []struct {
|
|
|
|
|
Index int `json:"index"`
|
|
|
|
|
Attachment models.Attachment `json:"attachment"`
|
|
|
|
|
} `json:"replacements,omitempty"`
|
2026-10-09 19:05:59 +03:30
|
|
|
}
|
|
|
|
|
if err := bodyJSON(r, &req); err != nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.Body = strings.TrimSpace(req.Body)
|
|
|
|
|
if len(req.Body) > 4000 {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 16:42:35 +03:30
|
|
|
current, err := s.store.GetOwnChatMessage(r.Context(), u.ID, partner, msgID, u.Role)
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
existing := storedAttachments(current)
|
|
|
|
|
drop := map[int]struct{}{}
|
|
|
|
|
for _, index := range req.RemoveIndexes {
|
|
|
|
|
if index < 0 || index >= len(existing) {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "پیوست انتخابشده پیدا نشد")
|
2026-10-09 19:05:59 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
drop[index] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
for _, rep := range req.Replacements {
|
|
|
|
|
if _, removed := drop[rep.Index]; removed {
|
|
|
|
|
continue
|
2026-10-09 19:05:59 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
if rep.Index < 0 || rep.Index >= len(existing) {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "پیوست انتخابشده پیدا نشد")
|
|
|
|
|
return
|
2026-10-09 19:05:59 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
prepared, attErr := prepareChatAttachments(u.ID, &rep.Attachment, nil)
|
|
|
|
|
if attErr != "" {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, attErr)
|
|
|
|
|
return
|
2026-10-09 19:05:59 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
if prepared[0].Type != existing[rep.Index].Type {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "فایل جدید باید از همان نوع باشد")
|
2026-10-09 19:05:59 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
existing[rep.Index] = prepared[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toAdd := req.AddAttachments
|
|
|
|
|
if req.Attachment != nil {
|
|
|
|
|
toAdd = append(toAdd, *req.Attachment)
|
|
|
|
|
}
|
|
|
|
|
added, attErr := prepareChatAttachments(u.ID, nil, toAdd)
|
|
|
|
|
if attErr != "" {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, attErr)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final := make([]models.Attachment, 0, len(existing)+len(added))
|
|
|
|
|
for i, att := range existing {
|
|
|
|
|
if _, removed := drop[i]; removed {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
final = append(final, att)
|
|
|
|
|
}
|
|
|
|
|
final = append(final, added...)
|
|
|
|
|
if len(final) > maxChatAttachments {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "در هر پیام حداکثر ۱۰ پیوست میتوان فرستاد")
|
|
|
|
|
return
|
2026-10-09 19:05:59 +03:30
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
if req.Body == "" && len(final) == 0 {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "پیام باید متن یا پیوست داشته باشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg, err := s.store.SaveEditedChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body, final)
|
2026-10-09 19:05:59 +03:30
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.resignChatMessage(msg)
|
|
|
|
|
event := map[string]any{"type": "chat_edit", "item": msg, "from": u.ID}
|
|
|
|
|
s.hub.Push(u.ID, event)
|
|
|
|
|
s.hub.Push(partner, event)
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"message": msg})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
partner := routeID(r, "partner")
|
|
|
|
|
if !s.canChatWith(r.Context(), u, partner) {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
msgID := routeID(r, "id")
|
|
|
|
|
ok, err := s.store.DeleteChatMessage(r.Context(), u.ID, partner, msgID, u.Role)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "حذف پیام ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !ok {
|
|
|
|
|
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.hub.Push(u.ID, map[string]any{"type": "chat_delete", "id": msgID, "from": u.ID})
|
|
|
|
|
s.hub.Push(partner, map[string]any{"type": "chat_delete", "id": msgID, "from": u.ID})
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChatPin(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
partner := routeID(r, "partner")
|
|
|
|
|
if !s.canChatWith(r.Context(), u, partner) {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
msgID := routeID(r, "id")
|
|
|
|
|
msg, err := s.store.TogglePinChatMessage(r.Context(), u.ID, partner, msgID)
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "پین پیام ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.resignChatMessage(msg)
|
|
|
|
|
event := map[string]any{"type": "chat_pin", "from": u.ID, "message": msg}
|
|
|
|
|
s.hub.Push(u.ID, event)
|
|
|
|
|
s.hub.Push(partner, event)
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": msg})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChatUpload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+1<<20)
|
|
|
|
|
if err := r.ParseMultipartForm(maxUploadSize + 1<<20); err != nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "حجم فایل باید کمتر از ۲۵ مگابایت باشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
file, h, err := r.FormFile("file")
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "فایلی ارسال نشده است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer file.Close()
|
|
|
|
|
if h.Size == 0 {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "فایل خالی است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if h.Size > maxUploadSize {
|
|
|
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۲۵ مگابایت باشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name := filepath.Base(strings.TrimSpace(h.Filename))
|
|
|
|
|
if name == "" {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, "نام فایل نامعتبر است")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
accepted, err := acceptChatUpload(h, file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
key := fmt.Sprintf("chats/%d-%d%s", u.ID, time.Now().UnixNano(), accepted.Ext)
|
|
|
|
|
rawURL, err := s.storage.Save(key, file, h.Size)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
att := models.Attachment{
|
|
|
|
|
Type: accepted.Kind,
|
|
|
|
|
URL: s.signExistingMediaURL(rawURL), Name: name, Size: h.Size,
|
|
|
|
|
}
|
|
|
|
|
if att.Type == "pdf" || att.Type == "office" || att.Type == "text" {
|
|
|
|
|
att.Type = "file"
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{"attachment": att})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var allowedExts = map[string]bool{
|
|
|
|
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
|
|
|
|
".mp4": true, ".webm": true, ".mov": true, ".m4v": true,
|
|
|
|
|
".mp3": true, ".wav": true, ".m4a": true, ".aac": true, ".ogg": true, ".opus": true, ".weba": true,
|
|
|
|
|
".pdf": true, ".txt": true, ".md": true, ".csv": true, ".json": true,
|
|
|
|
|
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func allowedExtension(ext string) bool {
|
|
|
|
|
return allowedExts[ext]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func attachmentType(mime, ext string) string {
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasPrefix(mime, "image/"):
|
|
|
|
|
return "image"
|
|
|
|
|
case strings.HasPrefix(mime, "video/"):
|
|
|
|
|
return "video"
|
|
|
|
|
case strings.HasPrefix(mime, "audio/"):
|
|
|
|
|
return "audio"
|
|
|
|
|
}
|
|
|
|
|
// Some browsers send a generic MIME (application/octet-stream); fall back to extension.
|
|
|
|
|
switch ext {
|
|
|
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif":
|
|
|
|
|
return "image"
|
|
|
|
|
case ".mp4", ".webm", ".mov", ".m4v":
|
|
|
|
|
return "video"
|
|
|
|
|
case ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".weba":
|
|
|
|
|
return "audio"
|
|
|
|
|
}
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u := currentUser(r)
|
|
|
|
|
partner := routeID(r, "partner")
|
|
|
|
|
if !s.canChatWith(r.Context(), u, partner) {
|
|
|
|
|
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-09 19:05:59 +03:30
|
|
|
if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil {
|
2026-10-07 19:40:26 +03:30
|
|
|
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت خواندهشدن ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 19:05:59 +03:30
|
|
|
func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User, partnerID int64) error {
|
|
|
|
|
if reader == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
n, err := s.store.MarkChatRead(ctx, reader.ID, partnerID, reader.Role)
|
|
|
|
|
if err != nil || n == 0 {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.hub.Push(partnerID, map[string]any{"type": "chat_read", "from": reader.ID})
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
|
2026-10-10 16:42:35 +03:30
|
|
|
if actor == nil || partnerID <= 0 {
|
2026-10-07 19:40:26 +03:30
|
|
|
return false
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
if actor.ID == partnerID {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
partner, err := s.store.GetUserByID(ctx, partnerID)
|
|
|
|
|
if err != nil || !partner.IsActive {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if actor.Role == models.RoleStudent {
|
|
|
|
|
return partner.Role == models.RoleMentor || partner.Role == models.RoleAdmin
|
|
|
|
|
}
|
|
|
|
|
return actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) resignChatMessages(msgs []models.ChatMessage) {
|
|
|
|
|
for i := range msgs {
|
|
|
|
|
s.resignChatMessage(&msgs[i])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) resignChatMessage(msg *models.ChatMessage) {
|
2026-10-10 16:42:35 +03:30
|
|
|
if msg == nil {
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-10 16:42:35 +03:30
|
|
|
for i := range msg.Attachments {
|
|
|
|
|
if msg.Attachments[i].URL != "" {
|
|
|
|
|
msg.Attachments[i].URL = s.signExistingMediaURL(msg.Attachments[i].URL)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(msg.Attachments) > 0 {
|
|
|
|
|
first := msg.Attachments[0]
|
|
|
|
|
msg.Attachment = &first
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if msg.Attachment != nil && msg.Attachment.URL != "" {
|
|
|
|
|
msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func storedAttachments(m *models.ChatMessage) []models.Attachment {
|
|
|
|
|
if m == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if len(m.Attachments) > 0 {
|
|
|
|
|
out := make([]models.Attachment, len(m.Attachments))
|
|
|
|
|
copy(out, m.Attachments)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
if m.Attachment != nil && m.Attachment.URL != "" {
|
|
|
|
|
return []models.Attachment{*m.Attachment}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const maxChatAttachments = 10
|
|
|
|
|
|
|
|
|
|
func prepareChatAttachments(ownerID int64, single *models.Attachment, many []models.Attachment) ([]models.Attachment, string) {
|
|
|
|
|
atts := many
|
|
|
|
|
if len(atts) == 0 && single != nil {
|
|
|
|
|
atts = []models.Attachment{*single}
|
|
|
|
|
}
|
|
|
|
|
if len(atts) > maxChatAttachments {
|
|
|
|
|
return nil, "در هر پیام حداکثر ۱۰ پیوست میتوان فرستاد"
|
|
|
|
|
}
|
|
|
|
|
out := make([]models.Attachment, 0, len(atts))
|
|
|
|
|
for i := range atts {
|
|
|
|
|
att := atts[i]
|
|
|
|
|
if att.Size > maxUploadSize {
|
|
|
|
|
return nil, "حجم پیوست بیش از حد مجاز است"
|
|
|
|
|
}
|
|
|
|
|
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
|
|
|
|
|
if att.Type != "image" && att.Type != "video" && att.Type != "audio" && att.Type != "file" {
|
|
|
|
|
return nil, "نوع پیوست پشتیبانی نمیشود"
|
|
|
|
|
}
|
|
|
|
|
if att.URL == "" || !strings.Contains(att.URL, "/media/") {
|
|
|
|
|
return nil, "نشانی پیوست نامعتبر است"
|
|
|
|
|
}
|
|
|
|
|
key := mediaKeyFromURL(att.URL)
|
|
|
|
|
if !mediaKeyOwnedByUser(key, ownerID) {
|
|
|
|
|
return nil, "پیوست متعلق به شما نیست"
|
|
|
|
|
}
|
|
|
|
|
if q := strings.Index(att.URL, "?"); q >= 0 {
|
|
|
|
|
att.URL = att.URL[:q]
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(att.Name) == "" {
|
|
|
|
|
att.Name = "file"
|
|
|
|
|
}
|
|
|
|
|
out = append(out, att)
|
|
|
|
|
}
|
|
|
|
|
return out, ""
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func itoa(v int64) string {
|
|
|
|
|
return strconvFormatInt(v)
|
|
|
|
|
}
|