feat: release v0.1.3 chat, profiles, and media

Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
This commit is contained in:
ahp
2026-10-09 19:05:59 +03:30
parent 3cce2158e9
commit d9a2abb44f
45 changed files with 2337 additions and 573 deletions
+15 -11
View File
@@ -22,6 +22,20 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"stats": stats}) writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
} }
func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": u})
}
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q") query := r.URL.Query().Get("q")
role := r.URL.Query().Get("role") role := r.URL.Query().Get("role")
@@ -389,27 +403,17 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
return return
} }
target, err := s.store.GetUserByID(r.Context(), id) if _, err := s.store.GetUserByID(r.Context(), id); err != nil {
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return return
} }
var req struct { var req struct {
SecurityAnswer string `json:"securityAnswer"`
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
} }
if err := bodyJSON(r, &req); err != nil { if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return return
} }
if target.SecurityAnswerHash == "" {
writeErr(w, http.StatusBadRequest, "برای این کاربر سوال امنیتی تعریف نشده است")
return
}
if !s.auth.CheckPassword(target.SecurityAnswerHash, strings.ToLower(strings.TrimSpace(req.SecurityAnswer))) {
writeErr(w, http.StatusForbidden, "جواب سوال امنیتی نادرست است")
return
}
if msg := validatePasswordOnly(req.NewPassword); msg != "" { if msg := validatePasswordOnly(req.NewPassword); msg != "" {
writeErr(w, http.StatusBadRequest, msg) writeErr(w, http.StatusBadRequest, msg)
return return
+1
View File
@@ -306,6 +306,7 @@ func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی آواتار ممکن نشد") writeErr(w, http.StatusInternalServerError, "به‌روزرسانی آواتار ممکن نشد")
return return
} }
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user}) writeJSON(w, http.StatusOK, map[string]any{"user": user})
} }
+3 -1
View File
@@ -22,7 +22,7 @@ import (
) )
const ( const (
captchaTTL = 5 * time.Minute captchaTTL = 2 * time.Minute
captchaKeyPrefix = "pargar:captcha:" captchaKeyPrefix = "pargar:captcha:"
captchaRateLimit int64 = 60 captchaRateLimit int64 = 60
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -260,6 +260,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{ resp := map[string]any{
"challengeId": "dev", "challengeId": "dev",
"imageBase64": img, "imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
} }
if s.cfg.CaptchaExposeAnswer { if s.cfg.CaptchaExposeAnswer {
resp["answer"] = "A2B3" resp["answer"] = "A2B3"
@@ -297,6 +298,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{ resp := map[string]any{
"challengeId": id, "challengeId": id,
"imageBase64": img, "imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
} }
// Never expose plaintext answers in production. Local tests may opt in via // Never expose plaintext answers in production. Local tests may opt in via
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true. // CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
+4
View File
@@ -30,6 +30,10 @@ func TestCaptchaImageChallenge(t *testing.T) {
if id == "" || img == "" || answer == "" { if id == "" || img == "" || answer == "" {
t.Fatalf("captcha missing fields: %v", body) t.Fatalf("captcha missing fields: %v", body)
} }
exp, ok := body["expiresIn"].(float64)
if !ok || exp < 60 || exp > 180 {
t.Fatalf("expiresIn should be a short window: %v", body["expiresIn"])
}
if _, ok := body["question"]; ok { if _, ok := body["question"]; ok {
t.Fatalf("legacy question field should be gone: %v", body) t.Fatalf("legacy question field should be gone: %v", body)
} }
+12 -2
View File
@@ -228,12 +228,22 @@ func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status") status := r.URL.Query().Get("status")
orders, err := s.store.ListPhysicalOrders(r.Context(), status) p := parsePageParams(r)
orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد") writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد")
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())}) if orders == nil {
orders = []models.CertificatePhysicalOrder{}
}
writeJSON(w, http.StatusOK, map[string]any{
"orders": orders,
"total": total,
"page": p.Page,
"pageSize": p.PageSize,
"physical": s.physicalSettings(r.Context()),
})
} }
func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) {
+89 -4
View File
@@ -37,6 +37,7 @@ func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) {
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin { if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
continue continue
} }
s.signUserMedia(&m)
safe = append(safe, map[string]any{ safe = append(safe, map[string]any{
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role, "id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto, "avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
@@ -111,10 +112,16 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
unread++ unread++
} }
} }
photo := p.AvatarPhoto
if photo != "" {
tmp := &models.User{ID: p.ID, AvatarPhoto: photo}
s.signUserMedia(tmp)
photo = tmp.AvatarPhoto
}
conv := conversation{ conv := conversation{
Partner: map[string]any{ Partner: map[string]any{
"id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role, "id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role,
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": p.AvatarPhoto, "avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
"online": s.hub.Online(p.ID), "online": s.hub.Online(p.ID),
}, },
UnreadCount: unread, UnreadCount: unread,
@@ -137,6 +144,7 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return return
} }
_ = s.markIncomingChatRead(r.Context(), u, partner)
if raw := r.URL.Query().Get("around"); raw != "" { if raw := r.URL.Query().Get("around"); raw != "" {
aroundID, err := strconv.ParseInt(raw, 10, 64) aroundID, err := strconv.ParseInt(raw, 10, 64)
@@ -156,7 +164,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = s.store.FillReactions(r.Context(), u.ID, msgs) _ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs) s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner) pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil { if pinned == nil {
@@ -197,7 +204,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
} }
} }
_ = s.store.FillReactions(r.Context(), u.ID, msgs) _ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs) s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner) pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil { if pinned == nil {
@@ -337,6 +343,73 @@ func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
} }
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 {
Body string `json:"body"`
Attachment *models.Attachment `json:"attachment,omitempty"`
}
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
}
var (
msg *models.ChatMessage
err error
)
if req.Attachment != nil {
att := req.Attachment
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
if att.Type != "image" && att.Type != "video" && att.Type != "audio" {
writeErr(w, http.StatusBadRequest, "فقط عکس، ویدیو یا صدا را می‌توان جایگزین کرد")
return
}
key := mediaKeyFromURL(att.URL)
if key == "" || !mediaKeyOwnedByUser(key, u.ID) {
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست")
return
}
if q := strings.Index(att.URL, "?"); q >= 0 {
att.URL = att.URL[:q]
}
if att.Name == "" {
att.Name = "file"
}
msg, err = s.store.ReplaceChatAttachment(r.Context(), u.ID, partner, msgID, u.Role, req.Body, att.Type, att.URL, att.Name, att.Size)
} else {
if req.Body == "" {
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
return
}
msg, err = s.store.EditChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body)
}
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})
}
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
u := currentUser(r) u := currentUser(r)
partner := routeID(r, "partner") partner := routeID(r, "partner")
@@ -473,13 +546,25 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return return
} }
if err := s.store.MarkChatRead(r.Context(), u.ID, partner); err != nil { if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت خوانده‌شدن ممکن نشد") writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت خوانده‌شدن ممکن نشد")
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
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
}
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool { func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
if actor == nil || partnerID <= 0 || actor.ID == partnerID { if actor == nil || partnerID <= 0 || actor.ID == partnerID {
return false return false
+2
View File
@@ -57,6 +57,7 @@ type chatExportMessage struct {
ReplyTo *int64 `json:"replyTo,omitempty"` ReplyTo *int64 `json:"replyTo,omitempty"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"` PinnedAt *time.Time `json:"pinnedAt,omitempty"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *chatExportAttachment `json:"attachment,omitempty"` Attachment *chatExportAttachment `json:"attachment,omitempty"`
} }
@@ -183,6 +184,7 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
ReplyTo: m.ReplyTo, ReplyTo: m.ReplyTo,
Pinned: m.PinnedAt != nil, Pinned: m.PinnedAt != nil,
PinnedAt: m.PinnedAt, PinnedAt: m.PinnedAt,
EditedAt: m.EditedAt,
} }
if m.Attachment != nil { if m.Attachment != nil {
att := &chatExportAttachment{ att := &chatExportAttachment{
+5 -5
View File
@@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) {
} }
} }
func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) { func TestAdminResetPassword(t *testing.T) {
e := setup(t) e := setup(t)
admin := login(t, e, bootstrapAdminEmail) admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
@@ -59,14 +59,14 @@ func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
uid := int64(body["user"].(map[string]any)["id"].(float64)) uid := int64(body["user"].(map[string]any)["id"].(float64))
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{ code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "اشتباه", "newPassword": "newpass123", "newPassword": "short",
}) })
if code != http.StatusForbidden { if code != http.StatusBadRequest {
t.Fatalf("wrong answer should 403, got %d", code) t.Fatalf("short password: %d", code)
} }
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{ code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "تهران", "newPassword": "newpass123", "newPassword": "newpass123",
}) })
if code != http.StatusOK { if code != http.StatusOK {
t.Fatalf("reset: %d %v", code, body) t.Fatalf("reset: %d %v", code, body)
+8 -1
View File
@@ -284,8 +284,15 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q")) q := strings.TrimSpace(r.URL.Query().Get("q"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
switch models.InviteStatus(status) {
case "", models.InviteActive, models.InvitePaused, models.InviteRevoked, models.InviteExhausted:
default:
writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است")
return
}
p := parsePageParams(r) p := parsePageParams(r)
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset) invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, status, p.PageSize, p.Offset)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد") writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد")
return return
+1
View File
@@ -63,6 +63,7 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
continue continue
} }
s.signUserMedia(usr)
entries = append(entries, lbEntry{ entries = append(entries, lbEntry{
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1, UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto, AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
+22 -3
View File
@@ -1,9 +1,11 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"pargar/backend/internal/observability" "pargar/backend/internal/observability"
"pargar/backend/internal/store"
) )
func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) {
@@ -21,12 +23,25 @@ func (s *Server) handleLearningStudents(w http.ResponseWriter, r *http.Request)
ctx, span := observability.StartSpan(r.Context(), "admin.learning.students") ctx, span := observability.StartSpan(r.Context(), "admin.learning.students")
defer span.End() defer span.End()
q := r.URL.Query().Get("q") q := r.URL.Query().Get("q")
rows, err := s.store.ListStudentLearning(ctx, q) filter := r.URL.Query().Get("filter")
switch filter {
case "active", "locked":
default:
filter = ""
}
sort := r.URL.Query().Get("sort")
switch sort {
case "progress", "streak", "activity":
default:
sort = "xp"
}
p := parsePageParams(r)
rows, total, err := s.store.ListStudentLearning(ctx, q, filter, sort, p.PageSize, p.Offset)
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد") writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد")
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"students": rows}) writePage(w, rows, total, p)
} }
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
@@ -34,9 +49,13 @@ func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
defer span.End() defer span.End()
id := routeID(r, "id") id := routeID(r, "id")
detail, err := s.store.UserLearningDetail(ctx, id) detail, err := s.store.UserLearningDetail(ctx, id)
if err != nil { if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return return
} }
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"learning": detail}) writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
} }
+17 -1
View File
@@ -13,12 +13,28 @@ import (
"pargar/backend/internal/models" "pargar/backend/internal/models"
) )
// mediaSignWindow keeps one signature for a stretch of time. The same object
// then keeps the same URL, so clients do not reload a photo that did not change.
const mediaSignWindow = 15 * time.Minute
func stableMediaExpiry(now time.Time, ttl, window time.Duration) int64 {
if window <= 0 {
window = mediaSignWindow
}
if ttl < window {
ttl = window
}
w := int64(window.Seconds())
slot := now.Unix() / w
return (slot+1)*w + int64(ttl.Seconds())
}
func (s *Server) signMediaURL(key string) string { func (s *Server) signMediaURL(key string) string {
key = strings.TrimPrefix(strings.TrimSpace(key), "/") key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" { if key == "" {
return "" return ""
} }
exp := time.Now().Add(s.cfg.MediaURLTTL).Unix() exp := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow)
sig := mediaHMAC(s.cfg.JWTSecret, key, exp) sig := mediaHMAC(s.cfg.JWTSecret, key, exp)
return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s", return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s",
s.cfg.PublicURL, s.cfg.PublicURL,
+18
View File
@@ -7,6 +7,24 @@ import (
"pargar/backend/internal/config" "pargar/backend/internal/config"
) )
func TestStableMediaExpiryHoldsInsideWindow(t *testing.T) {
start := time.Unix((1_700_000_000/900)*900, 0)
ttl := time.Hour
window := 15 * time.Minute
a := stableMediaExpiry(start, ttl, window)
b := stableMediaExpiry(start.Add(14*time.Minute), ttl, window)
if a != b {
t.Fatalf("same window should keep one expiry: %d vs %d", a, b)
}
c := stableMediaExpiry(start.Add(15*time.Minute), ttl, window)
if c == a {
t.Fatal("next window should mint a new expiry")
}
if c < a {
t.Fatal("expiry should move forward")
}
}
func TestMediaSignatureRoundTrip(t *testing.T) { func TestMediaSignatureRoundTrip(t *testing.T) {
s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}} s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}}
url := s.signMediaURL("videos/lesson-1.mp4") url := s.signMediaURL("videos/lesson-1.mp4")
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"errors"
"net/http"
"pargar/backend/internal/store"
)
func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
s.signUserMedia(u)
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": u.ID,
"name": u.Name,
"username": u.Username,
"role": u.Role,
"xp": u.XP,
"hearts": u.Hearts,
"streakCurrent": u.StreakCurrent,
"streakLongest": u.StreakLongest,
"avatarVariant": u.AvatarVariant,
"avatarPalette": u.AvatarPalette,
"avatarPhoto": u.AvatarPhoto,
"isLocked": u.IsLocked,
},
})
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
) )
// Public self-service password recovery is disabled for students. // Public self-service password recovery is disabled for students.
// Staff reset passwords from the admin panel (requires the account security answer). // Staff set a new password from the admin profile. Public recovery stays off.
func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است") writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است")
+3
View File
@@ -56,7 +56,9 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations)) mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations))
mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages)) mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages))
mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend)) mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend))
mux.HandleFunc("PUT /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatEdit))
mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete)) mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete))
mux.HandleFunc("GET /api/users/{id}", s.requireAuth(s.handlePublicProfile))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact)) mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin)) mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin))
mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload))) mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload)))
@@ -89,6 +91,7 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning)) mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning))
// Account administration is admin-only; mentors may unlock locked students only. // Account administration is admin-only; mentors may unlock locked students only.
mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers)) mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers))
mux.HandleFunc("GET /api/admin/users/{id}", admin(s.handleAdminGetUser))
mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser)) mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser))
mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser)) mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser))
mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser)) mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser))
+330
View File
@@ -0,0 +1,330 @@
package api_test
import (
"strings"
"testing"
)
func TestChatEditOwnMessage(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Chat Editor", "chat-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "سلام"})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": "سلام ویرایش"})
if code != 200 {
t.Fatalf("edit: %d %v", code, body)
}
edited := body["message"].(map[string]any)
if edited["body"] != "سلام ویرایش" || edited["editedAt"] == nil {
t.Fatalf("expected edited body, got %v", edited)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{"body": "نه"})
if code != 404 {
t.Fatalf("partner edit should be rejected: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": " "})
if code != 400 {
t.Fatalf("empty edit: %d", code)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("list: %d %v", code, body)
}
msgs := body["messages"].([]any)
last := msgs[len(msgs)-1].(map[string]any)
if last["body"] != "سلام ویرایش" || last["editedAt"] == nil {
t.Fatalf("listed message not edited: %v", last)
}
}
func TestChatReplaceAttachment(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Media Editor", "media-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
code, up := uploadChatFile(t, e, student, "one.png", png)
if code != 201 {
t.Fatalf("upload: %d %v", code, up)
}
first := up["attachment"].(map[string]any)
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"body": "",
"attachment": first,
})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
png[8] = 0x01
code, up = uploadChatFile(t, e, student, "two.png", png)
if code != 201 {
t.Fatalf("upload replacement: %d %v", code, up)
}
next := up["attachment"].(map[string]any)
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": next,
})
if code != 200 {
t.Fatalf("replace: %d %v", code, body)
}
edited := body["message"].(map[string]any)
newPath := mediaPath(edited["attachment"].(map[string]any)["url"])
if edited["editedAt"] == nil || newPath == "" || newPath == oldPath {
t.Fatalf("expected a replaced attachment, old %s new %v", oldPath, edited)
}
if edited["attachment"].(map[string]any)["type"] != "image" {
t.Fatalf("type: %v", edited["attachment"])
}
wrong := map[string]any{"type": "video", "url": next["url"], "name": "clip.mp4", "size": next["size"]}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": wrong,
})
if code != 404 {
t.Fatalf("different kind should miss: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1},
})
if code != 400 {
t.Fatalf("file replace: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{
"body": "",
"attachment": next,
})
if code != 400 {
t.Fatalf("partner replace: %d", code)
}
}
func TestChatReadMarksOnlyIncoming(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Seen Student", "seen-student@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "از هنرجو"})
if code != 201 {
t.Fatalf("student send: %d %v", code, body)
}
studentMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("student list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] != nil {
t.Fatalf("opening my own thread marked it seen: %v", chatMsg(t, body, studentMsg))
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("partner open should mark the incoming message seen")
}
code, body = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", admin, map[string]any{"body": "از ادمین"})
if code != 201 {
t.Fatalf("admin send: %d %v", code, body)
}
adminMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list again: %d %v", code, body)
}
if chatMsg(t, body, adminMsg)["readAt"] != nil {
t.Fatalf("admin own message was marked seen: %v", chatMsg(t, body, adminMsg))
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("already seen incoming message lost its receipt")
}
}
func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any {
t.Helper()
for _, raw := range body["messages"].([]any) {
msg := raw.(map[string]any)
if int64(msg["id"].(float64)) == id {
return msg
}
}
t.Fatalf("message %d missing in %v", id, body["messages"])
return nil
}
func mediaPath(raw any) string {
s, _ := raw.(string)
if i := strings.IndexByte(s, '?'); i >= 0 {
return s[:i]
}
return s
}
func TestLearningDetailMatchesActiveStudents(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 0 {
t.Fatalf("overview should ignore staff, got %v", body["stats"])
}
_, uid := register(t, e, "Active Student", "active-student@test.dev")
code, body = e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats after student: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 1 {
t.Fatalf("expected 1 active student, got %v", body["stats"])
}
code, body = e.do(t, "GET", "/api/admin/learning/overview", admin, nil)
if code != 200 {
t.Fatalf("overview: %d %v", code, body)
}
if body["overview"].(map[string]any)["studentsActive"].(float64) != 1 {
t.Fatalf("learning active mismatch: %v", body["overview"])
}
code, body = e.do(t, "GET", "/api/admin/users/"+itoa(uid)+"/learning", admin, nil)
if code != 200 {
t.Fatalf("learning detail: %d %v", code, body)
}
user := body["learning"].(map[string]any)["user"].(map[string]any)
if user["name"] != "Active Student" {
t.Fatalf("detail user: %v", user)
}
code, body = e.do(t, "GET", "/api/admin/users/999999/learning", admin, nil)
if code != 404 {
t.Fatalf("missing user: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/learning/students?page=1&pageSize=10", admin, nil)
if code != 200 {
t.Fatalf("students page: %d %v", code, body)
}
if body["total"].(float64) != 1 || len(body["items"].([]any)) != 1 {
t.Fatalf("expected one page row, got %v", body)
}
}
func TestInviteStatusFilter(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/invites", admin, map[string]any{"maxUses": 2, "label": "مهر"})
if code != 201 {
t.Fatalf("create invite: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=active", admin, nil)
if code != 200 {
t.Fatalf("active filter: %d %v", code, body)
}
if body["total"].(float64) < 1 {
t.Fatalf("expected active invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=revoked", admin, nil)
if code != 200 {
t.Fatalf("revoked filter: %d %v", code, body)
}
if body["total"].(float64) != 0 {
t.Fatalf("expected no revoked invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=nope", admin, nil)
if code != 400 {
t.Fatalf("bad status: %d %v", code, body)
}
}
func TestPublicProfileOmitsPrivateFields(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Public Name", "public-name@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "GET", "/api/users/"+itoa(adminID), student, nil)
if code != 200 {
t.Fatalf("profile: %d %v", code, body)
}
user := body["user"].(map[string]any)
if user["name"] == nil || user["name"] == "" {
t.Fatalf("missing name: %v", user)
}
if _, ok := user["email"]; ok {
t.Fatalf("email must not be public: %v", user)
}
if _, ok := user["phone"]; ok {
t.Fatalf("phone must not be public: %v", user)
}
code, adminView := e.do(t, "GET", "/api/admin/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("admin user: %d %v", code, adminView)
}
managed := adminView["user"].(map[string]any)
if managed["email"] != "public-name@test.dev" {
t.Fatalf("admin should see email: %v", managed)
}
if _, ok := managed["passwordHash"]; ok {
t.Fatalf("password hash must stay hidden: %v", managed)
}
code, hidden := e.do(t, "GET", "/api/admin/users/"+itoa(adminID), student, nil)
if code != 403 {
t.Fatalf("student must not open admin user: %d %v", code, hidden)
}
code, body = e.do(t, "GET", "/api/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("student profile: %d %v", code, body)
}
if body["user"].(map[string]any)["username"] != "public-name" {
t.Fatalf("username: %v", body["user"])
}
}
@@ -0,0 +1,3 @@
-- Own-message edits. Nullable so existing rows stay unedited.
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
+1
View File
@@ -219,6 +219,7 @@ type ChatMessage struct {
ReplyTo *int64 `json:"replyTo,omitempty"` ReplyTo *int64 `json:"replyTo,omitempty"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"` PinnedAt *time.Time `json:"pinnedAt,omitempty"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *Attachment `json:"attachment,omitempty"` Attachment *Attachment `json:"attachment,omitempty"`
Reactions []ChatReaction `json:"reactions,omitempty"` Reactions []ChatReaction `json:"reactions,omitempty"`
} }
+15
View File
@@ -0,0 +1,15 @@
package storage
import "testing"
func TestVoiceNoteContentType(t *testing.T) {
if got := mimeType("chats/2-1.weba"); got != "audio/webm" {
t.Fatalf("weba: %s", got)
}
if got := mediaContentType("chats/2-1.weba", "application/octet-stream"); got != "audio/webm" {
t.Fatalf("generic store type must not hide the voice type: %s", got)
}
if got := mediaContentType("chats/2-1.bin", "application/pdf"); got != "application/pdf" {
t.Fatalf("unknown extension keeps the stored type: %s", got)
}
}
+33 -3
View File
@@ -243,10 +243,11 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
} }
defer result.Body.Close() defer result.Body.Close()
ct := mimeType(key) stored := ""
if result.ContentType != nil && *result.ContentType != "" { if result.ContentType != nil {
ct = *result.ContentType stored = *result.ContentType
} }
ct := mediaContentType(key, stored)
w.Header().Set("Content-Type", ct) w.Header().Set("Content-Type", ct)
setInlineMediaHeaders(w, ct) setInlineMediaHeaders(w, ct)
w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Accept-Ranges", "bytes")
@@ -290,8 +291,20 @@ func mimeType(key string) string {
return "video/mp4" return "video/mp4"
case ".webm": case ".webm":
return "video/webm" return "video/webm"
case ".weba":
return "audio/webm"
case ".mp3": case ".mp3":
return "audio/mpeg" return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".ogg", ".opus":
return "audio/ogg"
case ".m4a", ".aac":
return "audio/mp4"
case ".mov":
return "video/quicktime"
case ".m4v":
return "video/mp4"
case ".pdf": case ".pdf":
return "application/pdf" return "application/pdf"
default: default:
@@ -299,6 +312,23 @@ func mimeType(key string) string {
} }
} }
// mediaContentType keeps a known extension ahead of a generic object-store type.
// Voice notes are stored as .weba; a default octet-stream plus nosniff stops playback.
func mediaContentType(key, stored string) string {
guessed := mimeType(key)
if guessed != "application/octet-stream" {
return guessed
}
stored = strings.TrimSpace(stored)
if i := strings.Index(stored, ";"); i >= 0 {
stored = strings.TrimSpace(stored[:i])
}
if stored != "" {
return stored
}
return guessed
}
// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact. // setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact.
func setInlineMediaHeaders(w http.ResponseWriter, contentType string) { func setInlineMediaHeaders(w http.ResponseWriter, contentType string) {
if contentType != "" { if contentType != "" {
+6 -3
View File
@@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite(
return scanInvite(row) return scanInvite(row)
} }
func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) { func (s *Store) ListRegistrationInvites(ctx context.Context, query, status string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
if limit <= 0 { if limit <= 0 {
limit = 10 limit = 10
} }
@@ -157,18 +157,21 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit
offset = 0 offset = 0
} }
query = strings.TrimSpace(query) query = strings.TrimSpace(query)
status = strings.TrimSpace(status)
var total int64 var total int64
if err := s.pool.QueryRow(ctx, ` if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM registration_invites SELECT COUNT(*) FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil { WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)`, query, status).Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT `+inviteCols+` SELECT `+inviteCols+`
FROM registration_invites FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%') WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, query, limit, offset) LIMIT $3 OFFSET $4`, query, status, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
+54 -27
View File
@@ -81,10 +81,40 @@ type StudentLearningRow struct {
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"` LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
} }
func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, error) { func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string, limit, offset int) ([]StudentLearningRow, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
var lessonsTotal int64 var lessonsTotal int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil { if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
return nil, err return nil, 0, err
}
where := `u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')`
switch filter {
case "active":
where += ` AND u.is_active AND NOT u.is_locked`
case "locked":
where += ` AND u.is_locked`
}
order := `u.xp DESC, u.id ASC`
switch sort {
case "progress":
order = `COALESCE(p.passed, 0) DESC, u.id ASC`
case "streak":
order = `u.streak_current DESC, u.id ASC`
case "activity":
order = `u.last_activity_date DESC NULLS LAST, u.id ASC`
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users u WHERE `+where, q).Scan(&total); err != nil {
return nil, 0, err
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current, SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
@@ -105,12 +135,11 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
WHERE created_at >= now() - interval '7 days' WHERE created_at >= now() - interval '7 days'
GROUP BY user_id GROUP BY user_id
) a ON a.user_id = u.id ) a ON a.user_id = u.id
WHERE u.role = 'student' WHERE `+where+`
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%') ORDER BY `+order+`
ORDER BY u.xp DESC, u.id ASC LIMIT $2 OFFSET $3`, q, limit, offset)
LIMIT 500`, q)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
out := make([]StudentLearningRow, 0) out := make([]StudentLearningRow, 0)
@@ -122,7 +151,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
&r.IsLocked, &r.IsActive, &r.LastActivityAt, &r.IsLocked, &r.IsActive, &r.LastActivityAt,
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d, &r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
); err != nil { ); err != nil {
return nil, err return nil, 0, err
} }
if r.LessonsTotal > 0 { if r.LessonsTotal > 0 {
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100 r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
@@ -132,7 +161,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
} }
out = append(out, r) out = append(out, r)
} }
return out, rows.Err() return out, total, rows.Err()
} }
type LessonLearningDetail struct { type LessonLearningDetail struct {
@@ -190,30 +219,28 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
if err != nil { if err != nil {
return nil, err return nil, err
} }
list, err := s.ListStudentLearning(ctx, "") row := StudentLearningRow{
if err != nil {
return nil, err
}
var row StudentLearningRow
found := false
for _, r := range list {
if r.ID == userID {
row = r
found = true
break
}
}
if !found {
row = StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email, ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent, XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate, IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
} }
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal) if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal); err != nil {
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed) return nil, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed); err != nil {
return nil, err
}
if row.LessonsTotal > 0 { if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100 row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
} }
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*), COUNT(*) FILTER (WHERE status='passed')
FROM quiz_attempts
WHERE user_id=$1 AND created_at >= now() - interval '7 days'`, userID).Scan(&row.Attempts7d, &row.Passes7d); err != nil {
return nil, err
}
if row.Attempts7d > 0 {
row.PassRate7d = float64(row.Passes7d) / float64(row.Attempts7d) * 100
} }
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}} detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
@@ -221,7 +248,7 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
lrows, err := s.pool.Query(ctx, ` lrows, err := s.pool.Query(ctx, `
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order, SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at, COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
COALESCE(la.status, ''), COALESCE(la.score_pct, 0) COALESCE(la.status::text, ''), COALESCE(la.score_pct, 0)
FROM lessons l FROM lessons l
JOIN chapters c ON c.id = l.chapter_id JOIN chapters c ON c.id = l.chapter_id
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1 LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
+1 -1
View File
@@ -28,7 +28,7 @@ func TestLearningOverviewSmoke(t *testing.T) {
if o == nil { if o == nil {
t.Fatal("nil overview") t.Fatal("nil overview")
} }
students, err := st.ListStudentLearning(ctx, "") students, _, err := st.ListStudentLearning(ctx, "", "", "xp", 10, 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+91 -26
View File
@@ -504,7 +504,26 @@ func (s *Store) GetPhysicalOrder(ctx context.Context, id int64) (*models.Certifi
return scanPhysicalOrder(row) return scanPhysicalOrder(row)
} }
func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models.CertificatePhysicalOrder, error) { func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, offset int) ([]models.CertificatePhysicalOrder, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
where := ""
args := []any{}
if status != "" {
where = ` WHERE o.status=$1`
args = append(args, status)
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM certificate_physical_orders o`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := ` q := `
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note, SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code, o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
@@ -512,16 +531,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
u.name, c.public_id u.name, c.public_id
FROM certificate_physical_orders o FROM certificate_physical_orders o
JOIN users u ON u.id = o.user_id JOIN users u ON u.id = o.user_id
JOIN certificates c ON c.id = o.certificate_id` JOIN certificates c ON c.id = o.certificate_id` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
args := []any{} args = append(args, limit, offset)
if status != "" {
q += ` WHERE o.status=$1`
args = append(args, status)
}
q += ` ORDER BY o.created_at DESC LIMIT 200`
rows, err := s.pool.Query(ctx, q, args...) rows, err := s.pool.Query(ctx, q, args...)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
out := make([]models.CertificatePhysicalOrder, 0) out := make([]models.CertificatePhysicalOrder, 0)
@@ -530,11 +544,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note, if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note,
&o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode, &o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode,
&o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil { &o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil {
return nil, err return nil, 0, err
} }
out = append(out, o) out = append(out, o)
} }
return out, rows.Err() return out, total, rows.Err()
} }
func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) { func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) {
@@ -1069,7 +1083,7 @@ func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, send
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size) INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize) userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize)
return scanMessage(row) return scanMessage(row)
} }
@@ -1094,7 +1108,7 @@ func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, s
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at) INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt) userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt)
return scanMessage(row) return scanMessage(row)
} }
@@ -1138,6 +1152,48 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in
return tag.RowsAffected() > 0, nil return tag.RowsAffected() > 0, nil
} }
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
// The new type must match the current attachment.
func (s *Store) ReplaceChatAttachment(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body, attType, attURL, attName string, attSize int64) (*models.ChatMessage, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_messages
SET body=$1, edited_at=now(),
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5
WHERE id=$6
AND ((user_id=$7 AND mentor_id=$8) OR (user_id=$8 AND mentor_id=$7))
AND sender_role=$9
AND (user_id=$7 OR mentor_id=$7)
AND attachment_type=$2
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
body, attType, attURL, attName, attSize, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
// EditChatMessage replaces the body of one of my own messages in this conversation.
func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string) (*models.ChatMessage, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_messages
SET body=$1, edited_at=now()
WHERE id=$2
AND ((user_id=$3 AND mentor_id=$4) OR (user_id=$4 AND mentor_id=$3))
AND sender_role=$5
AND (user_id=$3 OR mentor_id=$3)
AND ($1 <> '' OR attachment_url IS NOT NULL)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
body, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
const maxPinnedMessages = 3 const maxPinnedMessages = 3
// TogglePinChatMessage pins or unpins a message. If already at the pin limit, // TogglePinChatMessage pins or unpins a message. If already at the pin limit,
@@ -1160,7 +1216,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, ` out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1 UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID) attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
return scanMessage(out) return scanMessage(out)
} }
var n int var n int
@@ -1188,7 +1244,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, ` out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=now() WHERE id=$1 UPDATE chat_messages SET pinned_at=now() WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID) attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
return scanMessage(out) return scanMessage(out)
} }
@@ -1196,7 +1252,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) { func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages FROM chat_messages
WHERE pinned_at IS NOT NULL WHERE pinned_at IS NOT NULL
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
@@ -1222,7 +1278,7 @@ func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int
func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) { func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND ($3 = 0 OR id < $3) AND ($3 = 0 OR id < $3)
@@ -1270,7 +1326,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
older, err := s.pool.Query(ctx, ` older, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id <= $3 AND id <= $3
@@ -1302,7 +1358,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
newer, err := s.pool.Query(ctx, ` newer, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to, SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id > $3 AND id > $3
@@ -1345,7 +1401,7 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) {
var attSize *int64 var attSize *int64
if err := row.Scan( if err := row.Scan(
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo, &m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo,
&attType, &attURL, &attName, &attSize, &m.PinnedAt, &attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -1372,11 +1428,20 @@ func derefInt(p *int64) int64 {
return *p return *p
} }
func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error { // MarkChatRead stamps messages the other person sent. Opening the thread must not
_, err := s.pool.Exec(ctx, ` // mark my own messages as seen.
UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`, func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) {
userID, mentorID) tag, err := s.pool.Exec(ctx, `
return err UPDATE chat_messages
SET read_at=now()
WHERE read_at IS NULL
AND sender_role <> $3
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`,
meID, partnerID, meRole.String())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
} }
// MessageInConversation reports whether a message belongs to a given pair. // MessageInConversation reports whether a message belongs to a given pair.
@@ -1743,7 +1808,7 @@ func (s *Store) AllActiveUserIDs(ctx context.Context) ([]int64, error) {
func (s *Store) Stats(ctx context.Context) (map[string]any, error) { func (s *Store) Stats(ctx context.Context) (map[string]any, error) {
var users, lessons, questions, events, completions int64 var users, lessons, questions, events, completions int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users); err != nil { if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='student' AND is_active`).Scan(&users); err != nil {
return nil, err return nil, err
} }
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil { if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil {
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: pargar name: pargar
description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage
type: application type: application
version: 0.2.0 version: 0.2.1
appVersion: "0.1.0" appVersion: "0.1.3"
keywords: keywords:
- lms - lms
- pargar - pargar
+2 -2
View File
@@ -22,7 +22,7 @@ backend:
replicaCount: 2 replicaCount: 2
image: image:
repository: backend repository: backend
tag: "0.1.0" tag: "0.1.3"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
existingSecret: pargar-app existingSecret: pargar-app
env: env:
@@ -49,7 +49,7 @@ frontend:
replicaCount: 2 replicaCount: 2
image: image:
repository: frontend repository: frontend
tag: "0.1.0" tag: "0.1.3"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
resources: resources:
requests: requests:
+12 -74
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2, RefreshCw, ShieldCheck, Timer, Zap } from "lucide-react"; import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
const perks = [ const perks = [
{ icon: Timer, text: "زمان تماشای راستی‌آزمایی‌شده — بدون میان‌بر" }, { icon: Timer, text: "زمان تماشای راستی‌آزمایی‌شده — بدون میان‌بر" },
@@ -22,11 +23,6 @@ const perks = [
{ icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یک‌به‌یک" }, { icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یک‌به‌یک" },
]; ];
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const setSession = useAuth((s) => s.setSession); const setSession = useAuth((s) => s.setSession);
@@ -34,7 +30,7 @@ export default function LoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState(""); const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null); const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(true); const [captchaKey, setCaptchaKey] = useState(0);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -44,29 +40,11 @@ export default function LoginPage() {
queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"), queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"),
}); });
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
void loadCaptcha();
}, [loadCaptcha]);
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!challenge) { if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید"); setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha(); setCaptchaKey((k) => k + 1);
return; return;
} }
setLoading(true); setLoading(true);
@@ -83,7 +61,7 @@ export default function LoginPage() {
router.push("/cap"); router.push("/cap");
} catch (err) { } catch (err) {
setError(toUserError(err, "ورود ناموفق بود")); setError(toUserError(err, "ورود ناموفق بود"));
void loadCaptcha(); setCaptchaKey((k) => k + 1);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -153,54 +131,14 @@ export default function LoginPage() {
placeholder="رمز عبور" placeholder="رمز عبور"
/> />
</div> </div>
<div className="space-y-2"> <CaptchaField
<div className="flex items-center justify-between gap-2"> key={captchaKey}
<Label htmlFor="captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="captcha" id="captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer} value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())} onChange={setCaptchaAnswer}
required onChallenge={setChallenge}
dir="ltr" onError={setError}
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/> />
</div>
</div>
<label className="flex items-center justify-between gap-3 text-sm"> <label className="flex items-center justify-between gap-3 text-sm">
<span>مرا به یاد داشته باش</span> <span>مرا به یاد داشته باش</span>
<Switch <Switch
@@ -215,7 +153,7 @@ export default function LoginPage() {
variant="gradient" variant="gradient"
className="h-12 w-full" className="h-12 w-full"
size="lg" size="lg"
disabled={loading || captchaLoading || !challenge} disabled={loading || !challenge}
> >
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود
</Button> </Button>
+130
View File
@@ -0,0 +1,130 @@
"use client";
import type { ReactNode } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Flame, Heart, Sparkles, User as UserIcon } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import type { PublicProfile } from "@/lib/types";
import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
const AdminProfileManage = dynamic(
() => import("@/components/admin/profile-manage").then((m) => m.AdminProfileManage),
{ ssr: false },
);
function roleLabel(role: string) {
if (role === "mentor") return "منتور";
if (role === "admin") return "ادمین";
return "هنرجو";
}
export default function PublicProfilePage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const enabled = Number.isFinite(id) && id > 0;
const isAdmin = useAuth((s) => s.user?.role === "admin");
const profileQ = useQuery({
queryKey: ["public-profile", id],
queryFn: () => http.get<{ user: PublicProfile }>(`/api/users/${id}`),
enabled,
retry: 1,
});
if (!enabled) {
return (
<div className="mx-auto max-w-2xl py-16 text-center text-sm text-muted-foreground">
کاربر پیدا نشد.
</div>
);
}
if (profileQ.isPending) {
return (
<div className="mx-auto max-w-2xl space-y-4" aria-busy="true" aria-label="در حال بارگذاری پروفایل">
<Skeleton className="h-40 rounded-2xl" />
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 rounded-2xl" />
))}
</div>
</div>
);
}
if (profileQ.isError || !profileQ.data?.user) {
return (
<div className="mx-auto max-w-2xl space-y-4 py-16 text-center">
<p className="text-sm font-bold text-destructive">
{toUserError(profileQ.error, "کاربر پیدا نشد")}
</p>
<Button asChild variant="outline">
<Link href="/unwrap">بازگشت</Link>
</Button>
</div>
);
}
const person = profileQ.data.user;
return (
<div className="mx-auto max-w-2xl space-y-6">
<Panel
title={person.name}
description={roleLabel(person.role)}
icon={<UserIcon className="h-5 w-5" />}
tint="bg-primary/10 text-primary"
>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<UserAvatar name={person.name} className="h-20 w-20" {...avatarPropsOf(person)} />
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
<h1 className="text-2xl font-black tracking-tight">{person.name}</h1>
<p className="font-latin text-sm font-bold" dir="ltr">
@{person.username}
</p>
{person.isLocked && (
<p className="text-xs font-bold text-destructive">حساب قفل است</p>
)}
</div>
</div>
</Panel>
<div className="grid gap-3 sm:grid-cols-2">
<Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />
<Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلب‌ها" value={formatFaNumber(person.hearts)} />
<Stat
icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی"
value={formatStreakLabel(person.streakCurrent)}
/>
<Stat
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره"
value={formatStreakLabel(person.streakLongest)}
/>
</div>
{isAdmin && <AdminProfileManage userId={person.id} />}
</div>
);
}
function Stat({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3">
{icon}
<div>
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
<p className="text-lg font-black tabular-nums">{value}</p>
</div>
</div>
);
}
+8 -65
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
@@ -16,7 +17,6 @@ import {
Globe, Globe,
GripVertical, GripVertical,
Heart, Heart,
KeyRound,
Layers, Layers,
Loader2, Loader2,
Lock, Lock,
@@ -454,6 +454,10 @@ function UsersPanel() {
(currentUser?.role === "mentor" && u.role === "student"); (currentUser?.role === "mentor" && u.role === "student");
return ( return (
<div key={u.id} className="flex items-center gap-3 px-3 py-2.5"> <div key={u.id} className="flex items-center gap-3 px-3 py-2.5">
<Link
href={`/profile/${u.id}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-md outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} /> <UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm font-medium"> <p className="flex items-center gap-2 truncate text-sm font-medium">
@@ -475,6 +479,7 @@ function UsersPanel() {
{u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)} {u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)}
</span> </span>
</div> </div>
</Link>
<div className="flex shrink-0 gap-1.5"> <div className="flex shrink-0 gap-1.5">
{canMutate && u.id !== currentUser?.id && ( {canMutate && u.id !== currentUser?.id && (
u.isLocked ? ( u.isLocked ? (
@@ -515,7 +520,7 @@ function UsersPanel() {
); );
} }
function UserDialog({ export function UserDialog({
user, user,
onClose, onClose,
onSaved, onSaved,
@@ -529,18 +534,13 @@ function UserDialog({
const [form, setForm] = useState<User>(user ?? ({} as User)); const [form, setForm] = useState<User>(user ?? ({} as User));
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [securityAnswer, setSecurityAnswer] = useState(""); const [securityAnswer, setSecurityAnswer] = useState("");
const [resetAnswer, setResetAnswer] = useState("");
const [resetPassword, setResetPassword] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setForm(user); setForm(user);
setPassword(""); setPassword("");
setSecurityAnswer(""); setSecurityAnswer("");
setResetAnswer("");
setResetPassword("");
} }
}, [user]); }, [user]);
@@ -633,29 +633,6 @@ function UserDialog({
} }
}; };
const doReset = async () => {
if (!form?.id) return;
const pwErr = validatePassword(resetPassword);
if (!resetAnswer.trim() || pwErr) {
toast.error(pwErr ?? "جواب امنیتی و رمز جدید (حداقل ۸ کاراکتر) لازم است");
return;
}
setResetting(true);
try {
await http.post(`/api/admin/users/${form.id}/reset-password`, {
securityAnswer: resetAnswer,
newPassword: resetPassword,
});
toast.success("رمز عبور ریست شد");
setResetAnswer("");
setResetPassword("");
} catch (e) {
toast.error(toUserError(e));
} finally {
setResetting(false);
}
};
return ( return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -664,7 +641,7 @@ function UserDialog({
<DialogDescription> <DialogDescription>
{isNew {isNew
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر." ? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
: "اطلاعات کاربر را به‌روزرسانی کنید. ریست رمز فقط با جواب امنیتی."} : "اطلاعات کاربر را به‌روزرسانی کنید."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">
@@ -768,40 +745,6 @@ function UserDialog({
/> />
</div> </div>
)} )}
{!isNew && (
<div className="space-y-2 rounded-md border border-dashed border-border p-3">
<p className="text-sm font-medium">ریست رمز با سوال امنیتی</p>
{form.securityQuestion ? (
<p className="text-xs text-muted-foreground">سوال: {form.securityQuestion}</p>
) : (
<p className="text-xs text-destructive">سوال امنیتی تعریف نشده است.</p>
)}
<PasswordInput
value={resetAnswer}
onChange={(e) => setResetAnswer(e.target.value)}
placeholder="جواب امنیتی"
dir="rtl"
autoComplete="off"
/>
<PasswordInput
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
placeholder="رمز جدید (حداقل ۸ کاراکتر)"
minLength={8}
autoComplete="new-password"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={resetting || !form.hasSecurityAnswer}
onClick={doReset}
>
{resetting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
ریست رمز
</Button>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>انصراف</Button> <Button variant="outline" onClick={onClose}>انصراف</Button>
@@ -68,16 +68,18 @@ export function InvitesPanel() {
const [editBusy, setEditBusy] = useState(false); const [editBusy, setEditBusy] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [status, setStatus] = useState<"" | RegistrationInvite["status"]>("");
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [q]); }, [q, status]);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["admin", "invites", q, page], queryKey: ["admin", "invites", q, status, page],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page)); const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim()); if (q.trim()) params.set("q", q.trim());
if (status) params.set("status", status);
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`); return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
}, },
}); });
@@ -296,14 +298,43 @@ export function InvitesPanel() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<ListToolbar> <ListToolbar>
<div className="flex flex-wrap items-center gap-2">
{(
[
["", "همه"],
["active", "فعال"],
["paused", "متوقف"],
["exhausted", "تکمیل‌شده"],
["revoked", "لغو شده"],
] as const
).map(([value, label]) => (
<Button
key={value || "all"}
type="button"
size="sm"
variant={status === value ? "default" : "outline"}
onClick={() => setStatus(value)}
>
{label}
</Button>
))}
</div>
<Input <Input
placeholder="جستجو با برچسب…" placeholder="جستجو با برچسب…"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
className="max-w-xs" className="max-w-xs"
/> />
{q && ( {(q || status) && (
<Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}> <Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setQ("");
setStatus("");
}}
>
پاک کردن پاک کردن
</Button> </Button>
)} )}
@@ -315,7 +346,7 @@ export function InvitesPanel() {
)} )}
{!isLoading && invites.length === 0 && ( {!isLoading && invites.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground"> <p className="py-6 text-center text-sm text-muted-foreground">
{q.trim() ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."} {q.trim() || status ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
</p> </p>
)} )}
<div className="space-y-2"> <div className="space-y-2">
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertTriangle, AlertTriangle,
Flame, Flame,
@@ -34,6 +34,12 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toast } from "@/components/providers"; import { toast } from "@/components/providers";
import { cn, formatFaNumber } from "@/lib/utils"; import { cn, formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
type Paginated,
} from "@/components/admin/list-pagination";
type SortKey = "xp" | "progress" | "activity" | "streak"; type SortKey = "xp" | "progress" | "activity" | "streak";
type FilterKey = "all" | "active" | "locked"; type FilterKey = "all" | "active" | "locked";
@@ -58,6 +64,11 @@ export function LearningPanel() {
const [sort, setSort] = useState<SortKey>("xp"); const [sort, setSort] = useState<SortKey>("xp");
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [unlocking, setUnlocking] = useState(false); const [unlocking, setUnlocking] = useState(false);
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [q, filter, sort]);
const overviewQ = useQuery({ const overviewQ = useQuery({
queryKey: ["admin", "learning", "overview"], queryKey: ["admin", "learning", "overview"],
@@ -66,11 +77,17 @@ export function LearningPanel() {
}); });
const studentsQ = useQuery({ const studentsQ = useQuery({
queryKey: ["admin", "learning", "students", q], queryKey: ["admin", "learning", "students", q, filter, sort, page],
queryFn: () => queryFn: () => {
http.get<{ students: StudentLearningRow[] }>( const params = new URLSearchParams(buildPageQuery(page));
`/api/admin/learning/students?q=${encodeURIComponent(q)}` if (q.trim()) params.set("q", q.trim());
), if (filter !== "all") params.set("filter", filter);
params.set("sort", sort);
return http.get<Paginated<StudentLearningRow>>(
`/api/admin/learning/students?${params.toString()}`,
);
},
placeholderData: keepPreviousData,
retry: 1, retry: 1,
}); });
@@ -98,27 +115,9 @@ export function LearningPanel() {
}; };
const overview = overviewQ.data?.overview; const overview = overviewQ.data?.overview;
const students = useMemo(() => { const students = studentsQ.data?.items ?? [];
let rows = [...(studentsQ.data?.students ?? [])]; const studentsTotal = studentsQ.data?.total ?? 0;
if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked); const studentsPageSize = studentsQ.data?.pageSize ?? DEFAULT_PAGE_SIZE;
if (filter === "locked") rows = rows.filter((r) => r.isLocked);
rows.sort((a, b) => {
switch (sort) {
case "progress":
return b.progressPct - a.progressPct;
case "streak":
return b.streakCurrent - a.streakCurrent;
case "activity": {
const at = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0;
const bt = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0;
return bt - at;
}
default:
return b.xp - a.xp;
}
});
return rows;
}, [studentsQ.data?.students, filter, sort]);
const cards = overview const cards = overview
? [ ? [
@@ -177,7 +176,7 @@ export function LearningPanel() {
<div> <div>
<CardTitle className="text-base">جدول هنرجویان</CardTitle> <CardTitle className="text-base">جدول هنرجویان</CardTitle>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{formatFaNumber(students.length)} نفر · برای جزئیات روی ردیف بزنید {formatFaNumber(studentsTotal)} نفر · برای جزئیات روی ردیف بزنید
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -295,6 +294,12 @@ export function LearningPanel() {
<p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p> <p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p>
)} )}
</div> </div>
<ListPagination
page={page}
pageSize={studentsPageSize}
total={studentsTotal}
onPageChange={setPage}
/>
</CardContent> </CardContent>
</Card> </Card>
@@ -305,7 +310,9 @@ export function LearningPanel() {
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"} {detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{detailQ.data {detailQ.isError
? toUserError(detailQ.error, "بارگذاری جزئیات ممکن نشد")
: detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر` ? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"} : "در حال بارگذاری…"}
</DialogDescription> </DialogDescription>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types"; import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
@@ -11,6 +11,11 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { toast } from "@/components/providers"; import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils"; import { formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
} from "@/components/admin/list-pagination";
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = { const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
requested: "درخواست‌شده", requested: "درخواست‌شده",
@@ -27,16 +32,29 @@ const NEXT: Partial<Record<PhysicalOrderStatus, PhysicalOrderStatus>> = {
export function PhysicalOrdersPanel() { export function PhysicalOrdersPanel() {
const qc = useQueryClient(); const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const [notes, setNotes] = useState<Record<number, string>>({}); const [notes, setNotes] = useState<Record<number, string>>({});
const [tracking, setTracking] = useState<Record<number, string>>({}); const [tracking, setTracking] = useState<Record<number, string>>({});
const [busy, setBusy] = useState<number | null>(null); const [busy, setBusy] = useState<number | null>(null);
useEffect(() => {
setPage(1);
}, [statusFilter]);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["admin", "physical-orders", statusFilter], queryKey: ["admin", "physical-orders", statusFilter, page],
queryFn: () => queryFn: () => {
http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>( const params = new URLSearchParams(buildPageQuery(page));
`/api/admin/physical-orders?status=${encodeURIComponent(statusFilter)}`, if (statusFilter) params.set("status", statusFilter);
), return http.get<{
orders: CertificatePhysicalOrder[];
physical: PhysicalCertSettings;
total: number;
page: number;
pageSize: number;
}>(`/api/admin/physical-orders?${params.toString()}`);
},
placeholderData: keepPreviousData,
}); });
const update = async (id: number, status: PhysicalOrderStatus) => { const update = async (id: number, status: PhysicalOrderStatus) => {
@@ -151,6 +169,12 @@ export function PhysicalOrdersPanel() {
{!isLoading && (data?.orders ?? []).length === 0 && ( {!isLoading && (data?.orders ?? []).length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p> <p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
)} )}
<ListPagination
page={page}
pageSize={data?.pageSize ?? DEFAULT_PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -0,0 +1,245 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, Lock, Mail, Pencil, Phone, Shield, ShieldQuestion, Trash2, Unlock } from "lucide-react";
import { UserDialog } from "@/components/admin-studio";
import { useConfirm } from "@/components/admin/use-confirm";
import { Panel } from "@/components/panel";
import { toast } from "@/components/providers";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Skeleton } from "@/components/ui/skeleton";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import { validatePassword } from "@/lib/validation";
import { cn } from "@/lib/utils";
import type { User } from "@/lib/types";
export function AdminProfileManage({ userId }: { userId: number }) {
const me = useAuth((s) => s.user);
const router = useRouter();
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const [busy, setBusy] = useState<"lock" | "delete" | "reset" | null>(null);
const [editing, setEditing] = useState<User | null>(null);
const [resetOpen, setResetOpen] = useState(false);
const [nextPassword, setNextPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const accountQ = useQuery({
queryKey: ["admin", "user", userId],
queryFn: () => http.get<{ user: User }>(`/api/admin/users/${userId}`),
retry: 1,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin", "user", userId] });
qc.invalidateQueries({ queryKey: ["public-profile", userId] });
qc.invalidateQueries({ queryKey: ["admin", "users"] });
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
};
const account = accountQ.data?.user;
const isSelf = account?.id === me?.id;
const act = async (kind: "lock" | "unlock") => {
setBusy("lock");
try {
await http.post(`/api/admin/users/${userId}/${kind}`);
toast.success(kind === "lock" ? "حساب قفل شد" : "حساب فعال شد");
refresh();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const closeReset = () => {
setResetOpen(false);
setNextPassword("");
setConfirmPassword("");
};
const resetPassword = async () => {
const pwErr = validatePassword(nextPassword, confirmPassword);
if (pwErr) {
toast.error(pwErr);
return;
}
setBusy("reset");
try {
await http.post(`/api/admin/users/${userId}/reset-password`, { newPassword: nextPassword });
toast.success("رمز عبور عوض شد");
closeReset();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const del = async () => {
if (!account) {
return;
}
if (
!(await confirm(
"حذف کاربر",
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`,
))
) {
return;
}
setBusy("delete");
try {
await http.del(`/api/admin/users/${account.id}`);
toast.success("کاربر حذف شد");
router.push("/admin?tab=users");
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
return (
<>
{dialog}
<Panel
title="مدیریت حساب"
description="فقط ادمین این بخش را می‌بیند."
icon={<Shield className="h-5 w-5" />}
tint="bg-destructive/10 text-destructive"
>
{accountQ.isPending && (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-9 w-full" />
</div>
)}
{accountQ.isError && (
<p className="text-sm font-bold text-destructive">{toUserError(accountQ.error, "حساب پیدا نشد")}</p>
)}
{account && (
<div className="space-y-4">
<dl className="grid gap-3 sm:grid-cols-2">
<AccountFact icon={<Mail className="h-3.5 w-3.5" />} label="ایمیل" value={account.email} ltr />
<AccountFact
icon={<Phone className="h-3.5 w-3.5" />}
label="تلفن"
value={account.phone || "ثبت نشده"}
ltr
/>
<AccountFact
className="sm:col-span-2"
icon={<ShieldQuestion className="h-3.5 w-3.5" />}
label="سوال امنیتی"
value={account.securityQuestion || "تعریف نشده"}
/>
</dl>
<div className="space-y-2 border-t border-border/70 pt-4">
{!isSelf &&
(account.isLocked ? (
<Button size="sm" variant="success" disabled={busy !== null} onClick={() => act("unlock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Unlock className="h-3.5 w-3.5" />}
فعال‌سازی
</Button>
) : (
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => act("lock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lock className="h-3.5 w-3.5" />}
قفل
</Button>
))}
<div className={cn("grid gap-2", isSelf ? "sm:grid-cols-2" : "sm:grid-cols-3")}>
<Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}>
<Pencil className="h-3.5 w-3.5" /> ویرایش
</Button>
<Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}>
<KeyRound className="h-3.5 w-3.5" /> ریست رمز
</Button>
{!isSelf && (
<Button size="sm" variant="destructive" className="w-full" disabled={busy !== null} onClick={del}>
{busy === "delete" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
حذف
</Button>
)}
</div>
</div>
</div>
)}
</Panel>
<UserDialog user={editing} onClose={() => setEditing(null)} onSaved={refresh} />
<Dialog open={resetOpen} onOpenChange={(open) => !open && closeReset()}>
<DialogContent>
<DialogHeader>
<DialogTitle>ریست رمز</DialogTitle>
<DialogDescription>رمز جدید همین حساب را بنویسید.</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="reset-password">رمز جدید</Label>
<PasswordInput
id="reset-password"
value={nextPassword}
onChange={(e) => setNextPassword(e.target.value)}
autoComplete="new-password"
placeholder="حداقل ۸ کاراکتر"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="reset-password-confirm">تکرار رمز</Label>
<PasswordInput
id="reset-password-confirm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="همان رمز را دوباره بنویسید"
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={closeReset}>
انصراف
</Button>
<Button onClick={() => void resetPassword()} disabled={busy === "reset"}>
{busy === "reset" ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
ذخیره رمز
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function AccountFact({
icon,
label,
value,
ltr,
className,
}: {
icon: ReactNode;
label: string;
value: string;
ltr?: boolean;
className?: string;
}) {
return (
<div className={cn("rounded-xl border border-border/80 bg-muted/25 px-3.5 py-3", className)}>
<dt className="flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
{icon}
{label}
</dt>
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
{value}
</dd>
</div>
);
}
@@ -142,7 +142,7 @@ export function StatsPanel() {
const s = data?.stats; const s = data?.stats;
const cards = [ const cards = [
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "حساب‌های فعال در سامانه" }, { label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "هنرجویان با حساب فعال" },
{ label: "درس‌ها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درس‌های ثبت‌شده در درخت" }, { label: "درس‌ها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درس‌های ثبت‌شده در درخت" },
{ label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" }, { label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
{ label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاه‌ها" }, { label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاه‌ها" },
+1
View File
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
"/quiz", "/quiz",
"/notifications", "/notifications",
"/lockout", "/lockout",
"/profile",
]; ];
function needsAuth(p: string) { function needsAuth(p: string) {
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
expiresIn?: number;
};
const FALLBACK_SECONDS = 120;
export function CaptchaField({
id,
value,
onChange,
onChallenge,
onError,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onChallenge: (challenge: CaptchaChallenge | null) => void;
onError?: (message: string | null) => void;
}) {
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [loading, setLoading] = useState(true);
const [left, setLeft] = useState(FALLBACK_SECONDS);
const gen = useRef(0);
const rotating = useRef(false);
const onChallengeRef = useRef(onChallenge);
const onChangeRef = useRef(onChange);
const onErrorRef = useRef(onError);
onChallengeRef.current = onChallenge;
onChangeRef.current = onChange;
onErrorRef.current = onError;
const load = useCallback(async (silent: boolean) => {
if (silent && rotating.current) return;
if (silent) rotating.current = true;
const ticket = ++gen.current;
if (!silent) setLoading(true);
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
if (ticket !== gen.current) return;
setChallenge(data);
onChallengeRef.current(data);
setLeft(Math.max(15, Math.round(data.expiresIn || FALLBACK_SECONDS)));
onChangeRef.current("");
onErrorRef.current?.(null);
} catch (err) {
if (ticket !== gen.current) return;
if (!silent) {
setChallenge(null);
onChallengeRef.current(null);
}
onErrorRef.current?.(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
rotating.current = false;
if (ticket === gen.current && !silent) setLoading(false);
}
}, []);
useEffect(() => {
void load(false);
}, [load]);
useEffect(() => {
if (!challenge) return;
const timer = window.setInterval(() => {
setLeft((n) => {
if (n <= 1) {
void load(true);
return n;
}
return n - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [challenge, load]);
const minutes = String(Math.floor(left / 60)).padStart(1, "0");
const seconds = String(left % 60).padStart(2, "0");
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={id}>کد امنیتی</Label>
<button
type="button"
onClick={() => void load(false)}
disabled={loading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
تازه‌سازی
{challenge && (
<span className="tabular-nums text-muted-foreground" dir="ltr">
{minutes}:{seconds}
</span>
)}
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{challenge?.imageBase64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
) : (
<span className="text-sm text-muted-foreground">{loading ? "…" : "—"}</span>
)}
{challenge && (
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-0.5 bg-primary/15"
>
<span
className="block h-full bg-primary transition-[width] duration-1000 ease-linear"
style={{ width: `${Math.min(100, (left / Math.max(challenge.expiresIn || FALLBACK_SECONDS, 1)) * 100)}%` }}
/>
</span>
)}
</div>
<Input
id={id}
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Crown, Sparkles, Timer, Trophy } from "lucide-react"; import { Crown, Sparkles, Timer, Trophy } from "lucide-react";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
@@ -110,10 +111,11 @@ export function LeaderboardPanel() {
{rest.map((e) => { {rest.map((e) => {
const isMe = !!user && e.userId === user.id; const isMe = !!user && e.userId === user.id;
return ( return (
<div <Link
key={e.userId} key={e.userId}
href={`/profile/${e.userId}`}
className={cn( className={cn(
"flex items-center gap-3 rounded-xl border px-4 py-2.5", "flex items-center gap-3 rounded-xl border px-4 py-2.5 transition-colors hover:bg-muted/40",
isMe ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border bg-card" isMe ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border bg-card"
)} )}
> >
@@ -135,7 +137,7 @@ export function LeaderboardPanel() {
<span className="shrink-0 text-sm font-bold text-gold"> <span className="shrink-0 text-sm font-bold text-gold">
{formatFaNumber(Math.round(e.xp))} امتیاز {formatFaNumber(Math.round(e.xp))} امتیاز
</span> </span>
</div> </Link>
); );
})} })}
{rest.length === 0 && entries.length === 0 && ( {rest.length === 0 && entries.length === 0 && (
@@ -197,9 +199,10 @@ function PodiumCard({
}) { }) {
const isMe = myId != null && entry.userId === myId; const isMe = myId != null && entry.userId === myId;
return ( return (
<div <Link
href={`/profile/${entry.userId}`}
className={cn( className={cn(
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm", "flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm transition-colors hover:bg-muted/40",
tall ? "pb-6 pt-5" : "pb-4", tall ? "pb-6 pt-5" : "pb-4",
isMe && "border-primary/50 ring-2 ring-primary/30", isMe && "border-primary/50 ring-2 ring-primary/30",
className className
@@ -225,7 +228,7 @@ function PodiumCard({
> >
{entry.rank === 1 ? <Crown className="h-4 w-4" /> : formatFaNumber(entry.rank)} {entry.rank === 1 ? <Crown className="h-4 w-4" /> : formatFaNumber(entry.rank)}
</div> </div>
</div> </Link>
); );
} }
File diff suppressed because it is too large Load Diff
+12 -76
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, Loader2, RefreshCw, UserPlus } from "lucide-react"; import { ArrowLeft, ArrowRight, Loader2, UserPlus } from "lucide-react";
import { useAuth } from "@/lib/auth-store"; import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api"; import { http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
import { import {
validateEmail, validateEmail,
validateName, validateName,
@@ -30,11 +31,6 @@ const STEPS = [
{ id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" }, { id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" },
] as const; ] as const;
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
type RegisterFormProps = { type RegisterFormProps = {
endpoint: string; endpoint: string;
submitLabel: string; submitLabel: string;
@@ -73,30 +69,10 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
const [securityAnswer, setSecurityAnswer] = useState(""); const [securityAnswer, setSecurityAnswer] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState(""); const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null); const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaKey, setCaptchaKey] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
if (step === 3) {
void loadCaptcha();
}
}, [step, loadCaptcha]);
const goNext = () => { const goNext = () => {
setError(null); setError(null);
if (step === 1) { if (step === 1) {
@@ -152,7 +128,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
} }
if (!challenge) { if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید"); setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha(); setCaptchaKey((k) => k + 1);
return; return;
} }
setLoading(true); setLoading(true);
@@ -176,7 +152,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
router.push("/cap"); router.push("/cap");
} catch (err) { } catch (err) {
setError(toUserError(err, "ثبت‌نام ناموفق بود")); setError(toUserError(err, "ثبت‌نام ناموفق بود"));
void loadCaptcha(); setCaptchaKey((k) => k + 1);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -322,54 +298,14 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
placeholder="همان رمز را دوباره وارد کنید" placeholder="همان رمز را دوباره وارد کنید"
/> />
</Field> </Field>
<div className="space-y-2"> <CaptchaField
<div className="flex items-center justify-between gap-2"> key={captchaKey}
<Label htmlFor="reg-captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="reg-captcha" id="reg-captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer} value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())} onChange={setCaptchaAnswer}
required onChallenge={setChallenge}
dir="ltr" onError={setError}
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/> />
</div>
</div>
</> </>
)} )}
@@ -400,7 +336,7 @@ export function RegisterForm({ endpoint, submitLabel, description }: RegisterFor
variant="gradient" variant="gradient"
className="h-12 min-w-0 flex-1" className="h-12 min-w-0 flex-1"
size="lg" size="lg"
disabled={loading || captchaLoading || !challenge} disabled={loading || !challenge}
> >
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />} {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
{submitLabel} {submitLabel}
+46 -6
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react";
import BoringAvatar from "boring-avatars"; import BoringAvatar from "boring-avatars";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { mediaObjectKey } from "@/lib/media";
const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"]; const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"];
export { AVATAR_COLORS }; export { AVATAR_COLORS };
@@ -41,6 +42,40 @@ export function avatarPropsOf(u: AvatarOwner): { src: string | null; variant: Av
}; };
} }
function useSettledPhoto(src?: string | null): string | null {
const [painted, setPainted] = useState<string | null>(null);
const paintedRef = useRef<string | null>(null);
useEffect(() => {
if (!src) {
paintedRef.current = null;
setPainted(null);
return;
}
if (src === paintedRef.current) return;
let cancel = false;
const img = new Image();
img.onload = () => {
if (cancel) return;
paintedRef.current = src;
setPainted(src);
};
img.onerror = () => {
if (cancel) return;
if (mediaObjectKey(src) !== mediaObjectKey(paintedRef.current)) {
paintedRef.current = null;
setPainted(null);
}
};
img.src = src;
return () => {
cancel = true;
};
}, [src]);
return painted;
}
export function UserAvatar({ export function UserAvatar({
name, name,
src, src,
@@ -54,12 +89,17 @@ export function UserAvatar({
palette?: string[]; palette?: string[];
className?: string; className?: string;
}) { }) {
if (src) { const painted = useSettledPhoto(src);
if (painted) {
return ( return (
<Avatar className={cn("ring-1 ring-black/5 dark:ring-white/10", className)}> <span
<AvatarImage src={src} alt={name} /> className={cn(
<AvatarFallback /> "relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full bg-background ring-1 ring-black/5 dark:ring-white/10",
</Avatar> className
)}
>
<img src={painted} alt={name} className="h-full w-full object-cover" draggable={false} />
</span>
); );
} }
return ( return (
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { mediaObjectKey } from "@/lib/media";
import { loadCachedMedia } from "@/lib/transfer";
/** Local bytes for an image or similar object. A replacement has a new key and loads again. Video is not cached. */
export function useCachedObjectUrl(url: string, cacheable = true): string {
const key = mediaObjectKey(url);
const [local, setLocal] = useState("");
useEffect(() => {
if (!cacheable || !url) {
setLocal("");
return;
}
let dead = false;
let objectUrl = "";
void loadCachedMedia(url)
.then((blob) => {
if (dead) return;
objectUrl = URL.createObjectURL(blob);
setLocal(objectUrl);
})
.catch(() => {
if (!dead) setLocal(url);
});
return () => {
dead = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [cacheable, key, url]);
if (!cacheable) return url;
return local;
}
+8
View File
@@ -0,0 +1,8 @@
/** Storage path of a signed /media URL, without the rotating signature. */
export function mediaObjectKey(src: string | null | undefined): string {
if (!src) return "";
const cut = src.indexOf("?");
const path = cut >= 0 ? src.slice(0, cut) : src;
const i = path.indexOf("/media/");
return i >= 0 ? path.slice(i) : path;
}
+179
View File
@@ -0,0 +1,179 @@
import { useAuth } from "./auth-store";
import { ApiError, refreshSession, throwFromResponse } from "./api";
import { mediaObjectKey } from "./media";
const mediaCacheName = "pargar-media-v1";
function apiBase(): string {
const fromEnv = process.env.NEXT_PUBLIC_API_URL;
if (fromEnv !== undefined && fromEnv !== "") return fromEnv.replace(/\/$/, "");
return "";
}
function requestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
}
return Math.random().toString(16).slice(2, 18);
}
async function refreshOnce(): Promise<string | null> {
await refreshSession();
return useAuth.getState().accessToken;
}
type XHRResult = { status: number; body: string; blob: Blob | null };
function xhrSend(
method: "GET" | "POST",
url: string,
opts: {
token: string | null;
body?: FormData;
responseType: "json" | "blob";
onProgress?: (ratio: number) => void;
},
): Promise<XHRResult> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.withCredentials = true;
xhr.setRequestHeader("X-Request-ID", requestId());
if (opts.token) xhr.setRequestHeader("Authorization", `Bearer ${opts.token}`);
xhr.responseType = opts.responseType === "blob" ? "blob" : "text";
const progress = (e: ProgressEvent) => {
if (!opts.onProgress) return;
if (e.lengthComputable && e.total > 0) opts.onProgress(Math.min(1, e.loaded / e.total));
};
if (method === "POST") xhr.upload.onprogress = progress;
else xhr.onprogress = progress;
xhr.onload = () => {
resolve({
status: xhr.status,
body: opts.responseType === "json" ? String(xhr.response ?? "") : "",
blob: opts.responseType === "blob" ? (xhr.response as Blob) : null,
});
};
xhr.onerror = () => reject(new ApiError("ارتباط قطع شد", 0));
xhr.send(opts.body ?? null);
});
}
/** Multipart upload that reports how full the transfer is. */
export async function apiFormProgress<T>(
path: string,
form: FormData,
onProgress?: (ratio: number) => void,
): Promise<T> {
const url = `${apiBase()}${path}`;
const send = (token: string | null) =>
xhrSend("POST", url, { token, body: form, responseType: "json", onProgress });
let res = await send(useAuth.getState().accessToken);
if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
res = await send(await refreshOnce());
}
if (res.status < 200 || res.status >= 300) {
const fake = new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
await throwFromResponse(fake, `Upload failed (${res.status})`);
}
onProgress?.(1);
return JSON.parse(res.body) as T;
}
/** Download into memory and report fill ratio. Does not open a tab. */
export async function fetchBlob(
url: string,
onProgress?: (ratio: number) => void,
): Promise<Blob> {
const res = await xhrSend("GET", url, { token: null, responseType: "blob", onProgress });
if (res.status < 200 || res.status >= 300 || !res.blob) {
throw new ApiError("دانلود ممکن نشد", res.status);
}
onProgress?.(1);
return res.blob;
}
async function mediaCache(): Promise<Cache | null> {
if (typeof caches === "undefined") return null;
try {
return await caches.open(mediaCacheName);
} catch {
return null;
}
}
/** Bytes already saved for this object. A new photo, video, or voice is a new key. */
export async function readCachedMedia(url: string): Promise<Blob | null> {
try {
const key = mediaObjectKey(url);
if (!key) return null;
const cache = await mediaCache();
const hit = await cache?.match(key);
if (!hit) return null;
return hit.blob();
} catch {
return null;
}
}
async function writeCachedMedia(url: string, blob: Blob): Promise<void> {
try {
const key = mediaObjectKey(url);
if (!key) return;
const cache = await mediaCache();
if (!cache) return;
await cache.put(
key,
new Response(blob, { headers: { "Content-Type": blob.type || "application/octet-stream" } }),
);
} catch {
// A failed cache still leaves the file ready to save.
}
}
function triggerDownload(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename || "file";
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
}
/**
* Save a remote file after it has finished downloading.
* Image, audio, and other files are kept by object key, so a later save does not fetch again.
* Video is streamed and is not kept this way.
*/
export async function saveRemoteFile(
url: string,
filename: string,
onProgress?: (ratio: number) => void,
opts?: { cache?: boolean },
): Promise<void> {
const cache = opts?.cache !== false;
if (cache) {
const hit = await readCachedMedia(url);
if (hit) {
onProgress?.(1);
triggerDownload(hit, filename);
return;
}
}
const blob = await fetchBlob(url, onProgress);
if (cache) await writeCachedMedia(url, blob);
triggerDownload(blob, filename);
}
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
export async function loadCachedMedia(url: string): Promise<Blob> {
const hit = await readCachedMedia(url);
if (hit) return hit;
const blob = await fetchBlob(url);
await writeCachedMedia(url, blob);
return blob;
}
+16
View File
@@ -193,6 +193,7 @@ export interface ChatMessage {
replyTo?: number; replyTo?: number;
pinnedAt?: string; pinnedAt?: string;
pinned?: boolean; pinned?: boolean;
editedAt?: string;
attachment?: ChatAttachment; attachment?: ChatAttachment;
reactions?: ChatReaction[]; reactions?: ChatReaction[];
} }
@@ -264,6 +265,21 @@ export interface MCQQuestion {
explanation: string; explanation: string;
} }
export interface PublicProfile {
id: number;
name: string;
username: string;
role: Role;
xp: number;
hearts: number;
streakCurrent: number;
streakLongest: number;
avatarVariant?: User["avatarVariant"];
avatarPalette?: string;
avatarPhoto?: string;
isLocked: boolean;
}
export interface AdminStats { export interface AdminStats {
users: number; users: number;
lessons: number; lessons: number;