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

Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
This commit is contained in:
ahp
2026-10-09 19:05:59 +03:30
parent 3cce2158e9
commit d9a2abb44f
45 changed files with 2337 additions and 573 deletions
+16 -12
View File
@@ -22,6 +22,20 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
}
func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": u})
}
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
role := r.URL.Query().Get("role")
@@ -389,27 +403,17 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
return
}
target, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
if _, err := s.store.GetUserByID(r.Context(), id); err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
var req struct {
SecurityAnswer string `json:"securityAnswer"`
NewPassword string `json:"newPassword"`
NewPassword string `json:"newPassword"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if target.SecurityAnswerHash == "" {
writeErr(w, http.StatusBadRequest, "برای این کاربر سوال امنیتی تعریف نشده است")
return
}
if !s.auth.CheckPassword(target.SecurityAnswerHash, strings.ToLower(strings.TrimSpace(req.SecurityAnswer))) {
writeErr(w, http.StatusForbidden, "جواب سوال امنیتی نادرست است")
return
}
if msg := validatePasswordOnly(req.NewPassword); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
+1
View File
@@ -306,6 +306,7 @@ func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی آواتار ممکن نشد")
return
}
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+3 -1
View File
@@ -22,7 +22,7 @@ import (
)
const (
captchaTTL = 5 * time.Minute
captchaTTL = 2 * time.Minute
captchaKeyPrefix = "pargar:captcha:"
captchaRateLimit int64 = 60
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -260,6 +260,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"challengeId": "dev",
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
if s.cfg.CaptchaExposeAnswer {
resp["answer"] = "A2B3"
@@ -297,6 +298,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"challengeId": id,
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
// Never expose plaintext answers in production. Local tests may opt in via
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
+4
View File
@@ -30,6 +30,10 @@ func TestCaptchaImageChallenge(t *testing.T) {
if id == "" || img == "" || answer == "" {
t.Fatalf("captcha missing fields: %v", body)
}
exp, ok := body["expiresIn"].(float64)
if !ok || exp < 60 || exp > 180 {
t.Fatalf("expiresIn should be a short window: %v", body["expiresIn"])
}
if _, ok := body["question"]; ok {
t.Fatalf("legacy question field should be gone: %v", body)
}
+12 -2
View File
@@ -228,12 +228,22 @@ func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
orders, err := s.store.ListPhysicalOrders(r.Context(), status)
p := parsePageParams(r)
orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())})
if orders == nil {
orders = []models.CertificatePhysicalOrder{}
}
writeJSON(w, http.StatusOK, map[string]any{
"orders": orders,
"total": total,
"page": p.Page,
"pageSize": p.PageSize,
"physical": s.physicalSettings(r.Context()),
})
}
func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) {
+89 -4
View File
@@ -37,6 +37,7 @@ func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) {
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
continue
}
s.signUserMedia(&m)
safe = append(safe, map[string]any{
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
@@ -111,10 +112,16 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
unread++
}
}
photo := p.AvatarPhoto
if photo != "" {
tmp := &models.User{ID: p.ID, AvatarPhoto: photo}
s.signUserMedia(tmp)
photo = tmp.AvatarPhoto
}
conv := conversation{
Partner: map[string]any{
"id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role,
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": p.AvatarPhoto,
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
"online": s.hub.Online(p.ID),
},
UnreadCount: unread,
@@ -137,6 +144,7 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
_ = s.markIncomingChatRead(r.Context(), u, partner)
if raw := r.URL.Query().Get("around"); raw != "" {
aroundID, err := strconv.ParseInt(raw, 10, 64)
@@ -156,7 +164,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
return
}
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil {
@@ -197,7 +204,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
}
}
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil {
@@ -337,6 +343,73 @@ func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
}
func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
partner := routeID(r, "partner")
if !s.canChatWith(r.Context(), u, partner) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
msgID := routeID(r, "id")
var req struct {
Body string `json:"body"`
Attachment *models.Attachment `json:"attachment,omitempty"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
req.Body = strings.TrimSpace(req.Body)
if len(req.Body) > 4000 {
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
return
}
var (
msg *models.ChatMessage
err error
)
if req.Attachment != nil {
att := req.Attachment
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
if att.Type != "image" && att.Type != "video" && att.Type != "audio" {
writeErr(w, http.StatusBadRequest, "فقط عکس، ویدیو یا صدا را می‌توان جایگزین کرد")
return
}
key := mediaKeyFromURL(att.URL)
if key == "" || !mediaKeyOwnedByUser(key, u.ID) {
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست")
return
}
if q := strings.Index(att.URL, "?"); q >= 0 {
att.URL = att.URL[:q]
}
if att.Name == "" {
att.Name = "file"
}
msg, err = s.store.ReplaceChatAttachment(r.Context(), u.ID, partner, msgID, u.Role, req.Body, att.Type, att.URL, att.Name, att.Size)
} else {
if req.Body == "" {
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
return
}
msg, err = s.store.EditChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body)
}
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
return
}
s.resignChatMessage(msg)
event := map[string]any{"type": "chat_edit", "item": msg, "from": u.ID}
s.hub.Push(u.ID, event)
s.hub.Push(partner, event)
writeJSON(w, http.StatusOK, map[string]any{"message": msg})
}
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
partner := routeID(r, "partner")
@@ -473,13 +546,25 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
if err := s.store.MarkChatRead(r.Context(), u.ID, partner); err != nil {
if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت خوانده‌شدن ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User, partnerID int64) error {
if reader == nil {
return nil
}
n, err := s.store.MarkChatRead(ctx, reader.ID, partnerID, reader.Role)
if err != nil || n == 0 {
return err
}
s.hub.Push(partnerID, map[string]any{"type": "chat_read", "from": reader.ID})
return nil
}
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
if actor == nil || partnerID <= 0 || actor.ID == partnerID {
return false
+2
View File
@@ -57,6 +57,7 @@ type chatExportMessage struct {
ReplyTo *int64 `json:"replyTo,omitempty"`
Pinned bool `json:"pinned"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *chatExportAttachment `json:"attachment,omitempty"`
}
@@ -183,6 +184,7 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
ReplyTo: m.ReplyTo,
Pinned: m.PinnedAt != nil,
PinnedAt: m.PinnedAt,
EditedAt: m.EditedAt,
}
if m.Attachment != nil {
att := &chatExportAttachment{
+5 -5
View File
@@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) {
}
}
func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
func TestAdminResetPassword(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
@@ -59,14 +59,14 @@ func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
uid := int64(body["user"].(map[string]any)["id"].(float64))
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "اشتباه", "newPassword": "newpass123",
"newPassword": "short",
})
if code != http.StatusForbidden {
t.Fatalf("wrong answer should 403, got %d", code)
if code != http.StatusBadRequest {
t.Fatalf("short password: %d", code)
}
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "تهران", "newPassword": "newpass123",
"newPassword": "newpass123",
})
if code != http.StatusOK {
t.Fatalf("reset: %d %v", code, body)
+8 -1
View File
@@ -284,8 +284,15 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
switch models.InviteStatus(status) {
case "", models.InviteActive, models.InvitePaused, models.InviteRevoked, models.InviteExhausted:
default:
writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است")
return
}
p := parsePageParams(r)
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset)
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, status, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد")
return
+1
View File
@@ -63,6 +63,7 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
if err != nil {
continue
}
s.signUserMedia(usr)
entries = append(entries, lbEntry{
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
+22 -3
View File
@@ -1,9 +1,11 @@
package api
import (
"errors"
"net/http"
"pargar/backend/internal/observability"
"pargar/backend/internal/store"
)
func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) {
@@ -21,12 +23,25 @@ func (s *Server) handleLearningStudents(w http.ResponseWriter, r *http.Request)
ctx, span := observability.StartSpan(r.Context(), "admin.learning.students")
defer span.End()
q := r.URL.Query().Get("q")
rows, err := s.store.ListStudentLearning(ctx, q)
filter := r.URL.Query().Get("filter")
switch filter {
case "active", "locked":
default:
filter = ""
}
sort := r.URL.Query().Get("sort")
switch sort {
case "progress", "streak", "activity":
default:
sort = "xp"
}
p := parsePageParams(r)
rows, total, err := s.store.ListStudentLearning(ctx, q, filter, sort, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"students": rows})
writePage(w, rows, total, p)
}
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
@@ -34,9 +49,13 @@ func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
defer span.End()
id := routeID(r, "id")
detail, err := s.store.UserLearningDetail(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
}
+17 -1
View File
@@ -13,12 +13,28 @@ import (
"pargar/backend/internal/models"
)
// mediaSignWindow keeps one signature for a stretch of time. The same object
// then keeps the same URL, so clients do not reload a photo that did not change.
const mediaSignWindow = 15 * time.Minute
func stableMediaExpiry(now time.Time, ttl, window time.Duration) int64 {
if window <= 0 {
window = mediaSignWindow
}
if ttl < window {
ttl = window
}
w := int64(window.Seconds())
slot := now.Unix() / w
return (slot+1)*w + int64(ttl.Seconds())
}
func (s *Server) signMediaURL(key string) string {
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" {
return ""
}
exp := time.Now().Add(s.cfg.MediaURLTTL).Unix()
exp := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow)
sig := mediaHMAC(s.cfg.JWTSecret, key, exp)
return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s",
s.cfg.PublicURL,
+18
View File
@@ -7,6 +7,24 @@ import (
"pargar/backend/internal/config"
)
func TestStableMediaExpiryHoldsInsideWindow(t *testing.T) {
start := time.Unix((1_700_000_000/900)*900, 0)
ttl := time.Hour
window := 15 * time.Minute
a := stableMediaExpiry(start, ttl, window)
b := stableMediaExpiry(start.Add(14*time.Minute), ttl, window)
if a != b {
t.Fatalf("same window should keep one expiry: %d vs %d", a, b)
}
c := stableMediaExpiry(start.Add(15*time.Minute), ttl, window)
if c == a {
t.Fatal("next window should mint a new expiry")
}
if c < a {
t.Fatal("expiry should move forward")
}
}
func TestMediaSignatureRoundTrip(t *testing.T) {
s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}}
url := s.signMediaURL("videos/lesson-1.mp4")
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"errors"
"net/http"
"pargar/backend/internal/store"
)
func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
s.signUserMedia(u)
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": u.ID,
"name": u.Name,
"username": u.Username,
"role": u.Role,
"xp": u.XP,
"hearts": u.Hearts,
"streakCurrent": u.StreakCurrent,
"streakLongest": u.StreakLongest,
"avatarVariant": u.AvatarVariant,
"avatarPalette": u.AvatarPalette,
"avatarPhoto": u.AvatarPhoto,
"isLocked": u.IsLocked,
},
})
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
)
// Public self-service password recovery is disabled for students.
// Staff reset passwords from the admin panel (requires the account security answer).
// Staff set a new password from the admin profile. Public recovery stays off.
func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است")
+3
View File
@@ -56,7 +56,9 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations))
mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages))
mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend))
mux.HandleFunc("PUT /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatEdit))
mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete))
mux.HandleFunc("GET /api/users/{id}", s.requireAuth(s.handlePublicProfile))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin))
mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload)))
@@ -89,6 +91,7 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning))
// Account administration is admin-only; mentors may unlock locked students only.
mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers))
mux.HandleFunc("GET /api/admin/users/{id}", admin(s.handleAdminGetUser))
mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser))
mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser))
mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser))
+330
View File
@@ -0,0 +1,330 @@
package api_test
import (
"strings"
"testing"
)
func TestChatEditOwnMessage(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Chat Editor", "chat-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "سلام"})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": "سلام ویرایش"})
if code != 200 {
t.Fatalf("edit: %d %v", code, body)
}
edited := body["message"].(map[string]any)
if edited["body"] != "سلام ویرایش" || edited["editedAt"] == nil {
t.Fatalf("expected edited body, got %v", edited)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{"body": "نه"})
if code != 404 {
t.Fatalf("partner edit should be rejected: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": " "})
if code != 400 {
t.Fatalf("empty edit: %d", code)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("list: %d %v", code, body)
}
msgs := body["messages"].([]any)
last := msgs[len(msgs)-1].(map[string]any)
if last["body"] != "سلام ویرایش" || last["editedAt"] == nil {
t.Fatalf("listed message not edited: %v", last)
}
}
func TestChatReplaceAttachment(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Media Editor", "media-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
code, up := uploadChatFile(t, e, student, "one.png", png)
if code != 201 {
t.Fatalf("upload: %d %v", code, up)
}
first := up["attachment"].(map[string]any)
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"body": "",
"attachment": first,
})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
png[8] = 0x01
code, up = uploadChatFile(t, e, student, "two.png", png)
if code != 201 {
t.Fatalf("upload replacement: %d %v", code, up)
}
next := up["attachment"].(map[string]any)
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": next,
})
if code != 200 {
t.Fatalf("replace: %d %v", code, body)
}
edited := body["message"].(map[string]any)
newPath := mediaPath(edited["attachment"].(map[string]any)["url"])
if edited["editedAt"] == nil || newPath == "" || newPath == oldPath {
t.Fatalf("expected a replaced attachment, old %s new %v", oldPath, edited)
}
if edited["attachment"].(map[string]any)["type"] != "image" {
t.Fatalf("type: %v", edited["attachment"])
}
wrong := map[string]any{"type": "video", "url": next["url"], "name": "clip.mp4", "size": next["size"]}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": wrong,
})
if code != 404 {
t.Fatalf("different kind should miss: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
"attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1},
})
if code != 400 {
t.Fatalf("file replace: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{
"body": "",
"attachment": next,
})
if code != 400 {
t.Fatalf("partner replace: %d", code)
}
}
func TestChatReadMarksOnlyIncoming(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Seen Student", "seen-student@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "از هنرجو"})
if code != 201 {
t.Fatalf("student send: %d %v", code, body)
}
studentMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("student list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] != nil {
t.Fatalf("opening my own thread marked it seen: %v", chatMsg(t, body, studentMsg))
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("partner open should mark the incoming message seen")
}
code, body = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", admin, map[string]any{"body": "از ادمین"})
if code != 201 {
t.Fatalf("admin send: %d %v", code, body)
}
adminMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list again: %d %v", code, body)
}
if chatMsg(t, body, adminMsg)["readAt"] != nil {
t.Fatalf("admin own message was marked seen: %v", chatMsg(t, body, adminMsg))
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("already seen incoming message lost its receipt")
}
}
func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any {
t.Helper()
for _, raw := range body["messages"].([]any) {
msg := raw.(map[string]any)
if int64(msg["id"].(float64)) == id {
return msg
}
}
t.Fatalf("message %d missing in %v", id, body["messages"])
return nil
}
func mediaPath(raw any) string {
s, _ := raw.(string)
if i := strings.IndexByte(s, '?'); i >= 0 {
return s[:i]
}
return s
}
func TestLearningDetailMatchesActiveStudents(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 0 {
t.Fatalf("overview should ignore staff, got %v", body["stats"])
}
_, uid := register(t, e, "Active Student", "active-student@test.dev")
code, body = e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats after student: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 1 {
t.Fatalf("expected 1 active student, got %v", body["stats"])
}
code, body = e.do(t, "GET", "/api/admin/learning/overview", admin, nil)
if code != 200 {
t.Fatalf("overview: %d %v", code, body)
}
if body["overview"].(map[string]any)["studentsActive"].(float64) != 1 {
t.Fatalf("learning active mismatch: %v", body["overview"])
}
code, body = e.do(t, "GET", "/api/admin/users/"+itoa(uid)+"/learning", admin, nil)
if code != 200 {
t.Fatalf("learning detail: %d %v", code, body)
}
user := body["learning"].(map[string]any)["user"].(map[string]any)
if user["name"] != "Active Student" {
t.Fatalf("detail user: %v", user)
}
code, body = e.do(t, "GET", "/api/admin/users/999999/learning", admin, nil)
if code != 404 {
t.Fatalf("missing user: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/learning/students?page=1&pageSize=10", admin, nil)
if code != 200 {
t.Fatalf("students page: %d %v", code, body)
}
if body["total"].(float64) != 1 || len(body["items"].([]any)) != 1 {
t.Fatalf("expected one page row, got %v", body)
}
}
func TestInviteStatusFilter(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/invites", admin, map[string]any{"maxUses": 2, "label": "مهر"})
if code != 201 {
t.Fatalf("create invite: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=active", admin, nil)
if code != 200 {
t.Fatalf("active filter: %d %v", code, body)
}
if body["total"].(float64) < 1 {
t.Fatalf("expected active invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=revoked", admin, nil)
if code != 200 {
t.Fatalf("revoked filter: %d %v", code, body)
}
if body["total"].(float64) != 0 {
t.Fatalf("expected no revoked invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=nope", admin, nil)
if code != 400 {
t.Fatalf("bad status: %d %v", code, body)
}
}
func TestPublicProfileOmitsPrivateFields(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Public Name", "public-name@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "GET", "/api/users/"+itoa(adminID), student, nil)
if code != 200 {
t.Fatalf("profile: %d %v", code, body)
}
user := body["user"].(map[string]any)
if user["name"] == nil || user["name"] == "" {
t.Fatalf("missing name: %v", user)
}
if _, ok := user["email"]; ok {
t.Fatalf("email must not be public: %v", user)
}
if _, ok := user["phone"]; ok {
t.Fatalf("phone must not be public: %v", user)
}
code, adminView := e.do(t, "GET", "/api/admin/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("admin user: %d %v", code, adminView)
}
managed := adminView["user"].(map[string]any)
if managed["email"] != "public-name@test.dev" {
t.Fatalf("admin should see email: %v", managed)
}
if _, ok := managed["passwordHash"]; ok {
t.Fatalf("password hash must stay hidden: %v", managed)
}
code, hidden := e.do(t, "GET", "/api/admin/users/"+itoa(adminID), student, nil)
if code != 403 {
t.Fatalf("student must not open admin user: %d %v", code, hidden)
}
code, body = e.do(t, "GET", "/api/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("student profile: %d %v", code, body)
}
if body["user"].(map[string]any)["username"] != "public-name" {
t.Fatalf("username: %v", body["user"])
}
}
@@ -0,0 +1,3 @@
-- Own-message edits. Nullable so existing rows stay unedited.
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
+1
View File
@@ -219,6 +219,7 @@ type ChatMessage struct {
ReplyTo *int64 `json:"replyTo,omitempty"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
Pinned bool `json:"pinned"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *Attachment `json:"attachment,omitempty"`
Reactions []ChatReaction `json:"reactions,omitempty"`
}
+15
View File
@@ -0,0 +1,15 @@
package storage
import "testing"
func TestVoiceNoteContentType(t *testing.T) {
if got := mimeType("chats/2-1.weba"); got != "audio/webm" {
t.Fatalf("weba: %s", got)
}
if got := mediaContentType("chats/2-1.weba", "application/octet-stream"); got != "audio/webm" {
t.Fatalf("generic store type must not hide the voice type: %s", got)
}
if got := mediaContentType("chats/2-1.bin", "application/pdf"); got != "application/pdf" {
t.Fatalf("unknown extension keeps the stored type: %s", got)
}
}
+33 -3
View File
@@ -243,10 +243,11 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
}
defer result.Body.Close()
ct := mimeType(key)
if result.ContentType != nil && *result.ContentType != "" {
ct = *result.ContentType
stored := ""
if result.ContentType != nil {
stored = *result.ContentType
}
ct := mediaContentType(key, stored)
w.Header().Set("Content-Type", ct)
setInlineMediaHeaders(w, ct)
w.Header().Set("Accept-Ranges", "bytes")
@@ -290,8 +291,20 @@ func mimeType(key string) string {
return "video/mp4"
case ".webm":
return "video/webm"
case ".weba":
return "audio/webm"
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".ogg", ".opus":
return "audio/ogg"
case ".m4a", ".aac":
return "audio/mp4"
case ".mov":
return "video/quicktime"
case ".m4v":
return "video/mp4"
case ".pdf":
return "application/pdf"
default:
@@ -299,6 +312,23 @@ func mimeType(key string) string {
}
}
// mediaContentType keeps a known extension ahead of a generic object-store type.
// Voice notes are stored as .weba; a default octet-stream plus nosniff stops playback.
func mediaContentType(key, stored string) string {
guessed := mimeType(key)
if guessed != "application/octet-stream" {
return guessed
}
stored = strings.TrimSpace(stored)
if i := strings.Index(stored, ";"); i >= 0 {
stored = strings.TrimSpace(stored[:i])
}
if stored != "" {
return stored
}
return guessed
}
// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact.
func setInlineMediaHeaders(w http.ResponseWriter, contentType string) {
if contentType != "" {
+6 -3
View File
@@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite(
return scanInvite(row)
}
func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
func (s *Store) ListRegistrationInvites(ctx context.Context, query, status string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
if limit <= 0 {
limit = 10
}
@@ -157,18 +157,21 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit
offset = 0
}
query = strings.TrimSpace(query)
status = strings.TrimSpace(status)
var total int64
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)`, query, status).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT `+inviteCols+`
FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, query, limit, offset)
LIMIT $3 OFFSET $4`, query, status, limit, offset)
if err != nil {
return nil, 0, err
}
+85 -58
View File
@@ -7,17 +7,17 @@ import (
// LearningOverview is cohort-level learning analytics for staff.
type LearningOverview struct {
StudentsActive int64 `json:"studentsActive"`
StudentsLocked int64 `json:"studentsLocked"`
StudentsTotal int64 `json:"studentsTotal"`
LessonsTotal int64 `json:"lessonsTotal"`
Completions int64 `json:"completions"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
AvgWatchPct float64 `json:"avgWatchPct"`
XPAwarded7d int64 `json:"xpAwarded7d"`
StudentsStreaking int64 `json:"studentsStreaking"`
StudentsActive int64 `json:"studentsActive"`
StudentsLocked int64 `json:"studentsLocked"`
StudentsTotal int64 `json:"studentsTotal"`
LessonsTotal int64 `json:"lessonsTotal"`
Completions int64 `json:"completions"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
AvgWatchPct float64 `json:"avgWatchPct"`
XPAwarded7d int64 `json:"xpAwarded7d"`
StudentsStreaking int64 `json:"studentsStreaking"`
}
func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) {
@@ -81,10 +81,40 @@ type StudentLearningRow struct {
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
}
func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, error) {
func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string, limit, offset int) ([]StudentLearningRow, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
var lessonsTotal int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
return nil, err
return nil, 0, err
}
where := `u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')`
switch filter {
case "active":
where += ` AND u.is_active AND NOT u.is_locked`
case "locked":
where += ` AND u.is_locked`
}
order := `u.xp DESC, u.id ASC`
switch sort {
case "progress":
order = `COALESCE(p.passed, 0) DESC, u.id ASC`
case "streak":
order = `u.streak_current DESC, u.id ASC`
case "activity":
order = `u.last_activity_date DESC NULLS LAST, u.id ASC`
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users u WHERE `+where, q).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
@@ -105,12 +135,11 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
WHERE created_at >= now() - interval '7 days'
GROUP BY user_id
) a ON a.user_id = u.id
WHERE u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')
ORDER BY u.xp DESC, u.id ASC
LIMIT 500`, q)
WHERE `+where+`
ORDER BY `+order+`
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, err
return nil, 0, err
}
defer rows.Close()
out := make([]StudentLearningRow, 0)
@@ -122,7 +151,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
); err != nil {
return nil, err
return nil, 0, err
}
if r.LessonsTotal > 0 {
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
@@ -132,21 +161,21 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
}
out = append(out, r)
}
return out, rows.Err()
return out, total, rows.Err()
}
type LessonLearningDetail struct {
LessonID int64 `json:"lessonId"`
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
Title string `json:"title"`
SortOrder int `json:"sortOrder"`
WatchedPct float64 `json:"watchedPct"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
LessonID int64 `json:"lessonId"`
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
Title string `json:"title"`
SortOrder int `json:"sortOrder"`
WatchedPct float64 `json:"watchedPct"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
}
type AttemptLearningDetail struct {
@@ -178,11 +207,11 @@ type WatchSessionDetail struct {
}
type UserLearningDetail struct {
User StudentLearningRow `json:"user"`
Lessons []LessonLearningDetail `json:"lessons"`
Attempts []AttemptLearningDetail `json:"attempts"`
XPEvents []XPEventDetail `json:"xpEvents"`
Sessions []WatchSessionDetail `json:"sessions"`
User StudentLearningRow `json:"user"`
Lessons []LessonLearningDetail `json:"lessons"`
Attempts []AttemptLearningDetail `json:"attempts"`
XPEvents []XPEventDetail `json:"xpEvents"`
Sessions []WatchSessionDetail `json:"sessions"`
}
func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) {
@@ -190,30 +219,28 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
if err != nil {
return nil, err
}
list, err := s.ListStudentLearning(ctx, "")
if err != nil {
row := StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal); err != nil {
return nil, err
}
var row StudentLearningRow
found := false
for _, r := range list {
if r.ID == userID {
row = r
found = true
break
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed); err != nil {
return nil, err
}
if !found {
row = StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
}
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal)
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed)
if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
}
if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
}
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*), COUNT(*) FILTER (WHERE status='passed')
FROM quiz_attempts
WHERE user_id=$1 AND created_at >= now() - interval '7 days'`, userID).Scan(&row.Attempts7d, &row.Passes7d); err != nil {
return nil, err
}
if row.Attempts7d > 0 {
row.PassRate7d = float64(row.Passes7d) / float64(row.Attempts7d) * 100
}
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
@@ -221,7 +248,7 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
lrows, err := s.pool.Query(ctx, `
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
COALESCE(la.status, ''), COALESCE(la.score_pct, 0)
COALESCE(la.status::text, ''), COALESCE(la.score_pct, 0)
FROM lessons l
JOIN chapters c ON c.id = l.chapter_id
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
+1 -1
View File
@@ -28,7 +28,7 @@ func TestLearningOverviewSmoke(t *testing.T) {
if o == nil {
t.Fatal("nil overview")
}
students, err := st.ListStudentLearning(ctx, "")
students, _, err := st.ListStudentLearning(ctx, "", "", "xp", 10, 0)
if err != nil {
t.Fatal(err)
}
+91 -26
View File
@@ -504,7 +504,26 @@ func (s *Store) GetPhysicalOrder(ctx context.Context, id int64) (*models.Certifi
return scanPhysicalOrder(row)
}
func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models.CertificatePhysicalOrder, error) {
func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, offset int) ([]models.CertificatePhysicalOrder, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
where := ""
args := []any{}
if status != "" {
where = ` WHERE o.status=$1`
args = append(args, status)
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM certificate_physical_orders o`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
@@ -512,16 +531,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
u.name, c.public_id
FROM certificate_physical_orders o
JOIN users u ON u.id = o.user_id
JOIN certificates c ON c.id = o.certificate_id`
args := []any{}
if status != "" {
q += ` WHERE o.status=$1`
args = append(args, status)
}
q += ` ORDER BY o.created_at DESC LIMIT 200`
JOIN certificates c ON c.id = o.certificate_id` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
args = append(args, limit, offset)
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
return nil, 0, err
}
defer rows.Close()
out := make([]models.CertificatePhysicalOrder, 0)
@@ -530,11 +544,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note,
&o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode,
&o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil {
return nil, err
return nil, 0, err
}
out = append(out, o)
}
return out, rows.Err()
return out, total, rows.Err()
}
func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) {
@@ -1069,7 +1083,7 @@ func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, send
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize)
return scanMessage(row)
}
@@ -1094,7 +1108,7 @@ func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, s
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt)
return scanMessage(row)
}
@@ -1138,6 +1152,48 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in
return tag.RowsAffected() > 0, nil
}
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
// The new type must match the current attachment.
func (s *Store) ReplaceChatAttachment(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body, attType, attURL, attName string, attSize int64) (*models.ChatMessage, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_messages
SET body=$1, edited_at=now(),
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5
WHERE id=$6
AND ((user_id=$7 AND mentor_id=$8) OR (user_id=$8 AND mentor_id=$7))
AND sender_role=$9
AND (user_id=$7 OR mentor_id=$7)
AND attachment_type=$2
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
body, attType, attURL, attName, attSize, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
// EditChatMessage replaces the body of one of my own messages in this conversation.
func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string) (*models.ChatMessage, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_messages
SET body=$1, edited_at=now()
WHERE id=$2
AND ((user_id=$3 AND mentor_id=$4) OR (user_id=$4 AND mentor_id=$3))
AND sender_role=$5
AND (user_id=$3 OR mentor_id=$3)
AND ($1 <> '' OR attachment_url IS NOT NULL)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
body, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return m, err
}
const maxPinnedMessages = 3
// TogglePinChatMessage pins or unpins a message. If already at the pin limit,
@@ -1160,7 +1216,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
return scanMessage(out)
}
var n int
@@ -1188,7 +1244,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=now() WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
return scanMessage(out)
}
@@ -1196,7 +1252,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE pinned_at IS NOT NULL
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
@@ -1222,7 +1278,7 @@ func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int
func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND ($3 = 0 OR id < $3)
@@ -1270,7 +1326,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
older, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id <= $3
@@ -1302,7 +1358,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
newer, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id > $3
@@ -1345,7 +1401,7 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) {
var attSize *int64
if err := row.Scan(
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo,
&attType, &attURL, &attName, &attSize, &m.PinnedAt,
&attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt,
); err != nil {
return nil, err
}
@@ -1372,11 +1428,20 @@ func derefInt(p *int64) int64 {
return *p
}
func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`,
userID, mentorID)
return err
// MarkChatRead stamps messages the other person sent. Opening the thread must not
// mark my own messages as seen.
func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE chat_messages
SET read_at=now()
WHERE read_at IS NULL
AND sender_role <> $3
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`,
meID, partnerID, meRole.String())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// MessageInConversation reports whether a message belongs to a given pair.
@@ -1743,7 +1808,7 @@ func (s *Store) AllActiveUserIDs(ctx context.Context) ([]int64, error) {
func (s *Store) Stats(ctx context.Context) (map[string]any, error) {
var users, lessons, questions, events, completions int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users); err != nil {
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='student' AND is_active`).Scan(&users); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil {