feat: release v0.2.0 profiles, chat, freeze, and bots
Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"pargar/backend/internal/models"
|
||||
"pargar/backend/internal/store"
|
||||
)
|
||||
|
||||
var announcementCategories = map[string]bool{
|
||||
"event": true, "progress": true, "gamification": true, "mentor": true,
|
||||
"urgent": true, "curriculum": true, "community": true, "system": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||
s.createAnnouncement(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListAnnouncements(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.store.ListAnnouncements(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری اطلاعیهها ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"announcements": announceViews(list)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
a, ok := s.readAnnouncement(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := s.store.UpdateAnnouncement(r.Context(), a); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, "ویرایش اطلاعیه ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if err := s.store.DeleteAnnouncement(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, "حذف اطلاعیه ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handlePinnedAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.store.RecentAnnouncements(r.Context(), 8)
|
||||
var pinned any
|
||||
if err == nil {
|
||||
for _, a := range list {
|
||||
if a.Pinned {
|
||||
pinned = announceViews([]store.Announcement{a})[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if pinned == nil {
|
||||
if a, perr := s.store.PinnedAnnouncement(r.Context()); perr == nil {
|
||||
pinned = announceViews([]store.Announcement{*a})[0]
|
||||
}
|
||||
}
|
||||
views := []map[string]any{}
|
||||
if err == nil {
|
||||
views = announceViews(list)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"announcement": pinned, "announcements": views})
|
||||
}
|
||||
|
||||
func (s *Server) createAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.readAnnouncement(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.CreatedBy = currentUser(r).ID
|
||||
if err := s.store.CreateAnnouncement(r.Context(), &a); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ثبت اطلاعیه ممکن نشد")
|
||||
return
|
||||
}
|
||||
ids, err := s.store.AllActiveUserIDs(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, uid := range ids {
|
||||
n := &models.Notification{
|
||||
UserID: uid, Category: a.Category, Type: "announcement",
|
||||
Title: a.Title, Body: a.Body, Route: a.Route,
|
||||
}
|
||||
if err := s.store.CreateNotification(context.Background(), n); err == nil {
|
||||
_ = s.store.AttachAnnouncement(context.Background(), n.ID, a.ID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": a.ID, "recipients": len(ids)})
|
||||
}
|
||||
|
||||
func (s *Server) readAnnouncement(w http.ResponseWriter, r *http.Request) (store.Announcement, bool) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Route string `json:"route"`
|
||||
Category string `json:"category"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return store.Announcement{}, false
|
||||
}
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
req.Body = strings.TrimSpace(req.Body)
|
||||
req.Route = strings.TrimSpace(req.Route)
|
||||
if req.Category == "" {
|
||||
req.Category = "event"
|
||||
}
|
||||
if !announcementCategories[req.Category] {
|
||||
writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است")
|
||||
return store.Announcement{}, false
|
||||
}
|
||||
if utf8.RuneCountInString(req.Title) < 3 || utf8.RuneCountInString(req.Title) > 200 {
|
||||
writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه نامعتبر است")
|
||||
return store.Announcement{}, false
|
||||
}
|
||||
if utf8.RuneCountInString(req.Body) < 8 || utf8.RuneCountInString(req.Body) > 4000 {
|
||||
writeErr(w, http.StatusBadRequest, "متن اطلاعیه نامعتبر است")
|
||||
return store.Announcement{}, false
|
||||
}
|
||||
if req.Route != "" && !strings.HasPrefix(req.Route, "/") {
|
||||
writeErr(w, http.StatusBadRequest, "مسیر باید با / شروع شود")
|
||||
return store.Announcement{}, false
|
||||
}
|
||||
return store.Announcement{
|
||||
Title: req.Title, Body: req.Body, Category: req.Category, Route: req.Route, Pinned: req.Pinned,
|
||||
}, true
|
||||
}
|
||||
|
||||
func announceViews(list []store.Announcement) []map[string]any {
|
||||
if list == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for _, a := range list {
|
||||
out = append(out, map[string]any{
|
||||
"id": a.ID, "title": a.Title, "body": a.Body, "category": a.Category,
|
||||
"route": a.Route, "pinned": a.Pinned, "createdAt": a.CreatedAt, "updatedAt": a.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleChatPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
me := currentUser(r)
|
||||
partner := routeID(r, "partner")
|
||||
if !s.canChatWith(r.Context(), me, partner) {
|
||||
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PinnedRank *int `json:"pinnedRank"`
|
||||
Muted bool `json:"muted"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpsertChatPref(r.Context(), me.ID, partner, req.PinnedRank, req.Muted, req.Archived); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ذخیره تنظیم گفتگو ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatMuteAll(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetChatMutedAll(r.Context(), currentUser(r).ID, req.Muted); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "muted": req.Muted})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatPeek(w http.ResponseWriter, r *http.Request) {
|
||||
me := currentUser(r)
|
||||
partner := routeID(r, "partner")
|
||||
if !s.canChatWith(r.Context(), me, partner) {
|
||||
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
||||
return
|
||||
}
|
||||
msgs, err := s.store.ListChatMessages(r.Context(), me.ID, partner, 0, 8)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "پیشنمایش ممکن نشد")
|
||||
return
|
||||
}
|
||||
if len(msgs) > 8 {
|
||||
msgs = msgs[len(msgs)-8:]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) {
|
||||
me := currentUser(r)
|
||||
partner := routeID(r, "partner")
|
||||
if err := s.store.MarkChatUnread(r.Context(), me.ID, partner, me.Role.String()); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بازگشت به نخوانده ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatDeleteBoth(w http.ResponseWriter, r *http.Request) {
|
||||
me := currentUser(r)
|
||||
partner := routeID(r, "partner")
|
||||
if r.URL.Query().Get("confirm") != "both" {
|
||||
writeErr(w, http.StatusBadRequest, "برای پاک کردن دو طرفه confirm=both لازم است")
|
||||
return
|
||||
}
|
||||
if !s.canChatWith(r.Context(), me, partner) && me.Role != models.RoleAdmin {
|
||||
writeErr(w, http.StatusForbidden, "اجازه ندارید")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteChatBoth(r.Context(), me.ID, partner); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "حذف گفتگو ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatBroadcast(w http.ResponseWriter, r *http.Request) {
|
||||
me := currentUser(r)
|
||||
if !me.Role.IsStaff() {
|
||||
writeErr(w, http.StatusForbidden, "فقط مسئول دوره میتواند پیام همگانی بفرستد")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
UserIDs []int64 `json:"userIds"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil || strings.TrimSpace(req.Body) == "" {
|
||||
writeErr(w, http.StatusBadRequest, "متن پیام لازم است")
|
||||
return
|
||||
}
|
||||
ids := req.UserIDs
|
||||
if req.All {
|
||||
var err error
|
||||
ids, err = s.store.AllActiveUserIDs(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
|
||||
return
|
||||
}
|
||||
}
|
||||
sent := 0
|
||||
for _, id := range ids {
|
||||
if id == me.ID || !s.canChatWith(r.Context(), me, id) {
|
||||
continue
|
||||
}
|
||||
studentID, mentorID := id, me.ID
|
||||
if me.Role == models.RoleStudent {
|
||||
studentID, mentorID = me.ID, id
|
||||
}
|
||||
if _, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, me.Role, strings.TrimSpace(req.Body), nil, nil); err == nil {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sent": sent})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
AttachmentURL string `json:"attachmentUrl"`
|
||||
AttachmentType string `json:"attachmentType"`
|
||||
AttachmentName string `json:"attachmentName"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Body) == "" && req.AttachmentURL == "" {
|
||||
writeErr(w, http.StatusBadRequest, "پیامی برای ذخیره نیست")
|
||||
return
|
||||
}
|
||||
me := currentUser(r)
|
||||
var att *models.Attachment
|
||||
if req.AttachmentURL != "" {
|
||||
url := req.AttachmentURL
|
||||
if q := strings.Index(url, "?"); q >= 0 {
|
||||
url = url[:q]
|
||||
}
|
||||
att = &models.Attachment{Type: req.AttachmentType, URL: url, Name: req.AttachmentName}
|
||||
}
|
||||
var saved []models.Attachment
|
||||
if att != nil {
|
||||
saved = []models.Attachment{*att}
|
||||
}
|
||||
if _, err := s.store.AddChatMessage(r.Context(), me.ID, me.ID, me.Role, strings.TrimSpace(req.Body), nil, saved); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleListSaved(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.store.ListSaved(r.Context(), currentUser(r).ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": list})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMeProfile(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(p)})
|
||||
}
|
||||
|
||||
func (s *Server) handlePutMeProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Banner *string `json:"banner"`
|
||||
Public *bool `json:"public"`
|
||||
ShowActivity *bool `json:"showActivity"`
|
||||
ShowStats *bool `json:"showStats"`
|
||||
ShowPath *bool `json:"showPath"`
|
||||
ShowBadges *bool `json:"showBadges"`
|
||||
SocialLinks json.RawMessage `json:"socialLinks"`
|
||||
Theme *string `json:"theme"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
cur, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
|
||||
return
|
||||
}
|
||||
if req.Banner != nil {
|
||||
cur.Banner = strings.TrimSpace(*req.Banner)
|
||||
}
|
||||
if req.Public != nil {
|
||||
cur.Public = *req.Public
|
||||
}
|
||||
if req.ShowActivity != nil {
|
||||
cur.ShowActivity = *req.ShowActivity
|
||||
}
|
||||
if req.ShowStats != nil {
|
||||
cur.ShowStats = *req.ShowStats
|
||||
}
|
||||
if req.ShowPath != nil {
|
||||
cur.ShowPath = *req.ShowPath
|
||||
}
|
||||
if req.ShowBadges != nil {
|
||||
cur.ShowBadges = *req.ShowBadges
|
||||
}
|
||||
if req.Theme != nil {
|
||||
cur.Theme = strings.TrimSpace(*req.Theme)
|
||||
}
|
||||
if len(req.SocialLinks) > 0 {
|
||||
var links []map[string]string
|
||||
if err := json.Unmarshal(req.SocialLinks, &links); err != nil || len(links) > 5 {
|
||||
writeErr(w, http.StatusBadRequest, "حداکثر ۵ لینک شخصی مجاز است")
|
||||
return
|
||||
}
|
||||
cur.SocialLinks = req.SocialLinks
|
||||
}
|
||||
if i := strings.Index(cur.Banner, "?"); i >= 0 {
|
||||
cur.Banner = cur.Banner[:i]
|
||||
}
|
||||
if err := s.store.SaveProfileExtras(r.Context(), currentUser(r).ID, cur); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ذخیره پروفایل ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(cur)})
|
||||
}
|
||||
|
||||
func (s *Server) profileView(p store.ProfileExtras) map[string]any {
|
||||
links := json.RawMessage(p.SocialLinks)
|
||||
if len(links) == 0 {
|
||||
links = json.RawMessage(`[]`)
|
||||
}
|
||||
banner := p.Banner
|
||||
if i := strings.Index(banner, "?"); i >= 0 {
|
||||
banner = banner[:i]
|
||||
}
|
||||
return map[string]any{
|
||||
"banner": s.signExistingMediaURL(banner), "public": p.Public, "showActivity": p.ShowActivity,
|
||||
"showStats": p.ShowStats, "showPath": p.ShowPath, "showBadges": p.ShowBadges,
|
||||
"socialLinks": json.RawMessage(links), "theme": p.Theme,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMeAchievements(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"achievements": s.achievementsFor(r.Context(), u)})
|
||||
}
|
||||
|
||||
func (s *Server) achievementsFor(ctx context.Context, u *models.User) []map[string]any {
|
||||
passed := 0
|
||||
_ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, u.ID).Scan(&passed)
|
||||
certs := 0
|
||||
_ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM certificates WHERE user_id=$1 AND revoked_at IS NULL`, u.ID).Scan(&certs)
|
||||
items := []struct {
|
||||
id, title string
|
||||
on bool
|
||||
}{
|
||||
{"first_pass", "اولین قبولی", passed > 0},
|
||||
{"chapter_done", "اتمام فصل", s.finishedChapter(ctx, u.ID)},
|
||||
{"streak_7", "استمرار هفتروزه", u.StreakCurrent >= 7 || u.StreakLongest >= 7},
|
||||
{"course_done", "پایان دوره", certs > 0},
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, map[string]any{"id": it.id, "title": it.title, "unlocked": it.on})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) finishedChapter(ctx context.Context, userID int64) bool {
|
||||
var n int
|
||||
err := s.store.Pool().QueryRow(ctx, `
|
||||
SELECT count(*) FROM chapters c
|
||||
WHERE EXISTS (SELECT 1 FROM lessons l WHERE l.chapter_id=c.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lessons l
|
||||
LEFT JOIN lesson_progress p ON p.lesson_id=l.id AND p.user_id=$1
|
||||
WHERE l.chapter_id=c.id AND COALESCE(p.passed_quiz, false)=false
|
||||
)`, userID).Scan(&n)
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
func (s *Server) handleMeActivity(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.store.Pool().Query(r.Context(), `
|
||||
SELECT day::text, count(*) FROM (
|
||||
SELECT created_at::date AS day FROM quiz_attempts WHERE user_id=$1
|
||||
UNION ALL
|
||||
SELECT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1
|
||||
) d
|
||||
WHERE day >= current_date - 120
|
||||
GROUP BY day ORDER BY day`, currentUser(r).ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری فعالیت ممکن نشد")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var days []map[string]any
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var n int
|
||||
if err := rows.Scan(&day, &n); err != nil {
|
||||
continue
|
||||
}
|
||||
days = append(days, map[string]any{"day": day, "count": n})
|
||||
}
|
||||
if days == nil {
|
||||
days = []map[string]any{}
|
||||
}
|
||||
week, month := 0, 0
|
||||
_ = s.store.Pool().QueryRow(r.Context(), `
|
||||
SELECT
|
||||
count(*) FILTER (WHERE day >= current_date - 6),
|
||||
count(*) FILTER (WHERE day >= current_date - 29)
|
||||
FROM (
|
||||
SELECT DISTINCT created_at::date AS day FROM quiz_attempts WHERE user_id=$1
|
||||
UNION
|
||||
SELECT DISTINCT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1
|
||||
) d`, currentUser(r).ID).Scan(&week, &month)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": days, "weekDays": week, "monthDays": month})
|
||||
}
|
||||
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if utf8.RuneCountInString(q) < 2 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||
return
|
||||
}
|
||||
me := currentUser(r)
|
||||
like := "%" + q + "%"
|
||||
var results []map[string]any
|
||||
rows, err := s.store.Pool().Query(r.Context(), `
|
||||
SELECT id, title FROM lessons WHERE title ILIKE $1 ORDER BY id LIMIT 8`, like)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var title string
|
||||
if rows.Scan(&id, &title) == nil {
|
||||
results = append(results, map[string]any{"kind": "lesson", "title": title, "href": "/player/" + strconv.FormatInt(id, 10)})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
rows, err = s.store.Pool().Query(r.Context(), `
|
||||
SELECT id, title FROM events WHERE title ILIKE $1 ORDER BY starts_at DESC LIMIT 8`, like)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var title string
|
||||
if rows.Scan(&id, &title) == nil {
|
||||
results = append(results, map[string]any{"kind": "event", "title": title, "href": "/unwrap"})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
if me.Role.IsStaff() {
|
||||
people, _, _ := s.store.ListUsers(r.Context(), q, "", 8, 0)
|
||||
for _, p := range people {
|
||||
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
|
||||
}
|
||||
} else {
|
||||
people, _, _ := s.store.ListActiveByRoles(r.Context(), []string{"mentor", "admin"}, 40, 0)
|
||||
n := 0
|
||||
for _, p := range people {
|
||||
if n >= 8 || !strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
|
||||
n++
|
||||
}
|
||||
}
|
||||
pages := []struct{ title, href string }{
|
||||
{"درخت مهارت", "/cap"}, {"اعلانها", "/notifications"}, {"لیگ", "/leaderboard"},
|
||||
{"تنظیمات", "/settings"}, {"پروفایل", "/profile"},
|
||||
}
|
||||
for _, p := range pages {
|
||||
if strings.Contains(p.title, q) {
|
||||
results = append(results, map[string]any{"kind": "page", "title": p.title, "href": p.href})
|
||||
}
|
||||
}
|
||||
if results == nil {
|
||||
results = []map[string]any{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||
env := "production"
|
||||
if s.cfg.DevMode {
|
||||
env = "development"
|
||||
}
|
||||
version, built := appBuild()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"version": version,
|
||||
"built": built,
|
||||
"env": env,
|
||||
"health": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func appBuild() (version, built string) {
|
||||
version = "0.2.0"
|
||||
built = "dev"
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return version, built
|
||||
}
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Key == "vcs.time" && len(setting.Value) >= 10 {
|
||||
built = setting.Value[:10]
|
||||
}
|
||||
if setting.Key == "vcs.revision" && len(setting.Value) >= 7 {
|
||||
version = "0.2.0+" + setting.Value[:7]
|
||||
}
|
||||
}
|
||||
return version, built
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListBots(w http.ResponseWriter, r *http.Request) {
|
||||
p := parsePageParams(r)
|
||||
p.PageSize = 8
|
||||
p.Offset = (p.Page - 1) * p.PageSize
|
||||
|
||||
var total int64
|
||||
if err := s.store.Pool().QueryRow(r.Context(), `SELECT count(*) FROM bot_tokens`).Scan(&total); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد")
|
||||
return
|
||||
}
|
||||
rows, err := s.store.Pool().Query(r.Context(), `
|
||||
SELECT bt.id, bt.bot_user_id, COALESCE(u.name, ''), bt.created_at, bt.revoked_at IS NULL, bt.request_count
|
||||
FROM bot_tokens bt
|
||||
JOIN users u ON u.id = bt.bot_user_id
|
||||
ORDER BY bt.id DESC
|
||||
LIMIT $1 OFFSET $2`, p.PageSize, p.Offset)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, botID, requestCount int64
|
||||
var name string
|
||||
var createdAt time.Time
|
||||
var active bool
|
||||
if rows.Scan(&id, &botID, &name, &createdAt, &active, &requestCount) != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id,
|
||||
"botId": botID,
|
||||
"name": name,
|
||||
"createdAt": createdAt,
|
||||
"active": active,
|
||||
"requestCount": requestCount,
|
||||
})
|
||||
}
|
||||
writePage(w, items, total, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSetBotActive(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if id == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
var updated int64
|
||||
if req.Active {
|
||||
tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=NULL WHERE id=$1`, id)
|
||||
err = execErr
|
||||
updated = tag.RowsAffected()
|
||||
} else {
|
||||
tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=COALESCE(revoked_at, now()) WHERE id=$1`, id)
|
||||
err = execErr
|
||||
updated = tag.RowsAffected()
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی ممکن نشد")
|
||||
return
|
||||
}
|
||||
if updated == 0 {
|
||||
writeErr(w, http.StatusNotFound, "کلید پیدا نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "active": req.Active})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
|
||||
me := currentUser(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = bodyJSON(r, &req)
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "ربات پرگار"
|
||||
}
|
||||
raw := make([]byte, 24)
|
||||
_, _ = rand.Read(raw)
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
email := "bot-" + token[:8] + "@bots.pargar.local"
|
||||
bot, err := s.store.CreateUser(r.Context(), name, email, "bot"+token[:6], hash, "", "", "", models.RoleMentor)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ساخت ربات ممکن نشد")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.Pool().Exec(r.Context(), `
|
||||
INSERT INTO bot_tokens (bot_user_id, owner_id, token_hash) VALUES ($1,$2,$3)`, bot.ID, me.ID, hash); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ثبت توکن ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"botId": bot.ID, "token": token})
|
||||
}
|
||||
|
||||
func (s *Server) botFromRequest(r *http.Request) (int64, int64, bool) {
|
||||
token := strings.TrimSpace(r.Header.Get("X-Bot-Token"))
|
||||
if token == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
var botID, ownerID int64
|
||||
var active bool
|
||||
err := s.store.Pool().QueryRow(r.Context(), `
|
||||
SELECT bot_user_id, owner_id, revoked_at IS NULL FROM bot_tokens WHERE token_hash=$1`, hash).Scan(&botID, &ownerID, &active)
|
||||
if err != nil || !active {
|
||||
return 0, 0, false
|
||||
}
|
||||
_, _ = s.store.Pool().Exec(r.Context(), `
|
||||
UPDATE bot_tokens SET request_count = request_count + 1 WHERE token_hash=$1 AND revoked_at IS NULL`, hash)
|
||||
return botID, ownerID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleBotSend(w http.ResponseWriter, r *http.Request) {
|
||||
botID, _, ok := s.botFromRequest(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Text string `json:"text"`
|
||||
Buttons []any `json:"buttons"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil || req.UserID == 0 || strings.TrimSpace(req.Text) == "" {
|
||||
writeErr(w, http.StatusBadRequest, "userId و text لازم است")
|
||||
return
|
||||
}
|
||||
buttons, _ := json.Marshal(req.Buttons)
|
||||
var id int64
|
||||
err := s.store.Pool().QueryRow(r.Context(), `
|
||||
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, buttons)
|
||||
VALUES ($1,$2,'mentor',$3,$4) RETURNING id`,
|
||||
req.UserID, botID, strings.TrimSpace(req.Text), buttons).Scan(&id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ارسال ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messageId": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleBotUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
botID, _, ok := s.botFromRequest(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است")
|
||||
return
|
||||
}
|
||||
after, _ := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64)
|
||||
rows, err := s.store.Pool().Query(r.Context(), `
|
||||
SELECT id, payload, created_at FROM bot_updates WHERE bot_user_id=$1 AND id>$2 ORDER BY id LIMIT 50`, botID, after)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var updates []map[string]any
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var payload json.RawMessage
|
||||
var at time.Time
|
||||
if rows.Scan(&id, &payload, &at) == nil {
|
||||
updates = append(updates, map[string]any{"id": id, "payload": json.RawMessage(payload), "createdAt": at})
|
||||
}
|
||||
}
|
||||
if updates == nil {
|
||||
updates = []map[string]any{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"updates": updates})
|
||||
}
|
||||
|
||||
func (s *Server) recordBotReply(ctx context.Context, from *models.User, partnerID int64, body string) {
|
||||
var ownerID int64
|
||||
err := s.store.Pool().QueryRow(ctx, `SELECT owner_id FROM bot_tokens WHERE bot_user_id=$1`, partnerID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"from": from.ID, "name": from.Name, "text": body})
|
||||
_, _ = s.store.Pool().Exec(ctx, `INSERT INTO bot_updates (bot_user_id, payload) VALUES ($1,$2)`, partnerID, payload)
|
||||
_ = ownerID
|
||||
}
|
||||
|
||||
func (s *Server) noteBotReply(r *http.Request, partner int64, body string) {
|
||||
s.recordBotReply(r.Context(), currentUser(r), partner, body)
|
||||
}
|
||||
|
||||
// silence unused import if pgx is only used by callers
|
||||
var _ = pgx.ErrNoRows
|
||||
Reference in New Issue
Block a user