diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 96210cd..b001d38 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -22,6 +22,20 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) { 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) { query := r.URL.Query().Get("q") role := r.URL.Query().Get("role") @@ -389,27 +403,17 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") return } - target, err := s.store.GetUserByID(r.Context(), id) - if err != nil { + if _, err := s.store.GetUserByID(r.Context(), id); err != nil { writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") return } var req struct { - SecurityAnswer string `json:"securityAnswer"` - NewPassword string `json:"newPassword"` + NewPassword string `json:"newPassword"` } if err := bodyJSON(r, &req); err != nil { writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") 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 != "" { writeErr(w, http.StatusBadRequest, msg) return diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index c96fe40..1f4810e 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -306,6 +306,7 @@ func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "به‌روزرسانی آواتار ممکن نشد") return } + s.signUserMedia(user) writeJSON(w, http.StatusOK, map[string]any{"user": user}) } diff --git a/backend/internal/api/captcha.go b/backend/internal/api/captcha.go index c1aaee0..4d12084 100644 --- a/backend/internal/api/captcha.go +++ b/backend/internal/api/captcha.go @@ -22,7 +22,7 @@ import ( ) const ( - captchaTTL = 5 * time.Minute + captchaTTL = 2 * time.Minute captchaKeyPrefix = "pargar:captcha:" captchaRateLimit int64 = 60 captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" @@ -260,6 +260,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "challengeId": "dev", "imageBase64": img, + "expiresIn": int(captchaTTL.Seconds()), } if s.cfg.CaptchaExposeAnswer { resp["answer"] = "A2B3" @@ -297,6 +298,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "challengeId": id, "imageBase64": img, + "expiresIn": int(captchaTTL.Seconds()), } // Never expose plaintext answers in production. Local tests may opt in via // CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true. diff --git a/backend/internal/api/captcha_test.go b/backend/internal/api/captcha_test.go index 9a40769..1f0c265 100644 --- a/backend/internal/api/captcha_test.go +++ b/backend/internal/api/captcha_test.go @@ -30,6 +30,10 @@ func TestCaptchaImageChallenge(t *testing.T) { if id == "" || img == "" || answer == "" { 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 { t.Fatalf("legacy question field should be gone: %v", body) } diff --git a/backend/internal/api/certificate.go b/backend/internal/api/certificate.go index 37238eb..7c5bd33 100644 --- a/backend/internal/api/certificate.go +++ b/backend/internal/api/certificate.go @@ -228,12 +228,22 @@ func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) { 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 { writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد") 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) { diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go index 4c27c0f..d5f77f3 100644 --- a/backend/internal/api/chat.go +++ b/backend/internal/api/chat.go @@ -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 { continue } + s.signUserMedia(&m) safe = append(safe, map[string]any{ "id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role, "avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto, @@ -111,10 +112,16 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { unread++ } } + photo := p.AvatarPhoto + if photo != "" { + tmp := &models.User{ID: p.ID, AvatarPhoto: photo} + s.signUserMedia(tmp) + photo = tmp.AvatarPhoto + } conv := conversation{ Partner: map[string]any{ "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), }, UnreadCount: unread, @@ -137,6 +144,7 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") return } + _ = s.markIncomingChatRead(r.Context(), u, partner) if raw := r.URL.Query().Get("around"); raw != "" { aroundID, err := strconv.ParseInt(raw, 10, 64) @@ -156,7 +164,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) { return } _ = s.store.FillReactions(r.Context(), u.ID, msgs) - _ = s.store.MarkChatRead(r.Context(), u.ID, partner) s.resignChatMessages(msgs) pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner) 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.MarkChatRead(r.Context(), u.ID, partner) s.resignChatMessages(msgs) pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner) 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}) } +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) { u := currentUser(r) partner := routeID(r, "partner") @@ -473,13 +546,25 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") 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, "به‌روزرسانی وضعیت خوانده‌شدن ممکن نشد") return } 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 { if actor == nil || partnerID <= 0 || actor.ID == partnerID { return false diff --git a/backend/internal/api/chat_export.go b/backend/internal/api/chat_export.go index f72b187..520de45 100644 --- a/backend/internal/api/chat_export.go +++ b/backend/internal/api/chat_export.go @@ -57,6 +57,7 @@ type chatExportMessage struct { ReplyTo *int64 `json:"replyTo,omitempty"` Pinned bool `json:"pinned"` PinnedAt *time.Time `json:"pinnedAt,omitempty"` + EditedAt *time.Time `json:"editedAt,omitempty"` Attachment *chatExportAttachment `json:"attachment,omitempty"` } @@ -183,6 +184,7 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, [] ReplyTo: m.ReplyTo, Pinned: m.PinnedAt != nil, PinnedAt: m.PinnedAt, + EditedAt: m.EditedAt, } if m.Attachment != nil { att := &chatExportAttachment{ diff --git a/backend/internal/api/identity_test.go b/backend/internal/api/identity_test.go index 87f4052..668a50b 100644 --- a/backend/internal/api/identity_test.go +++ b/backend/internal/api/identity_test.go @@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) { } } -func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) { +func TestAdminResetPassword(t *testing.T) { e := setup(t) admin := login(t, e, bootstrapAdminEmail) 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)) 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 { - t.Fatalf("wrong answer should 403, got %d", code) + if code != http.StatusBadRequest { + t.Fatalf("short password: %d", code) } 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 { t.Fatalf("reset: %d %v", code, body) diff --git a/backend/internal/api/invites.go b/backend/internal/api/invites.go index 7b5743d..6a687ec 100644 --- a/backend/internal/api/invites.go +++ b/backend/internal/api/invites.go @@ -284,8 +284,15 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) { 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) - 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 { writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد") return diff --git a/backend/internal/api/leaderboard.go b/backend/internal/api/leaderboard.go index 971e726..5fc929b 100644 --- a/backend/internal/api/leaderboard.go +++ b/backend/internal/api/leaderboard.go @@ -63,6 +63,7 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) if err != nil { continue } + s.signUserMedia(usr) entries = append(entries, lbEntry{ UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1, AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto, diff --git a/backend/internal/api/learning.go b/backend/internal/api/learning.go index d4cb4ff..301c786 100644 --- a/backend/internal/api/learning.go +++ b/backend/internal/api/learning.go @@ -1,9 +1,11 @@ package api import ( + "errors" "net/http" "pargar/backend/internal/observability" + "pargar/backend/internal/store" ) 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") defer span.End() 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 { writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد") 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) { @@ -34,9 +49,13 @@ func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) { defer span.End() id := routeID(r, "id") detail, err := s.store.UserLearningDetail(ctx, id) - if err != nil { + if errors.Is(err, store.ErrNotFound) { writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") return } + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد") + return + } writeJSON(w, http.StatusOK, map[string]any{"learning": detail}) } diff --git a/backend/internal/api/media_sign.go b/backend/internal/api/media_sign.go index bb27298..5ea8ad4 100644 --- a/backend/internal/api/media_sign.go +++ b/backend/internal/api/media_sign.go @@ -13,12 +13,28 @@ import ( "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 { key = strings.TrimPrefix(strings.TrimSpace(key), "/") if key == "" { return "" } - exp := time.Now().Add(s.cfg.MediaURLTTL).Unix() + exp := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow) sig := mediaHMAC(s.cfg.JWTSecret, key, exp) return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s", s.cfg.PublicURL, diff --git a/backend/internal/api/media_sign_test.go b/backend/internal/api/media_sign_test.go index 4b76438..8ebeb2a 100644 --- a/backend/internal/api/media_sign_test.go +++ b/backend/internal/api/media_sign_test.go @@ -7,6 +7,24 @@ import ( "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) { 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") diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go new file mode 100644 index 0000000..0738049 --- /dev/null +++ b/backend/internal/api/profile.go @@ -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, + }, + }) +} diff --git a/backend/internal/api/recovery.go b/backend/internal/api/recovery.go index 1035774..4720420 100644 --- a/backend/internal/api/recovery.go +++ b/backend/internal/api/recovery.go @@ -5,7 +5,7 @@ import ( ) // 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) { writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است") diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 914de64..eedd606 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -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/{partner}/messages", s.requireAuth(s.handleChatMessages)) 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("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}/pin", s.requireAuth(s.handleChatPin)) 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)) // 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/{id}", admin(s.handleAdminGetUser)) 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}/unlock", staff(s.handleAdminUnlockUser)) diff --git a/backend/internal/api/surfaces_test.go b/backend/internal/api/surfaces_test.go new file mode 100644 index 0000000..71a7024 --- /dev/null +++ b/backend/internal/api/surfaces_test.go @@ -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"]) + } +} diff --git a/backend/internal/db/migrations/0022_chat_message_edits.sql b/backend/internal/db/migrations/0022_chat_message_edits.sql new file mode 100644 index 0000000..9074e7c --- /dev/null +++ b/backend/internal/db/migrations/0022_chat_message_edits.sql @@ -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; diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 0654adb..97b927e 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -219,6 +219,7 @@ type ChatMessage struct { ReplyTo *int64 `json:"replyTo,omitempty"` PinnedAt *time.Time `json:"pinnedAt,omitempty"` Pinned bool `json:"pinned"` + EditedAt *time.Time `json:"editedAt,omitempty"` Attachment *Attachment `json:"attachment,omitempty"` Reactions []ChatReaction `json:"reactions,omitempty"` } diff --git a/backend/internal/storage/mime_test.go b/backend/internal/storage/mime_test.go new file mode 100644 index 0000000..9d3a790 --- /dev/null +++ b/backend/internal/storage/mime_test.go @@ -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) + } +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index d05e82f..7ce9d77 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -243,10 +243,11 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error { } defer result.Body.Close() - ct := mimeType(key) - if result.ContentType != nil && *result.ContentType != "" { - ct = *result.ContentType + stored := "" + if result.ContentType != nil { + stored = *result.ContentType } + ct := mediaContentType(key, stored) w.Header().Set("Content-Type", ct) setInlineMediaHeaders(w, ct) w.Header().Set("Accept-Ranges", "bytes") @@ -290,8 +291,20 @@ func mimeType(key string) string { return "video/mp4" case ".webm": return "video/webm" + case ".weba": + return "audio/webm" case ".mp3": 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": return "application/pdf" 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. func setInlineMediaHeaders(w http.ResponseWriter, contentType string) { if contentType != "" { diff --git a/backend/internal/store/invites.go b/backend/internal/store/invites.go index 63746c1..ef0e0e4 100644 --- a/backend/internal/store/invites.go +++ b/backend/internal/store/invites.go @@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite( 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 { limit = 10 } @@ -157,18 +157,21 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit offset = 0 } query = strings.TrimSpace(query) + status = strings.TrimSpace(status) var total int64 if err := s.pool.QueryRow(ctx, ` 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 } rows, err := s.pool.Query(ctx, ` SELECT `+inviteCols+` FROM registration_invites WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%') + AND ($2 = '' OR status = $2) ORDER BY created_at DESC - LIMIT $2 OFFSET $3`, query, limit, offset) + LIMIT $3 OFFSET $4`, query, status, limit, offset) if err != nil { return nil, 0, err } diff --git a/backend/internal/store/learning.go b/backend/internal/store/learning.go index bd710eb..1be6b40 100644 --- a/backend/internal/store/learning.go +++ b/backend/internal/store/learning.go @@ -7,17 +7,17 @@ import ( // LearningOverview is cohort-level learning analytics for staff. type LearningOverview struct { - StudentsActive int64 `json:"studentsActive"` - StudentsLocked int64 `json:"studentsLocked"` - StudentsTotal int64 `json:"studentsTotal"` - LessonsTotal int64 `json:"lessonsTotal"` - Completions int64 `json:"completions"` - Attempts7d int64 `json:"attempts7d"` - Passes7d int64 `json:"passes7d"` - PassRate7d float64 `json:"passRate7d"` - AvgWatchPct float64 `json:"avgWatchPct"` - XPAwarded7d int64 `json:"xpAwarded7d"` - StudentsStreaking int64 `json:"studentsStreaking"` + StudentsActive int64 `json:"studentsActive"` + StudentsLocked int64 `json:"studentsLocked"` + StudentsTotal int64 `json:"studentsTotal"` + LessonsTotal int64 `json:"lessonsTotal"` + Completions int64 `json:"completions"` + Attempts7d int64 `json:"attempts7d"` + Passes7d int64 `json:"passes7d"` + PassRate7d float64 `json:"passRate7d"` + AvgWatchPct float64 `json:"avgWatchPct"` + XPAwarded7d int64 `json:"xpAwarded7d"` + StudentsStreaking int64 `json:"studentsStreaking"` } func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) { @@ -81,10 +81,40 @@ type StudentLearningRow struct { 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 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, ` 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' GROUP BY user_id ) a ON a.user_id = u.id - WHERE u.role = 'student' - AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%') - ORDER BY u.xp DESC, u.id ASC - LIMIT 500`, q) + WHERE `+where+` + ORDER BY `+order+` + LIMIT $2 OFFSET $3`, q, limit, offset) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() 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.LessonsPassed, &r.Attempts7d, &r.Passes7d, ); err != nil { - return nil, err + return nil, 0, err } if r.LessonsTotal > 0 { r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100 @@ -132,21 +161,21 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea } out = append(out, r) } - return out, rows.Err() + return out, total, rows.Err() } type LessonLearningDetail struct { - LessonID int64 `json:"lessonId"` - ChapterID int64 `json:"chapterId"` - ChapterTitle string `json:"chapterTitle"` - Title string `json:"title"` - SortOrder int `json:"sortOrder"` - WatchedPct float64 `json:"watchedPct"` - QuizUnlocked bool `json:"quizUnlocked"` - PassedQuiz bool `json:"passedQuiz"` - QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"` - LastAttemptStatus string `json:"lastAttemptStatus,omitempty"` - LastAttemptScore float64 `json:"lastAttemptScore,omitempty"` + LessonID int64 `json:"lessonId"` + ChapterID int64 `json:"chapterId"` + ChapterTitle string `json:"chapterTitle"` + Title string `json:"title"` + SortOrder int `json:"sortOrder"` + WatchedPct float64 `json:"watchedPct"` + QuizUnlocked bool `json:"quizUnlocked"` + PassedQuiz bool `json:"passedQuiz"` + QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"` + LastAttemptStatus string `json:"lastAttemptStatus,omitempty"` + LastAttemptScore float64 `json:"lastAttemptScore,omitempty"` } type AttemptLearningDetail struct { @@ -178,11 +207,11 @@ type WatchSessionDetail struct { } type UserLearningDetail struct { - User StudentLearningRow `json:"user"` - Lessons []LessonLearningDetail `json:"lessons"` - Attempts []AttemptLearningDetail `json:"attempts"` - XPEvents []XPEventDetail `json:"xpEvents"` - Sessions []WatchSessionDetail `json:"sessions"` + User StudentLearningRow `json:"user"` + Lessons []LessonLearningDetail `json:"lessons"` + Attempts []AttemptLearningDetail `json:"attempts"` + XPEvents []XPEventDetail `json:"xpEvents"` + Sessions []WatchSessionDetail `json:"sessions"` } func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) { @@ -190,30 +219,28 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear if err != nil { return nil, err } - list, err := s.ListStudentLearning(ctx, "") - if err != nil { + row := StudentLearningRow{ + ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email, + XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent, + IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate, + } + if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal); err != nil { return nil, err } - var row StudentLearningRow - found := false - for _, r := range list { - if r.ID == userID { - row = r - found = true - break - } + 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 !found { - row = StudentLearningRow{ - ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email, - XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent, - IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate, - } - _ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal) - _ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed) - if row.LessonsTotal > 0 { - row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100 - } + if row.LessonsTotal > 0 { + 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{}} @@ -221,7 +248,7 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear lrows, err := s.pool.Query(ctx, ` 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(la.status, ''), COALESCE(la.score_pct, 0) + COALESCE(la.status::text, ''), COALESCE(la.score_pct, 0) FROM lessons l 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 diff --git a/backend/internal/store/learning_test.go b/backend/internal/store/learning_test.go index 4e100b1..f817a2b 100644 --- a/backend/internal/store/learning_test.go +++ b/backend/internal/store/learning_test.go @@ -28,7 +28,7 @@ func TestLearningOverviewSmoke(t *testing.T) { if o == nil { t.Fatal("nil overview") } - students, err := st.ListStudentLearning(ctx, "") + students, _, err := st.ListStudentLearning(ctx, "", "", "xp", 10, 0) if err != nil { t.Fatal(err) } diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index fa5e28f..8956274 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -504,7 +504,26 @@ func (s *Store) GetPhysicalOrder(ctx context.Context, id int64) (*models.Certifi 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 := ` 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, @@ -512,16 +531,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models u.name, c.public_id FROM certificate_physical_orders o JOIN users u ON u.id = o.user_id - JOIN certificates c ON c.id = o.certificate_id` - args := []any{} - if status != "" { - q += ` WHERE o.status=$1` - args = append(args, status) - } - q += ` ORDER BY o.created_at DESC LIMIT 200` + 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 = append(args, limit, offset) rows, err := s.pool.Query(ctx, q, args...) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() 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, &o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode, &o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil { - return nil, err + return nil, 0, err } 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) { @@ -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) 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, - 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) 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) 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, - 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) return scanMessage(row) } @@ -1138,6 +1152,48 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in 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 // 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, ` 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, - 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) } var n int @@ -1188,7 +1244,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID out := s.pool.QueryRow(ctx, ` 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, - 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) } @@ -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) { rows, err := s.pool.Query(ctx, ` 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 WHERE pinned_at IS NOT NULL 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) { rows, err := s.pool.Query(ctx, ` 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 WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) 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, ` 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 WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) AND id <= $3 @@ -1302,7 +1358,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a newer, err := s.pool.Query(ctx, ` 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 WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) AND id > $3 @@ -1345,7 +1401,7 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) { var attSize *int64 if err := row.Scan( &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 { return nil, err } @@ -1372,11 +1428,20 @@ func derefInt(p *int64) int64 { return *p } -func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error { - _, err := s.pool.Exec(ctx, ` - UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`, - userID, mentorID) - return err +// MarkChatRead stamps messages the other person sent. Opening the thread must not +// mark my own messages as seen. +func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) { + tag, err := s.pool.Exec(ctx, ` + 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. @@ -1743,7 +1808,7 @@ func (s *Store) AllActiveUserIDs(ctx context.Context) ([]int64, error) { func (s *Store) Stats(ctx context.Context) (map[string]any, error) { 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 } if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil { diff --git a/deploy/helm/pargar/Chart.yaml b/deploy/helm/pargar/Chart.yaml index 43ab2a4..7e2807c 100644 --- a/deploy/helm/pargar/Chart.yaml +++ b/deploy/helm/pargar/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: pargar description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage type: application -version: 0.2.0 -appVersion: "0.1.0" +version: 0.2.1 +appVersion: "0.1.3" keywords: - lms - pargar diff --git a/deploy/helm/pargar/values-prod.yaml b/deploy/helm/pargar/values-prod.yaml index 7bddfe5..67e8be4 100644 --- a/deploy/helm/pargar/values-prod.yaml +++ b/deploy/helm/pargar/values-prod.yaml @@ -22,7 +22,7 @@ backend: replicaCount: 2 image: repository: backend - tag: "0.1.0" + tag: "0.1.3" pullPolicy: IfNotPresent existingSecret: pargar-app env: @@ -49,7 +49,7 @@ frontend: replicaCount: 2 image: repository: frontend - tag: "0.1.0" + tag: "0.1.3" pullPolicy: IfNotPresent resources: requests: diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index f7b9723..639d080 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; 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 { useAuth } from "@/lib/auth-store"; import { http, toUserError } from "@/lib/api"; @@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field"; const perks = [ { icon: Timer, text: "زمان تماشای راستی‌آزمایی‌شده — بدون میان‌بر" }, @@ -22,11 +23,6 @@ const perks = [ { icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یک‌به‌یک" }, ]; -type CaptchaChallenge = { - challengeId: string; - imageBase64: string; -}; - export default function LoginPage() { const router = useRouter(); const setSession = useAuth((s) => s.setSession); @@ -34,7 +30,7 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [captchaAnswer, setCaptchaAnswer] = useState(""); const [challenge, setChallenge] = useState(null); - const [captchaLoading, setCaptchaLoading] = useState(true); + const [captchaKey, setCaptchaKey] = useState(0); const [rememberMe, setRememberMe] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -44,29 +40,11 @@ export default function LoginPage() { queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"), }); - const loadCaptcha = useCallback(async () => { - setCaptchaLoading(true); - setCaptchaAnswer(""); - try { - const data = await http.get("/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) => { e.preventDefault(); if (!challenge) { setError("ابتدا کد امنیتی را بارگذاری کنید"); - void loadCaptcha(); + setCaptchaKey((k) => k + 1); return; } setLoading(true); @@ -83,7 +61,7 @@ export default function LoginPage() { router.push("/cap"); } catch (err) { setError(toUserError(err, "ورود ناموفق بود")); - void loadCaptcha(); + setCaptchaKey((k) => k + 1); } finally { setLoading(false); } @@ -153,54 +131,14 @@ export default function LoginPage() { placeholder="رمز عبور" /> -
-
- - -
-
-
- {captchaLoading || !challenge?.imageBase64 ? ( - … - ) : ( - // eslint-disable-next-line @next/next/no-img-element - کد امنیتی - )} -
- setCaptchaAnswer(e.target.value.toUpperCase())} - required - dir="ltr" - className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center" - placeholder="کد تصویر" - maxLength={6} - /> -
-
+