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:
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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, "بازیابی رمز عمومی غیرفعال است")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: "جلسات و کارگاهها" },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const PROTECTED_PREFIXES = [
|
|||||||
"/quiz",
|
"/quiz",
|
||||||
"/notifications",
|
"/notifications",
|
||||||
"/lockout",
|
"/lockout",
|
||||||
|
"/profile",
|
||||||
];
|
];
|
||||||
|
|
||||||
function needsAuth(p: string) {
|
function needsAuth(p: string) {
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user