From 22d92c133d01da2d490a6cdf43a200e20f865304 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sat, 10 Oct 2026 16:42:35 +0330 Subject: [PATCH] feat: release v0.2.0 profiles, chat, freeze, and bots Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists. --- README.md | 2 +- backend/internal/api/account.go | 172 ++ backend/internal/api/account_freeze.go | 105 + backend/internal/api/account_freeze_test.go | 102 + backend/internal/api/account_username_test.go | 186 ++ backend/internal/api/admin.go | 66 +- backend/internal/api/auth.go | 13 + backend/internal/api/chat.go | 377 ++- backend/internal/api/chat_export.go | 70 +- backend/internal/api/community.go | 56 +- backend/internal/api/community_test.go | 15 +- backend/internal/api/flow_test.go | 67 +- backend/internal/api/me_export.go | 604 ++++ backend/internal/api/me_export_test.go | 254 ++ backend/internal/api/me_export_time_test.go | 27 + backend/internal/api/product.go | 801 ++++++ backend/internal/api/product_pass_test.go | 458 ++++ backend/internal/api/profile.go | 57 +- backend/internal/api/quiz.go | 16 +- backend/internal/api/router.go | 28 + backend/internal/api/server.go | 7 + backend/internal/api/settings.go | 101 +- backend/internal/api/surfaces_test.go | 183 +- backend/internal/api/ws.go | 34 +- backend/internal/db/migrate.go | 17 + backend/internal/db/migrate_upgrade_test.go | 192 ++ .../db/migrations/0023_product_pass.sql | 71 + .../internal/db/migrations/0024_last_seen.sql | 1 + .../migrations/0025_chat_attachments_json.sql | 2 + .../migrations/0026_username_change_quota.sql | 2 + .../db/migrations/0027_account_freeze.sql | 2 + .../db/migrations/0028_bot_token_admin.sql | 2 + backend/internal/models/models.go | 135 +- backend/internal/service/hearts.go | 56 +- backend/internal/store/product.go | 277 ++ backend/internal/store/store.go | 383 ++- deploy/helm/pargar/Chart.yaml | 4 +- deploy/helm/pargar/values-prod.yaml | 4 +- frontend/src/app/admin/bot-docs/page.tsx | 115 + frontend/src/app/c/[id]/page.tsx | 18 +- frontend/src/app/cap/page.tsx | 245 +- frontend/src/app/globals.css | 25 + frontend/src/app/lockout/page.tsx | 5 +- frontend/src/app/login/page.tsx | 18 +- frontend/src/app/not-found.tsx | 60 +- frontend/src/app/player/[lessonId]/page.tsx | 25 +- frontend/src/app/profile/[id]/page.tsx | 98 +- frontend/src/app/profile/page.tsx | 234 +- frontend/src/app/settings/page.tsx | 874 ++++++ frontend/src/app/unwrap/page.tsx | 155 +- .../components/account-identity-fields.tsx | 138 + frontend/src/components/admin-studio.tsx | 197 +- .../src/components/admin/announce-panel.tsx | 165 +- frontend/src/components/admin/bot-panel.tsx | 192 ++ frontend/src/components/admin/nav.ts | 11 +- .../src/components/admin/profile-manage.tsx | 9 +- .../components/admin/site-settings-panel.tsx | 79 +- frontend/src/components/app-shell.tsx | 17 +- frontend/src/components/avatar-crop.tsx | 91 +- frontend/src/components/avatar-studio.tsx | 42 +- frontend/src/components/banner-editor.tsx | 225 ++ frontend/src/components/community-panel.tsx | 79 +- frontend/src/components/freeze-mark.tsx | 17 + frontend/src/components/global-search.tsx | 65 + frontend/src/components/mentor-chat.tsx | 2419 ++++++++++++++--- frontend/src/components/navbar.tsx | 15 +- frontend/src/components/not-found-state.tsx | 48 + frontend/src/components/notification-bell.tsx | 148 +- .../src/components/notifications-panel.tsx | 24 +- frontend/src/components/profile-header.tsx | 254 ++ frontend/src/components/providers.tsx | 9 +- frontend/src/components/quiz-experience.tsx | 20 +- frontend/src/components/register-form.tsx | 19 +- frontend/src/components/safe-markdown.tsx | 66 + frontend/src/components/section-nav.tsx | 125 + frontend/src/components/ui/dropdown-menu.tsx | 29 +- frontend/src/components/ui/user-avatar.tsx | 10 + frontend/src/components/verified-badge.tsx | 124 + frontend/src/hooks/use-live-user.ts | 50 +- .../{use-realtime.ts => use-realtime.tsx} | 30 +- frontend/src/lib/banner-pattern.ts | 159 ++ frontend/src/lib/hearts.ts | 4 +- frontend/src/lib/transfer.ts | 104 + frontend/src/lib/types.ts | 35 +- frontend/src/lib/utils.ts | 11 + 85 files changed, 10510 insertions(+), 1341 deletions(-) create mode 100644 backend/internal/api/account.go create mode 100644 backend/internal/api/account_freeze.go create mode 100644 backend/internal/api/account_freeze_test.go create mode 100644 backend/internal/api/account_username_test.go create mode 100644 backend/internal/api/me_export.go create mode 100644 backend/internal/api/me_export_test.go create mode 100644 backend/internal/api/me_export_time_test.go create mode 100644 backend/internal/api/product.go create mode 100644 backend/internal/api/product_pass_test.go create mode 100644 backend/internal/db/migrate_upgrade_test.go create mode 100644 backend/internal/db/migrations/0023_product_pass.sql create mode 100644 backend/internal/db/migrations/0024_last_seen.sql create mode 100644 backend/internal/db/migrations/0025_chat_attachments_json.sql create mode 100644 backend/internal/db/migrations/0026_username_change_quota.sql create mode 100644 backend/internal/db/migrations/0027_account_freeze.sql create mode 100644 backend/internal/db/migrations/0028_bot_token_admin.sql create mode 100644 backend/internal/store/product.go create mode 100644 frontend/src/app/admin/bot-docs/page.tsx create mode 100644 frontend/src/app/settings/page.tsx create mode 100644 frontend/src/components/account-identity-fields.tsx create mode 100644 frontend/src/components/admin/bot-panel.tsx create mode 100644 frontend/src/components/banner-editor.tsx create mode 100644 frontend/src/components/freeze-mark.tsx create mode 100644 frontend/src/components/global-search.tsx create mode 100644 frontend/src/components/not-found-state.tsx create mode 100644 frontend/src/components/profile-header.tsx create mode 100644 frontend/src/components/safe-markdown.tsx create mode 100644 frontend/src/components/section-nav.tsx create mode 100644 frontend/src/components/verified-badge.tsx rename frontend/src/hooks/{use-realtime.ts => use-realtime.tsx} (62%) create mode 100644 frontend/src/lib/banner-pattern.ts diff --git a/README.md b/README.md index e57aa39..c78c2ab 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- release + release AGPL-3.0

diff --git a/backend/internal/api/account.go b/backend/internal/api/account.go new file mode 100644 index 0000000..100c936 --- /dev/null +++ b/backend/internal/api/account.go @@ -0,0 +1,172 @@ +package api + +import ( + "errors" + "net/http" + "strings" + "time" + "unicode/utf8" + + "pargar/backend/internal/store" +) + +const msgUsernameCooldown = "سقف سه تغییر نام کاربری پر شده است. تا پایان مهلت نمی‌توانید دوباره نام کاربری را عوض کنید." + +func (s *Server) handleUpdateUsername(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if strings.TrimSpace(req.Password) != "" { + writeErr(w, http.StatusBadRequest, "تغییر گذرواژه از این مسیر ممکن نیست") + return + } + username := strings.ToLower(strings.TrimSpace(req.Username)) + if username == "" { + writeErr(w, http.StatusBadRequest, "شناسه کاربری الزامی است") + return + } + if !validUsername(username) { + writeErr(w, http.StatusBadRequest, msgUsernameInvalid) + return + } + user, err := s.store.ChangeUsername(r.Context(), me.ID, username, time.Now()) + if err != nil { + var cool *store.UsernameCooldownError + if errors.As(err, &cool) { + writeJSON(w, http.StatusTooManyRequests, map[string]any{ + "error": msgUsernameCooldown, + "code": "username_cooldown", + "requestId": requestIDFromWriter(w), + "usernameCooldownUntil": cool.Until, + "usernameChangesRemaining": 0, + }) + return + } + if errors.Is(err, store.ErrUsernameTaken) { + writeErr(w, http.StatusConflict, "این شناسه قبلاً گرفته شده است") + return + } + writeErr(w, http.StatusInternalServerError, "تغییر نام کاربری ممکن نشد") + return + } + s.signUserMedia(user) + writeJSON(w, http.StatusOK, map[string]any{ + "user": user, + "usernameChangesRemaining": user.UsernameChangesRemaining, + "usernameCooldownUntil": user.UsernameCooldownUntil, + }) +} + +func (s *Server) handleUpdateAccount(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + var req struct { + CurrentPassword string `json:"currentPassword"` + Password string `json:"password"` + Phone *string `json:"phone"` + Name *string `json:"name"` + Email *string `json:"email"` + Role *string `json:"role"` + Username *string `json:"username"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if req.Role != nil && strings.TrimSpace(*req.Role) != me.Role.String() { + writeErr(w, http.StatusForbidden, "نقش از این مسیر تغییر نمی‌کند") + return + } + if req.Username != nil { + next := strings.ToLower(strings.TrimSpace(*req.Username)) + if next != "" && next != strings.ToLower(me.Username) { + writeErr(w, http.StatusBadRequest, "نام کاربری از این مسیر تغییر نمی‌کند") + return + } + } + + hash := "" + changingPassword := strings.TrimSpace(req.Password) != "" + if changingPassword { + if !s.auth.CheckPassword(me.PasswordHash, req.CurrentPassword) { + writeErr(w, http.StatusForbidden, "گذرواژه فعلی نادرست است") + return + } + if utf8.RuneCountInString(req.Password) < minPasswordLen { + writeErr(w, http.StatusBadRequest, msgPasswordShort) + return + } + if len(req.Password) > maxPasswordLen { + writeErr(w, http.StatusBadRequest, msgPasswordLong) + return + } + var err error + hash, err = s.auth.HashPassword(req.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, "تغییر گذرواژه ممکن نشد") + return + } + } + + name := "" + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + if msg := validatePersonName(name); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + } + + email := "" + if req.Email != nil { + email = strings.ToLower(strings.TrimSpace(*req.Email)) + if !validEmail(email) { + writeErr(w, http.StatusBadRequest, msgEmailInvalid) + return + } + if !strings.EqualFold(email, me.Email) { + if _, err := s.store.GetUserByEmail(r.Context(), email); err == nil { + writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است") + return + } else if !errors.Is(err, store.ErrNotFound) { + writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد") + return + } + } + } + + phone := me.Phone + if req.Phone != nil { + phone = normalizePhone(*req.Phone) + if !validPhone(phone) { + writeErr(w, http.StatusBadRequest, msgPhoneInvalid) + return + } + if phone != "" && phone != me.Phone { + if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil { + writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است") + return + } else if !errors.Is(err, store.ErrNotFound) { + writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد") + return + } + } + } + + user, err := s.store.UpdateUser(r.Context(), me.ID, name, email, "", hash, phone, "", "", me.Role, me.Hearts, me.XP) + if err != nil { + if store.IsUniqueViolation(err) { + writeErr(w, http.StatusConflict, "این مقدار قبلاً ثبت شده است") + return + } + writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد") + return + } + s.signUserMedia(user) + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} diff --git a/backend/internal/api/account_freeze.go b/backend/internal/api/account_freeze.go new file mode 100644 index 0000000..dfb9555 --- /dev/null +++ b/backend/internal/api/account_freeze.go @@ -0,0 +1,105 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +const ( + msgAccountClosedLogin = "حساب بسته شده است و ورود ممکن نیست" + msgFreezeAlready = "حساب همین حالا فریز است" + msgAccountClosed = "حساب بسته شده است" + msgUnfreezeClosed = "مهلت فریز تمام شده و لغو فریز ممکن نیست" + msgUnfreezeNone = "حساب فریز نیست" + msgDeleteNotFrozen = "حذف دائمی فقط برای حساب فریز یا بسته ممکن است" +) + +// settleAccount writes closed_at when a freeze has passed the 30-day window. +func (s *Server) settleAccount(ctx context.Context, u *models.User) *models.User { + if u == nil || u.ClosedAt != nil || u.FrozenAt == nil || !u.IsClosed { + return u + } + next, err := s.store.MarkAccountClosed(ctx, u.ID) + if err != nil || next == nil { + return u + } + return next +} + +func (s *Server) handleFreezeAccount(w http.ResponseWriter, r *http.Request) { + me := s.settleAccount(r.Context(), currentUser(r)) + var req struct { + CurrentPassword string `json:"currentPassword"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if strings.TrimSpace(req.CurrentPassword) == "" { + writeErr(w, http.StatusBadRequest, "گذرواژه فعلی الزامی است") + return + } + if !s.auth.CheckPassword(me.PasswordHash, req.CurrentPassword) { + writeErr(w, http.StatusForbidden, "گذرواژه فعلی نادرست است") + return + } + if me.IsClosed { + writeErr(w, http.StatusConflict, msgAccountClosed) + return + } + if me.IsFrozen { + writeErr(w, http.StatusConflict, msgFreezeAlready) + return + } + user, err := s.store.FreezeUser(r.Context(), me.ID) + if err != nil { + if errors.Is(err, store.ErrAccountClosed) { + writeErr(w, http.StatusConflict, msgAccountClosed) + return + } + if errors.Is(err, store.ErrAccountFrozen) { + writeErr(w, http.StatusConflict, msgFreezeAlready) + return + } + writeErr(w, http.StatusInternalServerError, "فریز حساب ممکن نشد") + return + } + observability.Activity("account.frozen", "user_id", user.ID) + s.signUserMedia(user) + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} + +func (s *Server) handleUnfreezeAccount(w http.ResponseWriter, r *http.Request) { + me := s.settleAccount(r.Context(), currentUser(r)) + if me.IsClosed { + writeErr(w, http.StatusConflict, msgUnfreezeClosed) + return + } + if !me.IsFrozen { + writeErr(w, http.StatusConflict, msgUnfreezeNone) + return + } + user, err := s.store.UnfreezeUser(r.Context(), me.ID) + if err != nil { + if errors.Is(err, store.ErrAccountClosed) { + _, _ = s.store.MarkAccountClosed(r.Context(), me.ID) + writeErr(w, http.StatusConflict, msgUnfreezeClosed) + return + } + if errors.Is(err, store.ErrAccountNotFrozen) { + writeErr(w, http.StatusConflict, msgUnfreezeNone) + return + } + writeErr(w, http.StatusInternalServerError, "لغو فریز ممکن نشد") + return + } + observability.Activity("account.unfrozen", "user_id", user.ID) + s.signUserMedia(user) + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} diff --git a/backend/internal/api/account_freeze_test.go b/backend/internal/api/account_freeze_test.go new file mode 100644 index 0000000..285d88c --- /dev/null +++ b/backend/internal/api/account_freeze_test.go @@ -0,0 +1,102 @@ +package api_test + +import ( + "context" + "net/http" + "strings" + "testing" + "time" +) + +func TestAccountFreeze(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "Freeze", "freeze@test.dev") + + code, missing := e.do(t, "POST", "/api/me/freeze", token, map[string]any{}) + missingMsg, _ := missing["error"].(string) + if code == http.StatusOK || !strings.Contains(missingMsg, "گذرواژه") { + t.Fatalf("freeze without password: %d %q", code, missingMsg) + } + code, wrong := e.do(t, "POST", "/api/me/freeze", token, map[string]any{"currentPassword": "not-the-password"}) + wrongMsg, _ := wrong["error"].(string) + if code != http.StatusForbidden || !strings.Contains(wrongMsg, "گذرواژه") || !strings.Contains(wrongMsg, "نادرست") { + t.Fatalf("freeze with wrong password: %d %q", code, wrongMsg) + } + var untouched *time.Time + if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&untouched); err != nil { + t.Fatal(err) + } + if untouched != nil { + t.Fatal("failed freeze wrote frozen_at") + } + + code, body := e.do(t, "POST", "/api/me/freeze", token, map[string]any{"currentPassword": "password123"}) + if code != http.StatusOK { + t.Fatalf("freeze: %d %v", code, body["error"]) + } + user := body["user"].(map[string]any) + frozenAt, _ := user["frozenAt"].(string) + if frozenAt == "" || user["isFrozen"] != true || user["isClosed"] == true { + t.Fatalf("freeze state: frozenAt=%v isFrozen=%v isClosed=%v", user["frozenAt"], user["isFrozen"], user["isClosed"]) + } + if user["isLocked"] == true { + t.Fatal("freeze reused the admin lock flag") + } + var stored *time.Time + if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored == nil || stored.IsZero() { + t.Fatal("frozen_at was not stored") + } + + code, profile := e.do(t, "GET", "/api/users/"+itoa(uid), token, nil) + if code != http.StatusOK { + t.Fatalf("profile: %d %v", code, profile["error"]) + } + pub := profile["user"].(map[string]any) + if pub["isFrozen"] != true { + t.Fatalf("public profile missing freeze flag: %v", pub["isFrozen"]) + } + if _, ok := pub["email"]; ok { + t.Fatalf("public profile leaked email: %v", pub["email"]) + } + + _ = login(t, e, "freeze@test.dev") + + code, body = e.do(t, "POST", "/api/me/unfreeze", token, map[string]any{}) + if code != http.StatusOK { + t.Fatalf("unfreeze: %d %v", code, body["error"]) + } + cleared := body["user"].(map[string]any) + if cleared["isFrozen"] == true || cleared["frozenAt"] != nil { + t.Fatalf("unfreeze left a timestamp: %v", cleared["frozenAt"]) + } + var after *time.Time + if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&after); err != nil { + t.Fatal(err) + } + if after != nil { + t.Fatalf("frozen_at still set: %s", after.Format(time.RFC3339)) + } + + if _, err := e.pool.Exec(context.Background(), `UPDATE users SET closed_at = now() WHERE id=$1`, uid); err != nil { + t.Fatal(err) + } + code, denied := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "freeze@test.dev", "password123")) + errMsg, _ := denied["error"].(string) + if code != http.StatusForbidden || !strings.Contains(errMsg, "بسته") || !strings.Contains(errMsg, "ورود") { + t.Fatalf("closed login: %d %q", code, errMsg) + } + + code, undo := e.do(t, "POST", "/api/me/unfreeze", token, map[string]any{}) + undoMsg, _ := undo["error"].(string) + if code != http.StatusConflict || !strings.Contains(undoMsg, "لغو فریز") { + t.Fatalf("unfreeze after close: %d %q", code, undoMsg) + } + + code, forbidden := e.do(t, "DELETE", "/api/admin/users/"+itoa(uid), token, nil) + if code != http.StatusForbidden { + t.Fatalf("student permanent delete: %d %v", code, forbidden["error"]) + } +} diff --git a/backend/internal/api/account_username_test.go b/backend/internal/api/account_username_test.go new file mode 100644 index 0000000..10c7963 --- /dev/null +++ b/backend/internal/api/account_username_test.go @@ -0,0 +1,186 @@ +package api_test + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestAccountUsernameQuota(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "Quota", "quota@test.dev") + + code, body := e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quota"}) + if code != http.StatusOK { + t.Fatalf("same username: %d %v", code, body["error"]) + } + if body["usernameChangesRemaining"].(float64) != 3 { + t.Fatalf("same username consumed a slot: %v", body["usernameChangesRemaining"]) + } + + for i, next := range []string{"quotaone", "quotatwo", "quotathree"} { + code, body = e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": next}) + if code != http.StatusOK { + t.Fatalf("change %d: %d %v", i+1, code, body["error"]) + } + user := body["user"].(map[string]any) + if user["username"] != next { + t.Fatalf("username: %v", user["username"]) + } + wantRemaining := float64(2 - i) + if body["usernameChangesRemaining"].(float64) != wantRemaining { + t.Fatalf("remaining after change %d: %v", i+1, body["usernameChangesRemaining"]) + } + } + if body["usernameCooldownUntil"] == nil || body["usernameCooldownUntil"] == "" { + t.Fatalf("third change should start cooldown: %v", body["usernameCooldownUntil"]) + } + + code, blocked := e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quotafour"}) + errMsg, _ := blocked["error"].(string) + if code != http.StatusTooManyRequests || !strings.Contains(errMsg, "نام کاربری") || !strings.Contains(errMsg, "مهلت") { + t.Fatalf("fourth change: %d %q", code, errMsg) + } + + code, me := e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK { + t.Fatalf("me: %d", code) + } + if me["user"].(map[string]any)["username"] != "quotathree" { + t.Fatalf("username changed during cooldown: %v", me["user"].(map[string]any)["username"]) + } + + if _, err := e.pool.Exec(context.Background(), `UPDATE users SET username_cooldown_until = now() - interval '1 second' WHERE id=$1`, uid); err != nil { + t.Fatal(err) + } + code, body = e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quotafour"}) + if code != http.StatusOK { + t.Fatalf("after cooldown: %d %v", code, body["error"]) + } + if body["user"].(map[string]any)["username"] != "quotafour" { + t.Fatalf("username after cooldown: %v", body["user"]) + } + if body["usernameChangesRemaining"].(float64) != 2 { + t.Fatalf("allowance should reset then consume one: %v", body["usernameChangesRemaining"]) + } + + code, denied := e.do(t, "PUT", "/api/me/account", token, map[string]any{"password": "replacement-pass"}) + if code != http.StatusForbidden { + t.Fatalf("password change without current password: %d %v", code, denied["error"]) + } + _ = login(t, e, "quota@test.dev") + + code, rejected := e.do(t, "PUT", "/api/me/username", token, map[string]any{ + "username": "quotafive", + "password": "should-be-ignored", + }) + if code != http.StatusBadRequest { + t.Fatalf("username endpoint accepted a password: %d %v", code, rejected["error"]) + } + code, me = e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK || me["user"].(map[string]any)["username"] != "quotafour" { + t.Fatalf("username changed via password field: %d %v", code, me["user"]) + } +} + +func TestAccountIdentityWithoutPassword(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Identity", "identity@test.dev") + + code, me := e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK { + t.Fatalf("me: %d", code) + } + before := me["user"].(map[string]any) + username, _ := before["username"].(string) + role, _ := before["role"].(string) + + code, body := e.do(t, "PUT", "/api/me/account", token, map[string]any{ + "name": "هویت تازه", + "email": "identity-new@test.dev", + "phone": "09120001122", + }) + if code != http.StatusOK { + t.Fatalf("identity save: %d %v", code, body["error"]) + } + user := body["user"].(map[string]any) + if user["name"] != "هویت تازه" || user["email"] != "identity-new@test.dev" || user["phone"] != "09120001122" { + t.Fatalf("saved identity: name=%v email=%v phone=%v", user["name"], user["email"], user["phone"]) + } + if user["username"] != username || user["role"] != role { + t.Fatalf("username or role changed: username=%v role=%v", user["username"], user["role"]) + } + if user["securityQuestion"] != before["securityQuestion"] { + t.Fatalf("security question changed: %v", user["securityQuestion"]) + } + _ = login(t, e, "identity-new@test.dev") + + code, same := e.do(t, "PUT", "/api/me/account", token, map[string]any{ + "name": "هویت تازه", + "email": "identity-new@test.dev", + "phone": "09120001122", + }) + if code != http.StatusOK { + t.Fatalf("same identity: %d %v", code, same["error"]) + } + + _, _ = register(t, e, "Other", "taken-mail@test.dev") + code, dup := e.do(t, "PUT", "/api/me/account", token, map[string]any{"email": "taken-mail@test.dev"}) + if code != http.StatusConflict { + t.Fatalf("duplicate email: %d %v", code, dup["error"]) + } + code, bad := e.do(t, "PUT", "/api/me/account", token, map[string]any{"email": "not-an-email"}) + if code != http.StatusBadRequest { + t.Fatalf("invalid email: %d %v", code, bad["error"]) + } + code, phoneBad := e.do(t, "PUT", "/api/me/account", token, map[string]any{"phone": "123"}) + if code != http.StatusBadRequest { + t.Fatalf("invalid phone: %d %v", code, phoneBad["error"]) + } + + code, roleBody := e.do(t, "PUT", "/api/me/account", token, map[string]any{"role": "admin", "name": "نباید ذخیره شود"}) + if code != http.StatusForbidden { + t.Fatalf("role change: %d %v", code, roleBody["error"]) + } + code, userBody := e.do(t, "PUT", "/api/me/account", token, map[string]any{"username": "hijackname"}) + if code != http.StatusBadRequest { + t.Fatalf("username via account: %d %v", code, userBody["error"]) + } + + code, me = e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK { + t.Fatalf("me after rejects: %d", code) + } + after := me["user"].(map[string]any) + if after["role"] != role || after["username"] != username || after["name"] != "هویت تازه" || after["email"] != "identity-new@test.dev" { + t.Fatalf("account mutated by rejected fields: %v", after) + } + + code, denied := e.do(t, "PUT", "/api/me/account", token, map[string]any{"password": "replacement-pass"}) + if code != http.StatusForbidden { + t.Fatalf("password without current: %d %v", code, denied["error"]) + } + _ = login(t, e, "identity-new@test.dev") + + code, changed := e.do(t, "PUT", "/api/me/account", token, map[string]any{ + "currentPassword": "password123", + "password": "replacement-pass", + }) + if code != http.StatusOK { + t.Fatalf("password with current: %d %v", code, changed["error"]) + } + kept := changed["user"].(map[string]any) + if kept["name"] != "هویت تازه" || kept["email"] != "identity-new@test.dev" || kept["phone"] != "09120001122" || kept["role"] != role { + t.Fatalf("password save changed identity: %v", kept) + } + code, session := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "identity-new@test.dev", "replacement-pass")) + if code != http.StatusOK || session["accessToken"] == nil { + t.Fatalf("login after password change: %d %v", code, session["error"]) + } + code, old := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "identity-new@test.dev", "password123")) + if code == http.StatusOK { + t.Fatalf("previous password still works: %d", code) + } + _ = old["error"] +} diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index b001d38..ed5f9ee 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -33,6 +33,7 @@ func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") return } + u = s.settleAccount(r.Context(), u) writeJSON(w, http.StatusOK, map[string]any{"user": u}) } @@ -442,6 +443,16 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") return } + target, err := s.store.GetUserByID(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + target = s.settleAccount(r.Context(), target) + if !target.IsFrozen && !target.IsClosed { + writeErr(w, http.StatusConflict, msgDeleteNotFrozen) + return + } if err := s.store.DeleteUser(r.Context(), id); err != nil { writeErr(w, http.StatusInternalServerError, "حذف کاربر ممکن نشد") return @@ -837,58 +848,3 @@ func validateAdminEvent(e *models.Event, create bool) string { _ = create return "" } - -// ---- announcements ---- - -func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) { - var req struct { - Title string `json:"title"` - Body string `json:"body"` - Route string `json:"route"` - Category string `json:"category"` - } - if err := bodyJSON(r, &req); err != nil { - writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") - return - } - req.Title = strings.TrimSpace(req.Title) - req.Body = strings.TrimSpace(req.Body) - req.Route = strings.TrimSpace(req.Route) - if utf8.RuneCountInString(req.Title) < 3 { - writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی کوتاه است") - return - } - if utf8.RuneCountInString(req.Title) > 200 { - writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی طولانی است") - return - } - if utf8.RuneCountInString(req.Body) < 8 { - writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی کوتاه است") - return - } - if utf8.RuneCountInString(req.Body) > 500 { - writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی طولانی است") - return - } - if req.Category == "" { - req.Category = "event" - } - switch req.Category { - case "event", "progress", "gamification", "mentor": - default: - writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است") - return - } - userIDs, err := s.store.AllActiveUserIDs(r.Context()) - if err != nil { - writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد") - return - } - go func() { - for _, uid := range userIDs { - _ = s.notify.Notify(context.Background(), uid, req.Category, "announcement", - req.Title, req.Body, req.Route, nil) - } - }() - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recipients": len(userIDs)}) -} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 1f4810e..1e04aa2 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -175,6 +175,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusForbidden, "حساب غیرفعال است") return } + user = s.settleAccount(r.Context(), user) + if user.IsClosed { + observability.Logins.WithLabelValues("closed").Inc() + writeErr(w, http.StatusForbidden, msgAccountClosedLogin) + return + } refreshTTL := s.auth.refreshTTL(req.RememberMe) access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, refreshTTL) if err != nil { @@ -222,6 +228,12 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusUnauthorized, "حساب غیرفعال است") return } + user = s.settleAccount(r.Context(), user) + if user.IsClosed { + s.clearSessionCookies(w) + writeErr(w, http.StatusForbidden, msgAccountClosedLogin) + return + } // Preserve remember-me vs short session when rotating the refresh token. ttl := s.cfg.RefreshTokenTTLSession if lifetime >= 48*time.Hour { @@ -266,6 +278,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { if err != nil { user = hearts } + user = s.settleAccount(r.Context(), user) s.signUserMedia(user) writeJSON(w, http.StatusOK, map[string]any{"user": user}) } diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go index d5f77f3..4b6a4f4 100644 --- a/backend/internal/api/chat.go +++ b/backend/internal/api/chat.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "path/filepath" + "sort" "strconv" "strings" "time" @@ -18,38 +19,59 @@ const maxUploadSize = 25 << 20 // 25 MB func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) { actor := currentUser(r) - var mentors []models.User - var err error - if actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin { - mentors, _, err = s.store.ListUsers(r.Context(), "", "", 100, 0) - } else { - mentors, err = s.store.ListMentors(r.Context()) + page := parsePageParams(r) + if page.PageSize < 20 { + page.PageSize = 20 + page.Offset = (page.Page - 1) * page.PageSize } + roles := []string{models.RoleMentor.String(), models.RoleAdmin.String()} + if actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin { + roles = []string{models.RoleStudent.String(), models.RoleMentor.String(), models.RoleAdmin.String()} + } + people, total, err := s.store.ListActiveByRoles(r.Context(), roles, page.PageSize, page.Offset) if err != nil { - writeErr(w, http.StatusInternalServerError, "بارگذاری منتورها ممکن نشد") + writeErr(w, http.StatusInternalServerError, "بارگذاری افراد ممکن نشد") return } - safe := make([]map[string]any, 0, len(mentors)) - for _, m := range mentors { - if m.ID == actor.ID || !m.IsActive { - continue + ids := make([]int64, 0, len(people)) + for _, m := range people { + if m.ID != actor.ID { + ids = append(ids, m.ID) } - if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin { + } + seen, _ := s.store.LastSeenByIDs(r.Context(), ids) + safe := make([]map[string]any, 0, len(people)) + for _, m := range people { + if m.ID == actor.ID { continue } s.signUserMedia(&m) - safe = append(safe, map[string]any{ + item := map[string]any{ "id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role, "avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto, - }) + "online": s.hub.Online(m.ID), + } + if ts, ok := seen[m.ID]; ok && !ts.IsZero() { + item["lastSeen"] = ts.UTC() + } + safe = append(safe, item) } - writeJSON(w, http.StatusOK, map[string]any{"mentors": safe}) + writeJSON(w, http.StatusOK, map[string]any{ + "mentors": safe, + "total": total, + "page": page.Page, + "pageSize": page.PageSize, + }) } type conversation struct { - Partner map[string]any `json:"partner"` - LastMessage *models.ChatMessage `json:"lastMessage,omitempty"` - UnreadCount int `json:"unreadCount"` + Partner map[string]any `json:"partner"` + LastMessage *models.ChatMessage `json:"lastMessage,omitempty"` + UnreadCount int `json:"unreadCount"` + MessageCount int `json:"messageCount"` + PinnedRank *int `json:"pinnedRank"` + Muted bool `json:"muted"` + Archived bool `json:"archived"` } func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { @@ -94,11 +116,23 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { return } + showArchived := r.URL.Query().Get("archived") == "1" + partnerIDs := make([]int64, 0, len(partners)) + for _, p := range partners { + if p.ID != u.ID { + partnerIDs = append(partnerIDs, p.ID) + } + } + seen, _ := s.store.LastSeenByIDs(r.Context(), partnerIDs) out := make([]conversation, 0, len(partners)) for _, p := range partners { if !s.canChatWith(r.Context(), u, p.ID) { continue } + pref := s.store.ChatPref(r.Context(), u.ID, p.ID) + if pref.Archived != showArchived { + continue + } msgs, err := s.store.ListChatMessages(r.Context(), u.ID, p.ID, 0, 100) if err != nil { continue @@ -118,23 +152,95 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { s.signUserMedia(tmp) photo = tmp.AvatarPhoto } + name := p.Name + saved := p.ID == u.ID + if saved { + name = "پیام‌های ذخیره‌شده" + } + partner := map[string]any{ + "id": p.ID, "name": name, "email": p.Email, "role": p.Role, + "avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo, + "online": !saved && s.hub.Online(p.ID), "saved": saved, + } + if !saved { + if ts, ok := seen[p.ID]; ok && !ts.IsZero() { + partner["lastSeen"] = ts.UTC() + } + } 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": photo, - "online": s.hub.Online(p.ID), - }, - UnreadCount: unread, + Partner: partner, + UnreadCount: unread, + MessageCount: len(msgs), + PinnedRank: pref.PinnedRank, + Muted: pref.Muted, + Archived: pref.Archived, } if last != nil { conv.LastMessage = last } out = append(out, conv) } + if !showArchived { + hasSelf := false + for _, c := range out { + if id, _ := c.Partner["id"].(int64); id == u.ID { + hasSelf = true + break + } + } + if !hasSelf { + out = append(out, conversation{ + Partner: map[string]any{ + "id": u.ID, "name": "پیام‌های ذخیره‌شده", "email": u.Email, "role": u.Role, + "avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto, + "online": false, "saved": true, + }, + }) + } + } if out == nil { out = []conversation{} } - writeJSON(w, http.StatusOK, map[string]any{"conversations": out}) + sort.SliceStable(out, func(i, j int) bool { + pi, pj := out[i].PinnedRank, out[j].PinnedRank + if (pi != nil) != (pj != nil) { + return pi != nil + } + if pi != nil && pj != nil && *pi != *pj { + return *pi < *pj + } + ti := time.Time{} + tj := time.Time{} + if out[i].LastMessage != nil { + ti = out[i].LastMessage.CreatedAt + } + if out[j].LastMessage != nil { + tj = out[j].LastMessage.CreatedAt + } + return ti.After(tj) + }) + total := len(out) + slice := out + page := pageParams{Page: 1, PageSize: total} + if r.URL.Query().Get("page") != "" || r.URL.Query().Get("pageSize") != "" { + page = parsePageParams(r) + start := page.Offset + if start > total { + start = total + } + end := start + page.PageSize + if end > total { + end = total + } + slice = out[start:end] + } + writeJSON(w, http.StatusOK, map[string]any{ + "conversations": slice, + "total": total, + "page": page.Page, + "pageSize": page.PageSize, + "mutedAll": s.store.ChatMutedAll(r.Context(), u.ID), + }) } func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) { @@ -224,43 +330,22 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) { return } var req struct { - Body string `json:"body"` - ReplyTo *int64 `json:"replyTo"` - Attachment *models.Attachment `json:"attachment,omitempty"` + Body string `json:"body"` + ReplyTo *int64 `json:"replyTo"` + Attachment *models.Attachment `json:"attachment,omitempty"` + Attachments []models.Attachment `json:"attachments,omitempty"` } if err := bodyJSON(r, &req); err != nil { writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") return } req.Body = strings.TrimSpace(req.Body) - if att := req.Attachment; att != nil { - if att.Size > maxUploadSize { - writeErr(w, http.StatusBadRequest, "حجم پیوست بیش از حد مجاز است") - return - } - att.Type = strings.ToLower(att.Type) - if att.Type != "image" && att.Type != "video" && att.Type != "audio" && att.Type != "file" { - writeErr(w, http.StatusBadRequest, "نوع پیوست پشتیبانی نمی‌شود") - return - } - if att.URL == "" || !strings.Contains(att.URL, "/media/") { - writeErr(w, http.StatusBadRequest, "نشانی پیوست نامعتبر است") - return - } - key := mediaKeyFromURL(att.URL) - if !mediaKeyOwnedByUser(key, u.ID) { - writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست") - return - } - // Persist without signature query — re-signed when messages are returned. - if q := strings.Index(att.URL, "?"); q >= 0 { - att.URL = att.URL[:q] - } - if att.Name == "" { - att.Name = "file" - } + atts, attErr := prepareChatAttachments(u.ID, req.Attachment, req.Attachments) + if attErr != "" { + writeErr(w, http.StatusBadRequest, attErr) + return } - if req.Body == "" && req.Attachment == nil { + if req.Body == "" && len(atts) == 0 { writeErr(w, http.StatusBadRequest, "پیام باید شامل متن یا فایل باشد") return } @@ -281,7 +366,7 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) { mentorID = u.ID } - msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, req.Attachment) + msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, atts) if err != nil { writeErr(w, http.StatusInternalServerError, "ارسال پیام ممکن نشد") return @@ -291,6 +376,7 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) { // live push chat_message to both participants s.hub.Push(u.ID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID}) s.hub.Push(partnerID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID}) + s.noteBotReply(r, partnerID, req.Body) // notify the recipient if u.Role == models.RoleMentor || u.Role == models.RoleAdmin { @@ -352,8 +438,14 @@ func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) { } msgID := routeID(r, "id") var req struct { - Body string `json:"body"` - Attachment *models.Attachment `json:"attachment,omitempty"` + Body string `json:"body"` + Attachment *models.Attachment `json:"attachment,omitempty"` + AddAttachments []models.Attachment `json:"addAttachments,omitempty"` + RemoveIndexes []int `json:"removeIndexes,omitempty"` + Replacements []struct { + Index int `json:"index"` + Attachment models.Attachment `json:"attachment"` + } `json:"replacements,omitempty"` } if err := bodyJSON(r, &req); err != nil { writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") @@ -365,36 +457,73 @@ func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) { 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) + current, err := s.store.GetOwnChatMessage(r.Context(), u.ID, partner, msgID, u.Role) + if errors.Is(err, store.ErrNotFound) { + writeErr(w, http.StatusNotFound, "پیام پیدا نشد") + return } + if err != nil { + writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد") + return + } + + existing := storedAttachments(current) + drop := map[int]struct{}{} + for _, index := range req.RemoveIndexes { + if index < 0 || index >= len(existing) { + writeErr(w, http.StatusBadRequest, "پیوست انتخاب‌شده پیدا نشد") + return + } + drop[index] = struct{}{} + } + for _, rep := range req.Replacements { + if _, removed := drop[rep.Index]; removed { + continue + } + if rep.Index < 0 || rep.Index >= len(existing) { + writeErr(w, http.StatusBadRequest, "پیوست انتخاب‌شده پیدا نشد") + return + } + prepared, attErr := prepareChatAttachments(u.ID, &rep.Attachment, nil) + if attErr != "" { + writeErr(w, http.StatusBadRequest, attErr) + return + } + if prepared[0].Type != existing[rep.Index].Type { + writeErr(w, http.StatusBadRequest, "فایل جدید باید از همان نوع باشد") + return + } + existing[rep.Index] = prepared[0] + } + + toAdd := req.AddAttachments + if req.Attachment != nil { + toAdd = append(toAdd, *req.Attachment) + } + added, attErr := prepareChatAttachments(u.ID, nil, toAdd) + if attErr != "" { + writeErr(w, http.StatusBadRequest, attErr) + return + } + + final := make([]models.Attachment, 0, len(existing)+len(added)) + for i, att := range existing { + if _, removed := drop[i]; removed { + continue + } + final = append(final, att) + } + final = append(final, added...) + if len(final) > maxChatAttachments { + writeErr(w, http.StatusBadRequest, "در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد") + return + } + if req.Body == "" && len(final) == 0 { + writeErr(w, http.StatusBadRequest, "پیام باید متن یا پیوست داشته باشد") + return + } + + msg, err := s.store.SaveEditedChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body, final) if errors.Is(err, store.ErrNotFound) { writeErr(w, http.StatusNotFound, "پیام پیدا نشد") return @@ -566,9 +695,12 @@ func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User, } 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 { return false } + if actor.ID == partnerID { + return true + } partner, err := s.store.GetUserByID(ctx, partnerID) if err != nil || !partner.IsActive { return false @@ -586,10 +718,75 @@ func (s *Server) resignChatMessages(msgs []models.ChatMessage) { } func (s *Server) resignChatMessage(msg *models.ChatMessage) { - if msg == nil || msg.Attachment == nil || msg.Attachment.URL == "" { + if msg == nil { return } - msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL) + for i := range msg.Attachments { + if msg.Attachments[i].URL != "" { + msg.Attachments[i].URL = s.signExistingMediaURL(msg.Attachments[i].URL) + } + } + if len(msg.Attachments) > 0 { + first := msg.Attachments[0] + msg.Attachment = &first + return + } + if msg.Attachment != nil && msg.Attachment.URL != "" { + msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL) + } +} + +func storedAttachments(m *models.ChatMessage) []models.Attachment { + if m == nil { + return nil + } + if len(m.Attachments) > 0 { + out := make([]models.Attachment, len(m.Attachments)) + copy(out, m.Attachments) + return out + } + if m.Attachment != nil && m.Attachment.URL != "" { + return []models.Attachment{*m.Attachment} + } + return nil +} + +const maxChatAttachments = 10 + +func prepareChatAttachments(ownerID int64, single *models.Attachment, many []models.Attachment) ([]models.Attachment, string) { + atts := many + if len(atts) == 0 && single != nil { + atts = []models.Attachment{*single} + } + if len(atts) > maxChatAttachments { + return nil, "در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد" + } + out := make([]models.Attachment, 0, len(atts)) + for i := range atts { + att := atts[i] + if att.Size > maxUploadSize { + return nil, "حجم پیوست بیش از حد مجاز است" + } + att.Type = strings.ToLower(strings.TrimSpace(att.Type)) + if att.Type != "image" && att.Type != "video" && att.Type != "audio" && att.Type != "file" { + return nil, "نوع پیوست پشتیبانی نمی‌شود" + } + if att.URL == "" || !strings.Contains(att.URL, "/media/") { + return nil, "نشانی پیوست نامعتبر است" + } + key := mediaKeyFromURL(att.URL) + if !mediaKeyOwnedByUser(key, ownerID) { + return nil, "پیوست متعلق به شما نیست" + } + if q := strings.Index(att.URL, "?"); q >= 0 { + att.URL = att.URL[:q] + } + if strings.TrimSpace(att.Name) == "" { + att.Name = "file" + } + out = append(out, att) + } + return out, "" } func itoa(v int64) string { diff --git a/backend/internal/api/chat_export.go b/backend/internal/api/chat_export.go index 520de45..295a89d 100644 --- a/backend/internal/api/chat_export.go +++ b/backend/internal/api/chat_export.go @@ -13,6 +13,7 @@ import ( "regexp" "strconv" "strings" + "sync/atomic" "time" "pargar/backend/internal/models" @@ -49,16 +50,17 @@ type chatExportAttachment struct { } type chatExportMessage struct { - ID int64 `json:"id,omitempty"` - Body string `json:"body"` - SenderRole string `json:"senderRole"` - CreatedAt time.Time `json:"createdAt"` - ReadAt *time.Time `json:"readAt,omitempty"` - 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"` + ID int64 `json:"id,omitempty"` + Body string `json:"body"` + SenderRole string `json:"senderRole"` + CreatedAt time.Time `json:"createdAt"` + ReadAt *time.Time `json:"readAt,omitempty"` + 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"` + Attachments []chatExportAttachment `json:"attachments,omitempty"` } type chatExportConversation struct { @@ -186,20 +188,25 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, [] PinnedAt: m.PinnedAt, EditedAt: m.EditedAt, } - if m.Attachment != nil { - att := &chatExportAttachment{ - Type: m.Attachment.Type, - Name: m.Attachment.Name, - Size: m.Attachment.Size, - } - key := mediaKeyFromURL(m.Attachment.URL) + list := m.Attachments + if len(list) == 0 && m.Attachment != nil { + list = []models.Attachment{*m.Attachment} + } + exported := make([]chatExportAttachment, 0, len(list)) + for i, src := range list { + att := chatExportAttachment{Type: src.Type, Name: src.Name, Size: src.Size} + key := mediaKeyFromURL(src.URL) if key == "" { att.Missing = true } else { + label := safeExportFilename(src.Name) + if len(list) > 1 { + label = fmt.Sprintf("%d-%s", i+1, label) + } rel := filepath.ToSlash(filepath.Join( "attachments", convKey, - fmt.Sprintf("%d-%s", m.ID, safeExportFilename(m.Attachment.Name)), + fmt.Sprintf("%d-%s", m.ID, label), )) rc, _, _, err := s.storage.Open(key) if err != nil { @@ -210,7 +217,14 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, [] files = append(files, zipAttachmentFile{Path: rel, Key: key}) } } - item.Attachment = att + exported = append(exported, att) + } + if len(exported) > 0 { + first := exported[0] + item.Attachment = &first + if len(exported) > 1 { + item.Attachments = exported + } } batch = append(batch, item) } @@ -373,11 +387,17 @@ func (s *Server) handleAdminChatExportRestore(w http.ResponseWriter, r *http.Req if created.IsZero() { created = time.Now().UTC() } - var att *models.Attachment - if m.Attachment != nil { - att = s.materializeRestoredAttachment(studentID, m.Attachment, zipFiles) + srcs := m.Attachments + if len(srcs) == 0 && m.Attachment != nil { + srcs = []chatExportAttachment{*m.Attachment} } - if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, att, created, m.Pinned); err != nil { + atts := make([]models.Attachment, 0, len(srcs)) + for i := range srcs { + if got := s.materializeRestoredAttachment(studentID, &srcs[i], zipFiles); got != nil { + atts = append(atts, *got) + } + } + if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, atts, created, m.Pinned); err != nil { continue } imported++ @@ -459,6 +479,8 @@ type bytesFile struct { func (b bytesFile) Close() error { return nil } +var restoreMediaSeq atomic.Uint64 + func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAttachment, zipFiles map[string][]byte) *models.Attachment { if src == nil { return nil @@ -476,7 +498,7 @@ func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAtt if src.Path != "" && zipFiles != nil { path := filepath.ToSlash(src.Path) if raw, ok := zipFiles[path]; ok && len(raw) > 0 { - key := fmt.Sprintf("chats/%d-%d%s", ownerID, time.Now().UnixNano(), filepath.Ext(name)) + key := fmt.Sprintf("chats/%d-%d-%d%s", ownerID, time.Now().UnixNano(), restoreMediaSeq.Add(1), filepath.Ext(name)) url, err := s.storage.Save(key, bytesFile{Reader: bytes.NewReader(raw)}, int64(len(raw))) if err == nil { return &models.Attachment{ diff --git a/backend/internal/api/community.go b/backend/internal/api/community.go index f9f981e..1b2f063 100644 --- a/backend/internal/api/community.go +++ b/backend/internal/api/community.go @@ -1,65 +1,11 @@ package api import ( - "context" "net/http" - "strings" - - "pargar/backend/internal/models" ) func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - donationEnabled := s.cfg.DonationEnabled - if v, err := s.store.GetSetting(ctx, "donation_enabled"); err == nil { - donationEnabled = v == "true" || v == "1" - } - note := s.cfg.DonationNote - if v, err := s.store.GetSetting(ctx, "donation_note"); err == nil && strings.TrimSpace(v) != "" { - note = strings.TrimSpace(v) - } - if strings.TrimSpace(note) == "" { - note = "اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود." - } - - var contactID any - if u, err := s.Authenticate(r); err == nil && u != nil { - if id := s.donationContactID(ctx, u); id > 0 { - contactID = id - } - } - writeJSON(w, http.StatusOK, map[string]any{ - "donation": map[string]any{ - "enabled": donationEnabled, - "note": note, - "mode": "chat", - "contactId": contactID, - "draft": "سلام؛ دربارهٔ حمایت مالی / دونیت هماهنگ کنیم.", - }, + "socials": s.loadSocials(r.Context()), }) } - -func (s *Server) donationContactID(ctx context.Context, actor *models.User) int64 { - staff, err := s.store.ListMentors(ctx) - if err != nil { - return 0 - } - var adminID, mentorID int64 - for _, m := range staff { - if m.ID == actor.ID { - continue - } - if m.Role == models.RoleAdmin && adminID == 0 { - adminID = m.ID - } - if m.Role == models.RoleMentor && mentorID == 0 { - mentorID = m.ID - } - } - if adminID != 0 { - return adminID - } - return mentorID -} diff --git a/backend/internal/api/community_test.go b/backend/internal/api/community_test.go index 2f96757..08d2b16 100644 --- a/backend/internal/api/community_test.go +++ b/backend/internal/api/community_test.go @@ -11,18 +11,15 @@ func TestCommunityEndpointPublic(t *testing.T) { if code != http.StatusOK { t.Fatalf("community: %d %v", code, body) } - don, ok := body["donation"].(map[string]any) + socials, ok := body["socials"].([]any) if !ok { - t.Fatalf("missing donation: %v", body) + t.Fatalf("missing socials: %v", body) } - if _, ok := don["enabled"]; !ok { - t.Fatalf("missing donation.enabled: %v", don) + if socials == nil { + t.Fatal("socials must be a list") } - if don["mode"] != "chat" { - t.Fatalf("donation.mode want chat, got %v", don["mode"]) - } - if _, hasCard := don["cardNumber"]; hasCard { - t.Fatal("public card number must not be exposed") + if _, hasDonation := body["donation"]; hasDonation { + t.Fatal("donation must not be exposed") } // Telegram channel surface was removed from the product. if _, hasChannel := body["channel"]; hasChannel { diff --git a/backend/internal/api/flow_test.go b/backend/internal/api/flow_test.go index 2405178..4dc17af 100644 --- a/backend/internal/api/flow_test.go +++ b/backend/internal/api/flow_test.go @@ -3,6 +3,7 @@ package api_test import ( "fmt" "net/http" + "strings" "testing" ) @@ -189,26 +190,39 @@ func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) { token, uid := register(t, e, "Struggle", "struggle@test.dev") watchToUnlock(t, e, token, 1) + adminEarly := login(t, e, bootstrapAdminEmail) + code, _ := e.do(t, "PUT", "/api/admin/users/"+itoa(uid), adminEarly, map[string]any{"hearts": 3}) + if code != http.StatusOK { + t.Fatalf("set hearts: %d", code) + } - // all-wrong answers burn 3 hearts -> account locked - code, result := e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}}) + // all-wrong answers spend the starting hearts. Zero does not lock by default. + var result map[string]any + code, result = e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}}) if code != http.StatusOK { t.Fatalf("submit: %d", code) } if result["heartsLeft"].(float64) != 0 { t.Fatalf("expected 0 hearts") } - if result["locked"] != true { - t.Fatalf("expected locked") + if result["locked"] == true { + t.Fatalf("empty hearts must not lock by default") } - // locked users can no longer send heartbeats (423) code, _ = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{"position": 45, "delta": 1, "seq": 9}) - if code != http.StatusLocked { - t.Fatalf("expected 423 while locked, got %d", code) + if code == http.StatusLocked { + t.Fatalf("heartbeat should stay open while waiting for a heart") + } + code, _ = e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}}) + if code != http.StatusForbidden { + t.Fatalf("expected quiz blocked at 0 hearts, got %d", code) } - // locked users may request staff review (does not unlock); only staff unlocks + // A manual lock still accepts a review request. Empty hearts alone do not. + code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/lock", adminEarly, nil) + if code != http.StatusOK { + t.Fatalf("manual lock: %d", code) + } code, body := e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "لطفاً بررسی کنید"}) if code != http.StatusCreated && code != http.StatusOK { t.Fatalf("unlock request: %d %v", code, body) @@ -248,8 +262,8 @@ func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) { if code != http.StatusOK || u["isLocked"] != false { t.Fatalf("user should be unlocked: %v", u["isLocked"]) } - if u["hearts"].(float64) != 3 { - t.Fatalf("hearts should be restored to 3, got %v", u["hearts"]) + if u["hearts"].(float64) != 5 { + t.Fatalf("hearts should be restored to 5, got %v", u["hearts"]) } } @@ -264,12 +278,12 @@ func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) { } uid := int64(body["items"].([]any)[0].(map[string]any)["id"].(float64)) - code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 4}) + code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 9}) if code != http.StatusOK { t.Fatalf("update user: %d", code) } user := body["user"].(map[string]any) - if user["hearts"].(float64) != 3 || user["isLocked"] != true { + if user["hearts"].(float64) != 5 || user["isLocked"] != true { t.Fatalf("overflow must cap hearts and lock account: %v", user) } @@ -278,7 +292,7 @@ func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) { t.Fatalf("me: %d", code) } user = body["user"].(map[string]any) - if user["hearts"].(float64) != 3 || user["isLocked"] != true { + if user["hearts"].(float64) != 5 || user["isLocked"] != true { t.Fatalf("persisted overflow guard failed: %v", user) } } @@ -382,11 +396,13 @@ func TestChatRecipientPermissions(t *testing.T) { } mentorID := int64(body["user"].(map[string]any)["id"].(float64)) - for _, target := range []int64{studentID, otherStudentID} { - code, _ = e.do(t, "POST", "/api/chats/"+itoa(target)+"/messages", studentToken, map[string]any{"body": "not allowed"}) - if code != http.StatusForbidden { - t.Fatalf("student→student must be forbidden, got %d", code) - } + code, _ = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", studentToken, map[string]any{"body": "saved"}) + if code != http.StatusCreated { + t.Fatalf("saved messages: %d", code) + } + code, _ = e.do(t, "POST", "/api/chats/"+itoa(otherStudentID)+"/messages", studentToken, map[string]any{"body": "not allowed"}) + if code != http.StatusForbidden { + t.Fatalf("student→student must be forbidden, got %d", code) } code, _ = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", studentToken, map[string]any{"body": "ok"}) if code != http.StatusCreated { @@ -566,17 +582,16 @@ func TestAdminSettingsAndSponsors(t *testing.T) { code, body := e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{ "settings": map[string]any{ - "donation_enabled": "true", - "donation_note": "یادداشت تست", - "sponsors": `[{"name":"حامی یک","url":"https://example.com","blurb":"حمایت"}]`, + "social_links": `[{"name":"تلگرام","url":"https://t.me/pargar"}]`, + "sponsors": `[{"name":"حامی یک","url":"https://example.com","blurb":"حمایت"}]`, }, }) if code != http.StatusOK { t.Fatalf("put settings: %d %v", code, body) } settings := body["settings"].(map[string]any) - if settings["donation_note"] != "یادداشت تست" { - t.Fatalf("donation note not saved: %v", settings["donation_note"]) + if !strings.Contains(settings["social_links"].(string), "تلگرام") { + t.Fatalf("social links not saved: %v", settings["social_links"]) } code, body = e.do(t, "GET", "/api/sponsors", "", nil) @@ -595,8 +610,8 @@ func TestAdminSettingsAndSponsors(t *testing.T) { if _, ok := body["channel"]; ok { t.Fatalf("community must not expose telegram channel: %v", body["channel"]) } - donation := body["donation"].(map[string]any) - if donation["note"] != "یادداشت تست" { - t.Fatalf("community donation note: %v", donation["note"]) + socials, _ := body["socials"].([]any) + if len(socials) != 1 || socials[0].(map[string]any)["name"] != "تلگرام" { + t.Fatalf("community socials: %v", body["socials"]) } } diff --git a/backend/internal/api/me_export.go b/backend/internal/api/me_export.go new file mode 100644 index 0000000..bb759e7 --- /dev/null +++ b/backend/internal/api/me_export.go @@ -0,0 +1,604 @@ +package api + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" +) + +const ( + meExportHTMLBase int64 = 8 * 1024 + meExportHTMLPerMessage int64 = 512 +) + +func (s *Server) handleMeExport(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + writeErr(w, http.StatusUnauthorized, "ابتدا وارد شوید") + return + } + partnerID, ok := meExportChatID(r) + if !ok { + writeErr(w, http.StatusBadRequest, "گفتگو را مشخص کنید") + return + } + if !s.canChatWith(r.Context(), u, partnerID) { + writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد") + return + } + if r.URL.Query().Get("meta") == "1" { + n, last, err := s.estimateMeExportBytes(r.Context(), u.ID, partnerID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "برآورد حجم خروجی ممکن نشد") + return + } + payload := map[string]any{"bytes": n, "chat": partnerID} + if last != nil && !last.IsZero() { + payload["lastAt"] = last.UTC() + } + writeJSON(w, http.StatusOK, payload) + return + } + if err := s.writeMeChatHTMLZip(w, r, u, partnerID); err != nil { + observability.L().Error("me.export_zip", "error", err, "user_id", u.ID, "chat", partnerID) + } +} + +func meExportChatID(r *http.Request) (int64, bool) { + raw := strings.TrimSpace(r.URL.Query().Get("chat")) + if raw == "" { + return 0, false + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} + +func (s *Server) estimateMeExportBytes(ctx context.Context, userID, partnerID int64) (int64, *time.Time, error) { + var messages, media int64 + var last *time.Time + err := s.store.Pool().QueryRow(ctx, ` + SELECT + count(*)::bigint, + COALESCE(SUM( + CASE + WHEN attachments IS NOT NULL + AND jsonb_typeof(attachments) = 'array' + AND jsonb_array_length(attachments) > 0 + THEN COALESCE(( + SELECT SUM(GREATEST(COALESCE((elem->>'size')::bigint, 0), 0)) + FROM jsonb_array_elements(attachments) AS elem + ), 0) + ELSE GREATEST(COALESCE(attachment_size, 0), 0) + END + ), 0)::bigint, + MAX(created_at) + FROM chat_messages + WHERE (user_id = $1 AND mentor_id = $2) OR (user_id = $2 AND mentor_id = $1)`, userID, partnerID).Scan(&messages, &media, &last) + if err != nil { + return 0, nil, err + } + return media + meExportHTMLBase + messages*meExportHTMLPerMessage, last, nil +} + +type meExportMessage struct { + ID int64 + UserID int64 + MentorID int64 + SenderRole string + Body string + CreatedAt time.Time + StudentName string + MentorName string + Atts []models.Attachment +} + +type meExportFile struct { + Path string + Key string +} + +type mePlaced struct { + msg meExportMessage + rel []string +} + +func (s *Server) writeMeChatHTMLZip(w http.ResponseWriter, r *http.Request, u *models.User, partnerID int64) error { + title := "پیام‌های ذخیره‌شده" + if partnerID != u.ID { + partner, err := s.store.GetUserByID(r.Context(), partnerID) + if err != nil { + writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد") + return err + } + title = strings.TrimSpace(partner.Name) + if title == "" { + title = "گفتگو" + } + } + rows, err := s.store.Pool().Query(r.Context(), ` + SELECT m.id, m.user_id, m.mentor_id, m.sender_role, m.body, m.created_at, + m.attachment_type, m.attachment_url, m.attachment_name, m.attachment_size, m.attachments, + COALESCE(su.name, ''), COALESCE(mu.name, '') + FROM chat_messages m + LEFT JOIN users su ON su.id = m.user_id + LEFT JOIN users mu ON mu.id = m.mentor_id + WHERE (m.user_id = $1 AND m.mentor_id = $2) OR (m.user_id = $2 AND m.mentor_id = $1) + ORDER BY m.created_at ASC, m.id ASC`, u.ID, partnerID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + defer rows.Close() + + var messages []meExportMessage + for rows.Next() { + var m meExportMessage + var attType, attURL, attName *string + var attSize *int64 + var rawAtts []byte + if err := rows.Scan( + &m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.CreatedAt, + &attType, &attURL, &attName, &attSize, &rawAtts, + &m.StudentName, &m.MentorName, + ); err != nil { + continue + } + m.Atts = decodeExportAttachments(rawAtts, attType, attURL, attName, attSize) + messages = append(messages, m) + } + if err := rows.Err(); err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + + placed := make([]mePlaced, 0, len(messages)) + var files []meExportFile + seen := map[string]bool{} + for _, m := range messages { + item := mePlaced{msg: m, rel: make([]string, len(m.Atts))} + for i, att := range m.Atts { + key := mediaKeyFromURL(att.URL) + if key == "" || strings.Contains(key, "..") || !exportKeyAllowed(key, u.ID, m.UserID, m.MentorID) || !s.storage.Exists(key) { + continue + } + label := safeExportFilename(att.Name) + rel := filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-%s", m.ID, i+1, label))) + if seen[rel] { + rel = filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-b-%s", m.ID, i+1, label))) + } + seen[rel] = true + item.rel[i] = rel + files = append(files, meExportFile{Path: rel, Key: key}) + } + placed = append(placed, item) + } + + doc := renderMeExportHTML(u, title, placed) + tmp, err := os.CreateTemp("", "pargar-chats-*.zip") + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpName) + }() + + zw := zip.NewWriter(tmp) + hw, err := zw.CreateHeader(&zip.FileHeader{ + Name: "index.html", + Method: zip.Deflate, + Modified: time.Now().UTC(), + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + if _, err := io.WriteString(hw, doc); err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + for _, f := range files { + rc, _, _, err := s.storage.Open(f.Key) + if err != nil { + continue + } + aw, err := zw.CreateHeader(&zip.FileHeader{ + Name: f.Path, + Method: zip.Deflate, + Modified: time.Now().UTC(), + }) + if err != nil { + _ = rc.Close() + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + _, copyErr := io.Copy(aw, rc) + _ = rc.Close() + if copyErr != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return copyErr + } + } + if err := zw.Close(); err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + info, err := tmp.Stat() + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return err + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="pargar-chat-%d.zip"`, partnerID)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, tmp) + return err +} + +func exportKeyAllowed(key string, me, studentID, mentorID int64) bool { + return mediaKeyOwnedByUser(key, me) || mediaKeyOwnedByUser(key, studentID) || mediaKeyOwnedByUser(key, mentorID) +} + +func decodeExportAttachments(raw []byte, attType, attURL, attName *string, attSize *int64) []models.Attachment { + if len(raw) > 0 && string(raw) != "null" && string(raw) != "[]" { + var list []models.Attachment + if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 { + return list + } + } + if attURL != nil && *attURL != "" { + size := int64(0) + if attSize != nil { + size = *attSize + } + typ, name := "", "" + if attType != nil { + typ = *attType + } + if attName != nil { + name = *attName + } + return []models.Attachment{{Type: typ, URL: *attURL, Name: name, Size: size}} + } + return nil +} + +func renderMeExportHTML(owner *models.User, title string, messages []mePlaced) string { + var b strings.Builder + b.WriteString(` + + + + +`) + b.WriteString(html.EscapeString(title)) + b.WriteString(` + + + +
+
+

`) + b.WriteString(html.EscapeString(title)) + b.WriteString(`

+

خروجی پرگار · `) + b.WriteString(html.EscapeString(formatJalaliStamp(time.Now()))) + b.WriteString(`

+
+
+`) + if len(messages) == 0 { + b.WriteString(`

هنوز پیامی در این گفتگو نیست.

`) + } + var hasPrev bool + var prevMine bool + var prevAt time.Time + for _, item := range messages { + mine := meExportIsMine(owner.ID, item.msg) + if !hasPrev || !sameExportDay(prevAt, item.msg.CreatedAt) { + b.WriteString(`
`) + b.WriteString(html.EscapeString(formatJalaliDay(item.msg.CreatedAt))) + b.WriteString(`
`) + } + showName := !hasPrev || prevMine != mine || !sameExportDay(prevAt, item.msg.CreatedAt) + cls := "theirs" + if mine { + cls = "mine" + } + b.WriteString(`
`) + if showName { + b.WriteString(`

`) + b.WriteString(html.EscapeString(meExportSenderName(owner, item.msg))) + b.WriteString(`

`) + } + b.WriteString(`
`) + if strings.TrimSpace(item.msg.Body) != "" { + b.WriteString(`

` + escapeExportBody(item.msg.Body) + `

`) + } + for i, att := range item.msg.Atts { + rel := "" + if i < len(item.rel) { + rel = item.rel[i] + } + b.WriteString(renderExportAttachment(att, rel)) + } + b.WriteString(`
`) + hasPrev = true + prevMine = mine + prevAt = item.msg.CreatedAt + } + b.WriteString(`
+
+ + +`) + return b.String() +} + +func meExportSenderName(me *models.User, m meExportMessage) string { + if meExportIsMine(me.ID, m) { + name := strings.TrimSpace(me.Name) + if name == "" { + return "شما" + } + return name + } + name := m.StudentName + if me.ID == m.UserID { + name = m.MentorName + } + name = strings.TrimSpace(name) + if name == "" { + return "گفتگو" + } + return name +} + +func sameExportDay(a, b time.Time) bool { + al := a.In(tehranLocation()) + bl := b.In(tehranLocation()) + return al.Year() == bl.Year() && al.Month() == bl.Month() && al.Day() == bl.Day() +} + +func formatJalaliDay(t time.Time) string { + if t.IsZero() { + return "" + } + local := t.In(tehranLocation()) + _, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day()) + month := "ماه" + if jm >= 1 && jm <= len(jalaliMonthNames) { + month = jalaliMonthNames[jm-1] + } + return persianDigits(jd) + " " + month +} + +func meExportIsMine(meID int64, m meExportMessage) bool { + if m.UserID == m.MentorID { + return true + } + role := models.Role(m.SenderRole) + if meID == m.UserID && role == models.RoleStudent { + return true + } + if meID == m.MentorID && (role == models.RoleMentor || role == models.RoleAdmin) { + return true + } + return false +} + +func escapeExportBody(s string) string { + return html.EscapeString(s) +} + +func renderExportAttachment(att models.Attachment, rel string) string { + name := strings.TrimSpace(att.Name) + if name == "" { + name = "فایل" + } + safe := html.EscapeString(name) + if rel == "" { + return `

فایل همراه موجود نیست: ` + safe + `

` + } + src := html.EscapeString(rel) + switch strings.ToLower(strings.TrimSpace(att.Type)) { + case "image": + return `` + safe + `` + case "audio": + return `` + case "video": + return `` + default: + return `

` + safe + `

` + } +} + +func formatJalaliStamp(t time.Time) string { + if t.IsZero() { + return "" + } + local := t.In(tehranLocation()) + _, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day()) + month := "ماه" + if jm >= 1 && jm <= len(jalaliMonthNames) { + month = jalaliMonthNames[jm-1] + } + return persianDigits(jd) + " " + month + " · " + persianDigits2(local.Hour()) + ":" + persianDigits2(local.Minute()) +} + +func tehranLocation() *time.Location { + loc, err := time.LoadLocation("Asia/Tehran") + if err != nil { + return time.FixedZone("IRST", 3*3600+30*60) + } + return loc +} + +var jalaliMonthNames = []string{ + "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", + "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند", +} + +func persianDigits(n int) string { + if n < 0 { + n = 0 + } + return strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' { + return '۰' + (r - '0') + } + return r + }, strconv.Itoa(n)) +} + +func persianDigits2(n int) string { + if n < 0 { + n = 0 + } + return persianDigits(n/10) + persianDigits(n%10) +} + +func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) { + gdm := [12]int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334} + gy2 := gy + if gm > 2 { + gy2 = gy + 1 + } + days := 355666 + 365*gy + (gy2+3)/4 - (gy2+99)/100 + (gy2+399)/400 + gd + gdm[gm-1] + jy = -1595 + 33*(days/12053) + days %= 12053 + jy += 4 * (days / 1461) + days %= 1461 + if days > 365 { + jy += (days - 1) / 365 + days = (days - 1) % 365 + } + if days < 186 { + jm = 1 + days/31 + jd = 1 + days%31 + return jy, jm, jd + } + jm = 7 + (days-186)/30 + jd = 1 + (days-186)%30 + return jy, jm, jd +} diff --git a/backend/internal/api/me_export_test.go b/backend/internal/api/me_export_test.go new file mode 100644 index 0000000..9e9bdab --- /dev/null +++ b/backend/internal/api/me_export_test.go @@ -0,0 +1,254 @@ +package api_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "io" + "net/http" + "strings" + "testing" +) + +func TestMeChatHTMLZipExport(t *testing.T) { + e := setup(t) + student, studentID := register(t, e, "Export Student", "me-export@test.dev") + other, otherID := register(t, e, "Other Student", "me-export-other@test.dev") + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Mentor Export", "email": "mentor-export@test.dev", "username": "mentorexport", + "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d %v", code, body) + } + mentorID := int64(body["user"].(map[string]any)["id"].(float64)) + mentor := login(t, e, "mentor-export@test.dev") + + png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") + if err != nil || len(png) < 8 { + t.Fatalf("png: %v len=%d", err, len(png)) + } + wav := make([]byte, 44) + copy(wav, []byte("RIFF")) + copy(wav[8:], []byte("WAVE")) + note := []byte("note-bytes") + + code, upImg := uploadChatFile(t, e, student, "shot.png", png) + if code != http.StatusCreated { + t.Fatalf("png upload: %d %v", code, upImg) + } + code, upNote := uploadChatFile(t, e, student, "notes.txt", note) + if code != http.StatusCreated { + t.Fatalf("txt upload: %d %v", code, upNote) + } + code, upVoice := uploadChatFile(t, e, student, "voice-note.wav", wav) + if code != http.StatusCreated { + t.Fatalf("wav upload: %d %v", code, upVoice) + } + + code, sent := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{ + "body": "راز\nخط دوم", + "attachments": []any{upImg["attachment"], upNote["attachment"]}, + }) + if code != http.StatusCreated { + t.Fatalf("send: %d %v", code, sent) + } + msgID := int64(sent["message"].(map[string]any)["id"].(float64)) + if _, err := e.pool.Exec(context.Background(), `UPDATE chat_messages SET created_at = $1 WHERE id = $2`, "2026-03-21 12:00:00+00", msgID); err != nil { + t.Fatal(err) + } + code, voice := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{ + "body": "صدا", + "attachment": upVoice["attachment"], + }) + if code != http.StatusCreated { + t.Fatalf("voice: %d %v", code, voice) + } + if _, err := e.pool.Exec(context.Background(), ` + INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size) + VALUES ($1, $2, 'student', 'گمشده', 'audio', 'http://localhost:8080/media/chats/999-missing.wav', 'gone.wav', 5000)`, + studentID, mentorID); err != nil { + t.Fatal(err) + } + code, saved := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", student, map[string]any{"body": "یادداشت خصوصی"}) + if code != http.StatusCreated { + t.Fatalf("saved: %d %v", code, saved) + } + code, reply := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", mentor, map[string]any{"body": "از منتور"}) + if code != http.StatusCreated { + t.Fatalf("reply: %d %v", code, reply) + } + + code, _ = e.do(t, "GET", "/api/me/export?meta=1", student, nil) + if code != http.StatusBadRequest { + t.Fatalf("meta without chat: %d", code) + } + code, _, raw := e.doRaw(t, "GET", "/api/me/export", student, nil) + if code != http.StatusBadRequest || (len(raw) >= 2 && raw[0] == 'P' && raw[1] == 'K') { + t.Fatalf("export without chat should not be a zip: %d", code) + } + + code, meta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(mentorID), student, nil) + if code != http.StatusOK { + t.Fatalf("meta: %d %v", code, meta) + } + approx := int64(meta["bytes"].(float64)) + floor := int64(len(png) + len(wav) + len(note) + 5000) + if approx < floor { + t.Fatalf("estimate %d < media floor %d", approx, floor) + } + if int64(meta["chat"].(float64)) != mentorID { + t.Fatalf("meta chat: %v", meta["chat"]) + } + code, savedMeta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(studentID), student, nil) + if code != http.StatusOK || int64(savedMeta["bytes"].(float64)) >= approx { + t.Fatalf("saved meta should be only that chat: %d %v", code, savedMeta) + } + + code, ct, raw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(mentorID), student, nil) + if code != http.StatusOK { + t.Fatalf("zip: %d %s", code, ct) + } + if ct != "application/zip" || len(raw) < 2 || raw[0] != 'P' || raw[1] != 'K' { + t.Fatalf("not a zip: %s %d", ct, len(raw)) + } + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + var htmlDoc string + var sawImage, sawAudio, sawNote bool + for _, f := range zr.File { + if strings.Contains(f.Name, "gone.wav") || strings.Contains(f.Name, "missing") { + t.Fatalf("missing media was packed: %s", f.Name) + } + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + blob, _ := io.ReadAll(rc) + _ = rc.Close() + switch { + case f.Name == "index.html": + htmlDoc = string(blob) + case strings.HasSuffix(f.Name, ".png"): + sawImage = bytes.Equal(blob, png) + case strings.HasSuffix(f.Name, ".wav"): + sawAudio = bytes.Equal(blob, wav) + case strings.HasSuffix(f.Name, ".txt"): + sawNote = bytes.Equal(blob, note) + } + } + if htmlDoc == "" || !sawImage || !sawAudio || !sawNote { + t.Fatalf("zip parts image=%v audio=%v note=%v html=%d", sawImage, sawAudio, sawNote, len(htmlDoc)) + } + if !strings.Contains(htmlDoc, `lang="fa"`) || !strings.Contains(htmlDoc, `dir="rtl"`) { + t.Fatal("html is not a persian rtl document") + } + if strings.Contains(htmlDoc, "راز") || !strings.Contains(htmlDoc, "<b>راز</b>") { + t.Fatal("user text was not escaped") + } + if !strings.Contains(htmlDoc, "<b>راز</b>\nخط دوم") { + t.Fatal("newline was not kept in the bubble") + } + if !strings.Contains(htmlDoc, "white-space: pre-wrap") { + t.Fatal("bubble text does not keep whitespace") + } + if !strings.Contains(htmlDoc, `class="msg mine"`) || !strings.Contains(htmlDoc, `class="msg theirs"`) { + t.Fatal("own and partner bubbles are missing") + } + if !strings.Contains(htmlDoc, "#6d63f0") { + t.Fatal("own bubble is not the chat primary color") + } + if !strings.Contains(htmlDoc, ">Mentor Export") || !strings.Contains(htmlDoc, "Export Student") || !strings.Contains(htmlDoc, "از منتور") { + t.Fatal("single conversation header or sender names missing") + } + if strings.Contains(htmlDoc, "یادداشت خصوصی") || strings.Contains(htmlDoc, "گفتگوهای پرگار") { + t.Fatal("export included another conversation") + } + if !strings.Contains(htmlDoc, " 200 { + writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه نامعتبر است") + return store.Announcement{}, false + } + if utf8.RuneCountInString(req.Body) < 8 || utf8.RuneCountInString(req.Body) > 4000 { + writeErr(w, http.StatusBadRequest, "متن اطلاعیه نامعتبر است") + return store.Announcement{}, false + } + if req.Route != "" && !strings.HasPrefix(req.Route, "/") { + writeErr(w, http.StatusBadRequest, "مسیر باید با / شروع شود") + return store.Announcement{}, false + } + return store.Announcement{ + Title: req.Title, Body: req.Body, Category: req.Category, Route: req.Route, Pinned: req.Pinned, + }, true +} + +func announceViews(list []store.Announcement) []map[string]any { + if list == nil { + return []map[string]any{} + } + out := make([]map[string]any, 0, len(list)) + for _, a := range list { + out = append(out, map[string]any{ + "id": a.ID, "title": a.Title, "body": a.Body, "category": a.Category, + "route": a.Route, "pinned": a.Pinned, "createdAt": a.CreatedAt, "updatedAt": a.UpdatedAt, + }) + } + return out +} + +func (s *Server) handleChatPrefs(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + partner := routeID(r, "partner") + if !s.canChatWith(r.Context(), me, partner) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + var req struct { + PinnedRank *int `json:"pinnedRank"` + Muted bool `json:"muted"` + Archived bool `json:"archived"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if err := s.store.UpsertChatPref(r.Context(), me.ID, partner, req.PinnedRank, req.Muted, req.Archived); err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیره تنظیم گفتگو ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleChatMuteAll(w http.ResponseWriter, r *http.Request) { + var req struct { + Muted bool `json:"muted"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if err := s.store.SetChatMutedAll(r.Context(), currentUser(r).ID, req.Muted); err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "muted": req.Muted}) +} + +func (s *Server) handleChatPeek(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + partner := routeID(r, "partner") + if !s.canChatWith(r.Context(), me, partner) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + msgs, err := s.store.ListChatMessages(r.Context(), me.ID, partner, 0, 8) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پیش‌نمایش ممکن نشد") + return + } + if len(msgs) > 8 { + msgs = msgs[len(msgs)-8:] + } + writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) +} + +func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + partner := routeID(r, "partner") + if err := s.store.MarkChatUnread(r.Context(), me.ID, partner, me.Role.String()); err != nil { + writeErr(w, http.StatusInternalServerError, "بازگشت به نخوانده ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleChatDeleteBoth(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + partner := routeID(r, "partner") + if r.URL.Query().Get("confirm") != "both" { + writeErr(w, http.StatusBadRequest, "برای پاک کردن دو طرفه confirm=both لازم است") + return + } + if !s.canChatWith(r.Context(), me, partner) && me.Role != models.RoleAdmin { + writeErr(w, http.StatusForbidden, "اجازه ندارید") + return + } + if err := s.store.DeleteChatBoth(r.Context(), me.ID, partner); err != nil { + writeErr(w, http.StatusInternalServerError, "حذف گفتگو ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleChatBroadcast(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + if !me.Role.IsStaff() { + writeErr(w, http.StatusForbidden, "فقط مسئول دوره می‌تواند پیام همگانی بفرستد") + return + } + var req struct { + Body string `json:"body"` + UserIDs []int64 `json:"userIds"` + All bool `json:"all"` + } + if err := bodyJSON(r, &req); err != nil || strings.TrimSpace(req.Body) == "" { + writeErr(w, http.StatusBadRequest, "متن پیام لازم است") + return + } + ids := req.UserIDs + if req.All { + var err error + ids, err = s.store.AllActiveUserIDs(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد") + return + } + } + sent := 0 + for _, id := range ids { + if id == me.ID || !s.canChatWith(r.Context(), me, id) { + continue + } + studentID, mentorID := id, me.ID + if me.Role == models.RoleStudent { + studentID, mentorID = me.ID, id + } + if _, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, me.Role, strings.TrimSpace(req.Body), nil, nil); err == nil { + sent++ + } + } + writeJSON(w, http.StatusOK, map[string]any{"sent": sent}) +} + +func (s *Server) handleSaveMessage(w http.ResponseWriter, r *http.Request) { + var req struct { + Body string `json:"body"` + AttachmentURL string `json:"attachmentUrl"` + AttachmentType string `json:"attachmentType"` + AttachmentName string `json:"attachmentName"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if strings.TrimSpace(req.Body) == "" && req.AttachmentURL == "" { + writeErr(w, http.StatusBadRequest, "پیامی برای ذخیره نیست") + return + } + me := currentUser(r) + var att *models.Attachment + if req.AttachmentURL != "" { + url := req.AttachmentURL + if q := strings.Index(url, "?"); q >= 0 { + url = url[:q] + } + att = &models.Attachment{Type: req.AttachmentType, URL: url, Name: req.AttachmentName} + } + var saved []models.Attachment + if att != nil { + saved = []models.Attachment{*att} + } + if _, err := s.store.AddChatMessage(r.Context(), me.ID, me.ID, me.Role, strings.TrimSpace(req.Body), nil, saved); err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleListSaved(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListSaved(r.Context(), currentUser(r).ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"messages": list}) +} + +func (s *Server) handleGetMeProfile(w http.ResponseWriter, r *http.Request) { + p, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(p)}) +} + +func (s *Server) handlePutMeProfile(w http.ResponseWriter, r *http.Request) { + var req struct { + Banner *string `json:"banner"` + Public *bool `json:"public"` + ShowActivity *bool `json:"showActivity"` + ShowStats *bool `json:"showStats"` + ShowPath *bool `json:"showPath"` + ShowBadges *bool `json:"showBadges"` + SocialLinks json.RawMessage `json:"socialLinks"` + Theme *string `json:"theme"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + cur, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد") + return + } + if req.Banner != nil { + cur.Banner = strings.TrimSpace(*req.Banner) + } + if req.Public != nil { + cur.Public = *req.Public + } + if req.ShowActivity != nil { + cur.ShowActivity = *req.ShowActivity + } + if req.ShowStats != nil { + cur.ShowStats = *req.ShowStats + } + if req.ShowPath != nil { + cur.ShowPath = *req.ShowPath + } + if req.ShowBadges != nil { + cur.ShowBadges = *req.ShowBadges + } + if req.Theme != nil { + cur.Theme = strings.TrimSpace(*req.Theme) + } + if len(req.SocialLinks) > 0 { + var links []map[string]string + if err := json.Unmarshal(req.SocialLinks, &links); err != nil || len(links) > 5 { + writeErr(w, http.StatusBadRequest, "حداکثر ۵ لینک شخصی مجاز است") + return + } + cur.SocialLinks = req.SocialLinks + } + if i := strings.Index(cur.Banner, "?"); i >= 0 { + cur.Banner = cur.Banner[:i] + } + if err := s.store.SaveProfileExtras(r.Context(), currentUser(r).ID, cur); err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیره پروفایل ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(cur)}) +} + +func (s *Server) profileView(p store.ProfileExtras) map[string]any { + links := json.RawMessage(p.SocialLinks) + if len(links) == 0 { + links = json.RawMessage(`[]`) + } + banner := p.Banner + if i := strings.Index(banner, "?"); i >= 0 { + banner = banner[:i] + } + return map[string]any{ + "banner": s.signExistingMediaURL(banner), "public": p.Public, "showActivity": p.ShowActivity, + "showStats": p.ShowStats, "showPath": p.ShowPath, "showBadges": p.ShowBadges, + "socialLinks": json.RawMessage(links), "theme": p.Theme, + } +} + +func (s *Server) handleMeAchievements(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + writeJSON(w, http.StatusOK, map[string]any{"achievements": s.achievementsFor(r.Context(), u)}) +} + +func (s *Server) achievementsFor(ctx context.Context, u *models.User) []map[string]any { + passed := 0 + _ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, u.ID).Scan(&passed) + certs := 0 + _ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM certificates WHERE user_id=$1 AND revoked_at IS NULL`, u.ID).Scan(&certs) + items := []struct { + id, title string + on bool + }{ + {"first_pass", "اولین قبولی", passed > 0}, + {"chapter_done", "اتمام فصل", s.finishedChapter(ctx, u.ID)}, + {"streak_7", "استمرار هفت‌روزه", u.StreakCurrent >= 7 || u.StreakLongest >= 7}, + {"course_done", "پایان دوره", certs > 0}, + } + out := make([]map[string]any, 0, len(items)) + for _, it := range items { + out = append(out, map[string]any{"id": it.id, "title": it.title, "unlocked": it.on}) + } + return out +} + +func (s *Server) finishedChapter(ctx context.Context, userID int64) bool { + var n int + err := s.store.Pool().QueryRow(ctx, ` + SELECT count(*) FROM chapters c + WHERE EXISTS (SELECT 1 FROM lessons l WHERE l.chapter_id=c.id) + AND NOT EXISTS ( + SELECT 1 FROM lessons l + LEFT JOIN lesson_progress p ON p.lesson_id=l.id AND p.user_id=$1 + WHERE l.chapter_id=c.id AND COALESCE(p.passed_quiz, false)=false + )`, userID).Scan(&n) + return err == nil && n > 0 +} + +func (s *Server) handleMeActivity(w http.ResponseWriter, r *http.Request) { + rows, err := s.store.Pool().Query(r.Context(), ` + SELECT day::text, count(*) FROM ( + SELECT created_at::date AS day FROM quiz_attempts WHERE user_id=$1 + UNION ALL + SELECT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1 + ) d + WHERE day >= current_date - 120 + GROUP BY day ORDER BY day`, currentUser(r).ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری فعالیت ممکن نشد") + return + } + defer rows.Close() + var days []map[string]any + for rows.Next() { + var day string + var n int + if err := rows.Scan(&day, &n); err != nil { + continue + } + days = append(days, map[string]any{"day": day, "count": n}) + } + if days == nil { + days = []map[string]any{} + } + week, month := 0, 0 + _ = s.store.Pool().QueryRow(r.Context(), ` + SELECT + count(*) FILTER (WHERE day >= current_date - 6), + count(*) FILTER (WHERE day >= current_date - 29) + FROM ( + SELECT DISTINCT created_at::date AS day FROM quiz_attempts WHERE user_id=$1 + UNION + SELECT DISTINCT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1 + ) d`, currentUser(r).ID).Scan(&week, &month) + writeJSON(w, http.StatusOK, map[string]any{"days": days, "weekDays": week, "monthDays": month}) +} + +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if utf8.RuneCountInString(q) < 2 { + writeJSON(w, http.StatusOK, map[string]any{"results": []any{}}) + return + } + me := currentUser(r) + like := "%" + q + "%" + var results []map[string]any + rows, err := s.store.Pool().Query(r.Context(), ` + SELECT id, title FROM lessons WHERE title ILIKE $1 ORDER BY id LIMIT 8`, like) + if err == nil { + for rows.Next() { + var id int64 + var title string + if rows.Scan(&id, &title) == nil { + results = append(results, map[string]any{"kind": "lesson", "title": title, "href": "/player/" + strconv.FormatInt(id, 10)}) + } + } + rows.Close() + } + rows, err = s.store.Pool().Query(r.Context(), ` + SELECT id, title FROM events WHERE title ILIKE $1 ORDER BY starts_at DESC LIMIT 8`, like) + if err == nil { + for rows.Next() { + var id int64 + var title string + if rows.Scan(&id, &title) == nil { + results = append(results, map[string]any{"kind": "event", "title": title, "href": "/unwrap"}) + } + } + rows.Close() + } + if me.Role.IsStaff() { + people, _, _ := s.store.ListUsers(r.Context(), q, "", 8, 0) + for _, p := range people { + results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)}) + } + } else { + people, _, _ := s.store.ListActiveByRoles(r.Context(), []string{"mentor", "admin"}, 40, 0) + n := 0 + for _, p := range people { + if n >= 8 || !strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) { + continue + } + results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)}) + n++ + } + } + pages := []struct{ title, href string }{ + {"درخت مهارت", "/cap"}, {"اعلان‌ها", "/notifications"}, {"لیگ", "/leaderboard"}, + {"تنظیمات", "/settings"}, {"پروفایل", "/profile"}, + } + for _, p := range pages { + if strings.Contains(p.title, q) { + results = append(results, map[string]any{"kind": "page", "title": p.title, "href": p.href}) + } + } + if results == nil { + results = []map[string]any{} + } + writeJSON(w, http.StatusOK, map[string]any{"results": results}) +} + +func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) { + env := "production" + if s.cfg.DevMode { + env = "development" + } + version, built := appBuild() + writeJSON(w, http.StatusOK, map[string]any{ + "version": version, + "built": built, + "env": env, + "health": "ok", + }) +} + +func appBuild() (version, built string) { + version = "0.2.0" + built = "dev" + info, ok := debug.ReadBuildInfo() + if !ok { + return version, built + } + for _, setting := range info.Settings { + if setting.Key == "vcs.time" && len(setting.Value) >= 10 { + built = setting.Value[:10] + } + if setting.Key == "vcs.revision" && len(setting.Value) >= 7 { + version = "0.2.0+" + setting.Value[:7] + } + } + return version, built +} + +func (s *Server) handleAdminListBots(w http.ResponseWriter, r *http.Request) { + p := parsePageParams(r) + p.PageSize = 8 + p.Offset = (p.Page - 1) * p.PageSize + + var total int64 + if err := s.store.Pool().QueryRow(r.Context(), `SELECT count(*) FROM bot_tokens`).Scan(&total); err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد") + return + } + rows, err := s.store.Pool().Query(r.Context(), ` + SELECT bt.id, bt.bot_user_id, COALESCE(u.name, ''), bt.created_at, bt.revoked_at IS NULL, bt.request_count + FROM bot_tokens bt + JOIN users u ON u.id = bt.bot_user_id + ORDER BY bt.id DESC + LIMIT $1 OFFSET $2`, p.PageSize, p.Offset) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد") + return + } + defer rows.Close() + items := make([]map[string]any, 0) + for rows.Next() { + var id, botID, requestCount int64 + var name string + var createdAt time.Time + var active bool + if rows.Scan(&id, &botID, &name, &createdAt, &active, &requestCount) != nil { + continue + } + items = append(items, map[string]any{ + "id": id, + "botId": botID, + "name": name, + "createdAt": createdAt, + "active": active, + "requestCount": requestCount, + }) + } + writePage(w, items, total, p) +} + +func (s *Server) handleAdminSetBotActive(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + if id == 0 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + var req struct { + Active bool `json:"active"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + var err error + var updated int64 + if req.Active { + tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=NULL WHERE id=$1`, id) + err = execErr + updated = tag.RowsAffected() + } else { + tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=COALESCE(revoked_at, now()) WHERE id=$1`, id) + err = execErr + updated = tag.RowsAffected() + } + if err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی ممکن نشد") + return + } + if updated == 0 { + writeErr(w, http.StatusNotFound, "کلید پیدا نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "active": req.Active}) +} + +func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) { + me := currentUser(r) + var req struct { + Name string `json:"name"` + } + _ = bodyJSON(r, &req) + name := strings.TrimSpace(req.Name) + if name == "" { + name = "ربات پرگار" + } + raw := make([]byte, 24) + _, _ = rand.Read(raw) + token := hex.EncodeToString(raw) + sum := sha256.Sum256([]byte(token)) + hash := hex.EncodeToString(sum[:]) + email := "bot-" + token[:8] + "@bots.pargar.local" + bot, err := s.store.CreateUser(r.Context(), name, email, "bot"+token[:6], hash, "", "", "", models.RoleMentor) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت ربات ممکن نشد") + return + } + if _, err := s.store.Pool().Exec(r.Context(), ` + INSERT INTO bot_tokens (bot_user_id, owner_id, token_hash) VALUES ($1,$2,$3)`, bot.ID, me.ID, hash); err != nil { + writeErr(w, http.StatusInternalServerError, "ثبت توکن ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"botId": bot.ID, "token": token}) +} + +func (s *Server) botFromRequest(r *http.Request) (int64, int64, bool) { + token := strings.TrimSpace(r.Header.Get("X-Bot-Token")) + if token == "" { + return 0, 0, false + } + sum := sha256.Sum256([]byte(token)) + hash := hex.EncodeToString(sum[:]) + var botID, ownerID int64 + var active bool + err := s.store.Pool().QueryRow(r.Context(), ` + SELECT bot_user_id, owner_id, revoked_at IS NULL FROM bot_tokens WHERE token_hash=$1`, hash).Scan(&botID, &ownerID, &active) + if err != nil || !active { + return 0, 0, false + } + _, _ = s.store.Pool().Exec(r.Context(), ` + UPDATE bot_tokens SET request_count = request_count + 1 WHERE token_hash=$1 AND revoked_at IS NULL`, hash) + return botID, ownerID, true +} + +func (s *Server) handleBotSend(w http.ResponseWriter, r *http.Request) { + botID, _, ok := s.botFromRequest(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است") + return + } + var req struct { + UserID int64 `json:"userId"` + Text string `json:"text"` + Buttons []any `json:"buttons"` + } + if err := bodyJSON(r, &req); err != nil || req.UserID == 0 || strings.TrimSpace(req.Text) == "" { + writeErr(w, http.StatusBadRequest, "userId و text لازم است") + return + } + buttons, _ := json.Marshal(req.Buttons) + var id int64 + err := s.store.Pool().QueryRow(r.Context(), ` + INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, buttons) + VALUES ($1,$2,'mentor',$3,$4) RETURNING id`, + req.UserID, botID, strings.TrimSpace(req.Text), buttons).Scan(&id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ارسال ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"messageId": id}) +} + +func (s *Server) handleBotUpdates(w http.ResponseWriter, r *http.Request) { + botID, _, ok := s.botFromRequest(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است") + return + } + after, _ := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64) + rows, err := s.store.Pool().Query(r.Context(), ` + SELECT id, payload, created_at FROM bot_updates WHERE bot_user_id=$1 AND id>$2 ORDER BY id LIMIT 50`, botID, after) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد") + return + } + defer rows.Close() + var updates []map[string]any + for rows.Next() { + var id int64 + var payload json.RawMessage + var at time.Time + if rows.Scan(&id, &payload, &at) == nil { + updates = append(updates, map[string]any{"id": id, "payload": json.RawMessage(payload), "createdAt": at}) + } + } + if updates == nil { + updates = []map[string]any{} + } + writeJSON(w, http.StatusOK, map[string]any{"updates": updates}) +} + +func (s *Server) recordBotReply(ctx context.Context, from *models.User, partnerID int64, body string) { + var ownerID int64 + err := s.store.Pool().QueryRow(ctx, `SELECT owner_id FROM bot_tokens WHERE bot_user_id=$1`, partnerID).Scan(&ownerID) + if err != nil { + return + } + payload, _ := json.Marshal(map[string]any{"from": from.ID, "name": from.Name, "text": body}) + _, _ = s.store.Pool().Exec(ctx, `INSERT INTO bot_updates (bot_user_id, payload) VALUES ($1,$2)`, partnerID, payload) + _ = ownerID +} + +func (s *Server) noteBotReply(r *http.Request, partner int64, body string) { + s.recordBotReply(r.Context(), currentUser(r), partner, body) +} + +// silence unused import if pgx is only used by callers +var _ = pgx.ErrNoRows diff --git a/backend/internal/api/product_pass_test.go b/backend/internal/api/product_pass_test.go new file mode 100644 index 0000000..a028002 --- /dev/null +++ b/backend/internal/api/product_pass_test.go @@ -0,0 +1,458 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" +) + +func TestAnnouncementRecordEditDeleteAndPin(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + student, _ := register(t, e, "Ann", "ann@test.dev") + code, created := e.do(t, "POST", "/api/admin/announce", admin, map[string]any{ + "title": "اطلاعیه تازه", "body": "متن **مارک‌داون** برای همه.", "category": "urgent", "route": "", "pinned": true, + }) + if code != http.StatusOK { + t.Fatalf("create: %d %v", code, created) + } + id := int64(created["id"].(float64)) + code, list := e.do(t, "GET", "/api/admin/announcements", admin, nil) + if code != http.StatusOK || len(list["announcements"].([]any)) == 0 { + t.Fatalf("list: %d %v", code, list) + } + code, pin := e.do(t, "GET", "/api/announcements/pinned", student, nil) + if code != http.StatusOK { + t.Fatalf("pin: %d %v", code, pin) + } + item := pin["announcement"].(map[string]any) + if item["title"] != "اطلاعیه تازه" { + t.Fatalf("pinned title: %v", item) + } + code, second := e.do(t, "POST", "/api/admin/announce", admin, map[string]any{ + "title": "اطلاعیه دوم", "body": "متن دوم برای اسلاید.", "category": "event", "route": "", "pinned": false, + }) + if code != http.StatusOK { + t.Fatalf("second: %d %v", code, second) + } + code, slidesRes := e.do(t, "GET", "/api/announcements/pinned", student, nil) + slides, _ := slidesRes["announcements"].([]any) + if code != http.StatusOK || len(slides) < 2 { + t.Fatalf("slides: %d %v", code, slidesRes["announcements"]) + } + code, _ = e.do(t, "PUT", "/api/admin/announcements/"+itoa(id), admin, map[string]any{ + "title": "اطلاعیه ویرایش‌شده", "body": "متن ویرایش‌شده برای رونوشت.", "category": "curriculum", "route": "/cap", "pinned": true, + }) + if code != http.StatusOK { + t.Fatalf("update: %d", code) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + code, pin = e.do(t, "GET", "/api/announcements/pinned", student, nil) + if code == http.StatusOK { + if got, _ := pin["announcement"].(map[string]any); got != nil && got["title"] == "اطلاعیه ویرایش‌شده" { + break + } + } + time.Sleep(50 * time.Millisecond) + } + code, _ = e.do(t, "DELETE", "/api/admin/announcements/"+itoa(id), admin, nil) + if code != http.StatusOK { + t.Fatalf("delete: %d", code) + } +} + +func TestHeartsRegenerateAndDoNotLock(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "Heart", "heart@test.dev") + admin := login(t, e, bootstrapAdminEmail) + code, _ := e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 0}) + if code != http.StatusOK { + t.Fatalf("set hearts: %d", code) + } + if _, err := e.pool.Exec(context.Background(), `UPDATE users SET hearts_updated_at = now() - interval '5 hours' WHERE id=$1`, uid); err != nil { + t.Fatal(err) + } + code, me := e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK { + t.Fatalf("me: %d", code) + } + user := me["user"].(map[string]any) + if user["hearts"].(float64) < 1 { + t.Fatalf("expected a returned heart, got %v", user["hearts"]) + } + if user["isLocked"] == true { + t.Fatal("zero hearts must not lock by default") + } + code, _ = e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{ + "settings": map[string]string{"hearts_max": "5", "hearts_regen_minutes": "240", "hearts_lock_on_empty": "false"}, + }) + if code != http.StatusOK { + t.Fatalf("settings: %d", code) + } +} + +func TestProfilePrivateByDefault(t *testing.T) { + e := setup(t) + viewer, _ := register(t, e, "Viewer", "viewer@test.dev") + _, other := register(t, e, "Hidden", "hidden@test.dev") + code, body := e.do(t, "GET", "/api/users/"+itoa(other), viewer, nil) + if code != http.StatusOK { + t.Fatalf("profile: %d %v", code, body) + } + user := body["user"].(map[string]any) + if user["private"] != true { + t.Fatalf("expected private profile, got %v", user) + } + if _, ok := user["xp"]; ok { + t.Fatalf("private profile leaked xp: %v", user) + } +} + +func TestMentorDirectoryUsesRealRoles(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + _, uid := register(t, e, "StudentRow", "row@test.dev") + code, body := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil) + if code != http.StatusOK { + t.Fatalf("mentors: %d %v", code, body) + } + found := false + for _, raw := range body["mentors"].([]any) { + row := raw.(map[string]any) + if int64(row["id"].(float64)) == uid { + found = true + if row["role"] != "student" { + t.Fatalf("role: %v", row["role"]) + } + } + } + if !found { + t.Fatal("student missing from staff directory") + } +} + +func TestSavedMessageBecomesSelfChat(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "Saver", "saver@test.dev") + code, body := e.do(t, "POST", "/api/me/saved", token, map[string]any{"body": "برای بعد"}) + if code != http.StatusOK { + t.Fatalf("save: %d %v", code, body) + } + code, body = e.do(t, "GET", "/api/chats/"+itoa(uid)+"/messages", token, nil) + if code != http.StatusOK { + t.Fatalf("self chat: %d %v", code, body) + } + msgs, _ := body["messages"].([]any) + if len(msgs) == 0 { + t.Fatal("saved text missing from self chat") + } + last := msgs[len(msgs)-1].(map[string]any) + if last["body"] != "برای بعد" { + t.Fatalf("body: %v", last["body"]) + } +} + +func TestBroadcastCanTargetSomePeople(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + student, sid := register(t, e, "One", "one@test.dev") + code, me := e.do(t, "GET", "/api/auth/me", admin, nil) + if code != http.StatusOK { + t.Fatal(code) + } + adminID := int64(me["user"].(map[string]any)["id"].(float64)) + code, body := e.do(t, "POST", "/api/chats/broadcast", admin, map[string]any{ + "body": "فقط برای تو", "userIds": []int64{sid}, + }) + if code != http.StatusOK || body["sent"].(float64) != 1 { + t.Fatalf("broadcast: %d %v", code, body) + } + code, thread := e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil) + if code != http.StatusOK { + t.Fatalf("thread: %d %v", code, thread) + } + found := false + for _, raw := range thread["messages"].([]any) { + if raw.(map[string]any)["body"] == "فقط برای تو" { + found = true + } + } + if !found { + t.Fatal("targeted broadcast missing") + } +} + +func TestChatPinAndBotReply(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + student, sid := register(t, e, "Chatty", "chatty@test.dev") + code, _ := e.do(t, "PUT", "/api/chats/"+itoa(sid)+"/prefs", admin, map[string]any{ + "pinnedRank": 1, "muted": true, "archived": false, + }) + if code != http.StatusOK { + t.Fatalf("prefs: %d", code) + } + code, conv := e.do(t, "GET", "/api/chats/conversations?page=1&pageSize=20", admin, nil) + if code != http.StatusOK { + t.Fatalf("conversations: %d %v", code, conv) + } + code, bot := e.do(t, "POST", "/api/admin/bots", admin, map[string]any{"name": "ربات آزمون"}) + if code != http.StatusCreated { + t.Fatalf("bot: %d %v", code, bot) + } + token := bot["token"].(string) + botID := int64(bot["botId"].(float64)) + code, sent := e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": token}, map[string]any{ + "userId": sid, "text": "سلام از ربات", "buttons": []any{map[string]string{"text": "باشه"}}, + }) + if code != http.StatusOK { + t.Fatalf("bot send: %d %v", code, sent) + } + code, _ = e.do(t, "POST", "/api/chats/"+itoa(botID)+"/messages", student, map[string]any{"body": "جواب هنرجو"}) + if code != http.StatusOK && code != http.StatusCreated { + t.Fatalf("reply: %d", code) + } + code, updates := e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": token}, nil) + if code != http.StatusOK || len(updates["updates"].([]any)) == 0 { + t.Fatalf("updates: %d %v", code, updates) + } +} + +func TestLastSeenAndPinnedReorder(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + _, alpha := register(t, e, "آلفا", "alpha-pin@test.dev") + _, beta := register(t, e, "بتا", "beta-pin@test.dev") + for _, id := range []int64{alpha, beta} { + code, body := e.do(t, "POST", "/api/chats/"+itoa(id)+"/messages", admin, map[string]any{"body": "سلام"}) + if code != http.StatusCreated && code != http.StatusOK { + t.Fatalf("message %d: %d %v", id, code, body) + } + } + code, _ := e.do(t, "PUT", "/api/chats/"+itoa(alpha)+"/prefs", admin, map[string]any{ + "pinnedRank": 0, "muted": false, "archived": false, + }) + if code != http.StatusOK { + t.Fatalf("pin alpha: %d", code) + } + code, _ = e.do(t, "PUT", "/api/chats/"+itoa(beta)+"/prefs", admin, map[string]any{ + "pinnedRank": 1, "muted": false, "archived": false, + }) + if code != http.StatusOK { + t.Fatalf("pin beta: %d", code) + } + code, _ = e.do(t, "PUT", "/api/chats/"+itoa(alpha)+"/prefs", admin, map[string]any{ + "pinnedRank": 1, "muted": false, "archived": false, + }) + if code != http.StatusOK { + t.Fatalf("swap alpha: %d", code) + } + code, _ = e.do(t, "PUT", "/api/chats/"+itoa(beta)+"/prefs", admin, map[string]any{ + "pinnedRank": 0, "muted": false, "archived": false, + }) + if code != http.StatusOK { + t.Fatalf("swap beta: %d", code) + } + code, conv := e.do(t, "GET", "/api/chats/conversations", admin, nil) + if code != http.StatusOK { + t.Fatalf("conversations: %d %v", code, conv) + } + var pinned []float64 + for _, raw := range conv["conversations"].([]any) { + row := raw.(map[string]any) + partner := row["partner"].(map[string]any) + id := partner["id"].(float64) + if id != float64(alpha) && id != float64(beta) { + continue + } + rank, ok := row["pinnedRank"].(float64) + if !ok { + t.Fatalf("missing pinnedRank for %v", id) + } + pinned = append(pinned, id) + if id == float64(beta) && rank != 0 { + t.Fatalf("beta rank: %v", rank) + } + if id == float64(alpha) && rank != 1 { + t.Fatalf("alpha rank: %v", rank) + } + } + if len(pinned) != 2 || pinned[0] != float64(beta) { + t.Fatalf("pin order: %v", pinned) + } + + seenAt := time.Now().Add(-2 * time.Hour).UTC() + if _, err := e.pool.Exec(context.Background(), `UPDATE users SET last_seen_at=$2 WHERE id=$1`, alpha, seenAt); err != nil { + t.Fatalf("last seen: %v", err) + } + code, people := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil) + if code != http.StatusOK { + t.Fatalf("mentors: %d %v", code, people) + } + var found bool + for _, raw := range people["mentors"].([]any) { + person := raw.(map[string]any) + if person["id"].(float64) != float64(alpha) { + continue + } + found = true + stamp, _ := person["lastSeen"].(string) + if stamp == "" { + t.Fatalf("lastSeen missing: %v", person) + } + parsed, err := time.Parse(time.RFC3339, stamp) + if err != nil { + parsed, err = time.Parse(time.RFC3339Nano, stamp) + } + if err != nil || parsed.Sub(seenAt).Abs() > time.Minute { + t.Fatalf("lastSeen %q: %v", stamp, err) + } + } + if !found { + t.Fatal("alpha missing from mentors") + } +} + +func TestBotAPIKeys(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + _, sid := register(t, e, "BotPeer", "bot-peer@test.dev") + + code, created := e.do(t, "POST", "/api/admin/bots", admin, map[string]any{"name": "تست"}) + if code != http.StatusCreated { + t.Fatalf("create: %d", code) + } + secret, _ := created["token"].(string) + if len(secret) < 32 { + t.Fatal("create did not return a secret") + } + if created["botId"] == nil { + t.Fatal("botId missing") + } + botUserID := created["botId"].(float64) + code, people := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil) + if code != http.StatusOK { + t.Fatalf("mentors: %d %v", code, people["error"]) + } + for _, raw := range people["mentors"].([]any) { + person := raw.(map[string]any) + if person["id"].(float64) == botUserID { + t.Fatal("bot appeared in the mentor directory") + } + } + + code, list := e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil) + if code != http.StatusOK { + t.Fatalf("list: %d", code) + } + if list["pageSize"] != float64(8) { + t.Fatalf("pageSize: %v", list["pageSize"]) + } + assertBotListHidesSecret(t, list, secret) + item := findBotItem(t, list, "تست") + createdAt, _ := item["createdAt"].(string) + if item["active"] != true || item["requestCount"] != float64(0) || createdAt == "" { + t.Fatalf("row shape: active=%v count=%v hasCreated=%v", item["active"], item["requestCount"], createdAt != "") + } + id := int64(item["id"].(float64)) + + code, _ = e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": "not-a-real-token"}, map[string]any{ + "userId": sid, "text": "نباید بشمرد", + }) + if code != http.StatusUnauthorized { + t.Fatalf("bad token: %d", code) + } + + code, sent := e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": secret}, map[string]any{ + "userId": sid, "text": "سلام از ربات", "buttons": []any{map[string]string{"text": "باشه"}}, + }) + if code != http.StatusOK { + t.Fatalf("send: %d %v", code, sent["error"]) + } + code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil) + if code != http.StatusOK { + t.Fatalf("list after send: %d", code) + } + assertBotListHidesSecret(t, list, secret) + item = findBotItem(t, list, "تست") + if item["requestCount"] != float64(1) { + t.Fatalf("requestCount after send: %v", item["requestCount"]) + } + + code, updates := e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": secret}, nil) + if code != http.StatusOK { + t.Fatalf("updates: %d %v", code, updates["error"]) + } + code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil) + item = findBotItem(t, list, "تست") + if item["requestCount"] != float64(2) { + t.Fatalf("requestCount after updates: %v", item["requestCount"]) + } + + code, stopped := e.do(t, "PUT", "/api/admin/bots/"+itoa(id), admin, map[string]any{"active": false}) + if code != http.StatusOK || stopped["active"] != false { + t.Fatalf("stop: %d %v", code, stopped["error"]) + } + code, _ = e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": secret}, map[string]any{ + "userId": sid, "text": "بعد از توقف", + }) + if code != http.StatusUnauthorized { + t.Fatalf("stopped send: %d", code) + } + code, _ = e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": secret}, nil) + if code != http.StatusUnauthorized { + t.Fatalf("stopped updates: %d", code) + } + code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil) + assertBotListHidesSecret(t, list, secret) + item = findBotItem(t, list, "تست") + if item["active"] != false || item["requestCount"] != float64(2) { + t.Fatalf("after stop: active=%v count=%v", item["active"], item["requestCount"]) + } +} + +func findBotItem(t *testing.T, list map[string]any, name string) map[string]any { + t.Helper() + items, _ := list["items"].([]any) + for _, raw := range items { + row, _ := raw.(map[string]any) + if row["name"] == name { + return row + } + } + t.Fatalf("missing bot %s", name) + return nil +} + +func assertBotListHidesSecret(t *testing.T, list map[string]any, secret string) { + t.Helper() + raw, err := json.Marshal(list) + if err != nil { + t.Fatal(err) + } + if secret != "" && strings.Contains(string(raw), secret) { + t.Fatal("list included the bot secret") + } + var walk func(any) + walk = func(v any) { + switch x := v.(type) { + case map[string]any: + for k, child := range x { + switch k { + case "token", "tokenHash", "token_hash", "secret": + t.Fatalf("unexpected field %s", k) + } + walk(child) + } + case []any: + for _, child := range x { + walk(child) + } + } + } + walk(list) +} diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go index 0738049..2d1d744 100644 --- a/backend/internal/api/profile.go +++ b/backend/internal/api/profile.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "errors" "net/http" @@ -22,21 +23,45 @@ func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد") return } + u = s.settleAccount(r.Context(), u) 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, - }, - }) + me := currentUser(r) + extras, err := s.store.GetProfileExtras(r.Context(), u.ID) + if err != nil { + extras = store.ProfileExtras{} + } + full := me.ID == u.ID || me.Role.IsStaff() + if !full && !extras.Public { + writeJSON(w, http.StatusOK, map[string]any{ + "user": map[string]any{ + "id": u.ID, "name": u.Name, "role": u.Role, + "avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto, + "private": true, "isFrozen": u.IsFrozen, "isClosed": u.IsClosed, + }, + }) + return + } + view := map[string]any{ + "id": u.ID, "name": u.Name, "username": u.Username, "role": u.Role, + "avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto, + "private": false, "public": extras.Public, + "createdAt": u.CreatedAt, + "isFrozen": u.IsFrozen, "isClosed": u.IsClosed, + } + if full || extras.ShowStats { + view["xp"] = u.XP + view["hearts"] = u.Hearts + view["streakCurrent"] = u.StreakCurrent + view["streakLongest"] = u.StreakLongest + view["isLocked"] = u.IsLocked + } + if full || extras.Public { + view["banner"] = s.signExistingMediaURL(extras.Banner) + links := extras.SocialLinks + if len(links) == 0 { + links = []byte(`[]`) + } + view["socialLinks"] = json.RawMessage(links) + } + writeJSON(w, http.StatusOK, map[string]any{"user": view}) } diff --git a/backend/internal/api/quiz.go b/backend/internal/api/quiz.go index fa9ce70..a2f1d53 100644 --- a/backend/internal/api/quiz.go +++ b/backend/internal/api/quiz.go @@ -77,6 +77,10 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error()) return } + if !pg.PassedQuiz && !u.Role.IsStaff() && u.Hearts <= 0 { + writeErr(w, http.StatusForbidden, "جان‌ها تمام شده است. تا بازگشت یک جان صبر کنید.") + return + } var req quizSubmitRequest if err := bodyJSON(r, &req); err != nil { @@ -155,15 +159,11 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) { } if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil { observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID) - go func() { - _ = s.notify.Notify(context.Background(), u.ID, "progress", "certificate_issued", - "گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil) - }() + _ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_issued", + "گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil) } - go func() { - s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent) - _ = s.notifyUnlockedLessons(u.ID, id) - }() + s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent) + _ = s.notifyUnlockedLessons(u.ID, id) } } else if pg.PassedQuiz { // Already cleared — practice attempts do not burn hearts. diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index eedd606..925779a 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -152,6 +152,34 @@ func newRouter(s *Server) http.Handler { mux.HandleFunc("DELETE /api/admin/challenges/{id}", admin(s.handleAdminDeleteChallenge)) mux.HandleFunc("POST /api/admin/announce", admin(s.handleAdminAnnounce)) + mux.HandleFunc("GET /api/admin/announcements", admin(s.handleAdminListAnnouncements)) + mux.HandleFunc("PUT /api/admin/announcements/{id}", admin(s.handleAdminUpdateAnnouncement)) + mux.HandleFunc("DELETE /api/admin/announcements/{id}", admin(s.handleAdminDeleteAnnouncement)) + mux.HandleFunc("GET /api/admin/bots", admin(s.handleAdminListBots)) + mux.HandleFunc("POST /api/admin/bots", admin(s.handleCreateBot)) + mux.HandleFunc("PUT /api/admin/bots/{id}", admin(s.handleAdminSetBotActive)) + mux.HandleFunc("GET /api/announcements/pinned", s.requireAuth(s.handlePinnedAnnouncement)) + mux.HandleFunc("PUT /api/chats/{partner}/prefs", s.requireAuth(s.handleChatPrefs)) + mux.HandleFunc("POST /api/chats/mute-all", s.requireAuth(s.handleChatMuteAll)) + mux.HandleFunc("GET /api/chats/{partner}/peek", s.requireAuth(s.handleChatPeek)) + mux.HandleFunc("POST /api/chats/{partner}/unread", s.requireAuth(s.handleChatUnread)) + mux.HandleFunc("DELETE /api/chats/{partner}", s.requireAuth(s.handleChatDeleteBoth)) + mux.HandleFunc("POST /api/chats/broadcast", s.requireAuth(s.handleChatBroadcast)) + mux.HandleFunc("GET /api/me/saved", s.requireAuth(s.handleListSaved)) + mux.HandleFunc("POST /api/me/saved", s.requireAuth(s.handleSaveMessage)) + mux.HandleFunc("GET /api/me/profile", s.requireAuth(s.handleGetMeProfile)) + mux.HandleFunc("PUT /api/me/profile", s.requireAuth(s.handlePutMeProfile)) + mux.HandleFunc("PUT /api/me/account", s.requireAuth(s.handleUpdateAccount)) + mux.HandleFunc("POST /api/me/freeze", s.requireAuth(s.handleFreezeAccount)) + mux.HandleFunc("POST /api/me/unfreeze", s.requireAuth(s.handleUnfreezeAccount)) + mux.HandleFunc("PUT /api/me/username", s.requireAuth(s.handleUpdateUsername)) + mux.HandleFunc("GET /api/me/achievements", s.requireAuth(s.handleMeAchievements)) + mux.HandleFunc("GET /api/me/activity", s.requireAuth(s.handleMeActivity)) + mux.HandleFunc("GET /api/me/export", s.requireAuth(s.handleMeExport)) + mux.HandleFunc("GET /api/search", s.requireAuth(s.handleSearch)) + mux.HandleFunc("GET /api/meta", s.handleMeta) + mux.HandleFunc("POST /api/bot/send", s.handleBotSend) + mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates) return mux } diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 5236248..5f64630 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -1,10 +1,12 @@ package api import ( + "context" "net" "net/http" "net/url" "strings" + "time" "github.com/gorilla/websocket" "github.com/redis/go-redis/v9" @@ -31,6 +33,11 @@ type Server struct { func NewServer(cfg *config.Config, st *store.Store, rdb *redis.Client, stg storage.Storage) *Server { hub := NewHub(rdb) + hub.SetSeen(func(userID int64) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = st.TouchLastSeen(ctx, userID) + }) notifier := service.NewNotifier(st, hub) trustedProxyConfig = cfg return &Server{ diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 34cf1d2..7ecb41b 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -9,6 +9,7 @@ import ( "unicode/utf8" "pargar/backend/internal/observability" + "pargar/backend/internal/store" ) func (s *Server) handleGetSponsors(w http.ResponseWriter, r *http.Request) { @@ -41,12 +42,66 @@ func (s *Server) loadSponsors(ctx context.Context) []map[string]string { return out } +func (s *Server) loadSocials(ctx context.Context) []map[string]string { + raw, err := s.store.GetSetting(ctx, "social_links") + if err != nil || strings.TrimSpace(raw) == "" { + return []map[string]string{} + } + var list []map[string]string + if err := json.Unmarshal([]byte(raw), &list); err != nil { + return []map[string]string{} + } + out := make([]map[string]string, 0, len(list)) + for _, item := range list { + name := strings.TrimSpace(item["name"]) + url := strings.TrimSpace(item["url"]) + if name == "" || url == "" { + continue + } + out = append(out, map[string]string{"name": name, "url": url}) + } + return out +} + +func validateSocialLinks(v string) string { + if v == "" { + return "" + } + var tmp []map[string]string + if err := json.Unmarshal([]byte(v), &tmp); err != nil { + return "فرمت شبکه‌ها نامعتبر است" + } + if len(tmp) > 8 { + return "حداکثر ۸ شبکه مجاز است" + } + for _, item := range tmp { + name := strings.TrimSpace(item["name"]) + url := strings.TrimSpace(item["url"]) + if name == "" || url == "" { + return "نام و لینک شبکه الزامی است" + } + if utf8.RuneCountInString(name) > 40 { + return "نام شبکه خیلی طولانی است" + } + if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") { + return "لینک شبکه باید با http شروع شود" + } + if utf8.RuneCountInString(url) > 500 { + return "لینک شبکه خیلی طولانی است" + } + } + return "" +} + func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) { ctx := r.Context() keys := []string{ - "donation_enabled", "donation_note", "physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days", "sponsors", + "social_links", + "hearts_max", + "hearts_regen_minutes", + "hearts_lock_on_empty", "registration_enabled", "registration_require_whitelist", } @@ -58,15 +113,17 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) out[k] = "" } } - if strings.TrimSpace(out["donation_note"]) == "" { - out["donation_note"] = s.cfg.DonationNote + if strings.TrimSpace(out["social_links"]) == "" { + out["social_links"] = "[]" } - if strings.TrimSpace(out["donation_enabled"]) == "" { - if s.cfg.DonationEnabled { - out["donation_enabled"] = "true" - } else { - out["donation_enabled"] = "false" - } + if strings.TrimSpace(out["hearts_max"]) == "" { + out["hearts_max"] = "5" + } + if strings.TrimSpace(out["hearts_regen_minutes"]) == "" { + out["hearts_regen_minutes"] = "240" + } + if strings.TrimSpace(out["hearts_lock_on_empty"]) == "" { + out["hearts_lock_on_empty"] = "false" } if strings.TrimSpace(out["physical_cert_enabled"]) == "" { if s.cfg.PhysicalCertEnabled { @@ -99,14 +156,16 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request) return } allowed := map[string]bool{ - "donation_enabled": true, - "donation_note": true, "physical_cert_enabled": true, "physical_cert_price_irr": true, "physical_cert_window_days": true, "sponsors": true, "registration_enabled": true, "registration_require_whitelist": true, + "social_links": true, + "hearts_max": true, + "hearts_regen_minutes": true, + "hearts_lock_on_empty": true, } for k, v := range req.Settings { if !allowed[k] { @@ -114,7 +173,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request) } v = strings.TrimSpace(v) switch k { - case "donation_enabled", "physical_cert_enabled", "registration_enabled", "registration_require_whitelist": + case "physical_cert_enabled", "registration_enabled", "registration_require_whitelist", "hearts_lock_on_empty": if v == "1" || strings.EqualFold(v, "true") || v == "yes" { v = "true" } else { @@ -162,9 +221,21 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request) return } } - case "donation_note": - if utf8.RuneCountInString(v) > 2000 { - writeErr(w, http.StatusBadRequest, "یادداشت دونیت خیلی طولانی است") + case "social_links": + if msg := validateSocialLinks(v); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + case "hearts_max": + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > store.MaxHearts { + writeErr(w, http.StatusBadRequest, "سقف قلب باید بین ۱ تا ۵ باشد") + return + } + case "hearts_regen_minutes": + n, err := strconv.Atoi(v) + if err != nil || n < 5 || n > 24*60 { + writeErr(w, http.StatusBadRequest, "فاصله بازگشت قلب باید بین ۵ تا ۱۴۴۰ دقیقه باشد") return } } diff --git a/backend/internal/api/surfaces_test.go b/backend/internal/api/surfaces_test.go index 71a7024..0a2796b 100644 --- a/backend/internal/api/surfaces_test.go +++ b/backend/internal/api/surfaces_test.go @@ -80,41 +80,88 @@ func TestChatReplaceAttachment(t *testing.T) { msgID := int64(msg["id"].(float64)) oldPath := mediaPath(msg["attachment"].(map[string]any)["url"]) + code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ + "body": "کپشن صدا", + }) + if code != 200 { + t.Fatalf("caption: %d %v", code, body) + } + captioned := body["message"].(map[string]any) + if captioned["body"] != "کپشن صدا" || captioned["editedAt"] == nil { + t.Fatalf("caption edit: %v", captioned) + } + if mediaPath(captioned["attachment"].(map[string]any)["url"]) != oldPath { + t.Fatalf("caption edit replaced the file: %v", captioned) + } + + code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ + "body": "", + }) + if code != 200 { + t.Fatalf("clear caption: %d %v", code, body) + } + cleared := body["message"].(map[string]any) + if cleared["body"] != "" || mediaPath(cleared["attachment"].(map[string]any)["url"]) != oldPath { + t.Fatalf("empty caption should keep the file: %v", cleared) + } + png[8] = 0x01 code, up = uploadChatFile(t, e, student, "two.png", png) + if code != 201 { + t.Fatalf("upload extra: %d %v", code, up) + } + extra := up["attachment"].(map[string]any) + code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ + "body": "کپشن صدا", + "attachment": extra, + }) + if code != 200 { + t.Fatalf("append: %d %v", code, body) + } + appended := body["message"].(map[string]any) + appendedAtts := appended["attachments"].([]any) + if len(appendedAtts) != 2 || mediaPath(appendedAtts[0].(map[string]any)["url"]) != oldPath { + t.Fatalf("append wiped the original: %v", appended) + } + if mediaPath(appendedAtts[1].(map[string]any)["url"]) == "" || mediaPath(appendedAtts[1].(map[string]any)["url"]) == oldPath { + t.Fatalf("appended file: %v", appendedAtts) + } + + png[8] = 0x02 + code, up = uploadChatFile(t, e, student, "three.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, + "body": "کپشن صدا", + "replacements": []any{map[string]any{"index": 0, "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) + editedAtts := edited["attachments"].([]any) + newPath := mediaPath(editedAtts[0].(map[string]any)["url"]) + if edited["editedAt"] == nil || len(editedAtts) != 2 || newPath == "" || newPath == oldPath { + t.Fatalf("expected only the first file to be replaced, got %v", edited) } - if edited["attachment"].(map[string]any)["type"] != "image" { - t.Fatalf("type: %v", edited["attachment"]) + if mediaPath(editedAtts[1].(map[string]any)["url"]) != mediaPath(extra["url"]) { + t.Fatalf("replace dropped the added file: %v", editedAtts) } 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, + "body": "کپشن صدا", + "replacements": []any{map[string]any{"index": 0, "attachment": wrong}}, }) - if code != 404 { - t.Fatalf("different kind should miss: %d", code) + if code != 400 { + t.Fatalf("different kind should be rejected: %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}, + "body": "", + "replacements": []any{map[string]any{"index": 0, "attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1}}}, }) if code != 400 { t.Fatalf("file replace: %d", code) @@ -124,8 +171,8 @@ func TestChatReplaceAttachment(t *testing.T) { "body": "", "attachment": next, }) - if code != 400 { - t.Fatalf("partner replace: %d", code) + if code != 404 { + t.Fatalf("partner edit should be rejected: %d", code) } } @@ -179,6 +226,110 @@ func TestChatReadMarksOnlyIncoming(t *testing.T) { } } +func TestChatSendMultipleAttachments(t *testing.T) { + e := setup(t) + student, _ := register(t, e, "Album Student", "album-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)) + + png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00} + code, up1 := uploadChatFile(t, e, student, "one.png", png) + if code != 201 { + t.Fatalf("upload one: %d %v", code, up1) + } + png[8] = 0x01 + code, up2 := uploadChatFile(t, e, student, "two.png", png) + if code != 201 { + t.Fatalf("upload two: %d %v", code, up2) + } + a := up1["attachment"].(map[string]any) + b := up2["attachment"].(map[string]any) + + code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{ + "body": "کپشن مشترک", + "attachments": []any{a, b}, + }) + if code != 201 { + t.Fatalf("send album: %d %v", code, body) + } + msg := body["message"].(map[string]any) + if msg["body"] != "کپشن مشترک" { + t.Fatalf("caption: %v", msg["body"]) + } + atts := msg["attachments"].([]any) + if len(atts) != 2 { + t.Fatalf("attachments: %v", msg["attachments"]) + } + if mediaPath(atts[0].(map[string]any)["url"]) == "" || mediaPath(atts[0].(map[string]any)["url"]) == mediaPath(atts[1].(map[string]any)["url"]) { + t.Fatalf("photo urls: %v", atts) + } + if mediaPath(msg["attachment"].(map[string]any)["url"]) != mediaPath(atts[0].(map[string]any)["url"]) { + t.Fatalf("first attachment should mirror the album: %v", msg["attachment"]) + } + msgID := int64(msg["id"].(float64)) + + code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil) + if code != 200 { + t.Fatalf("list: %d %v", code, body) + } + listed := chatMsg(t, body, msgID) + if listed["readAt"] != nil { + t.Fatalf("own album was marked seen: %v", listed) + } + listedAtts := listed["attachments"].([]any) + if len(listedAtts) != 2 || listed["body"] != "کپشن مشترک" { + t.Fatalf("listed album: %v", listed) + } + + code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{ + "body": "کپشن ویرایش‌شده", + }) + if code != 200 { + t.Fatalf("album caption: %d %v", code, body) + } + albumEdit := body["message"].(map[string]any) + albumAtts := albumEdit["attachments"].([]any) + if albumEdit["body"] != "کپشن ویرایش‌شده" || len(albumAtts) != 2 { + t.Fatalf("album caption edit: %v", albumEdit) + } + if mediaPath(albumAtts[0].(map[string]any)["url"]) != mediaPath(atts[0].(map[string]any)["url"]) || + mediaPath(albumAtts[1].(map[string]any)["url"]) != mediaPath(atts[1].(map[string]any)["url"]) { + t.Fatalf("album caption edit changed photos: %v", albumAtts) + } + + code, up3 := uploadChatFile(t, e, student, "notes.txt", []byte("hello")) + if code != 201 { + t.Fatalf("upload txt: %d %v", code, up3) + } + code, up4 := uploadChatFile(t, e, student, "more.txt", []byte("world")) + if code != 201 { + t.Fatalf("upload txt 2: %d %v", code, up4) + } + code, body = e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{ + "attachments": []any{up3["attachment"], up4["attachment"]}, + }) + if code != 201 { + t.Fatalf("send files: %d %v", code, body) + } + files := body["message"].(map[string]any)["attachments"].([]any) + if len(files) != 2 || files[0].(map[string]any)["type"] != "file" || files[1].(map[string]any)["name"] != "more.txt" { + t.Fatalf("files: %v", files) + } + + many := make([]any, 0, 11) + for i := 0; i < 11; i++ { + many = append(many, a) + } + code, _ = e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"attachments": many}) + if code != 400 { + t.Fatalf("too many: %d", code) + } +} + func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any { t.Helper() for _, raw := range body["messages"].([]any) { diff --git a/backend/internal/api/ws.go b/backend/internal/api/ws.go index 4598408..6331db4 100644 --- a/backend/internal/api/ws.go +++ b/backend/internal/api/ws.go @@ -16,9 +16,10 @@ const pushChannel = "pargar:push" // Hub manages per-user websocket connections and fans out messages. // Messages are published on a shared Redis channel so all instances deliver to their own local clients. type Hub struct { - mu sync.RWMutex - conns map[int64]map[*Client]struct{} - rdb *redis.Client + mu sync.RWMutex + conns map[int64]map[*Client]struct{} + rdb *redis.Client + onSeen func(userID int64) } type Client struct { @@ -84,6 +85,26 @@ func (h *Hub) Online(userID int64) bool { return len(h.conns[userID]) > 0 } +// SetSeen records a callback invoked when a user connects, stays connected, or disconnects. +func (h *Hub) SetSeen(fn func(userID int64)) { + h.mu.Lock() + h.onSeen = fn + h.mu.Unlock() +} + +func (h *Hub) noteSeen(userID int64) { + if userID == 0 { + return + } + h.mu.RLock() + fn := h.onSeen + h.mu.RUnlock() + if fn == nil { + return + } + go fn(userID) +} + // Register adds a new client connection for a user. func (h *Hub) Register(userID int64, conn *websocket.Conn) *Client { c := &Client{hub: h, conn: conn, user: userID, send: make(chan []byte, 16)} @@ -93,23 +114,29 @@ func (h *Hub) Register(userID int64, conn *websocket.Conn) *Client { } h.conns[userID][c] = struct{}{} h.mu.Unlock() + h.noteSeen(userID) go c.writePump() go c.readPump() return c } func (h *Hub) Unregister(c *Client) { + offline := false h.mu.Lock() if set, ok := h.conns[c.user]; ok { if _, ok := set[c]; ok { delete(set, c) if len(set) == 0 { delete(h.conns, c.user) + offline = true } } } h.mu.Unlock() close(c.send) + if offline { + h.noteSeen(c.user) + } } // close unregisters the client and closes its connection exactly once, @@ -148,6 +175,7 @@ func (c *Client) writePump() { if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } + c.hub.noteSeen(c.user) } } } diff --git a/backend/internal/db/migrate.go b/backend/internal/db/migrate.go index 494228f..150aeed 100644 --- a/backend/internal/db/migrate.go +++ b/backend/internal/db/migrate.go @@ -11,9 +11,26 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +// migrateLockID is held for the whole migration pass so two replicas cannot +// apply the same file at once. The lock lives on one pooled connection and +// is released when that session ends. +const migrateLockID int64 = 764727013 + // Migrate runs all *.sql files in the migrations fs in lexicographic order. // Tracks applied migrations in schema_migrations. func Migrate(ctx context.Context, pool *pgxpool.Pool, migrations fs.FS) error { + conn, err := pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("migrate lock: %w", err) + } + defer conn.Release() + if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockID); err != nil { + return fmt.Errorf("migrate lock: %w", err) + } + defer func() { + _, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, migrateLockID) + }() + if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil { return fmt.Errorf("create migrations table: %w", err) } diff --git a/backend/internal/db/migrate_upgrade_test.go b/backend/internal/db/migrate_upgrade_test.go new file mode 100644 index 0000000..addf659 --- /dev/null +++ b/backend/internal/db/migrate_upgrade_test.go @@ -0,0 +1,192 @@ +package db + +import ( + "context" + "fmt" + "io/fs" + "os" + "strings" + "sync" + "testing" + "testing/fstest" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestMigrateFromV013Schema(t *testing.T) { + user := os.Getenv("POSTGRES_USER") + password := os.Getenv("POSTGRES_PASSWORD") + if user == "" || password == "" { + t.Skip("postgres env not set") + } + host := "127.0.0.1" + port := "5432" + adminURL := fmt.Sprintf("postgres://%s:%s@%s:%s/postgres?sslmode=disable", user, password, host, port) + ctx := context.Background() + admin, err := pgxpool.New(ctx, adminURL) + if err != nil { + t.Skipf("postgres unavailable: %v", err) + } + defer admin.Close() + if err := admin.Ping(ctx); err != nil { + t.Skipf("postgres unavailable: %v", err) + } + + const name = "pargar_upgrade_test" + _, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`) + if _, err := admin.Exec(ctx, `CREATE DATABASE `+name); err != nil { + t.Fatalf("create database: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec(context.Background(), `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`) + }) + + url := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, name) + pool, err := Connect(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + sub, err := fs.Sub(MigrationFiles, "migrations") + if err != nil { + t.Fatal(err) + } + legacy := fstest.MapFS{} + entries, err := fs.ReadDir(sub, ".") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") || entry.Name() >= "0023" { + continue + } + body, err := fs.ReadFile(sub, entry.Name()) + if err != nil { + t.Fatal(err) + } + legacy[entry.Name()] = &fstest.MapFile{Data: body} + } + if len(legacy) == 0 { + t.Fatal("no v0.1.3 migrations") + } + if err := Migrate(ctx, pool, legacy); err != nil { + t.Fatalf("migrate through 0022: %v", err) + } + + if _, err := pool.Exec(ctx, ` + INSERT INTO users (name, email, username, password_hash, role, hearts) + VALUES ('legacy', 'legacy@example.com', 'legacy', 'x', 'student', 3)`); err != nil { + t.Fatalf("seed hearts=3: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO users (name, email, username, password_hash, role, hearts) + VALUES ('over', 'over@example.com', 'over', 'x', 'student', 5)`); err == nil { + t.Fatal("hearts=5 was accepted on the v0.1.3 constraint") + } + + if err := Migrate(ctx, pool, sub); err != nil { + t.Fatalf("upgrade 0023-0028: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO users (name, email, username, password_hash, role, hearts) + VALUES ('over', 'over@example.com', 'over', 'x', 'student', 5)`); err != nil { + t.Fatalf("hearts=5 after upgrade: %v", err) + } + + var hearts int + var locked bool + if err := pool.QueryRow(ctx, `SELECT hearts, is_locked FROM users WHERE email='legacy@example.com'`).Scan(&hearts, &locked); err != nil { + t.Fatal(err) + } + if hearts != 3 || locked { + t.Fatalf("legacy account changed: hearts=%d locked=%v", hearts, locked) + } + + var constraint string + if err := pool.QueryRow(ctx, `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname='users_hearts_max'`).Scan(&constraint); err != nil { + t.Fatal(err) + } + if !strings.Contains(constraint, "5") { + t.Fatalf("hearts constraint: %s", constraint) + } + + var labels string + if err := pool.QueryRow(ctx, ` + SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder) + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'notif_category'`).Scan(&labels); err != nil { + t.Fatal(err) + } + for _, want := range []string{"urgent", "curriculum", "community", "system"} { + if !strings.Contains(labels, want) { + t.Fatalf("enum %s missing from %s", want, labels) + } + } + + var cols int + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM information_schema.columns + WHERE table_name='users' AND column_name IN ('last_seen_at','username_change_count','frozen_at','closed_at')`).Scan(&cols); err != nil { + t.Fatal(err) + } + if cols != 4 { + t.Fatalf("user columns: %d", cols) + } + var attachments bool + if err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='chat_messages' AND column_name='attachments' + )`).Scan(&attachments); err != nil { + t.Fatal(err) + } + if !attachments { + t.Fatal("chat attachments column missing") + } + var botCols int + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM information_schema.columns + WHERE table_name='bot_tokens' AND column_name IN ('request_count','revoked_at')`).Scan(&botCols); err != nil { + t.Fatal(err) + } + if botCols != 2 { + t.Fatalf("bot token columns: %d", botCols) + } + + var before int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&before); err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, pool, sub); err != nil { + t.Fatalf("second migrate: %v", err) + } + var after int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&after); err != nil { + t.Fatal(err) + } + if before != after { + t.Fatalf("repeat migrate wrote rows: %d -> %d", before, after) + } + + lockCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + var wg sync.WaitGroup + errCh := make(chan error, 2) + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + errCh <- Migrate(lockCtx, pool, sub) + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatalf("parallel migrate: %v", err) + } + } +} diff --git a/backend/internal/db/migrations/0023_product_pass.sql b/backend/internal/db/migrations/0023_product_pass.sql new file mode 100644 index 0000000..8f10fdb --- /dev/null +++ b/backend/internal/db/migrations/0023_product_pass.sql @@ -0,0 +1,71 @@ +ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'urgent'; +ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'curriculum'; +ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'community'; +ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'system'; + +CREATE TABLE IF NOT EXISTS announcements ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'event', + route TEXT NOT NULL DEFAULT '', + pinned BOOLEAN NOT NULL DEFAULT false, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS announcement_id BIGINT REFERENCES announcements(id) ON DELETE CASCADE; + +CREATE TABLE IF NOT EXISTS chat_prefs ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + partner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pinned_rank INT, + muted BOOLEAN NOT NULL DEFAULT false, + archived BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (user_id, partner_id) +); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_muted_all BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS saved_messages ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL DEFAULT '', + attachment_url TEXT NOT NULL DEFAULT '', + attachment_type TEXT NOT NULL DEFAULT '', + attachment_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + banner_photo TEXT NOT NULL DEFAULT '', + profile_public BOOLEAN NOT NULL DEFAULT false, + show_activity BOOLEAN NOT NULL DEFAULT true, + show_stats BOOLEAN NOT NULL DEFAULT true, + show_path BOOLEAN NOT NULL DEFAULT true, + show_badges BOOLEAN NOT NULL DEFAULT true, + social_links JSONB NOT NULL DEFAULT '[]', + theme TEXT NOT NULL DEFAULT 'system' +); + +CREATE TABLE IF NOT EXISTS bot_tokens ( + id BIGSERIAL PRIMARY KEY, + bot_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + owner_id BIGINT NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS bot_updates ( + id BIGSERIAL PRIMARY KEY, + bot_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS buttons JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE users ALTER COLUMN hearts SET DEFAULT 5; +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_hearts_max; +ALTER TABLE users ADD CONSTRAINT users_hearts_max CHECK (hearts BETWEEN 0 AND 5); diff --git a/backend/internal/db/migrations/0024_last_seen.sql b/backend/internal/db/migrations/0024_last_seen.sql new file mode 100644 index 0000000..edc4753 --- /dev/null +++ b/backend/internal/db/migrations/0024_last_seen.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ; diff --git a/backend/internal/db/migrations/0025_chat_attachments_json.sql b/backend/internal/db/migrations/0025_chat_attachments_json.sql new file mode 100644 index 0000000..785b33e --- /dev/null +++ b/backend/internal/db/migrations/0025_chat_attachments_json.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_messages + ADD COLUMN IF NOT EXISTS attachments JSONB; diff --git a/backend/internal/db/migrations/0026_username_change_quota.sql b/backend/internal/db/migrations/0026_username_change_quota.sql new file mode 100644 index 0000000..2a3c4ca --- /dev/null +++ b/backend/internal/db/migrations/0026_username_change_quota.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS username_change_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS username_cooldown_until TIMESTAMPTZ; diff --git a/backend/internal/db/migrations/0027_account_freeze.sql b/backend/internal/db/migrations/0027_account_freeze.sql new file mode 100644 index 0000000..250d742 --- /dev/null +++ b/backend/internal/db/migrations/0027_account_freeze.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS frozen_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; diff --git a/backend/internal/db/migrations/0028_bot_token_admin.sql b/backend/internal/db/migrations/0028_bot_token_admin.sql new file mode 100644 index 0000000..7d8f183 --- /dev/null +++ b/backend/internal/db/migrations/0028_bot_token_admin.sql @@ -0,0 +1,2 @@ +ALTER TABLE bot_tokens ADD COLUMN IF NOT EXISTS request_count BIGINT NOT NULL DEFAULT 0; +ALTER TABLE bot_tokens ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ; diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 97b927e..a9570fb 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -21,32 +21,87 @@ func (r Role) IsStaff() bool { } type User struct { - ID int64 `json:"id"` - Email string `json:"email"` - Username string `json:"username"` - PasswordHash string `json:"-"` - Name string `json:"name"` - Role Role `json:"role"` - XP int `json:"xp"` - Hearts int `json:"hearts"` - HeartsUpdatedAt time.Time `json:"heartsUpdatedAt"` - StreakCurrent int `json:"streakCurrent"` - StreakLongest int `json:"streakLongest"` - LastActivityDate *time.Time `json:"lastActivityDate,omitempty"` - IsLocked bool `json:"isLocked"` - LockedAt *time.Time `json:"lockedAt,omitempty"` - UnlockedBy *int64 `json:"unlockedBy,omitempty"` - IsActive bool `json:"isActive"` - AvatarVariant string `json:"avatarVariant"` - AvatarPalette string `json:"avatarPalette"` - AvatarPhoto string `json:"avatarPhoto,omitempty"` - Phone string `json:"phone"` - TelegramID *int64 `json:"-"` - TelegramUsername string `json:"-"` - SecurityQuestion string `json:"securityQuestion,omitempty"` - SecurityAnswerHash string `json:"-"` - HasSecurityAnswer bool `json:"hasSecurityAnswer"` - CreatedAt time.Time `json:"createdAt"` + ID int64 `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Name string `json:"name"` + Role Role `json:"role"` + XP int `json:"xp"` + Hearts int `json:"hearts"` + HeartsUpdatedAt time.Time `json:"heartsUpdatedAt"` + StreakCurrent int `json:"streakCurrent"` + StreakLongest int `json:"streakLongest"` + LastActivityDate *time.Time `json:"lastActivityDate,omitempty"` + IsLocked bool `json:"isLocked"` + LockedAt *time.Time `json:"lockedAt,omitempty"` + UnlockedBy *int64 `json:"unlockedBy,omitempty"` + IsActive bool `json:"isActive"` + AvatarVariant string `json:"avatarVariant"` + AvatarPalette string `json:"avatarPalette"` + AvatarPhoto string `json:"avatarPhoto,omitempty"` + Phone string `json:"phone"` + TelegramID *int64 `json:"-"` + TelegramUsername string `json:"-"` + SecurityQuestion string `json:"securityQuestion,omitempty"` + SecurityAnswerHash string `json:"-"` + HasSecurityAnswer bool `json:"hasSecurityAnswer"` + CreatedAt time.Time `json:"createdAt"` + UsernameChangeCount int `json:"-"` + UsernameCooldownUntil *time.Time `json:"usernameCooldownUntil,omitempty"` + UsernameChangesRemaining int `json:"usernameChangesRemaining"` + FrozenAt *time.Time `json:"frozenAt,omitempty"` + ClosedAt *time.Time `json:"closedAt,omitempty"` + IsFrozen bool `json:"isFrozen"` + IsClosed bool `json:"isClosed"` + ClosesAt *time.Time `json:"closesAt,omitempty"` +} + +const ( + UsernameChangeLimit = 3 + UsernameChangeCooldown = 7 * 24 * time.Hour + AccountFreezeGraceDays = 30 +) + +// ApplyUsernameQuota fills UsernameChangesRemaining from the stored count. +// An expired cooldown clears the allowance so the next window starts at the limit. +func (u *User) ApplyUsernameQuota(now time.Time) { + if u.UsernameCooldownUntil != nil && !now.Before(*u.UsernameCooldownUntil) { + u.UsernameChangeCount = 0 + u.UsernameCooldownUntil = nil + } + if u.UsernameCooldownUntil != nil && now.Before(*u.UsernameCooldownUntil) { + u.UsernameChangesRemaining = 0 + return + } + remaining := UsernameChangeLimit - u.UsernameChangeCount + if remaining < 0 { + remaining = 0 + } + u.UsernameChangesRemaining = remaining +} + +// ApplyFreezeState derives freeze and closure from frozen_at and closed_at. +// Frozen is frozen_at set, closed_at empty, and now still inside the 30-day window. +// Closed is closed_at set, or the window having elapsed. +func (u *User) ApplyFreezeState(now time.Time) { + u.IsFrozen = false + u.IsClosed = false + u.ClosesAt = nil + if u.ClosedAt != nil { + u.IsClosed = true + return + } + if u.FrozenAt == nil { + return + } + closes := u.FrozenAt.AddDate(0, 0, AccountFreezeGraceDays) + if !now.Before(closes) { + u.IsClosed = true + return + } + u.IsFrozen = true + u.ClosesAt = &closes } type Certificate struct { @@ -209,19 +264,21 @@ type Challenge struct { } type ChatMessage struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` - MentorID int64 `json:"mentorId"` - SenderRole Role `json:"senderRole"` - Body string `json:"body"` - ReadAt *time.Time `json:"readAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - 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"` + ID int64 `json:"id"` + UserID int64 `json:"userId"` + MentorID int64 `json:"mentorId"` + SenderRole Role `json:"senderRole"` + Body string `json:"body"` + ReadAt *time.Time `json:"readAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + 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"` + Attachments []Attachment `json:"attachments,omitempty"` + Reactions []ChatReaction `json:"reactions,omitempty"` + Buttons json.RawMessage `json:"buttons,omitempty"` } type ChatReaction struct { diff --git a/backend/internal/service/hearts.go b/backend/internal/service/hearts.go index 63f6819..5ab9198 100644 --- a/backend/internal/service/hearts.go +++ b/backend/internal/service/hearts.go @@ -2,6 +2,7 @@ package service import ( "context" + "strconv" "time" "pargar/backend/internal/config" @@ -9,19 +10,62 @@ import ( "pargar/backend/internal/store" ) -// HeartsService manages the fixed 3-heart pool and lockout. -// Hearts do not regenerate over time — only a mentor/admin unlock restores them. +// HeartsService manages a regenerating heart pool. +// Reaching zero waits for the next heart unless an admin turns lock-on-empty on. type HeartsService struct { store *store.Store } +type HeartPolicy struct { + Max int + Every time.Duration + LockEmpty bool +} + func NewHeartsService(st *store.Store, _ *config.Config) *HeartsService { return &HeartsService{store: st} } -// RefreshHearts is a no-op kept for call-site compatibility. -// The game has no timed heart refill; the stored count is authoritative. -func (h *HeartsService) RefreshHearts(_ context.Context, u *models.User) (*models.User, error) { +func (h *HeartsService) Policy(ctx context.Context) HeartPolicy { + p := HeartPolicy{Max: store.MaxHearts, Every: 4 * time.Hour, LockEmpty: false} + if v, err := h.store.GetSetting(ctx, "hearts_max"); err == nil { + if n, convErr := strconv.Atoi(v); convErr == nil && n >= 1 && n <= store.MaxHearts { + p.Max = n + } + } + if v, err := h.store.GetSetting(ctx, "hearts_regen_minutes"); err == nil { + if n, convErr := strconv.Atoi(v); convErr == nil && n >= 5 && n <= 24*60 { + p.Every = time.Duration(n) * time.Minute + } + } + if v, err := h.store.GetSetting(ctx, "hearts_lock_on_empty"); err == nil { + p.LockEmpty = v == "true" || v == "1" + } + return p +} + +// RefreshHearts adds hearts that have come due since hearts_updated_at. +func (h *HeartsService) RefreshHearts(ctx context.Context, u *models.User) (*models.User, error) { + if u == nil || u.Role.IsStaff() { + return u, nil + } + p := h.Policy(ctx) + if u.Hearts >= p.Max || u.HeartsUpdatedAt.IsZero() { + return u, nil + } + gain := int(time.Since(u.HeartsUpdatedAt) / p.Every) + if gain <= 0 { + return u, nil + } + next := u.Hearts + gain + if next > p.Max { + next = p.Max + } + if err := h.store.SetHearts(ctx, u.ID, next); err != nil { + return u, err + } + u.Hearts = next + u.HeartsUpdatedAt = time.Now() return u, nil } @@ -45,7 +89,7 @@ func (h *HeartsService) ConsumeHearts(ctx context.Context, u *models.User, loss return nil, 0, err } u.Hearts = cur - if cur == 0 && !u.IsLocked { + if cur == 0 && h.Policy(ctx).LockEmpty && !u.IsLocked { locked, err := h.store.LockUser(ctx, u.ID) if err != nil { return nil, 0, err diff --git a/backend/internal/store/product.go b/backend/internal/store/product.go new file mode 100644 index 0000000..be8de56 --- /dev/null +++ b/backend/internal/store/product.go @@ -0,0 +1,277 @@ +package store + +import ( + "context" + "encoding/json" + "time" +) + +type Announcement struct { + ID int64 + Title string + Body string + Category string + Route string + Pinned bool + CreatedBy int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (s *Store) CreateAnnouncement(ctx context.Context, a *Announcement) error { + if a.Pinned { + _, _ = s.pool.Exec(ctx, `UPDATE announcements SET pinned=false WHERE pinned`) + } + return s.pool.QueryRow(ctx, ` + INSERT INTO announcements (title, body, category, route, pinned, created_by) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING id, created_at, updated_at`, + a.Title, a.Body, a.Category, a.Route, a.Pinned, a.CreatedBy, + ).Scan(&a.ID, &a.CreatedAt, &a.UpdatedAt) +} + +func (s *Store) ListAnnouncements(ctx context.Context) ([]Announcement, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, title, body, category, route, pinned, COALESCE(created_by,0), created_at, updated_at + FROM announcements ORDER BY pinned DESC, id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Announcement + for rows.Next() { + var a Announcement + if err := rows.Scan(&a.ID, &a.Title, &a.Body, &a.Category, &a.Route, &a.Pinned, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Store) UpdateAnnouncement(ctx context.Context, a Announcement) error { + if a.Pinned { + _, _ = s.pool.Exec(ctx, `UPDATE announcements SET pinned=false WHERE pinned AND id<>$1`, a.ID) + } + tag, err := s.pool.Exec(ctx, ` + UPDATE announcements + SET title=$2, body=$3, category=$4, route=$5, pinned=$6, updated_at=now() + WHERE id=$1`, a.ID, a.Title, a.Body, a.Category, a.Route, a.Pinned) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + _, err = s.pool.Exec(ctx, ` + UPDATE notifications + SET title=$2, body=$3, category=$4::notif_category, route=$5 + WHERE announcement_id=$1`, a.ID, a.Title, a.Body, a.Category, a.Route) + return err +} + +func (s *Store) DeleteAnnouncement(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM announcements WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) RecentAnnouncements(ctx context.Context, limit int) ([]Announcement, error) { + if limit <= 0 || limit > 20 { + limit = 8 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, title, body, category, route, pinned, COALESCE(created_by,0), created_at, updated_at + FROM announcements ORDER BY pinned DESC, id DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Announcement + for rows.Next() { + var a Announcement + if err := rows.Scan(&a.ID, &a.Title, &a.Body, &a.Category, &a.Route, &a.Pinned, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Store) PinnedAnnouncement(ctx context.Context) (*Announcement, error) { + var a Announcement + err := s.pool.QueryRow(ctx, ` + SELECT id, title, body, category, route, pinned, COALESCE(created_by,0), created_at, updated_at + FROM announcements WHERE pinned ORDER BY updated_at DESC LIMIT 1`). + Scan(&a.ID, &a.Title, &a.Body, &a.Category, &a.Route, &a.Pinned, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, err + } + return &a, nil +} + +func (s *Store) AttachAnnouncement(ctx context.Context, notifID, announcementID int64) error { + _, err := s.pool.Exec(ctx, `UPDATE notifications SET announcement_id=$2 WHERE id=$1`, notifID, announcementID) + return err +} + +type ChatPref struct { + PinnedRank *int + Muted bool + Archived bool +} + +func (s *Store) UpsertChatPref(ctx context.Context, userID, partnerID int64, rank *int, muted, archived bool) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO chat_prefs (user_id, partner_id, pinned_rank, muted, archived) + VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (user_id, partner_id) DO UPDATE + SET pinned_rank=$3, muted=$4, archived=$5`, userID, partnerID, rank, muted, archived) + return err +} + +func (s *Store) ChatPref(ctx context.Context, userID, partnerID int64) ChatPref { + var p ChatPref + _ = s.pool.QueryRow(ctx, ` + SELECT pinned_rank, muted, archived FROM chat_prefs WHERE user_id=$1 AND partner_id=$2`, + userID, partnerID).Scan(&p.PinnedRank, &p.Muted, &p.Archived) + return p +} + +func (s *Store) SetChatMutedAll(ctx context.Context, userID int64, muted bool) error { + _, err := s.pool.Exec(ctx, `UPDATE users SET chat_muted_all=$2 WHERE id=$1`, userID, muted) + return err +} + +func (s *Store) ChatMutedAll(ctx context.Context, userID int64) bool { + var v bool + _ = s.pool.QueryRow(ctx, `SELECT chat_muted_all FROM users WHERE id=$1`, userID).Scan(&v) + return v +} + +func (s *Store) TouchLastSeen(ctx context.Context, userID int64) error { + _, err := s.pool.Exec(ctx, `UPDATE users SET last_seen_at = now() WHERE id=$1`, userID) + return err +} + +func (s *Store) LastSeenByIDs(ctx context.Context, ids []int64) (map[int64]time.Time, error) { + out := map[int64]time.Time{} + if len(ids) == 0 { + return out, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT id, last_seen_at FROM users + WHERE id = ANY($1::bigint[]) AND last_seen_at IS NOT NULL`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id int64 + var ts time.Time + if err := rows.Scan(&id, &ts); err != nil { + return nil, err + } + out[id] = ts + } + return out, rows.Err() +} + +func (s *Store) MarkChatUnread(ctx context.Context, meID, partnerID int64, meRole string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE chat_messages SET read_at=NULL + WHERE read_at IS NOT NULL + AND sender_role <> $3 + AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`, + meID, partnerID, meRole) + return err +} + +func (s *Store) DeleteChatBoth(ctx context.Context, a, b int64) error { + _, err := s.pool.Exec(ctx, ` + DELETE FROM chat_messages + WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`, a, b) + return err +} + +func (s *Store) SaveMessage(ctx context.Context, userID int64, body, url, kind, name string) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO saved_messages (user_id, body, attachment_url, attachment_type, attachment_name) + VALUES ($1,$2,$3,$4,$5)`, userID, body, url, kind, name) + return err +} + +func (s *Store) ListSaved(ctx context.Context, userID int64) ([]map[string]any, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, body, attachment_url, attachment_type, attachment_name, created_at + FROM saved_messages WHERE user_id=$1 ORDER BY id DESC LIMIT 100`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []map[string]any + for rows.Next() { + var id int64 + var body, url, kind, name string + var at time.Time + if err := rows.Scan(&id, &body, &url, &kind, &name, &at); err != nil { + return nil, err + } + out = append(out, map[string]any{ + "id": id, "body": body, "attachmentUrl": url, "attachmentType": kind, "attachmentName": name, "createdAt": at, + }) + } + if out == nil { + out = []map[string]any{} + } + return out, rows.Err() +} + +type ProfileExtras struct { + Banner string + Public bool + ShowActivity bool + ShowStats bool + ShowPath bool + ShowBadges bool + SocialLinks json.RawMessage + Theme string +} + +func (s *Store) EnsureProfile(ctx context.Context, userID int64) error { + _, err := s.pool.Exec(ctx, `INSERT INTO user_profiles (user_id) VALUES ($1) ON CONFLICT DO NOTHING`, userID) + return err +} + +func (s *Store) GetProfileExtras(ctx context.Context, userID int64) (ProfileExtras, error) { + if err := s.EnsureProfile(ctx, userID); err != nil { + return ProfileExtras{}, err + } + var p ProfileExtras + err := s.pool.QueryRow(ctx, ` + SELECT banner_photo, profile_public, show_activity, show_stats, show_path, show_badges, social_links, theme + FROM user_profiles WHERE user_id=$1`, userID). + Scan(&p.Banner, &p.Public, &p.ShowActivity, &p.ShowStats, &p.ShowPath, &p.ShowBadges, &p.SocialLinks, &p.Theme) + return p, err +} + +func (s *Store) SaveProfileExtras(ctx context.Context, userID int64, p ProfileExtras) error { + if err := s.EnsureProfile(ctx, userID); err != nil { + return err + } + if len(p.SocialLinks) == 0 { + p.SocialLinks = json.RawMessage(`[]`) + } + _, err := s.pool.Exec(ctx, ` + UPDATE user_profiles SET + banner_photo=$2, profile_public=$3, show_activity=$4, show_stats=$5, + show_path=$6, show_badges=$7, social_links=$8, theme=$9 + WHERE user_id=$1`, + userID, p.Banner, p.Public, p.ShowActivity, p.ShowStats, p.ShowPath, p.ShowBadges, p.SocialLinks, p.Theme) + return err +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 8956274..56c2223 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -17,8 +17,19 @@ import ( var ErrNotFound = errors.New("not found") var ErrPinLimit = errors.New("pin limit") +var ErrUsernameTaken = errors.New("username taken") +var ErrAccountFrozen = errors.New("account frozen") +var ErrAccountClosed = errors.New("account closed") +var ErrAccountNotFrozen = errors.New("account not frozen") -const MaxHearts = 3 +// UsernameCooldownError means the caller already used the username-change allowance. +type UsernameCooldownError struct { + Until time.Time +} + +func (e *UsernameCooldownError) Error() string { return "username cooldown" } + +const MaxHearts = 5 type Store struct { pool *pgxpool.Pool @@ -80,18 +91,26 @@ func scanUser(row rowScanner) (*models.User, error) { &u.LastActivityDate, &u.IsLocked, &u.LockedAt, &u.UnlockedBy, &u.IsActive, &u.AvatarVariant, &u.AvatarPalette, &u.AvatarPhoto, &u.Phone, &u.TelegramID, &u.TelegramUsername, &u.SecurityQuestion, &u.SecurityAnswerHash, - &u.CreatedAt) + &u.CreatedAt, &u.UsernameChangeCount, &u.UsernameCooldownUntil, + &u.FrozenAt, &u.ClosedAt) if err != nil { return nil, err } u.HasSecurityAnswer = u.SecurityAnswerHash != "" + now := time.Now() + u.ApplyUsernameQuota(now) + u.ApplyFreezeState(now) return &u, nil } +// humanOnly keeps bot accounts, which share the mentor role, out of people lists. +const humanOnly = `NOT EXISTS (SELECT 1 FROM bot_tokens WHERE bot_user_id = users.id)` + const userCols = `id, name, email, username, password_hash, role, xp, hearts, hearts_updated_at, streak_current, streak_longest, last_activity_date, is_locked, locked_at, unlocked_by, is_active, avatar_variant, avatar_palette, avatar_photo, - phone, telegram_id, telegram_username, security_question, security_answer_hash, created_at` + phone, telegram_id, telegram_username, security_question, security_answer_hash, created_at, + username_change_count, username_cooldown_until, frozen_at, closed_at` func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { row := s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE email=$1`, email) @@ -195,7 +214,7 @@ func (s *Store) LockUser(ctx context.Context, userID int64) (*models.User, error func (s *Store) UnlockUser(ctx context.Context, userID int64, by int64) (*models.User, error) { row := s.pool.QueryRow(ctx, ` - UPDATE users SET is_locked=false, locked_at=NULL, unlocked_by=$1, hearts=3, hearts_updated_at=now() + UPDATE users SET is_locked=false, locked_at=NULL, unlocked_by=$1, hearts=5, hearts_updated_at=now() WHERE id=$2 RETURNING `+userCols, by, userID) u, err := scanUser(row) if errors.Is(err, pgx.ErrNoRows) { @@ -204,6 +223,65 @@ func (s *Store) UnlockUser(ctx context.Context, userID int64, by int64) (*models return u, err } +// FreezeUser records the start of the 30-day self-service freeze. It does not touch is_locked. +func (s *Store) FreezeUser(ctx context.Context, id int64) (*models.User, error) { + row := s.pool.QueryRow(ctx, ` + UPDATE users SET frozen_at = now() + WHERE id=$1 AND frozen_at IS NULL AND closed_at IS NULL + RETURNING `+userCols, id) + u, err := scanUser(row) + if errors.Is(err, pgx.ErrNoRows) { + existing, gerr := s.GetUserByID(ctx, id) + if gerr != nil { + return nil, gerr + } + if existing.IsClosed { + return nil, ErrAccountClosed + } + return nil, ErrAccountFrozen + } + return u, err +} + +// UnfreezeUser clears frozen_at only while the 30-day window is still open and the account is not closed. +func (s *Store) UnfreezeUser(ctx context.Context, id int64) (*models.User, error) { + row := s.pool.QueryRow(ctx, ` + UPDATE users SET frozen_at = NULL + WHERE id=$1 + AND closed_at IS NULL + AND frozen_at IS NOT NULL + AND now() < frozen_at + interval '30 days' + RETURNING `+userCols, id) + u, err := scanUser(row) + if errors.Is(err, pgx.ErrNoRows) { + existing, gerr := s.GetUserByID(ctx, id) + if gerr != nil { + return nil, gerr + } + if existing.IsClosed { + return nil, ErrAccountClosed + } + return nil, ErrAccountNotFrozen + } + return u, err +} + +// MarkAccountClosed sets closed_at once the freeze window has elapsed. A no-op returns the current user. +func (s *Store) MarkAccountClosed(ctx context.Context, id int64) (*models.User, error) { + row := s.pool.QueryRow(ctx, ` + UPDATE users SET closed_at = now() + WHERE id=$1 + AND closed_at IS NULL + AND frozen_at IS NOT NULL + AND now() >= frozen_at + interval '30 days' + RETURNING `+userCols, id) + u, err := scanUser(row) + if errors.Is(err, pgx.ErrNoRows) { + return s.GetUserByID(ctx, id) + } + return u, err +} + func (s *Store) ListUsers(ctx context.Context, query string, role string, limit, offset int) ([]models.User, int64, error) { if limit <= 0 { limit = 10 @@ -214,7 +292,7 @@ func (s *Store) ListUsers(ctx context.Context, query string, role string, limit, if offset < 0 { offset = 0 } - where := `($1='' OR name ILIKE '%'||$1||'%' OR email ILIKE '%'||$1||'%' OR username ILIKE '%'||$1||'%')` + where := `($1='' OR name ILIKE '%'||$1||'%' OR email ILIKE '%'||$1||'%' OR username ILIKE '%'||$1||'%') AND ` + humanOnly args := []any{query} if role != "" { args = append(args, role) @@ -246,8 +324,47 @@ func (s *Store) ListUsers(ctx context.Context, query string, role string, limit, return out, total, rows.Err() } +// ListActiveByRoles returns active users whose role is in roles, ordered by name. +func (s *Store) ListActiveByRoles(ctx context.Context, roles []string, limit, offset int) ([]models.User, int64, error) { + if limit <= 0 { + limit = 20 + } + if limit > 50 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + if len(roles) == 0 { + return []models.User{}, 0, nil + } + var total int64 + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM users WHERE is_active AND role::text = ANY($1::text[]) AND `+humanOnly, roles).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.pool.Query(ctx, ` + SELECT `+userCols+` FROM users + WHERE is_active AND role::text = ANY($1::text[]) AND `+humanOnly+` + ORDER BY name ASC + LIMIT $2 OFFSET $3`, roles, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []models.User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, 0, err + } + out = append(out, *u) + } + return out, total, rows.Err() +} + func (s *Store) ListMentors(ctx context.Context) ([]models.User, error) { - rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE role IN ('mentor','admin') AND is_active ORDER BY name`) + rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE role IN ('mentor','admin') AND is_active AND `+humanOnly+` ORDER BY name`) if err != nil { return nil, err } @@ -294,6 +411,101 @@ func (s *Store) UpdateUser(ctx context.Context, id int64, name, email, username, return u, err } +// ChangeUsername sets a new username and consumes one allowance slot. +// The same username is a no-op. The third change in a window starts a cooldown; +// after that cooldown the allowance returns to the limit. +func (s *Store) ChangeUsername(ctx context.Context, id int64, username string, now time.Time) (*models.User, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var current string + var count int + var until *time.Time + err = tx.QueryRow(ctx, ` + SELECT username, username_change_count, username_cooldown_until + FROM users WHERE id=$1 FOR UPDATE`, id).Scan(¤t, &count, &until) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + reset := false + if until != nil && !now.Before(*until) { + count = 0 + until = nil + reset = true + } + + if strings.EqualFold(current, username) { + if reset { + if _, err := tx.Exec(ctx, ` + UPDATE users SET username_change_count=0, username_cooldown_until=NULL WHERE id=$1`, id); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return s.GetUserByID(ctx, id) + } + + if count >= models.UsernameChangeLimit { + if until == nil { + t := now.Add(models.UsernameChangeCooldown) + until = &t + if _, err := tx.Exec(ctx, ` + UPDATE users SET username_change_count=$2, username_cooldown_until=$3 WHERE id=$1`, + id, count, until); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + } + return nil, &UsernameCooldownError{Until: *until} + } + + var taken int64 + err = tx.QueryRow(ctx, `SELECT id FROM users WHERE lower(username)=lower($1) AND id<>$2`, username, id).Scan(&taken) + if err == nil { + return nil, ErrUsernameTaken + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + + count++ + var nextUntil *time.Time + if count >= models.UsernameChangeLimit { + t := now.Add(models.UsernameChangeCooldown) + nextUntil = &t + } + row := tx.QueryRow(ctx, ` + UPDATE users + SET username=$2, username_change_count=$3, username_cooldown_until=$4 + WHERE id=$1 + RETURNING `+userCols, id, username, count, nextUntil) + u, err := scanUser(row) + if err != nil { + if isUniqueViolation(err) { + return nil, ErrUsernameTaken + } + return nil, err + } + if err := tx.Commit(ctx); err != nil { + if isUniqueViolation(err) { + return nil, ErrUsernameTaken + } + return nil, err + } + return u, nil +} + func (s *Store) GetUserByPhone(ctx context.Context, phone string) (*models.User, error) { phone = strings.TrimSpace(phone) if phone == "" { @@ -1072,29 +1284,48 @@ func (s *Store) EventRsvpUsers(ctx context.Context, eventID int64) ([]models.Use // ---- Chat ---- -func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, replyTo *int64, att *models.Attachment) (*models.ChatMessage, error) { - var attType, attURL, attName *string - var attSize *int64 - if att != nil { - attType, attURL, attName = &att.Type, &att.URL, &att.Name - attSize = &att.Size +func packChatAttachments(atts []models.Attachment) (typ, url, name *string, size *int64, raw []byte) { + if len(atts) == 0 { + return nil, nil, nil, nil, nil + } + first := atts[0] + typ = new(string) + *typ = first.Type + url = new(string) + *url = first.URL + name = new(string) + *name = first.Name + sz := first.Size + size = &sz + if len(atts) > 1 { + if b, err := json.Marshal(atts); err == nil { + raw = b + } + } + return +} + +func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, replyTo *int64, atts []models.Attachment) (*models.ChatMessage, error) { + attType, attURL, attName, attSize, raw := packChatAttachments(atts) + var rawArg any + if len(raw) > 0 { + rawArg = string(raw) } row := s.pool.QueryRow(ctx, ` - 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) + INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size, attachments) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb) 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`, - userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize) + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, + userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize, rawArg) return scanMessage(row) } // ImportChatMessage inserts a historical message (backup restore), preserving created_at / pin. -func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, att *models.Attachment, createdAt time.Time, pinned bool) (*models.ChatMessage, error) { - var attType, attURL, attName *string - var attSize *int64 - if att != nil { - attType, attURL, attName = &att.Type, &att.URL, &att.Name - attSize = &att.Size +func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, senderRole models.Role, body string, atts []models.Attachment, createdAt time.Time, pinned bool) (*models.ChatMessage, error) { + attType, attURL, attName, attSize, raw := packChatAttachments(atts) + var rawArg any + if len(raw) > 0 { + rawArg = string(raw) } if createdAt.IsZero() { createdAt = time.Now().UTC() @@ -1105,11 +1336,11 @@ func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, s pinnedAt = &t } row := s.pool.QueryRow(ctx, ` - 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) + INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, attachments, created_at, pinned_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11) 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`, - userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt) + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, + userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, rawArg, createdAt, pinnedAt) return scanMessage(row) } @@ -1155,18 +1386,28 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in // 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) { + elem, err := json.Marshal(models.Attachment{Type: attType, URL: attURL, Name: attName, Size: attSize}) + if err != nil { + return nil, err + } 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) + attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5, + attachments = CASE + WHEN attachments IS NULL THEN attachments + WHEN jsonb_typeof(attachments) <> 'array' THEN attachments + WHEN jsonb_array_length(attachments) = 0 THEN attachments + ELSE jsonb_set(attachments, '{0}', $6::jsonb, false) + END + WHERE id=$7 + AND ((user_id=$8 AND mentor_id=$9) OR (user_id=$9 AND mentor_id=$8)) + AND sender_role=$10 + AND (user_id=$8 OR mentor_id=$8) 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()) + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, + body, attType, attURL, attName, attSize, string(elem), msgID, meID, partnerID, meRole.String()) m, err := scanMessage(row) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -1185,7 +1426,7 @@ func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int6 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`, + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, body, msgID, meID, partnerID, meRole.String()) m, err := scanMessage(row) if errors.Is(err, pgx.ErrNoRows) { @@ -1194,6 +1435,51 @@ func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int6 return m, err } +// GetOwnChatMessage loads one of my messages in this conversation, including its attachments. +func (s *Store) GetOwnChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role) (*models.ChatMessage, error) { + row := s.pool.QueryRow(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, edited_at, buttons, attachments + FROM chat_messages + WHERE id=$1 + AND ((user_id=$2 AND mentor_id=$3) OR (user_id=$3 AND mentor_id=$2)) + AND sender_role=$4 + AND (user_id=$2 OR mentor_id=$2)`, + msgID, meID, partnerID, meRole.String()) + m, err := scanMessage(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return m, err +} + +// SaveEditedChatMessage writes the caption and the full attachment set for one of my messages. +// Callers pass the attachments that should remain; an empty list is stored as no attachment. +func (s *Store) SaveEditedChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string, atts []models.Attachment) (*models.ChatMessage, error) { + attType, attURL, attName, attSize, raw := packChatAttachments(atts) + var rawArg any + if len(raw) > 0 { + rawArg = string(raw) + } + 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, + attachments=$6::jsonb + WHERE id=$7 + AND ((user_id=$8 AND mentor_id=$9) OR (user_id=$9 AND mentor_id=$8)) + AND sender_role=$10 + AND (user_id=$8 OR mentor_id=$8) + 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, buttons, attachments`, + body, attType, attURL, attName, attSize, rawArg, 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, @@ -1216,7 +1502,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, edited_at`, msgID) + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, msgID) return scanMessage(out) } var n int @@ -1244,7 +1530,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, edited_at`, msgID) + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, msgID) return scanMessage(out) } @@ -1252,7 +1538,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, edited_at + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments 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)) @@ -1278,7 +1564,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, edited_at + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments 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) @@ -1326,7 +1612,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, edited_at + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments FROM chat_messages WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) AND id <= $3 @@ -1358,7 +1644,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, edited_at + attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments FROM chat_messages WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)) AND id > $3 @@ -1399,14 +1685,27 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) { var m models.ChatMessage var attType, attURL, attName *string var attSize *int64 + var buttons []byte + var rawAtts []byte 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, &m.EditedAt, + &attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt, &buttons, &rawAtts, ); err != nil { return nil, err } + if len(buttons) > 0 && string(buttons) != "[]" && string(buttons) != "null" { + m.Buttons = buttons + } m.Pinned = m.PinnedAt != nil - if attURL != nil && *attURL != "" { + if len(rawAtts) > 0 && string(rawAtts) != "null" && string(rawAtts) != "[]" { + var list []models.Attachment + if err := json.Unmarshal(rawAtts, &list); err == nil && len(list) > 0 { + m.Attachments = list + first := list[0] + m.Attachment = &first + } + } + if m.Attachment == nil && attURL != nil && *attURL != "" { m.Attachment = &models.Attachment{ Type: derefStr(attType), URL: *attURL, Name: derefStr(attName), Size: derefInt(attSize), } diff --git a/deploy/helm/pargar/Chart.yaml b/deploy/helm/pargar/Chart.yaml index 7e2807c..05d8efa 100644 --- a/deploy/helm/pargar/Chart.yaml +++ b/deploy/helm/pargar/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: pargar description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage type: application -version: 0.2.1 -appVersion: "0.1.3" +version: 0.2.2 +appVersion: "0.2.0" keywords: - lms - pargar diff --git a/deploy/helm/pargar/values-prod.yaml b/deploy/helm/pargar/values-prod.yaml index 67e8be4..b4afcf4 100644 --- a/deploy/helm/pargar/values-prod.yaml +++ b/deploy/helm/pargar/values-prod.yaml @@ -22,7 +22,7 @@ backend: replicaCount: 2 image: repository: backend - tag: "0.1.3" + tag: "0.2.0" pullPolicy: IfNotPresent existingSecret: pargar-app env: @@ -49,7 +49,7 @@ frontend: replicaCount: 2 image: repository: frontend - tag: "0.1.3" + tag: "0.2.0" pullPolicy: IfNotPresent resources: requests: diff --git a/frontend/src/app/admin/bot-docs/page.tsx b/frontend/src/app/admin/bot-docs/page.tsx new file mode 100644 index 0000000..20f9cd5 --- /dev/null +++ b/frontend/src/app/admin/bot-docs/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth-store"; + +export default function BotDocsPage() { + const user = useAuth((s) => s.user); + const router = useRouter(); + + useEffect(() => { + if (user && user.role !== "admin") { + router.replace("/admin"); + } + }, [user, router]); + + if (!user || user.role !== "admin") { + return null; + } + + return ( +
+
+

+ + ربات + +

+

راهنمای ربات

+

+ با یک کلید API می‌توانید از بیرون پرگار به هنرجو پیام بفرستید و جواب‌های او را بخوانید. +

+
+ +
+

ساخت کلید

+

+ در مدیریت، بخش ربات، یک نام بگذارید و «ساخت ربات» را بزنید. پاسخ ساخت شامل فیلد token است. + توکن فقط یک بار نشان داده می‌شود. بعد از بستن یا بارگذاری دوبارهٔ صفحه دیگر برنمی‌گردد؛ سرور فقط هش آن را نگه می‌دارد. +

+

+ هر ردیف نام، زمان ساخت، وضعیت و تعداد درخواست‌های تأییدشده را نشان می‌دهد. با «توقف» کلید متوقف می‌شود و با «فعال‌کردن» دوباره کار می‌کند. +

+
+ +
+

احراز هویت

+

+ هر دو مسیر زیر هدر X-Bot-Token می‌خواهند. توکن خالی، ناشناس یا متوقف با وضعیت ۴۰۱ و پیام «توکن ربات نامعتبر است» رد می‌شود. + درخواست ناموفق در شمارنده حساب نمی‌شود. هر فراخوانی که توکن فعالش پذیرفته شود، یک واحد به شمار درخواست همان کلید اضافه می‌کند. +

+
+ +
+

ارسال پیام

+

+ POST /api/bot/send پیامی از طرف ربات در گفتگوی هنرجو می‌سازد. بدنه JSON: +

+
    +
  • + userId شناسهٔ عددی هنرجو. لازم است. +
  • +
  • + text متن پیام. لازم است و نباید خالی باشد. +
  • +
  • + buttons آرایهٔ دکمه‌ها. هر عضو شیئی با فیلد text است. در گفتگو، زدن دکمه همان متن را به عنوان پیام هنرجو برای ربات می‌فرستد. +
  • +
+

+ پاسخ موفق: {`{ "messageId": 123 }`}. اگر userId یا text نباشد، وضعیت ۴۰۰ و پیام «userId و text لازم است». +

+ +
+ +
+

خواندن پاسخ‌ها

+

+ GET /api/bot/updates صف پاسخ‌هایی را برمی‌گرداند که هنرجو برای این ربات فرستاده است؛ چه متن آزاد، چه متن یک دکمه. + پارامتر offset آخرین شناسه‌ای است که قبلاً دیده‌اید. فقط ردیف‌های با id بزرگ‌تر برمی‌گردند، حداکثر ۵۰ تا. اگر نفرستید، از صفر شروع می‌شود. +

+

+ پاسخ: {`{ "updates": [ { "id": 1, "payload": { "from": 12, "name": "نام", "text": "جواب" }, "createdAt": "..." } ] }`}. + from شناسهٔ فرستنده است، name نام او و text متن پیام. اگر چیزی نباشد، updates آرایهٔ خالی است. +

+
+
+ ); +} + +function Code({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function Example({ title, code }: { title: string; code: string }) { + return ( +
+

{title}

+
+        {code}
+      
+
+ ); +} diff --git a/frontend/src/app/c/[id]/page.tsx b/frontend/src/app/c/[id]/page.tsx index f3abddc..f995474 100644 --- a/frontend/src/app/c/[id]/page.tsx +++ b/frontend/src/app/c/[id]/page.tsx @@ -1,10 +1,10 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; import { useParams } from "next/navigation"; import { Loader2, Package } from "lucide-react"; import { CertificateCard } from "@/components/certificate-card"; +import { NotFoundState, notFoundSentence } from "@/components/not-found-state"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { http, toUserError } from "@/lib/api"; @@ -96,12 +96,16 @@ export default function CertificatePage() { if (error || !data?.certificate) { return ( -
-

گواهینامه پیدا نشد

- -
+ ); } diff --git a/frontend/src/app/cap/page.tsx b/frontend/src/app/cap/page.tsx index ca192d2..67e7cfe 100644 --- a/frontend/src/app/cap/page.tsx +++ b/frontend/src/app/cap/page.tsx @@ -2,7 +2,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Suspense, useEffect, useRef } from "react"; +import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Award, ArrowLeft, @@ -28,6 +28,7 @@ import { useAuth } from "@/lib/auth-store"; import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils"; import { clampHearts, hasInfiniteHearts, INFINITE_HEARTS_LABEL, MAX_HEARTS } from "@/lib/hearts"; import { toast } from "@/components/providers"; +import { SafeMarkdown } from "@/components/safe-markdown"; export default function CapPage() { return ( @@ -150,6 +151,8 @@ function CapPageInner() { + + {lowHearts && (
@@ -157,7 +160,7 @@ function CapPageInner() {

فقط {formatFaNumber(hearts)} جان مانده

- یک اشتباه دیگر حساب را قفل می‌کند. قبل از آزمون مطمئن شوید؛ اگر گیر کردید با منتور حرف بزنید. + جان‌ها خودشان برمی‌گردند. تا برگشتن یک جان، آزمون تازه صبر می‌کند.

@@ -450,6 +453,244 @@ function CapSkeleton() { ); } +type CapAnnouncement = { title: string; body: string; route: string }; + +function PinnedAnnouncement() { + const router = useRouter(); + const scroller = useRef(null); + const gesture = useRef<{ + x: number; + y: number; + scroll: number; + index: number; + pointerId: number; + axis: "x" | "y" | null; + dx: number; + } | null>(null); + const moved = useRef(0); + const [index, setIndex] = useState(0); + const q = useQuery({ + queryKey: ["announcement-pinned"], + queryFn: () => + http.get<{ announcement: CapAnnouncement | null; announcements?: CapAnnouncement[] }>("/api/announcements/pinned"), + }); + const items = q.data?.announcements?.length + ? q.data.announcements + : q.data?.announcement + ? [q.data.announcement] + : []; + const count = items.length; + const indexRef = useRef(0); + const hoverRef = useRef(false); + const downRef = useRef(false); + const programmatic = useRef(0); + const scrollSeq = useRef(0); + const wantScroll = useRef(false); + const scrollBehavior = useRef("smooth"); + const [epoch, setEpoch] = useState(0); + + const stride = (el: HTMLDivElement) => { + const card = el.firstElementChild as HTMLElement | null; + const gap = 12; + return (card?.offsetWidth || el.clientWidth || 1) + gap; + }; + + const goTo = (next: number, behavior: ScrollBehavior = "smooth") => { + if (count < 2) return; + const i = ((next % count) + count) % count; + const from = indexRef.current; + const neighbor = Math.abs(i - from) === 1; + scrollBehavior.current = neighbor && behavior === "smooth" ? "smooth" : "auto"; + wantScroll.current = true; + indexRef.current = i; + setIndex(i); + setEpoch((n) => n + 1); + }; + + const stepBy = (delta: number) => { + const from = indexRef.current; + const dest = from + delta < 0 ? count - 1 : from + delta >= count ? 0 : from + delta; + goTo(dest, Math.abs(dest - from) === 1 ? "smooth" : "auto"); + }; + + const stepRef = useRef(stepBy); + stepRef.current = stepBy; + + useLayoutEffect(() => { + const el = scroller.current; + if (!el || count < 2 || !wantScroll.current) return; + wantScroll.current = false; + const token = scrollSeq.current + 1; + scrollSeq.current = token; + programmatic.current = token; + const left = indexRef.current * stride(el); + let settledOnce = false; + const finish = (force: boolean) => { + if (settledOnce || scrollSeq.current !== token) return; + if (gesture.current?.axis === "x") return; + const target = indexRef.current * stride(el); + const settled = Math.abs(el.scrollLeft - target) <= 2; + if (!settled && !force) return; + if (!settled) { + el.style.scrollBehavior = "auto"; + el.scrollLeft = target; + } + settledOnce = true; + if (programmatic.current === token) programmatic.current = 0; + el.removeEventListener("scrollend", onEnd); + }; + const onEnd = () => { + if (scrollSeq.current !== token || !programmatic.current) return; + finish(false); + }; + el.addEventListener("scrollend", onEnd); + el.style.scrollBehavior = scrollBehavior.current === "smooth" ? "smooth" : "auto"; + el.scrollTo({ left, behavior: scrollBehavior.current }); + const soon = window.setTimeout(() => finish(false), scrollBehavior.current === "auto" ? 40 : 700); + const later = window.setTimeout(() => finish(true), 1200); + return () => { + window.clearTimeout(soon); + window.clearTimeout(later); + el.removeEventListener("scrollend", onEnd); + }; + }, [index, epoch, count]); + + useEffect(() => { + if (count < 2) return; + const id = window.setInterval(() => { + if (hoverRef.current || downRef.current) return; + stepRef.current(1); + }, 6000); + return () => window.clearInterval(id); + }, [count, epoch]); + + if (count === 0) return null; + + const hold = (hover: boolean | null, down: boolean | null) => { + if (hover != null) hoverRef.current = hover; + if (down != null) downRef.current = down; + }; + + const go = (next: number) => goTo(next); + + const onTrackPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + const el = scroller.current; + if (!el) return; + gesture.current = { + x: e.clientX, + y: e.clientY, + scroll: el.scrollLeft, + index: indexRef.current, + pointerId: e.pointerId, + axis: null, + dx: 0, + }; + moved.current = 0; + }; + + const onTrackPointerMove = (e: React.PointerEvent) => { + const g = gesture.current; + if (!g || g.pointerId !== e.pointerId) return; + const dx = e.clientX - g.x; + const dy = e.clientY - g.y; + g.dx = dx; + if (g.axis == null) { + if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; + g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + } + if (g.axis !== "x" || count < 2) return; + const el = scroller.current; + if (!el) return; + if (!el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId); + const width = stride(el); + const max = width * (count - 1); + const pulled = g.scroll - dx; + const limited = Math.min(g.scroll + width, Math.max(g.scroll - width, pulled)); + el.style.scrollBehavior = "auto"; + el.scrollLeft = Math.min(max, Math.max(0, limited)); + }; + + const onTrackPointerUp = (e: React.PointerEvent) => { + const g = gesture.current; + if (!g || g.pointerId !== e.pointerId) return; + moved.current = Math.max(Math.abs(g.dx), Math.abs(e.clientY - g.y)); + const el = scroller.current; + if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); + if (g.axis !== "x" || count < 2) { + gesture.current = null; + if (el) el.style.scrollBehavior = ""; + return; + } + const threshold = 40; + let next = g.index; + if (g.dx <= -threshold) next = g.index + 1; + else if (g.dx >= threshold) next = g.index - 1; + go(next); + gesture.current = null; + }; + + return ( +
hold(true, null)} + onPointerLeave={() => { + if (gesture.current?.axis === "x") { + hold(false, null); + return; + } + hold(false, false); + setEpoch((n) => n + 1); + }} + onPointerDown={() => hold(null, true)} + onPointerUp={() => hold(null, false)} + onPointerCancel={() => hold(null, false)} + > +
+ {items.map((a, i) => ( + + ))} +
+ {items.length > 1 && ( +
+ {items.map((a, i) => ( +
+ )} +
+ ); +} + function ZapIcon(props: { className?: string }) { return ( diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a1ca495..37a91e4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -254,6 +254,31 @@ border-radius: 999px; border-width: 0; } + textarea.chat-composer { + unicode-bidi: plaintext; + text-align: start; + white-space: pre-wrap; + overflow-wrap: anywhere; + field-sizing: content; + max-height: 16rem; + overflow-y: auto; + } + textarea.chat-composer:placeholder-shown { + direction: rtl; + text-align: right; + } + textarea.chat-composer::placeholder { + unicode-bidi: plaintext; + text-align: start; + } + .chat-bubble-text { + unicode-bidi: plaintext; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + max-width: 100%; + text-align: start; + } .section-kicker { display: flex; align-items: center; diff --git a/frontend/src/app/lockout/page.tsx b/frontend/src/app/lockout/page.tsx index e4ba8d0..a0be565 100644 --- a/frontend/src/app/lockout/page.tsx +++ b/frontend/src/app/lockout/page.tsx @@ -89,8 +89,7 @@ export default function LockoutPage() {

حساب قفل شد

جان‌هایتان تمام شد

- هر پاسخ اشتباه در آزمون یک جان کم می‌کند. با تمام شدن سه جان، یادگیری و آزمون قفل می‌شود تا - تیم مسیر را بررسی کند — خودتان نمی‌توانید قفل را باز کنید. + جان‌ها معمولاً خودشان برمی‌گردند. این صفحه وقتی دیده می‌شود که مدیریت حساب را قفل کرده باشد.

@@ -144,7 +143,7 @@ export default function LockoutPage() { className={cn("w-full text-muted-foreground")} onClick={() => router.push("/unwrap?tab=community")} > - جامعه و حمایت + شبکه‌های پرگار
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 639d080..4d68918 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query"; import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react"; import { BrandMark } from "@/components/brand-mark"; import { useAuth } from "@/lib/auth-store"; -import { http, toUserError } from "@/lib/api"; +import { ApiError, http, toUserError } from "@/lib/api"; import type { User } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -34,6 +34,7 @@ export default function LoginPage() { const [rememberMe, setRememberMe] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [captchaError, setCaptchaError] = useState(null); const { data: registerStatus } = useQuery({ queryKey: ["auth", "register-status"], @@ -43,7 +44,7 @@ export default function LoginPage() { const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!challenge) { - setError("ابتدا کد امنیتی را بارگذاری کنید"); + setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید"); setCaptchaKey((k) => k + 1); return; } @@ -61,7 +62,10 @@ export default function LoginPage() { router.push("/cap"); } catch (err) { setError(toUserError(err, "ورود ناموفق بود")); - setCaptchaKey((k) => k + 1); + // 429 is returned before the captcha is consumed, so keep this challenge. + if (!(err instanceof ApiError) || err.status !== 429) { + setCaptchaKey((k) => k + 1); + } } finally { setLoading(false); } @@ -137,7 +141,7 @@ export default function LoginPage() { value={captchaAnswer} onChange={setCaptchaAnswer} onChallenge={setChallenge} - onError={setError} + onError={setCaptchaError} /> - {error &&

{error}

} + {(error || captchaError) && ( +

+ {error || captchaError} +

+ )} - - - ) : ( - <> - - - - )} - - - + ); } diff --git a/frontend/src/app/player/[lessonId]/page.tsx b/frontend/src/app/player/[lessonId]/page.tsx index 0166a46..1efac3d 100644 --- a/frontend/src/app/player/[lessonId]/page.tsx +++ b/frontend/src/app/player/[lessonId]/page.tsx @@ -6,6 +6,7 @@ import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { ArrowRight, Lock, TreePine } from "lucide-react"; import { VideoPlayer } from "@/components/video-player"; +import { NotFoundState, notFoundSentence } from "@/components/not-found-state"; import { ApiError, http, toUserError } from "@/lib/api"; import type { Lesson, LessonProgress } from "@/lib/types"; import { Button } from "@/components/ui/button"; @@ -24,6 +25,7 @@ function isLessonLockedError(error: unknown): boolean { export default function PlayerPage() { const { lessonId } = useParams<{ lessonId: string }>(); const id = Number(lessonId); + const idOk = Number.isFinite(id) && id > 0; const router = useRouter(); const user = useAuth((s) => s.user); @@ -37,7 +39,7 @@ export default function PlayerPage() { progress: LessonProgress | null; quizStatus: string; }>(`/api/lessons/${id}`), - enabled: !!user && (!user.isLocked || hasInfiniteHearts(user.role)), + enabled: idOk && !!user && (!user.isLocked || hasInfiniteHearts(user.role)), staleTime: 5 * 60_000, retry: (count, err) => { if (isLessonLockedError(err)) return false; @@ -53,6 +55,17 @@ export default function PlayerPage() { return null; } + if (!idOk) { + return ( + + ); + } + if (isLoading) { return (
@@ -79,6 +92,16 @@ export default function PlayerPage() { if (isLessonLockedError(error)) { return router.push("/cap")} />; } + if (error instanceof ApiError && error.status === 404) { + return ( + + ); + } return (

{toUserError(error, "بارگذاری درس ممکن نشد")}

diff --git a/frontend/src/app/profile/[id]/page.tsx b/frontend/src/app/profile/[id]/page.tsx index ce2bee8..2bfda6b 100644 --- a/frontend/src/app/profile/[id]/page.tsx +++ b/frontend/src/app/profile/[id]/page.tsx @@ -2,17 +2,15 @@ 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 { Flame, Heart, Sparkles } from "lucide-react"; +import { ProfileHeader } from "@/components/profile-header"; +import { NotFoundState, notFoundSentence } from "@/components/not-found-state"; 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( @@ -20,12 +18,6 @@ const AdminProfileManage = dynamic( { 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); @@ -41,9 +33,12 @@ export default function PublicProfilePage() { if (!enabled) { return ( -
- کاربر پیدا نشد. -
+ ); } @@ -62,14 +57,16 @@ export default function PublicProfilePage() { if (profileQ.isError || !profileQ.data?.user) { return ( -
-

- {toUserError(profileQ.error, "کاربر پیدا نشد")} -

- -
+ ); } @@ -77,40 +74,55 @@ export default function PublicProfilePage() { return (
- } - tint="bg-primary/10 text-primary" - > -
- -
-

{person.name}

-

- @{person.username} -

- {person.isLocked && ( -

حساب قفل است

- )} -
-
-
+ + {person.isLocked &&

حساب قفل است

} + {person.private ? ( +

جز نام و آواتار چیزی نمایش داده نمی‌شود.

+ ) : (
- } label="امتیاز" value={formatFaNumber(person.xp)} /> - } label="قلب‌ها" value={formatFaNumber(person.hearts)} /> + {person.xp != null && } label="امتیاز" value={formatFaNumber(person.xp)} />} + {person.hearts != null && } label="قلب‌ها" value={formatFaNumber(person.hearts)} />} + {person.streakCurrent != null && ( } label="زنجیرهٔ فعلی" value={formatStreakLabel(person.streakCurrent)} /> + )} + {person.streakLongest != null && ( } label="بلندترین زنجیره" value={formatStreakLabel(person.streakLongest)} /> + )}
+ )} + {(person.socialLinks ?? []).length > 0 && ( + + )} {isAdmin && }
diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 85a97ef..e3522f9 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -2,16 +2,16 @@ import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; -import { Flame, Heart, Sparkles, Trophy, User as UserIcon } from "lucide-react"; +import { Flame, Heart, Sparkles, Trophy } from "lucide-react"; +import { useState } from "react"; import { useAuth } from "@/lib/auth-store"; import { http, toUserError } from "@/lib/api"; -import type { LeaderboardData } from "@/lib/types"; -import { Panel } from "@/components/panel"; +import type { LeaderboardData, MeetingEvent } from "@/lib/types"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar"; import { ProfileCertificateSlot } from "@/components/profile-certificate-slot"; +import { OwnerProfileHeader } from "@/components/profile-header"; import { ChallengeCards } from "@/components/challenge-cards"; import { AvatarStudio } from "@/components/avatar-studio"; import { @@ -20,8 +20,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { formatFaNumber, formatStreakLabel } from "@/lib/utils"; -import { useState } from "react"; +import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils"; export default function ProfilePage() { const user = useAuth((s) => s.user); @@ -45,56 +44,7 @@ export default function ProfilePage() { return (
- } - tint="bg-primary/10 text-primary" - actions={ - - } - > -
- -
-

{user.name}

-
-
-
نام کاربری
-
- @{user.username} -
-
-
-
ایمیل
-
- {user.email} -
-
- {user.phone && ( -
-
تلفن
-
- {user.phone} -
-
- )} -
-
عضویت از
-
- {new Date(user.createdAt).toLocaleDateString("fa-IR", { - year: "numeric", - month: "long", - day: "numeric", - })} -
-
-
-
-
-
+ setStudioOpen(true)} />
} label="امتیاز کل" value={formatFaNumber(user.xp)} /> @@ -139,14 +89,16 @@ export default function ProfilePage() {
+ +

گواهینامه

- - + + ویرایش آواتار setStudioOpen(false)} /> @@ -156,6 +108,172 @@ export default function ProfilePage() { ); } +function ProfileActivity() { + const activityQ = useQuery({ + queryKey: ["me-activity"], + queryFn: () => http.get<{ days: { day: string; count: number }[] }>("/api/me/activity"), + }); + const eventsQ = useQuery({ + queryKey: ["events", "profile"], + queryFn: () => http.get<{ events: MeetingEvent[] }>("/api/events"), + }); + + return ( +
+

فعالیت

+ + +
+

تاریخچهٔ ماهانه

+

هر بار یک ماه. پررنگ‌تر یعنی فعالیت بیشتر.

+ {activityQ.isPending ? ( + + ) : activityQ.isError ? ( +

{toUserError(activityQ.error, "بارگذاری فعالیت ممکن نشد")}

+ ) : ( + + )} +
+
+

رویدادها

+ {eventsQ.isPending ? ( +

+ در حال بارگذاری… +

+ ) : eventsQ.isError ? ( +

{toUserError(eventsQ.error, "بارگذاری رویدادها ممکن نشد")}

+ ) : (eventsQ.data?.events ?? []).length === 0 ? ( +

رویدادی نیست.

+ ) : ( +
    + {(eventsQ.data?.events ?? []).map((ev) => ( +
  • + {ev.title} + +
  • + ))} +
+ )} +
+
+
+
+ ); +} + +function activityCountLabel(count: number) { + if (count <= 0) return "بدون فعالیت"; + return `${formatFaNumber(count)} فعالیت`; +} + +const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"]; + +function ActivityGrid({ days }: { days: { day: string; count: number }[] }) { + const [index, setIndex] = useState(0); + const map = new Map(days.map((d) => [d.day.slice(0, 10), d.count])); + const order: string[] = []; + const months = new Map(); + const today = new Date(); + today.setHours(12, 0, 0, 0); + for (let i = 119; i >= 0; i--) { + const d = new Date(today); + d.setDate(today.getDate() - i); + const key = d.toISOString().slice(0, 10); + const id = jalaliMonthId(d); + if (!months.has(id)) { + order.push(id); + months.set(id, { label: jalaliMonth(d), cells: [] }); + } + months.get(id)!.cells.push({ day: key, count: map.get(key) ?? 0 }); + } + const newestFirst = [...order].reverse(); + const safeIndex = Math.min(index, Math.max(newestFirst.length - 1, 0)); + const id = newestFirst[safeIndex]; + const month = id ? months.get(id) : undefined; + if (!month || !id) return null; + const pad = weekdayOffset(month.cells[0]?.day ?? ""); + + return ( +
+
+ +

{month.label}

+ +
+
+ {WEEKDAYS.map((name) => ( + + {name} + + ))} + {Array.from({ length: pad }, (_, i) => ( + + ))} + {month.cells.map((c) => { + const date = jalaliDay(c.day); + const countLabel = activityCountLabel(c.count); + return ( + + ); + })} +
+
+ ); +} + +function weekdayOffset(day: string) { + if (!day) return 0; + const d = new Date(day + "T12:00:00"); + return (d.getDay() + 1) % 7; +} + +function jalaliMonthId(d: Date) { + const parts = new Intl.DateTimeFormat("en-u-ca-persian", { year: "numeric", month: "2-digit" }).formatToParts(d); + const year = parts.find((p) => p.type === "year")?.value ?? ""; + const month = parts.find((p) => p.type === "month")?.value ?? ""; + return `${year}-${month}`; +} + +function jalaliMonth(d: Date) { + const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { month: "long", year: "numeric" }).formatToParts(d); + const month = parts.find((p) => p.type === "month")?.value ?? ""; + const year = parts.find((p) => p.type === "year")?.value ?? ""; + return `${month} ${year}`; +} + +function jalaliDay(day: string) { + return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { year: "numeric", month: "short", day: "numeric" }).format( + new Date(day + "T12:00:00"), + ); +} + function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..cd31bd1 --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -0,0 +1,874 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CalendarDays, Flame, Sparkles } from "lucide-react"; +import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields"; +import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ApiError, http, toUserError } from "@/lib/api"; +import { validateEmail, validateName, validatePassword, validatePhone, validateUsername } from "@/lib/validation"; +import { useAuth } from "@/lib/auth-store"; +import { clearMediaCache } from "@/lib/transfer"; +import { toast } from "@/components/providers"; +import type { Conversation } from "@/lib/types"; +import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils"; + +type SocialLink = { name: string; url: string }; + +type Extras = { + banner: string; + public: boolean; + showActivity: boolean; + showStats: boolean; + showPath: boolean; + showBadges: boolean; + socialLinks: SocialLink[]; + theme: string; +}; + +type Activity = { days: { day: string; count: number }[]; weekDays: number; monthDays: number }; +type BadgeItem = { id: string; title: string; unlocked: boolean }; + +const SECTIONS = [ + { id: "account", label: "حساب", title: "حساب", description: "نام، ایمیل و تلفن را اینجا ذخیره می‌کنید. نام کاربری جدا عوض می‌شود. برای گذرواژه، گذرواژهٔ فعلی لازم است." }, + { id: "privacy", label: "حریم", title: "حریم", description: "مشخص کنید دیگران در پروفایل شما چه چیزی ببینند." }, + { id: "stats", label: "آمار", title: "آمار", description: "امتیاز، زنجیره و نشان‌های همین حساب." }, + { id: "export", label: "خروجی", title: "خروجی", description: "گفتگوها را دانلود کنید یا حافظه موقت را از همین دستگاه حذف کنید." }, +] as const; + +const EXPORT_PAGE_SIZE = 8; + +type SectionId = (typeof SECTIONS)[number]["id"]; + +export default function SettingsPage() { + const user = useAuth((s) => s.user); + const setUser = useAuth((s) => s.setUser); + const profileQ = useQuery({ + queryKey: ["me-profile"], + queryFn: () => http.get<{ profile: Extras }>("/api/me/profile"), + enabled: !!user, + }); + const activityQ = useQuery({ + queryKey: ["me-activity"], + queryFn: () => http.get("/api/me/activity"), + enabled: !!user && (profileQ.data?.profile.showActivity ?? true), + }); + const badgesQ = useQuery({ + queryKey: ["me-achievements"], + queryFn: () => http.get<{ achievements: BadgeItem[] }>("/api/me/achievements"), + enabled: !!user && (profileQ.data?.profile.showBadges ?? true), + }); + const [section, setSection] = useState("account"); + const [exportingId, setExportingId] = useState(null); + const [exportPage, setExportPage] = useState(1); + const exportChatsQ = useQuery({ + queryKey: ["settings-export-chats", user?.id], + queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"), + enabled: !!user && section === "export", + }); + const exportChats = exportChatsQ.data?.conversations ?? []; + const exportPageCount = Math.max(1, Math.ceil(exportChats.length / EXPORT_PAGE_SIZE)); + const exportPageSafe = Math.min(Math.max(exportPage, 1), exportPageCount); + const exportPageStart = (exportPageSafe - 1) * EXPORT_PAGE_SIZE; + const exportPageChats = exportChats.slice(exportPageStart, exportPageStart + EXPORT_PAGE_SIZE); + const exportSizes = useQueries({ + queries: exportChats.map((chat) => ({ + queryKey: ["me-export-meta", user?.id, chat.partner.id], + queryFn: () => http.get<{ bytes: number; chat: number; lastAt?: string }>(`/api/me/export?meta=1&chat=${chat.partner.id}`), + enabled: !!user && section === "export", + })), + }); + const [form, setForm] = useState(null); + const [displayName, setDisplayName] = useState(user?.name ?? ""); + const [email, setEmail] = useState(user?.email ?? ""); + const [phone, setPhone] = useState(user?.phone ?? ""); + const [nextUsername, setNextUsername] = useState(""); + const [password, setPassword] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [identityDirty, setIdentityDirty] = useState(false); + const [savingIdentity, setSavingIdentity] = useState(false); + const [savingUsername, setSavingUsername] = useState(false); + const [savingPassword, setSavingPassword] = useState(false); + const [freezeConfirm, setFreezeConfirm] = useState(false); + const [freezePassword, setFreezePassword] = useState(""); + const [freezeBusy, setFreezeBusy] = useState(false); + const qc = useQueryClient(); + + useEffect(() => { + const profile = profileQ.data?.profile; + if (!profile) return; + setForm({ + ...profile, + socialLinks: normalizeLinks(profile.socialLinks), + }); + }, [profileQ.data]); + + useEffect(() => { + if (!user || identityDirty) return; + setDisplayName(user.name ?? ""); + setEmail(user.email ?? ""); + setPhone(user.phone ?? ""); + }, [user, identityDirty]); + + if (!user) return null; + + const usernameCooling = Boolean( + user.usernameCooldownUntil && new Date(user.usernameCooldownUntil).getTime() > Date.now(), + ); + const usernameRemaining = user.usernameChangesRemaining ?? 3; + const active = SECTIONS.find((item) => item.id === section) ?? SECTIONS[0]; + const activityOn = profileQ.data?.profile.showActivity ?? true; + const badgesOn = profileQ.data?.profile.showBadges ?? true; + + const patch = (partial: Partial) => { + setForm((prev) => (prev ? { ...prev, ...partial } : prev)); + }; + + const saveProfile = async () => { + if (!form) return; + try { + await http.put("/api/me/profile", { + public: form.public, + showActivity: form.showActivity, + showStats: form.showStats, + showPath: form.showPath, + showBadges: form.showBadges, + socialLinks: form.socialLinks.slice(0, 5), + banner: form.banner, + }); + toast.success("تنظیمات ذخیره شد"); + } catch (e) { + toast.error(toUserError(e, "ذخیره ممکن نشد")); + } + }; + + const saveIdentity = async () => { + const nameErr = validateName(displayName); + if (nameErr) { + toast.error(nameErr); + return; + } + const emailErr = validateEmail(email); + if (emailErr) { + toast.error(emailErr); + return; + } + const phoneErr = validatePhone(phone, false); + if (phoneErr) { + toast.error(phoneErr); + return; + } + setSavingIdentity(true); + try { + const res = await http.put<{ user: typeof user }>("/api/me/account", { + name: displayName.trim(), + email: email.trim().toLowerCase(), + phone: phone.trim(), + }); + setUser(res.user); + setDisplayName(res.user.name ?? ""); + setEmail(res.user.email ?? ""); + setPhone(res.user.phone ?? ""); + setIdentityDirty(false); + toast.success("مشخصات ذخیره شد"); + } catch (e) { + toast.error(toUserError(e, "ذخیره مشخصات ممکن نشد")); + } finally { + setSavingIdentity(false); + } + }; + + const changeUsername = async () => { + const err = validateUsername(nextUsername); + if (err) { + toast.error(err); + return; + } + const next = nextUsername.trim().toLowerCase(); + if (next === user.username) { + toast.success("نام کاربری همان است"); + return; + } + setSavingUsername(true); + try { + const res = await http.put<{ user: typeof user }>("/api/me/username", { username: next }); + setUser(res.user); + setNextUsername(""); + toast.success("نام کاربری تغییر کرد"); + } catch (e) { + if (!(e instanceof ApiError && e.status === 429)) { + toast.error(toUserError(e, "تغییر نام کاربری ممکن نشد")); + } + } finally { + setSavingUsername(false); + } + }; + + const savePassword = async () => { + const err = validatePassword(password); + if (err) { + toast.error(err); + return; + } + setSavingPassword(true); + try { + const res = await http.put<{ user: typeof user }>("/api/me/account", { + currentPassword, + password, + }); + setUser(res.user); + setPassword(""); + setCurrentPassword(""); + toast.success("گذرواژه ذخیره شد"); + } catch (e) { + toast.error(toUserError(e, "تغییر گذرواژه ممکن نشد")); + } finally { + setSavingPassword(false); + } + }; + + const freezeAccount = async () => { + if (!freezeConfirm) { + setFreezeConfirm(true); + return; + } + if (!freezePassword.trim()) { + toast.error("گذرواژهٔ فعلی را بنویسید"); + return; + } + setFreezeBusy(true); + try { + const res = await http.post<{ user: typeof user }>("/api/me/freeze", { currentPassword: freezePassword }); + setUser(res.user); + setFreezeConfirm(false); + setFreezePassword(""); + await qc.invalidateQueries({ queryKey: ["me"] }); + toast.success("حساب فریز شد"); + } catch (e) { + toast.error(toUserError(e, "فریز حساب ممکن نشد")); + } finally { + setFreezeBusy(false); + } + }; + + const unfreezeAccount = async () => { + setFreezeBusy(true); + try { + const res = await http.post<{ user: typeof user }>("/api/me/unfreeze"); + setUser(res.user); + await qc.invalidateQueries({ queryKey: ["me"] }); + toast.success("فریز لغو شد"); + } catch (e) { + toast.error(toUserError(e, "لغو فریز ممکن نشد")); + } finally { + setFreezeBusy(false); + } + }; + + const closeDate = formatFreezeCloseDate(user.closesAt || freezeCloseIso(user.frozenAt)); + + return ( +
+
+

تنظیمات

+

حساب، حریم و داده‌های همین حساب.

+
+ + + ({ id: item.id, label: item.label })) }]} + activeId={section} + onSelect={setSection} + /> + + + {section === "account" && ( +
+ { + setIdentityDirty(true); + setDisplayName(value); + }} + onEmail={(value) => { + setIdentityDirty(true); + setEmail(value); + }} + onPhone={(value) => { + setIdentityDirty(true); + setPhone(value); + }} + action={ + + } + /> + + + سه بار نام کاربری را عوض کرده‌اید. تغییر بعدی از {formatJalaliStamp(user.usernameCooldownUntil)} ممکن است. +

+ ) : usernameAllowanceLabel(usernameRemaining) ? ( +

{usernameAllowanceLabel(usernameRemaining)}

+ ) : null + } + action={ + + } + /> + +
+
+

گذرواژه

+

برای ذخیره، گذرواژهٔ فعلی لازم است.

+
+
+ + setPassword(e.target.value)} + dir="ltr" + autoComplete="new-password" + /> + + + setCurrentPassword(e.target.value)} + dir="ltr" + autoComplete="current-password" + /> + +
+ +
+ +
+

فریز حساب

+

+ فریز تا ۳۰ روز قابل بازگشت است، بعد از آن حساب بسته می‌شود و حذف دائمی فقط با مجوز ادمین است. +

+ {user.isClosed ? ( +

حساب بسته شده است. لغو فریز ممکن نیست.

+ ) : user.isFrozen ? ( +
+

+ {closeDate ? `این حساب در ${closeDate} بسته می‌شود.` : "این حساب تا ۳۰ روز دیگر بسته می‌شود."} +

+ +
+ ) : ( +
+ {freezeConfirm && ( + <> +

برای فریز، گذرواژهٔ فعلی را بنویسید.

+ + setFreezePassword(e.target.value)} + dir="ltr" + autoComplete="current-password" + /> + + + )} +
+ + {freezeConfirm && ( + + )} +
+
+ )} +
+
+ )} + + {section === "privacy" && ( + void saveProfile()} + /> + )} + + {section === "stats" && ( +
+
+ } + label="امتیاز" + value={formatFaNumber(user.xp)} + /> + } + label="زنجیرهٔ فعلی" + value={formatStreakLabel(user.streakCurrent)} + /> + } + label="بلندترین زنجیره" + value={formatStreakLabel(user.streakLongest)} + /> + } + label="روزهای این هفته" + value={countLabel(activityOn, activityQ.isPending, activityQ.data?.weekDays)} + /> + } + label="روزهای این ماه" + value={countLabel(activityOn, activityQ.isPending, activityQ.data?.monthDays)} + /> +
+
+

نشان‌ها

+ {badgesOn ? ( +
+

رنگ‌دارها باز شده‌اند و کمرنگ‌ها هنوز قفل‌اند.

+ {badgesQ.isPending ? ( +
+ + + +
+ ) : badgesQ.isError ? ( +

{toUserError(badgesQ.error, "بارگذاری نشان‌ها ممکن نشد")}

+ ) : (badgesQ.data?.achievements ?? []).length === 0 ? ( +

نشانی ثبت نشده.

+ ) : ( +
    + {(badgesQ.data?.achievements ?? []).map((b) => ( +
  • + + {b.title} + +
  • + ))} +
+ )} +
+ ) : ( +

نمایش نشان‌ها در بخش حریم خاموش است.

+ )} +
+
+ )} + + {section === "export" && ( +
+ {exportChatsQ.isPending ? ( +
+ + +
+ ) : exportChatsQ.isError ? ( +

{toUserError(exportChatsQ.error, "بارگذاری گفتگوها ممکن نشد")}

+ ) : exportChats.length === 0 ? ( +

گفتگویی نیست.

+ ) : ( +
+
    + {exportPageChats.map((chat, offset) => { + const saved = chat.partner.saved || chat.partner.id === user.id; + const label = saved ? "پیام‌های ذخیره‌شده" : chat.partner.name; + const size = exportSizes[exportPageStart + offset]; + const last = chat.lastMessage?.createdAt || size?.data?.lastAt; + return ( +
  • +
    +

    {label}

    +

    + {last ? `آخرین فعالیت: ${formatJalaliStamp(last)}` : "هنوز پیامی نیست"} +

    +

    + {size?.isPending + ? "در حال برآورد حجم…" + : size?.isError + ? "برآورد حجم ممکن نشد" + : `حجم تقریبی: ${formatApproxSize(size?.data?.bytes ?? 0)}`} +

    +
    + +
  • + ); + })} +
+ {exportChats.length > EXPORT_PAGE_SIZE && ( +
+ +

+ {formatFaNumber(exportPageSafe)} از {formatFaNumber(exportPageCount)} +

+ +
+ )} +
+ )} +
    +
  • +
    +

    حذف حافظه موقت

    +

    عکس و صدای ذخیره‌شده روی همین دستگاه حذف می‌شود.

    +
    + +
  • +
+
+ )} +
+
+
+ ); +} + +function PrivacyFields({ + form, + loading, + error, + onPatch, + onSave, +}: { + form: Extras | null; + loading: boolean; + error: string | null; + onPatch: (partial: Partial) => void; + onSave: () => void; +}) { + if (loading || !form) { + if (error) return

{error}

; + return ( +
+ + + +
+ ); + } + + const links = form.socialLinks; + + return ( +
+
+ onPatch({ public: v })} + /> + onPatch({ showActivity: v })} + /> + onPatch({ showStats: v })} + /> + onPatch({ showPath: v })} + /> + onPatch({ showBadges: v })} + /> +
+ +
+
+

لینک‌های شخصی

+

حداکثر پنج لینک. نام و نشانی را وارد کنید.

+
+ {links.length === 0 ? ( +

هنوز لینکی اضافه نشده.

+ ) : ( +
    + {links.map((link, i) => ( +
  • +
    + + { + const socialLinks = links.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row)); + onPatch({ socialLinks }); + }} + /> +
    +
    + + { + const socialLinks = links.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row)); + onPatch({ socialLinks }); + }} + /> +
    + +
  • + ))} +
+ )} + {links.length < 5 && ( + + )} +
+ + +
+ ); +} + +function PrivacyRow({ + id, + label, + hint, + checked, + onCheckedChange, +}: { + id: string; + label: string; + hint: string; + checked: boolean; + onCheckedChange: (v: boolean) => void; +}) { + const hintId = `${id}-hint`; + return ( +
+
+ +

+ {hint} +

+
+ +
+ ); +} + +function formatFreezeCloseDate(iso?: string) { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" }); +} + +function freezeCloseIso(frozenAt?: string) { + if (!frozenAt) return ""; + const start = new Date(frozenAt); + if (Number.isNaN(start.getTime())) return ""; + const close = new Date(start); + close.setDate(close.getDate() + 30); + return close.toISOString(); +} + +function usernameAllowanceLabel(remaining: number): string { + if (remaining >= 3) return "۳ بار دیگر"; + if (remaining === 2) return "۲ بار دیگر"; + if (remaining === 1) return "۱ بار دیگر"; + return ""; +} + +function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function StatTile({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { + return ( +
+ {icon} +
+

{label}

+

{value}

+
+
+ ); +} + +function countLabel(enabled: boolean, pending: boolean, value: number | undefined) { + if (!enabled) return "خاموش"; + if (pending) return "…"; + return formatFaNumber(value ?? 0); +} + +function normalizeLinks(links: SocialLink[] | null | undefined): SocialLink[] { + if (!Array.isArray(links)) return []; + return links.slice(0, 5).map((row) => ({ name: row?.name ?? "", url: row?.url ?? "" })); +} + +function formatApproxSize(bytes: number) { + if (!Number.isFinite(bytes) || bytes < 1024) return `حدود ${formatFaNumber(1)} کیلوبایت`; + if (bytes < 1024 * 1024) return `حدود ${formatFaNumber(Math.round(bytes / 1024))} کیلوبایت`; + const mb = bytes / (1024 * 1024); + return `حدود ${formatFaNumber(mb, mb < 10 ? 1 : 0)} مگابایت`; +} + +function exportFileName(label: string, id: number) { + const clean = label.replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim(); + return `${clean || `chat-${id}`}.zip`; +} + +async function downloadChatExport(chatId: number, filename: string) { + const token = useAuth.getState().accessToken; + const base = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + try { + const res = await fetch(`${base}/api/me/export?chat=${chatId}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: "include", + }); + if (!res.ok) { + toast.error("دانلود خروجی گفتگو ممکن نشد"); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error("دانلود خروجی گفتگو ممکن نشد"); + } +} diff --git a/frontend/src/app/unwrap/page.tsx b/frontend/src/app/unwrap/page.tsx index 43126ba..700a739 100644 --- a/frontend/src/app/unwrap/page.tsx +++ b/frontend/src/app/unwrap/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import { motion } from "framer-motion"; import { useRouter, useSearchParams } from "next/navigation"; -import { Award, CalendarDays, HeartHandshake, MessageSquareHeart, Sparkles } from "lucide-react"; +import { Award, CalendarDays, MessageSquareHeart, Share2, Sparkles } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PageHeader } from "@/components/page-header"; @@ -17,7 +18,9 @@ import { Badge } from "@/components/ui/badge"; import type { Mentor } from "@/lib/types"; import { http } from "@/lib/api"; import type { ChatMessage, Conversation } from "@/lib/types"; -import { cn } from "@/lib/utils"; +import { cn, formatJalaliStamp } from "@/lib/utils"; +import { useAuth } from "@/lib/auth-store"; +import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge"; type Tab = "events" | "chats" | "leagues" | "community"; @@ -69,7 +72,7 @@ function HubTabs() { جامعه، استمرار و منتورها } - description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو با منتور، و هماهنگی حمایت مالی." + description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو، و شبکه‌های پرگار." className="mb-0" /> @@ -88,26 +91,11 @@ function HubTabs() { لیگ‌ها - - حمایت + + شبکه‌ها - @@ -128,17 +116,40 @@ function openChat(withId?: number) { window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true, with: withId } })); } +function roleWord(role?: string) { + if (role === "admin") return "مدیر"; + if (role === "mentor") return "منتور"; + if (role === "student") return "هنرجو"; + return "کاربر"; +} + function ChatsLauncher() { + const me = useAuth((s) => s.user); + const staff = me?.role === "admin" || me?.role === "mentor"; + const [peoplePage, setPeoplePage] = useState(1); + const [alpha, setAlpha] = useState(false); + const [convPage, setConvPage] = useState(1); const { data, isLoading } = useQuery({ - queryKey: ["mentors"], - queryFn: () => http.get<{ mentors: Mentor[] }>("/api/mentors"), + queryKey: ["mentors", peoplePage], + queryFn: () => http.get<{ mentors: Mentor[]; total: number; pageSize: number }>(`/api/mentors?page=${peoplePage}&pageSize=20`), }); const { data: convData, isLoading: convLoading } = useQuery({ - queryKey: ["conversations"], - queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"), + queryKey: ["conversations", convPage], + queryFn: () => + http.get<{ conversations: Conversation[]; total: number; pageSize: number }>( + `/api/chats/conversations?page=${convPage}&pageSize=10`, + ), }); const mentors = data?.mentors ?? []; + const people = useMemo(() => { + const list = [...mentors]; + if (alpha) list.sort((a, b) => a.name.localeCompare(b.name, "fa")); + else list.sort((a, b) => Number(!!b.online) - Number(!!a.online) || a.name.localeCompare(b.name, "fa")); + return list; + }, [mentors, alpha]); const conversations = convData?.conversations ?? []; + const peopleTotal = data?.total ?? mentors.length; + const convTotal = convData?.total ?? conversations.length; return (
@@ -156,7 +167,7 @@ function ChatsLauncher() {
- +
{convLoading && (
@@ -183,9 +194,12 @@ function ChatsLauncher() {
-

{c.partner.name}

+

+ {c.partner.name} + {isVerifiedRole(c.partner.role) && } +

{c.lastMessage && ( - {timeAgo(c.lastMessage.createdAt)} + {formatJalaliStamp(c.lastMessage.createdAt)} )}

- هنوز گفتگویی ندارید — از «شروع سریع» یک منتور انتخاب کنید. + هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید.

)} + {convTotal > (convData?.pageSize ?? 10) && ( +
+ + {convPage} + +
+ )}
- + setAlpha((v) => !v)}> + {alpha ? "آنلاین بالا" : "الفبایی"} + + } + >
{isLoading && (
@@ -229,24 +268,46 @@ function ChatsLauncher() { ))}
)} - {mentors.map((m) => ( - + ))} {!isLoading && mentors.length === 0 && ( -

هنوز منتوری در دسترس نیست.

+

{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}

+ )} + {peopleTotal > (data?.pageSize ?? 20) && ( +
+ + {peoplePage} + +
)}
@@ -256,19 +317,13 @@ function ChatsLauncher() { function messagePreview(m: ChatMessage) { if (m.body) return m.body; - if (m.attachment) { + const atts = m.attachments?.length ? m.attachments : m.attachment ? [m.attachment] : []; + if (atts.length > 1 && atts.every((a) => a.type === "image")) return `📷 ${atts.length.toLocaleString("fa-IR")} عکس`; + if (atts.length > 1 && atts.every((a) => a.type === "file")) return `📎 ${atts.length.toLocaleString("fa-IR")} فایل`; + if (atts.length > 1) return `📎 ${atts.length.toLocaleString("fa-IR")} پیوست`; + if (atts[0]) { const labels: Record = { image: "📷 عکس", video: "🎬 ویدیو", audio: "🎵 پیام صوتی" }; - return labels[m.attachment.type] ?? `📎 ${m.attachment.name}`; + return labels[atts[0].type] ?? `📎 ${atts[0].name}`; } return ""; -} - -function timeAgo(iso: string) { - const diff = Date.now() - new Date(iso).getTime(); - const m = Math.floor(diff / 60_000); - if (m < 1) return "الان"; - if (m < 60) return `${m} دقیقه قبل`; - const h = Math.floor(m / 60); - if (h < 24) return `${h} ساعت قبل`; - return new Date(iso).toLocaleDateString("fa-IR"); } \ No newline at end of file diff --git a/frontend/src/components/account-identity-fields.tsx b/frontend/src/components/account-identity-fields.tsx new file mode 100644 index 0000000..c76ea5b --- /dev/null +++ b/frontend/src/components/account-identity-fields.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +/** نام، ایمیل، تلفن — same block on /settings and in the admin account editor. */ +export function AccountIdentityFields({ + idPrefix, + name, + email, + phone, + onName, + onEmail, + onPhone, + phoneMaxLength, + action, +}: { + idPrefix: string; + name: string; + email: string; + phone: string; + onName: (value: string) => void; + onEmail: (value: string) => void; + onPhone: (value: string) => void; + phoneMaxLength?: number; + action?: ReactNode; +}) { + return ( +
+

هویت

+
+ + onName(e.target.value)} autoComplete="name" /> + + + onEmail(e.target.value)} + dir="ltr" + autoComplete="email" + inputMode="email" + /> + + + onPhone(e.target.value)} + dir="ltr" + autoComplete="tel" + inputMode="tel" + maxLength={phoneMaxLength} + /> + +
+ {action} +
+ ); +} + +type UsernameSelf = { + mode: "self"; + idPrefix: string; + current: string; + next: string; + onNext: (value: string) => void; + disabled?: boolean; + hint?: ReactNode; + action?: ReactNode; +}; + +type UsernameAdmin = { + mode: "admin"; + idPrefix: string; + value: string; + onChange: (value: string) => void; +}; + +/** نام کاربری follows هویت. Self-service keeps the cooldown fields; admin sets the name directly. */ +export function AccountUsernameSection(props: UsernameSelf | UsernameAdmin) { + const headingId = `${props.idPrefix}-username-heading`; + return ( +
+

نام کاربری

+ {props.mode === "self" && props.hint} + {props.mode === "self" ? ( +
+ + + + + props.onNext(e.target.value.toLowerCase())} + dir="ltr" + autoComplete="off" + disabled={props.disabled} + /> + +
+ ) : ( +
+ + props.onChange(e.target.value.toLowerCase())} + dir="ltr" + autoComplete="off" + placeholder="مثلاً ahp" + /> + +
+ )} + {props.mode === "self" ? props.action : null} +
+ ); +} diff --git a/frontend/src/components/admin-studio.tsx b/frontend/src/components/admin-studio.tsx index b48694e..933191f 100644 --- a/frontend/src/components/admin-studio.tsx +++ b/frontend/src/components/admin-studio.tsx @@ -25,7 +25,6 @@ import { Plus, Rocket, Save, - ShieldCheck, Sparkles, Target, Terminal, @@ -82,13 +81,14 @@ import { isValidAdminTab, type AdminTabId, } from "@/components/admin/nav"; -import { AdminPanelHeader } from "@/components/admin/panel-header"; +import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav"; import { useConfirm } from "@/components/admin/use-confirm"; import { StatsPanel } from "@/components/admin/stats-panel"; import { LearningPanel } from "@/components/admin/learning-panel"; import { QuestionsPanel } from "@/components/admin/questions-panel"; import { VideosPanel, VideoKeyPicker } from "@/components/admin/videos-panel"; import { AnnouncePanel } from "@/components/admin/announce-panel"; +import { BotPanel } from "@/components/admin/bot-panel"; import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel"; import { DEFAULT_PAGE_SIZE, @@ -96,6 +96,7 @@ import { buildPageQuery, type Paginated, } from "@/components/admin/list-pagination"; +import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields"; import { AdminSelect, ListToolbar } from "@/components/admin/list-toolbar"; import { SiteSettingsPanel } from "@/components/admin/site-settings-panel"; import { RegistrationPanel } from "@/components/admin/registration-panel"; @@ -196,113 +197,32 @@ export function AdminStudio() { })).filter((g) => g.items.length > 0); return ( -
-
-
-

{isAdmin ? "استودیو ادمین" : "استودیو منتور"}

-

- {isAdmin ? ( - <> - پنل مدیریت - - ) : ( - <> - پنل منتور - - )} -

-

- {isAdmin - ? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت — گروه‌بندی‌شده تا چیزی گم نشود." - : MENTOR_HINT} -

-
- - {isAdmin ? "ادمین" : "منتور"} - -
+
+
+

{isAdmin ? "مدیریت" : "منتور"}

+

+ {isAdmin + ? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت." + : MENTOR_HINT} +

+
- {/* Mobile: horizontal grouped chips */} - - -
- - -
- {navItem && ( - - )} + + ({ + title: group.title, + items: group.items.map((item) => ({ id: item.id, label: item.label })), + }))} + activeId={tab} + onSelect={setTab} + /> + {tab === "stats" && isAdmin && } {tab === "learning" && } {tab === "users" && isAdmin && } @@ -318,9 +238,10 @@ export function AdminStudio() { {tab === "events" && } {tab === "challenges" && isAdmin && } {tab === "announce" && isAdmin && } + {tab === "bots" && isAdmin && } {tab === "chat-export" && isAdmin && } -
-
+ +
); } @@ -374,7 +295,7 @@ function UsersPanel() { const del = async (u: User) => { if ( !(await confirm( - "حذف کاربر", + "حذف دائمی", `حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.` )) ) { @@ -497,8 +418,8 @@ function UsersPanel() { )} - {canMutate && u.id !== currentUser?.id && ( - )} @@ -644,37 +565,24 @@ export function UserDialog({ : "اطلاعات کاربر را به‌روزرسانی کنید."} -
-
- - setForm({ ...form, name: e.target.value })} /> -
-
- - setForm({ ...form, username: e.target.value.toLowerCase() })} - dir="ltr" - className="text-left" - placeholder="مثلاً ahp" - /> -
-
- - setForm({ ...form, email: e.target.value })} /> -
-
- - setForm({ ...form, phone: clampPhoneInput(e.target.value) })} - dir="ltr" - className="text-left" - placeholder="09xxxxxxxxx" - inputMode="tel" - maxLength={PHONE_INPUT_MAX_LEN} - /> -
+
+ setForm({ ...form, name: value })} + onEmail={(value) => setForm({ ...form, email: value })} + onPhone={(value) => setForm({ ...form, phone: clampPhoneInput(value) })} + phoneMaxLength={PHONE_INPUT_MAX_LEN} + /> + setForm({ ...form, username: value })} + /> +