- Raise Traefik read timeout to 1h (compose dev/prod + k3s HelmChartConfig) so admin video uploads up to 200MB no longer 502 mid-upload. - Migration 0029: cascade watch/quiz history on lesson delete and unlink dependent lessons, so removing a lesson or chapter stops 500ing. - Add the Apollo cluster Helm overlay (values-apollo.yaml) for the deployed environment. - k3s Traefik chart config also enables access logs, cross-namespace IngressRoutes, and fixes the Prometheus scrape port. Bump appVersion/product.go/values tags to 0.2.1; chart version 0.2.3.
802 lines
26 KiB
Go
802 lines
26 KiB
Go
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.1"
|
|
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.1+" + 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
|