13 Commits
Author SHA1 Message Date
ahp 6228e66d2c feat: production-grade video player — ambient mode, speed controls, hls.js, double-tap, settings flyout
- Ambient canvas (32×18, ~8fps rAF, blur(55px)) behind video; toggleable, persisted
  - Speed controls 0.5×–2× via settings flyout; capRate replaces lockRate, anti-cheat
  preserved
  - hls.js dynamic import for .m3u8 URLs; Safari native HLS fallback; MP4 unchanged
  - Double-tap left=seek back 10s, right=blocked; single-tap toggles play on touch
  - Keyboard: Space/K play-pause, J/← back, L/→ blocked, ↑↓ volume, M mute, F fullscreen
  - Settings flyout with speed picker + ambient toggle (animated, framer-motion)
  - Theater mode toggle, fullscreen API, volume slider with localStorage persistence
  - Buffer visualization behind progress bar, hover timestamp tooltip, playhead thumb
  - All anti-cheat intact: maxTrustedPosRef, PiP block, visibility pause, heartbeat system
2026-10-18 11:10:11 +03:30
ahpandClaude Sonnet 4.6 089c114019 feat: mobile-first responsive overhaul — safe areas, touch targets, bottom sheet, bottom nav
Viewport:
- Add `viewport` export with `viewport-fit=cover` (enables CSS safe-area-inset-*
  on bezel-less iOS/Android) and `interactive-widget=resizes-content` (prevents
  layout jumps from virtual keyboard open/close)

iOS zoom prevention:
- input.tsx, textarea.tsx: change text-sm (14px) → text-base sm:text-sm (16px)
  on mobile; iOS Safari auto-zooms any input with font-size < 16px

Touch targets:
- button.tsx: default h-10 → h-11 (44px), sm h-8 → h-9 (36px), icon h-10→h-11
  matching the 44×44px iOS HIG and WCAG 2.5.5 minimum
- Add touch-manipulation to buttons, inputs, textarea to eliminate 300ms tap delay

Dialog → mobile bottom sheet:
- On mobile (< 640px): anchored to bottom with full width, rounded top corners,
  slide-in-from-bottom / slide-out-to-bottom animation, drag handle affordance,
  max-h-[90svh] + overflow-y-auto to prevent viewport overflow
- Safe area padding: pb-[max(1.25rem,env(safe-area-inset-bottom))]
- On desktop (sm+): unchanged centered modal with zoom animation
- Close button gets a 32×32 tap area on mobile

Mobile navigation:
- New bottom-nav.tsx: persistent 3-tab bottom bar (مسیر/مرکز/لیگ) shown below
  md: breakpoint, hidden on desktop; 56px height + safe-area-inset-bottom;
  active tab gets heavier stroke and scale
- Only shown for authenticated non-admin users
- app-shell.tsx: injects BottomNav; main content gets dynamic padding-bottom to
  clear the bar (56px + safe area) on mobile, reverts to py-10 on md:
- Footer: safe-area-inset-bottom padding so content clears iPhone home indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:51:37 +03:30
ahpandClaude Sonnet 4.6 602d3f2f4d feat: remove glassmorphism from notification toast; improve video player UX
Task 4 — Event Registration Confirmation (glassmorphism removal):
- Replace translucent gradient + backdrop-blur-md toast in use-realtime.tsx
  with a solid bg-card container, full-opacity border, and a colored
  left accent bar — high contrast, readable in all themes, no blur

Task 5 — Video player UX (within anti-cheat constraints):
- Keyboard shortcuts: Space (play/pause), M (mute), ← (seek back 10s),
  F (fullscreen) — active globally, disabled when an input is focused
- Fullscreen button added to the control bar (F key shortcut)
- Volume slider (0–100%) replaces mute-only toggle; volume is persisted
  to localStorage across sessions under key `pargar:volume`
- Buffer range visualized as a translucent white bar behind the playback
  progress track so users see how much is buffered
- Exponential backoff auto-retry on video error: 1s, 2s, 4s … 30s cap;
  retry count shown in error message; manual retry clears the timer
- Speed controls (0.75x–2x) are intentionally absent: the verified-watch
  anti-cheat locks playbackRate=1; changing it would break the heartbeat
  accumulation that gates quiz unlock and certification
- HLS/ABR requires backend FFmpeg transcoding; the player is structured
  so swapping src=mp4 → src=.m3u8 + hls.js is a one-file change when
  the backend pipeline is ready

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:40:13 +03:30
ahpandClaude Sonnet 4.6 40fa0b8581 fix: quality audit — memory leaks, dead prose classes, stale state scope
- safe-markdown: add `cancelled` flag to async useEffect; no more
  setState after unmount when component is destroyed mid-import
- rich-editor: same cleanup for preview tab effect; also remove dead
  `prose prose-sm max-w-none` classes (Tailwind Typography not installed)
  and replace with explicit arbitrary-selector markdown styling that
  actually renders using existing design tokens
- profile-certificate-slot: drop dead `ORDER_LABEL`, `order` variable,
  and `physicalOrder` from `MineCertificateResponse`; physical orders
  are disabled — these were leftover references causing lint noise
- learning-panel: change `issuingCert: boolean` → `issuingCertFor:
  number | null` so only the targeted user's button shows a spinner;
  a boolean shared across all users caused the wrong row to animate
  when the admin switched selected users mid-request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:29:58 +03:30
ahpandClaude Sonnet 4.6 b4a7e7e1d3 fix: resolve TipTap v3 type errors in RichEditor
- Cast editor.storage to any for tiptap-markdown's getMarkdown (untyped extension storage)
- Replace setContent(value, false) with setContent(value, { emitUpdate: false }) for TipTap v3 API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:18:51 +03:30
ahp 03610bc3d3 feat: implement 8 features across full stack
- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
    deterministic tie-breaking (xp DESC, user_id ASC)
  - Mentor chat: md: breakpoints, safe-area-inset-bottom padding
  - Learning progress: server-side progressPct, curriculum expansion safety
  - Physical orders: hard-disabled with 410 Gone
  - Certificate approval: pending/issued flow, admin issue/revoke endpoints,
    certificate_requires_approval setting (migration 0031)
  - Draft/publish: is_published on lessons and chapters, admin publish UI
    (migration 0030)
  - Video panel: static thumbnails (no preload), client-side pagination
  - Rich editor: TipTap + tiptap-markdown for lesson descriptions,
    marked + DOMPurify for SafeMarkdown rendering
2026-10-18 10:14:22 +03:30
ahp 75e2892e36 fix: release v0.2.1 lesson deletes and large uploads
- Raise Traefik read timeout to 1h (compose dev/prod + k3s HelmChartConfig)
  so admin video uploads up to 200MB no longer 502 mid-upload.
- Migration 0029: cascade watch/quiz history on lesson delete and unlink
  dependent lessons, so removing a lesson or chapter stops 500ing.
- Add the Apollo cluster Helm overlay (values-apollo.yaml) for the deployed
  environment.
- k3s Traefik chart config also enables access logs, cross-namespace
  IngressRoutes, and fixes the Prometheus scrape port.

Bump appVersion/product.go/values tags to 0.2.1; chart version 0.2.3.
2026-10-16 09:44:23 +03:30
ahp 22d92c133d 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.
2026-10-10 16:42:35 +03:30
ahp 8586bbafcc Merge branch 'hotfix/v0.1.3-otel-schema'
Fix OTel schema URL conflict that crashed the v0.1.3 backend at startup
when an OTLP endpoint is configured (resource.Merge merged
resource.Default() schema 1.43.0 with semconv/v1.24.0's schema URL).
Use resource.NewSchemaless for the service.name attribute.
2026-10-09 20:58:22 +03:30
ahp e05c4327d9 fix(observability): use schemaless resource attrs to avoid schema URL conflict
SetupTracing merged resource.Default() (schema 1.43.0 under otel 1.45.0)
with semconv/v1.24.0's schema URL, so resource.Merge errored with
'conflicting Schema URL' and the backend exited at startup whenever an
OTLP endpoint was configured. resource.NewSchemaless keeps service.name
without pinning a schema, surviving otel major bumps.
2026-10-09 20:35:24 +03:30
ahp 9f5b3b5593 docs: rewrite the README as a first look at Pargar
Lead with the mark and the learning path so the repository explains the product before the engineering map.
2026-10-09 19:47:18 +03:30
ahp e222a7dcd7 fix: patch dependency advisories on the current release
Keep Next on the patched 15.5 line instead of the Next 16 Dependabot bump, and move OpenTelemetry and gRPC to the versions that close the open Go alerts.
2026-10-09 19:37:29 +03:30
ahp d9a2abb44f feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
2026-10-09 19:05:59 +03:30
133 changed files with 15310 additions and 2644 deletions
+63 -43
View File
@@ -1,67 +1,87 @@
# Pargar
<p align="center">
<img src="frontend/public/logo.png" alt="Pargar mark" width="96" />
</p>
A gated LMS for bootcamps: verified watch → quiz with hearts → skill-tree unlock → mentor chat, events, weekly league, monthly challenges, and certificates.
<h1 align="center">Pargar</h1>
| Layer | Stack |
|-------|--------|
| API | Go 1.27+ · Postgres · Redis · S3/RustFS · WebSocket |
| UI | Next.js 15 · React Query · Tailwind · Vazirmatn FD |
| Edge | Traefik v3.7 (Compose and k3s) |
<p align="center">
<strong>A bootcamp learning path with no shortcuts.</strong><br />
Verified watch, quizzes with hearts, a skill tree, and a mentor. The product UI is Persian (پرگار).
</p>
## Quick start (local)
<p align="center">
<a href="https://github.com/Panahifarah/pargar/releases/tag/v0.2.1"><img src="https://img.shields.io/github/v/tag/Panahifarah/pargar?label=release&color=5B4CDB" alt="release" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-5B4CDB" alt="AGPL-3.0" /></a>
</p>
Pargar is a gated LMS for a bootcamp. A student watches the lesson for real, passes that lesson's quiz, and only then opens the next branch of the skill tree. Alongside the path: mentor chat, events, a weekly league, a monthly challenge, and a certificate.
Public registration sits behind a phone whitelist. Invite links work on their own. Students cannot recover a password themselves; an admin sets a new one from that person's profile.
## The path
| Step | What happens |
| --- | --- |
| Verified watch | Watch time is checked with periodic heartbeats. Skipping ahead does not finish the lesson. |
| Quiz with hearts | Each wrong answer spends a heart. An empty heart meter locks the account until staff reviews it. |
| Skill tree | The next branch opens only after its parent is passed. Staff unlock the following lesson. |
| Community | Mentor chat, events, a weekly league that resets on Monday, and a monthly challenge. |
| Certificate | An unlisted digital link. A physical copy, when enabled, is offline payment only. |
Three roles: a **student** walks the path, a **mentor** sees curriculum and student learning, and an **admin** also manages users, invites, site settings, and physical certificate orders.
Product detail: [docs/product.md](docs/product.md)
## Run it locally
Docker is required. Copy `.env.example` and replace every `replace-with-…` value. The admin password is `ADMIN_PASSWORD`. The default username is `admin`.
```bash
cp .env.example .env # fill in real secrets
cp .env.example .env
./scripts/compose-dev.sh up -d --build
```
- App: **https://localhost** (Traefik default cert → one browser warning is expected)
- Traefik dashboard: http://127.0.0.1:8088
- Health: `curl -k https://localhost/api/health`
k3s:
The app is at [https://localhost](https://localhost). Traefik uses its own certificate, so the browser shows one warning. API health:
```bash
./scripts/k3s-load-images.sh
./scripts/helm-k3s.sh
curl -k https://pargar.local/api/health
curl -k https://localhost/api/health
```
Deployment details: [docs/deployment.md](docs/deployment.md)
k3s and production: [docs/deployment.md](docs/deployment.md)
## Documentation
## Stack
| Doc | Contents |
|-----|----------|
| [docs/README.md](docs/README.md) | Doc index and reading map |
| [docs/architecture.md](docs/architecture.md) | System architecture and data flow |
| [docs/product.md](docs/product.md) | Student / admin paths and features |
| [docs/development.md](docs/development.md) | Development, tests, code conventions |
| [docs/deployment.md](docs/deployment.md) | Compose · Helm/k3s · production |
| [docs/security.md](docs/security.md) | Auth, secrets, rate limits, uploads |
| [docs/operations.md](docs/operations.md) | Backup, metrics, troubleshooting |
| [docs/api.md](docs/api.md) | API contract and key routes |
| [docs/configuration.md](docs/configuration.md) | Environment variable reference |
| Layer | Technology |
| --- | --- |
| API | Go, Postgres, Redis, S3/RustFS, WebSocket |
| UI | Next.js 15, React, Tailwind |
| Edge | Traefik, on Compose and Helm |
Domain summary for agents: [CONTEXT.md](CONTEXT.md)
The browser talks to one origin. Traefik splits the UI from `/api`. Detail: [docs/architecture.md](docs/architecture.md)
## Repository layout
## Repository
```text
backend/ Go API, migrations, seed, media/sample.mp4
frontend/ Next.js App Router
deploy/ Compose + Traefik + Helm chart
scripts/ compose / k3s / backup / regenerate-sample
docs/ engineering documentation
backend/ API, migrations, seed data
frontend/ Next.js UI
deploy/ Compose, Traefik, Helm chart
scripts/ Local run, k3s, backup
docs/ Engineering docs
```
## Tests
| Doc | Read it for |
| --- | --- |
| [Product](docs/product.md) | Student path, admin, invites, certificates |
| [Architecture](docs/architecture.md) | Data flow and service boundaries |
| [Development](docs/development.md) | Local workflow and tests |
| [Deployment](docs/deployment.md) | Compose, Helm, production |
| [Security](docs/security.md) | Sessions, secrets, rate limits, uploads |
| [Operations](docs/operations.md) | Backup, metrics, troubleshooting |
| [API](docs/api.md) | Route contract |
| [Configuration](docs/configuration.md) | Environment variables |
```bash
cd backend && go test ./internal/api/... ./internal/config/...
cd frontend && bunx tsc --noEmit
```
Index: [docs/README.md](docs/README.md)
## License
This project is released under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0).
Released under [AGPL-3.0](LICENSE).
+15 -15
View File
@@ -12,10 +12,10 @@ require (
github.com/jackc/pgx/v5 v5.11.0
github.com/redis/go-redis/v9 v9.22.0
github.com/rs/cors v1.11.1
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/crypto v0.57.0
)
@@ -34,26 +34,26 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+34 -32
View File
@@ -38,16 +38,16 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -60,8 +60,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -85,24 +85,24 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -117,14 +117,16 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+172
View File
@@ -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})
}
+105
View File
@@ -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})
}
+102
View File
@@ -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"])
}
}
@@ -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"]
}
+59 -67
View File
@@ -22,6 +22,21 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
}
func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u = s.settleAccount(r.Context(), u)
writeJSON(w, http.StatusOK, map[string]any{"user": u})
}
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
role := r.URL.Query().Get("role")
@@ -389,27 +404,17 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
return
}
target, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
if _, err := s.store.GetUserByID(r.Context(), id); err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
var req struct {
SecurityAnswer string `json:"securityAnswer"`
NewPassword string `json:"newPassword"`
NewPassword string `json:"newPassword"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if target.SecurityAnswerHash == "" {
writeErr(w, http.StatusBadRequest, "برای این کاربر سوال امنیتی تعریف نشده است")
return
}
if !s.auth.CheckPassword(target.SecurityAnswerHash, strings.ToLower(strings.TrimSpace(req.SecurityAnswer))) {
writeErr(w, http.StatusForbidden, "جواب سوال امنیتی نادرست است")
return
}
if msg := validatePasswordOnly(req.NewPassword); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
@@ -438,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
@@ -560,6 +575,38 @@ func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminPublishLesson(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
var body struct {
Publish bool `json:"publish"`
}
if err := bodyJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.PublishLesson(r.Context(), id, body.Publish); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت درس ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminPublishChapter(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
var body struct {
Publish bool `json:"publish"`
}
if err := bodyJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.PublishChapter(r.Context(), id, body.Publish); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت فصل ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---- questions ----
func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) {
@@ -833,58 +880,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)})
}
+14
View File
@@ -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})
}
@@ -306,6 +319,7 @@ func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی آواتار ممکن نشد")
return
}
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+3 -1
View File
@@ -22,7 +22,7 @@ import (
)
const (
captchaTTL = 5 * time.Minute
captchaTTL = 2 * time.Minute
captchaKeyPrefix = "pargar:captcha:"
captchaRateLimit int64 = 60
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -260,6 +260,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"challengeId": "dev",
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
if s.cfg.CaptchaExposeAnswer {
resp["answer"] = "A2B3"
@@ -297,6 +298,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"challengeId": id,
"imageBase64": img,
"expiresIn": int(captchaTTL.Seconds()),
}
// Never expose plaintext answers in production. Local tests may opt in via
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
+4
View File
@@ -30,6 +30,10 @@ func TestCaptchaImageChallenge(t *testing.T) {
if id == "" || img == "" || answer == "" {
t.Fatalf("captcha missing fields: %v", body)
}
exp, ok := body["expiresIn"].(float64)
if !ok || exp < 60 || exp > 180 {
t.Fatalf("expiresIn should be a short window: %v", body["expiresIn"])
}
if _, ok := body["question"]; ok {
t.Fatalf("legacy question field should be gone: %v", body)
}
+91 -82
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"strconv"
"strings"
"time"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
@@ -22,7 +21,24 @@ func newPublicID() (string, error) {
return hex.EncodeToString(b), nil
}
func (s *Server) certEnabled(ctx context.Context) bool {
if v, err := s.store.GetSetting(ctx, "certificate_enabled"); err == nil {
return v == "true" || v == "1"
}
return true
}
func (s *Server) certRequiresApproval(ctx context.Context) bool {
if v, err := s.store.GetSetting(ctx, "certificate_requires_approval"); err == nil {
return v == "true" || v == "1"
}
return true
}
func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) {
if !s.certEnabled(ctx) {
return nil, false, nil
}
ok, err := s.store.HasCompletedCurriculum(ctx, user.ID)
if err != nil {
return nil, false, err
@@ -37,6 +53,13 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us
} else if err != store.ErrNotFound {
return nil, false, err
}
if s.certRequiresApproval(ctx) {
cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
if err != nil {
return nil, false, err
}
return cert, cert.Status == "pending", nil
}
publicID, err := newPublicID()
if err != nil {
return nil, false, err
@@ -140,100 +163,86 @@ func (s *Server) physicalSettings(ctx context.Context) map[string]any {
}
func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
settings := s.physicalSettings(r.Context())
if enabled, _ := settings["enabled"].(bool); !enabled {
writeErr(w, http.StatusBadRequest, "درخواست نسخه فیزیکی فعلاً فعال نیست")
return
}
cert, err := s.store.GetCertificateByUser(r.Context(), u.ID)
if err != nil || cert.RevokedAt != nil {
writeErr(w, http.StatusConflict, "ابتدا گواهینامه دیجیتال را دریافت کنید")
return
}
days, _ := settings["windowDays"].(int)
if days <= 0 {
days = 30
}
windowEnds := cert.IssuedAt.Add(time.Duration(days) * 24 * time.Hour)
if time.Now().After(windowEnds) {
writeErr(w, http.StatusConflict, "مهلت درخواست نسخه فیزیکی به پایان رسیده است")
return
}
if existing, err := s.store.GetLatestPhysicalOrder(r.Context(), u.ID); err == nil {
if existing.Status != models.PhysicalCancelled {
writeErr(w, http.StatusConflict, "سفارش فیزیکی فعال دارید")
return
}
}
var req struct {
RecipientName string `json:"recipientName"`
Phone string `json:"phone"`
Address string `json:"address"`
City string `json:"city"`
PostalCode string `json:"postalCode"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
ship := models.CertificatePhysicalOrder{
RecipientName: strings.TrimSpace(req.RecipientName),
Phone: normalizePhone(req.Phone),
Address: strings.TrimSpace(req.Address),
City: strings.TrimSpace(req.City),
PostalCode: strings.TrimSpace(req.PostalCode),
}
if ship.RecipientName == "" {
ship.RecipientName = u.Name
}
if ship.Phone == "" {
ship.Phone = normalizePhone(u.Phone)
}
if ship.Phone == "" || !validPhone(ship.Phone) {
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
return
}
if ship.Address == "" || ship.City == "" {
writeErr(w, http.StatusBadRequest, "آدرس و شهر برای ارسال الزامی است")
return
}
if len(ship.Address) > 500 || len(ship.City) > 80 || len(ship.RecipientName) > 120 {
writeErr(w, http.StatusBadRequest, "اطلاعات ارسال بیش از حد طولانی است")
return
}
order, err := s.store.CreatePhysicalOrder(r.Context(), cert.ID, u.ID, windowEnds, ship)
writeErr(w, http.StatusGone, "سفارش فیزیکی موقتاً غیرفعال است")
}
func (s *Server) handleAdminIssueCertificate(w http.ResponseWriter, r *http.Request) {
userID := routeID(r, "id")
u, err := s.store.GetUserByID(r.Context(), userID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد")
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
existing, err := s.store.GetCertificateByUser(r.Context(), userID)
if err != nil && err != store.ErrNotFound {
writeInternalErr(w, "cert.admin.issue", err, "بارگذاری گواهینامه ممکن نشد")
return
}
if existing != nil && existing.RevokedAt == nil && existing.Status == "issued" {
writeErr(w, http.StatusConflict, "گواهینامه قبلاً صادر شده است")
return
}
publicID, err := newPublicID()
if err != nil {
writeInternalErr(w, "cert.admin.pubid", err, "تولید شناسه ممکن نشد")
return
}
var cert *models.Certificate
if existing != nil {
cert, err = s.store.IssuePendingCertificate(r.Context(), userID, publicID, u.Name)
} else {
cert, err = s.store.CreateCertificate(r.Context(), userID, publicID, u.Name)
}
if err != nil {
writeInternalErr(w, "cert.admin.create", err, "صدور گواهینامه ممکن نشد")
return
}
staff, _ := s.store.ListMentors(r.Context())
go func() {
for _, m := range staff {
if m.Role != models.RoleAdmin {
continue
}
_ = s.notify.Notify(context.Background(), m.ID, "progress", "physical_order",
"سفارش گواهینامه فیزیکی", u.Name+" درخواست نسخه فیزیکی ثبت کرد.", "/admin",
map[string]any{"orderId": order.ID, "userId": u.ID})
}
_ = s.notify.Notify(context.Background(), userID, "progress", "certificate_issued",
"گواهینامه صادر شد", "گواهینامه پرگار شما صادر شد. برای مشاهده به صفحه گواهینامه مراجعه کنید.", "/cap",
map[string]any{"publicId": cert.PublicID})
}()
observability.Activity("certificate.physical_requested", "user_id", u.ID, "order_id", order.ID)
writeJSON(w, http.StatusCreated, map[string]any{
"order": order,
"message": "پس از واریز با ادمین هماهنگ کنید",
"physical": settings,
})
observability.Activity("certificate.admin_issued", "user_id", userID, "actor_id", currentUser(r).ID)
writeJSON(w, http.StatusOK, map[string]any{"certificate": cert})
}
func (s *Server) handleAdminRevokeCertificate(w http.ResponseWriter, r *http.Request) {
userID := routeID(r, "id")
if err := s.store.RevokeCertificate(r.Context(), userID); err != nil {
writeInternalErr(w, "cert.admin.revoke", err, "لغو گواهینامه ممکن نشد")
return
}
observability.Activity("certificate.admin_revoked", "user_id", userID, "actor_id", currentUser(r).ID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminListPendingCertificates(w http.ResponseWriter, r *http.Request) {
certs, err := s.store.ListCertificatesByStatus(r.Context(), "pending")
if err != nil {
writeInternalErr(w, "cert.admin.pending", err, "بارگذاری گواهینامه‌های در انتظار ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"certificates": certs})
}
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
orders, err := s.store.ListPhysicalOrders(r.Context(), status)
p := parsePageParams(r)
orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())})
if orders == nil {
orders = []models.CertificatePhysicalOrder{}
}
writeJSON(w, http.StatusOK, map[string]any{
"orders": orders,
"total": total,
"page": p.Page,
"pageSize": p.PageSize,
"physical": s.physicalSettings(r.Context()),
})
}
func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) {
+345 -63
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -18,37 +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 {
ids := make([]int64, 0, len(people))
for _, m := range people {
if m.ID != actor.ID {
ids = append(ids, m.ID)
}
}
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
}
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
continue
}
safe = append(safe, map[string]any{
s.signUserMedia(&m)
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) {
@@ -93,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
@@ -111,23 +146,101 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
unread++
}
}
photo := p.AvatarPhoto
if photo != "" {
tmp := &models.User{ID: p.ID, AvatarPhoto: photo}
s.signUserMedia(tmp)
photo = tmp.AvatarPhoto
}
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": p.AvatarPhoto,
"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) {
@@ -137,6 +250,7 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
_ = s.markIncomingChatRead(r.Context(), u, partner)
if raw := r.URL.Query().Get("around"); raw != "" {
aroundID, err := strconv.ParseInt(raw, 10, 64)
@@ -156,7 +270,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
return
}
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil {
@@ -197,7 +310,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
}
}
_ = s.store.FillReactions(r.Context(), u.ID, msgs)
_ = s.store.MarkChatRead(r.Context(), u.ID, partner)
s.resignChatMessages(msgs)
pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner)
if pinned == nil {
@@ -218,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
}
@@ -275,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
@@ -285,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 {
@@ -337,6 +429,116 @@ func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
}
func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
partner := routeID(r, "partner")
if !s.canChatWith(r.Context(), u, partner) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
msgID := routeID(r, "id")
var req struct {
Body string `json:"body"`
Attachment *models.Attachment `json:"attachment,omitempty"`
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, "دادهٔ ارسالی نامعتبر است")
return
}
req.Body = strings.TrimSpace(req.Body)
if len(req.Body) > 4000 {
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
return
}
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
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
return
}
s.resignChatMessage(msg)
event := map[string]any{"type": "chat_edit", "item": msg, "from": u.ID}
s.hub.Push(u.ID, event)
s.hub.Push(partner, event)
writeJSON(w, http.StatusOK, map[string]any{"message": msg})
}
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
partner := routeID(r, "partner")
@@ -473,17 +675,32 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
if err := s.store.MarkChatRead(r.Context(), u.ID, partner); err != nil {
if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت خوانده‌شدن ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User, partnerID int64) error {
if reader == nil {
return nil
}
n, err := s.store.MarkChatRead(ctx, reader.ID, partnerID, reader.Role)
if err != nil || n == 0 {
return err
}
s.hub.Push(partnerID, map[string]any{"type": "chat_read", "from": reader.ID})
return nil
}
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
if actor == nil || partnerID <= 0 || actor.ID == partnerID {
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
@@ -501,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 {
+47 -23
View File
@@ -13,6 +13,7 @@ import (
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
"pargar/backend/internal/models"
@@ -49,15 +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"`
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 {
@@ -183,21 +186,27 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
ReplyTo: m.ReplyTo,
Pinned: m.PinnedAt != nil,
PinnedAt: m.PinnedAt,
EditedAt: m.EditedAt,
}
if m.Attachment != nil {
att := &chatExportAttachment{
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 {
@@ -208,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)
}
@@ -371,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++
@@ -457,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
@@ -474,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{
+1 -55
View File
@@ -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
}
+6 -9
View File
@@ -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 {
+41 -26
View File
@@ -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"])
}
}
+5 -5
View File
@@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) {
}
}
func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
func TestAdminResetPassword(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
@@ -59,14 +59,14 @@ func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
uid := int64(body["user"].(map[string]any)["id"].(float64))
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "اشتباه", "newPassword": "newpass123",
"newPassword": "short",
})
if code != http.StatusForbidden {
t.Fatalf("wrong answer should 403, got %d", code)
if code != http.StatusBadRequest {
t.Fatalf("short password: %d", code)
}
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
"securityAnswer": "تهران", "newPassword": "newpass123",
"newPassword": "newpass123",
})
if code != http.StatusOK {
t.Fatalf("reset: %d %v", code, body)
+8 -1
View File
@@ -284,8 +284,15 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
switch models.InviteStatus(status) {
case "", models.InviteActive, models.InvitePaused, models.InviteRevoked, models.InviteExhausted:
default:
writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است")
return
}
p := parsePageParams(r)
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset)
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, status, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد")
return
+65 -14
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"sort"
"strconv"
"time"
@@ -15,21 +16,40 @@ type lbEntry struct {
Email string `json:"email"`
XP float64 `json:"xp"`
Rank int `json:"rank"`
Tier string `json:"tier"`
AvatarVariant string `json:"avatarVariant"`
AvatarPalette string `json:"avatarPalette"`
AvatarPhoto string `json:"avatarPhoto,omitempty"`
}
// tierFromRank assigns a league tier based on rank relative to total participants.
// Thresholds: rank 1 = champion, top 10% = gold, top 30% = silver, rest = bronze.
func tierFromRank(rank, total int) string {
if rank <= 0 || total <= 0 {
return "bronze"
}
if rank == 1 {
return "champion"
}
if rank*10 <= total {
return "gold"
}
if rank*10 <= total*3 {
return "silver"
}
return "bronze"
}
func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
key := "lb:" + service.ISOWeek(time.Now())
entries := make([]lbEntry, 0, 20)
type scored struct {
userID int64
xp float64
}
var scoredList []scored
var totalParticipants int64
if s.redis != nil {
z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result()
@@ -39,12 +59,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
scoredList = append(scoredList, scored{userID: id, xp: member.Score})
}
}
if count, err := s.redis.ZCard(context.Background(), key).Result(); err == nil {
totalParticipants = count
}
}
// fallback to DB if redis empty (e.g. dev bootstrap)
// Fallback to DB if Redis is empty (e.g. dev bootstrap).
if len(scoredList) == 0 {
rows, err := s.store.Pool().Query(context.Background(), `
SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1 ORDER BY xp DESC LIMIT 20`,
rows, err := s.store.Pool().Query(context.Background(),
`SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1
ORDER BY xp DESC, user_id ASC LIMIT 20`,
service.ISOWeek(time.Now()))
if err == nil {
for rows.Next() {
@@ -56,29 +80,50 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
}
rows.Close()
}
if err := s.store.Pool().QueryRow(context.Background(),
`SELECT COUNT(*) FROM weekly_leaderboard WHERE week=$1`,
service.ISOWeek(time.Now())).Scan(&totalParticipants); err != nil {
totalParticipants = int64(len(scoredList))
}
}
// Deterministic tie-breaking: XP descending, then user_id ascending.
sort.SliceStable(scoredList, func(i, j int) bool {
if scoredList[i].xp != scoredList[j].xp {
return scoredList[i].xp > scoredList[j].xp
}
return scoredList[i].userID < scoredList[j].userID
})
entries := make([]lbEntry, 0, len(scoredList))
for i, sc := range scoredList {
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
if err != nil {
continue
}
s.signUserMedia(usr)
rank := i + 1
entries = append(entries, lbEntry{
UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1,
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
UserID: sc.userID,
Name: usr.Name,
Email: usr.Email,
XP: sc.xp,
Rank: rank,
Tier: tierFromRank(rank, int(totalParticipants)),
AvatarVariant: usr.AvatarVariant,
AvatarPalette: usr.AvatarPalette,
AvatarPhoto: usr.AvatarPhoto,
})
}
// my position
// Current user's position (may be outside top 20).
myRank := -1
var myXP float64
if s.redis != nil {
rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
if err == nil {
if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myRank = int(rank) + 1
}
score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
if err == nil {
if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myXP = score
}
}
@@ -87,8 +132,14 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
entries = []lbEntry{}
}
writeJSON(w, http.StatusOK, map[string]any{
"week": service.ISOWeek(time.Now()),
"entries": entries,
"me": map[string]any{"rank": myRank, "xp": myXP, "streak": u.StreakCurrent},
"week": service.ISOWeek(time.Now()),
"entries": entries,
"totalParticipants": totalParticipants,
"me": map[string]any{
"rank": myRank,
"xp": myXP,
"streak": u.StreakCurrent,
"tier": tierFromRank(myRank, int(totalParticipants)),
},
})
}
+31
View File
@@ -0,0 +1,31 @@
package api
import "testing"
func TestTierFromRank(t *testing.T) {
cases := []struct {
rank, total int
want string
}{
{1, 1, "champion"},
{1, 100, "champion"},
{1, 0, "bronze"}, // zero total → bronze (guard)
{0, 100, "bronze"}, // zero rank → bronze (guard)
{10, 100, "gold"}, // exactly 10%
{11, 100, "silver"}, // 11%
{30, 100, "silver"}, // exactly 30%
{31, 100, "bronze"}, // 31%
{5, 50, "gold"}, // 10%
{15, 50, "silver"}, // exactly 30%
{16, 50, "bronze"}, // 32% → bronze
{50, 100, "bronze"},
{2, 10, "silver"}, // 20% → silver
{1, 10, "champion"},
}
for _, c := range cases {
got := tierFromRank(c.rank, c.total)
if got != c.want {
t.Errorf("tierFromRank(%d, %d) = %q; want %q", c.rank, c.total, got, c.want)
}
}
}
+22 -3
View File
@@ -1,9 +1,11 @@
package api
import (
"errors"
"net/http"
"pargar/backend/internal/observability"
"pargar/backend/internal/store"
)
func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) {
@@ -21,12 +23,25 @@ func (s *Server) handleLearningStudents(w http.ResponseWriter, r *http.Request)
ctx, span := observability.StartSpan(r.Context(), "admin.learning.students")
defer span.End()
q := r.URL.Query().Get("q")
rows, err := s.store.ListStudentLearning(ctx, q)
filter := r.URL.Query().Get("filter")
switch filter {
case "active", "locked":
default:
filter = ""
}
sort := r.URL.Query().Get("sort")
switch sort {
case "progress", "streak", "activity":
default:
sort = "xp"
}
p := parsePageParams(r)
rows, total, err := s.store.ListStudentLearning(ctx, q, filter, sort, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"students": rows})
writePage(w, rows, total, p)
}
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
@@ -34,9 +49,13 @@ func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
defer span.End()
id := routeID(r, "id")
detail, err := s.store.UserLearningDetail(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
}
+604
View File
@@ -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(`<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #eceef8;
color: #2a2745;
font-family: Vazirmatn, "IRANSans", Tahoma, sans-serif;
}
.sheet {
max-width: 440px;
margin: 24px auto 40px;
overflow: hidden;
background: #f7f7fc;
border: 1px solid rgba(255,255,255,.75);
border-radius: 26px;
box-shadow: 0 24px 70px -18px rgba(42,39,69,.35);
}
header.bar {
position: relative;
padding: 16px 16px 12px;
background: rgba(255,255,255,.78);
border-bottom: 1px solid #e3e5f2;
}
header.bar::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(to left, #6d63f0, #ec6fa9, #f2a437);
}
header.bar h1 { margin: 0; font-size: .95rem; font-weight: 800; }
header.bar p { margin: 3px 0 0; color: #6b6c8a; font-size: .75rem; }
.log { min-height: 240px; padding: 12px 12px 18px; }
.day { display: flex; justify-content: center; margin: 10px 0 8px; }
.day span {
border: 1px solid #d9dbe9;
background: rgba(255,255,255,.9);
color: #6b6c8a;
border-radius: 999px;
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
}
article.msg { display: flex; margin: 0 0 6px; }
article.msg.mine { justify-content: flex-start; }
article.msg.theirs { justify-content: flex-end; }
.stack { display: flex; flex-direction: column; max-width: min(78%, 20rem); }
article.mine .stack { align-items: flex-start; }
article.theirs .stack { align-items: flex-end; }
.who { margin: 0 6px 3px; font-size: 11px; font-weight: 800; color: #6b6c8a; }
.bubble {
width: fit-content;
max-width: 100%;
padding: 8px 14px;
font-size: .875rem;
line-height: 1.65;
}
article.mine .bubble {
background: #6d63f0;
color: #fff;
border-radius: 18px 6px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.12);
}
article.theirs .bubble {
background: #fff;
color: #2a2745;
border: 1px solid #d9dbe9;
border-radius: 6px 18px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.06);
}
.body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
unicode-bidi: plaintext;
}
time { display: block; margin-top: 4px; font-size: 10px; line-height: 1; opacity: .78; }
article.mine time { text-align: right; }
article.theirs time { text-align: left; }
img, video {
display: block;
max-width: min(100%, 240px);
height: auto;
border-radius: 12px;
margin-top: 6px;
}
audio { display: block; width: min(100%, 240px); margin-top: 6px; }
.file, .missing { margin: 6px 0 0; font-size: .82rem; }
article.mine a { color: #fff; }
article.theirs a { color: #4c45c6; }
.missing { opacity: .85; }
.empty { margin: 28px 8px; text-align: center; color: #6b6c8a; font-size: .875rem; }
</style>
</head>
<body>
<main class="sheet">
<header class="bar">
<h1>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</h1>
<p>خروجی پرگار · `)
b.WriteString(html.EscapeString(formatJalaliStamp(time.Now())))
b.WriteString(`</p>
</header>
<div class="log">
`)
if len(messages) == 0 {
b.WriteString(`<p class="empty">هنوز پیامی در این گفتگو نیست.</p>`)
}
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(`<div class="day"><span>`)
b.WriteString(html.EscapeString(formatJalaliDay(item.msg.CreatedAt)))
b.WriteString(`</span></div>`)
}
showName := !hasPrev || prevMine != mine || !sameExportDay(prevAt, item.msg.CreatedAt)
cls := "theirs"
if mine {
cls = "mine"
}
b.WriteString(`<article class="msg ` + cls + `"><div class="stack">`)
if showName {
b.WriteString(`<p class="who">`)
b.WriteString(html.EscapeString(meExportSenderName(owner, item.msg)))
b.WriteString(`</p>`)
}
b.WriteString(`<div class="bubble">`)
if strings.TrimSpace(item.msg.Body) != "" {
b.WriteString(`<p class="body">` + escapeExportBody(item.msg.Body) + `</p>`)
}
for i, att := range item.msg.Atts {
rel := ""
if i < len(item.rel) {
rel = item.rel[i]
}
b.WriteString(renderExportAttachment(att, rel))
}
b.WriteString(`<time datetime="` + html.EscapeString(item.msg.CreatedAt.UTC().Format(time.RFC3339)) + `">`)
b.WriteString(html.EscapeString(formatJalaliStamp(item.msg.CreatedAt)))
b.WriteString(`</time></div></div></article>`)
hasPrev = true
prevMine = mine
prevAt = item.msg.CreatedAt
}
b.WriteString(`</div>
</main>
</body>
</html>
`)
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 `<p class="missing">فایل همراه موجود نیست: ` + safe + `</p>`
}
src := html.EscapeString(rel)
switch strings.ToLower(strings.TrimSpace(att.Type)) {
case "image":
return `<img src="` + src + `" alt="` + safe + `">`
case "audio":
return `<audio controls preload="metadata" src="` + src + `"></audio>`
case "video":
return `<video controls preload="metadata" src="` + src + `"></video>`
default:
return `<p class="file"><a href="` + src + `">` + safe + `</a></p>`
}
}
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
}
+254
View File
@@ -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": "<b>راز</b>\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, "<b>راز") || !strings.Contains(htmlDoc, "&lt;b&gt;راز&lt;/b&gt;") {
t.Fatal("user text was not escaped")
}
if !strings.Contains(htmlDoc, "&lt;b&gt;راز&lt;/b&gt;\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</h1>") || !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, "<img ") || !strings.Contains(htmlDoc, `src="media/`) {
t.Fatal("image is not inlined with a relative src")
}
if !strings.Contains(htmlDoc, "<audio controls") {
t.Fatal("voice has no native audio control")
}
if !strings.Contains(htmlDoc, "<a href=\"media/") {
t.Fatal("other file is not a relative link")
}
if !strings.Contains(htmlDoc, "۱ فروردین · ۱۵:۳۰") {
t.Fatal("jalali stamp missing")
}
if !strings.Contains(htmlDoc, "فایل همراه موجود نیست") || !strings.Contains(htmlDoc, "gone.wav") {
t.Fatal("missing file was not reported in the transcript")
}
code, ct, savedRaw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(studentID), student, nil)
if code != http.StatusOK || ct != "application/zip" || len(savedRaw) < 2 || savedRaw[0] != 'P' {
t.Fatalf("saved zip: %d %s", code, ct)
}
savedZip, err := zip.NewReader(bytes.NewReader(savedRaw), int64(len(savedRaw)))
if err != nil {
t.Fatal(err)
}
var savedHTML string
for _, f := range savedZip.File {
if strings.HasSuffix(f.Name, ".png") || strings.HasSuffix(f.Name, ".wav") || strings.HasSuffix(f.Name, ".txt") {
t.Fatalf("saved chat packed another conversation's media: %s", f.Name)
}
if f.Name != "index.html" {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
savedHTML = string(blob)
}
if !strings.Contains(savedHTML, "پیام‌های ذخیره‌شده") || !strings.Contains(savedHTML, "یادداشت خصوصی") {
t.Fatal("saved chat export is missing its own transcript")
}
if strings.Contains(savedHTML, "راز") || strings.Contains(savedHTML, "از منتور") {
t.Fatal("saved chat export included the mentor thread")
}
code, _, _ = e.doRaw(t, "GET", "/api/me/export?chat="+itoa(studentID), other, nil)
if code != http.StatusNotFound {
t.Fatalf("other user chat: %d", code)
}
code, _, otherRaw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(otherID), other, nil)
if code != http.StatusOK || len(otherRaw) < 2 || otherRaw[0] != 'P' {
t.Fatalf("other user own export failed: %d", code)
}
otherZip, err := zip.NewReader(bytes.NewReader(otherRaw), int64(len(otherRaw)))
if err != nil {
t.Fatal(err)
}
for _, f := range otherZip.File {
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
if bytes.Contains(blob, []byte("راز")) || bytes.Contains(blob, []byte("یادداشت خصوصی")) {
t.Fatalf("other user export leaked %s", f.Name)
}
}
code, mentorMeta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(studentID), mentor, nil)
if code != http.StatusOK || int64(mentorMeta["bytes"].(float64)) < floor {
t.Fatalf("mentor meta: %d %v", code, mentorMeta)
}
code, _ = e.do(t, "GET", "/api/me/export?chat=999999", student, nil)
if code != http.StatusNotFound {
t.Fatalf("missing partner: %d", code)
}
code, _, _ = e.doRaw(t, "GET", "/api/me/export?chat="+itoa(mentorID), "", nil)
if code != http.StatusUnauthorized {
t.Fatalf("anon: %d", code)
}
}
@@ -0,0 +1,27 @@
package api
import (
"testing"
"time"
)
func TestGregorianToJalaliKnownDates(t *testing.T) {
cases := []struct {
y, m, d int
jy, jm, jd int
}{
{2026, 3, 21, 1405, 1, 1},
{2026, 10, 10, 1405, 7, 18},
{2024, 3, 20, 1403, 1, 1},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.y, c.m, c.d)
if jy != c.jy || jm != c.jm || jd != c.jd {
t.Fatalf("%04d-%02d-%02d: got %d-%d-%d want %d-%d-%d", c.y, c.m, c.d, jy, jm, jd, c.jy, c.jm, c.jd)
}
}
stamp := formatJalaliStamp(time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC))
if stamp != "۱ فروردین · ۱۵:۳۰" {
t.Fatalf("stamp: %q", stamp)
}
}
+17 -1
View File
@@ -13,12 +13,28 @@ import (
"pargar/backend/internal/models"
)
// mediaSignWindow keeps one signature for a stretch of time. The same object
// then keeps the same URL, so clients do not reload a photo that did not change.
const mediaSignWindow = 15 * time.Minute
func stableMediaExpiry(now time.Time, ttl, window time.Duration) int64 {
if window <= 0 {
window = mediaSignWindow
}
if ttl < window {
ttl = window
}
w := int64(window.Seconds())
slot := now.Unix() / w
return (slot+1)*w + int64(ttl.Seconds())
}
func (s *Server) signMediaURL(key string) string {
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" {
return ""
}
exp := time.Now().Add(s.cfg.MediaURLTTL).Unix()
exp := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow)
sig := mediaHMAC(s.cfg.JWTSecret, key, exp)
return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s",
s.cfg.PublicURL,
+18
View File
@@ -7,6 +7,24 @@ import (
"pargar/backend/internal/config"
)
func TestStableMediaExpiryHoldsInsideWindow(t *testing.T) {
start := time.Unix((1_700_000_000/900)*900, 0)
ttl := time.Hour
window := 15 * time.Minute
a := stableMediaExpiry(start, ttl, window)
b := stableMediaExpiry(start.Add(14*time.Minute), ttl, window)
if a != b {
t.Fatalf("same window should keep one expiry: %d vs %d", a, b)
}
c := stableMediaExpiry(start.Add(15*time.Minute), ttl, window)
if c == a {
t.Fatal("next window should mint a new expiry")
}
if c < a {
t.Fatal("expiry should move forward")
}
}
func TestMediaSignatureRoundTrip(t *testing.T) {
s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}}
url := s.signMediaURL("videos/lesson-1.mp4")
+801
View File
@@ -0,0 +1,801 @@
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"runtime/debug"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"pargar/backend/internal/models"
"pargar/backend/internal/store"
)
var announcementCategories = map[string]bool{
"event": true, "progress": true, "gamification": true, "mentor": true,
"urgent": true, "curriculum": true, "community": true, "system": true,
}
func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) {
s.createAnnouncement(w, r)
}
func (s *Server) handleAdminListAnnouncements(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListAnnouncements(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری اطلاعیه‌ها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"announcements": announceViews(list)})
}
func (s *Server) handleAdminUpdateAnnouncement(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
a, ok := s.readAnnouncement(w, r)
if !ok {
return
}
a.ID = id
if err := s.store.UpdateAnnouncement(r.Context(), a); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "ویرایش اطلاعیه ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if err := s.store.DeleteAnnouncement(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "حذف اطلاعیه ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handlePinnedAnnouncement(w http.ResponseWriter, r *http.Request) {
list, err := s.store.RecentAnnouncements(r.Context(), 8)
var pinned any
if err == nil {
for _, a := range list {
if a.Pinned {
pinned = announceViews([]store.Announcement{a})[0]
break
}
}
}
if pinned == nil {
if a, perr := s.store.PinnedAnnouncement(r.Context()); perr == nil {
pinned = announceViews([]store.Announcement{*a})[0]
}
}
views := []map[string]any{}
if err == nil {
views = announceViews(list)
}
writeJSON(w, http.StatusOK, map[string]any{"announcement": pinned, "announcements": views})
}
func (s *Server) createAnnouncement(w http.ResponseWriter, r *http.Request) {
a, ok := s.readAnnouncement(w, r)
if !ok {
return
}
a.CreatedBy = currentUser(r).ID
if err := s.store.CreateAnnouncement(r.Context(), &a); err != nil {
writeErr(w, http.StatusInternalServerError, "ثبت اطلاعیه ممکن نشد")
return
}
ids, err := s.store.AllActiveUserIDs(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
return
}
go func() {
for _, uid := range ids {
n := &models.Notification{
UserID: uid, Category: a.Category, Type: "announcement",
Title: a.Title, Body: a.Body, Route: a.Route,
}
if err := s.store.CreateNotification(context.Background(), n); err == nil {
_ = s.store.AttachAnnouncement(context.Background(), n.ID, a.ID)
}
}
}()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": a.ID, "recipients": len(ids)})
}
func (s *Server) readAnnouncement(w http.ResponseWriter, r *http.Request) (store.Announcement, bool) {
var req struct {
Title string `json:"title"`
Body string `json:"body"`
Route string `json:"route"`
Category string `json:"category"`
Pinned bool `json:"pinned"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return store.Announcement{}, false
}
req.Title = strings.TrimSpace(req.Title)
req.Body = strings.TrimSpace(req.Body)
req.Route = strings.TrimSpace(req.Route)
if req.Category == "" {
req.Category = "event"
}
if !announcementCategories[req.Category] {
writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است")
return store.Announcement{}, false
}
if utf8.RuneCountInString(req.Title) < 3 || utf8.RuneCountInString(req.Title) > 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.1"
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.1+" + 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
+458
View File
@@ -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)
}
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"pargar/backend/internal/store"
)
func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id <= 0 {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
u = s.settleAccount(r.Context(), u)
s.signUserMedia(u)
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})
}
+8 -8
View File
@@ -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.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
)
// Public self-service password recovery is disabled for students.
// Staff reset passwords from the admin panel (requires the account security answer).
// Staff set a new password from the admin profile. Public recovery stays off.
func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است")
+36
View File
@@ -56,7 +56,9 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations))
mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages))
mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend))
mux.HandleFunc("PUT /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatEdit))
mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete))
mux.HandleFunc("GET /api/users/{id}", s.requireAuth(s.handlePublicProfile))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact))
mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin))
mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload)))
@@ -89,6 +91,7 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning))
// Account administration is admin-only; mentors may unlock locked students only.
mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers))
mux.HandleFunc("GET /api/admin/users/{id}", admin(s.handleAdminGetUser))
mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser))
mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser))
mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser))
@@ -98,6 +101,9 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword))
mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders))
mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder))
mux.HandleFunc("GET /api/admin/certificates/pending", staff(s.handleAdminListPendingCertificates))
mux.HandleFunc("POST /api/admin/users/{id}/certificate/issue", staff(s.handleAdminIssueCertificate))
mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate))
mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings))
mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings))
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
@@ -124,11 +130,13 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter))
mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter))
mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter))
mux.HandleFunc("POST /api/admin/chapters/{id}/publish", staff(s.handleAdminPublishChapter))
mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons))
mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson))
mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson))
mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson))
mux.HandleFunc("POST /api/admin/lessons/{id}/publish", staff(s.handleAdminPublishLesson))
mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions))
mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion))
mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion))
@@ -149,6 +157,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
}
+7
View File
@@ -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{
+86 -15
View File
@@ -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
}
}
+481
View File
@@ -0,0 +1,481 @@
package api_test
import (
"strings"
"testing"
)
func TestChatEditOwnMessage(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Chat Editor", "chat-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "سلام"})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": "سلام ویرایش"})
if code != 200 {
t.Fatalf("edit: %d %v", code, body)
}
edited := body["message"].(map[string]any)
if edited["body"] != "سلام ویرایش" || edited["editedAt"] == nil {
t.Fatalf("expected edited body, got %v", edited)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{"body": "نه"})
if code != 404 {
t.Fatalf("partner edit should be rejected: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": " "})
if code != 400 {
t.Fatalf("empty edit: %d", code)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("list: %d %v", code, body)
}
msgs := body["messages"].([]any)
last := msgs[len(msgs)-1].(map[string]any)
if last["body"] != "سلام ویرایش" || last["editedAt"] == nil {
t.Fatalf("listed message not edited: %v", last)
}
}
func TestChatReplaceAttachment(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Media Editor", "media-editor@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
code, up := uploadChatFile(t, e, student, "one.png", png)
if code != 201 {
t.Fatalf("upload: %d %v", code, up)
}
first := up["attachment"].(map[string]any)
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"body": "",
"attachment": first,
})
if code != 201 {
t.Fatalf("send: %d %v", code, body)
}
msg := body["message"].(map[string]any)
msgID := int64(msg["id"].(float64))
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
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": "کپشن صدا",
"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)
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 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": "کپشن صدا",
"replacements": []any{map[string]any{"index": 0, "attachment": wrong}},
})
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": "",
"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)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{
"body": "",
"attachment": next,
})
if code != 404 {
t.Fatalf("partner edit should be rejected: %d", code)
}
}
func TestChatReadMarksOnlyIncoming(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Seen Student", "seen-student@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "از هنرجو"})
if code != 201 {
t.Fatalf("student send: %d %v", code, body)
}
studentMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("student list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] != nil {
t.Fatalf("opening my own thread marked it seen: %v", chatMsg(t, body, studentMsg))
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list: %d %v", code, body)
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("partner open should mark the incoming message seen")
}
code, body = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", admin, map[string]any{"body": "از ادمین"})
if code != 201 {
t.Fatalf("admin send: %d %v", code, body)
}
adminMsg := int64(body["message"].(map[string]any)["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
if code != 200 {
t.Fatalf("admin list again: %d %v", code, body)
}
if chatMsg(t, body, adminMsg)["readAt"] != nil {
t.Fatalf("admin own message was marked seen: %v", chatMsg(t, body, adminMsg))
}
if chatMsg(t, body, studentMsg)["readAt"] == nil {
t.Fatalf("already seen incoming message lost its receipt")
}
}
func 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) {
msg := raw.(map[string]any)
if int64(msg["id"].(float64)) == id {
return msg
}
}
t.Fatalf("message %d missing in %v", id, body["messages"])
return nil
}
func mediaPath(raw any) string {
s, _ := raw.(string)
if i := strings.IndexByte(s, '?'); i >= 0 {
return s[:i]
}
return s
}
func TestLearningDetailMatchesActiveStudents(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 0 {
t.Fatalf("overview should ignore staff, got %v", body["stats"])
}
_, uid := register(t, e, "Active Student", "active-student@test.dev")
code, body = e.do(t, "GET", "/api/admin/stats", admin, nil)
if code != 200 {
t.Fatalf("stats after student: %d %v", code, body)
}
if body["stats"].(map[string]any)["users"].(float64) != 1 {
t.Fatalf("expected 1 active student, got %v", body["stats"])
}
code, body = e.do(t, "GET", "/api/admin/learning/overview", admin, nil)
if code != 200 {
t.Fatalf("overview: %d %v", code, body)
}
if body["overview"].(map[string]any)["studentsActive"].(float64) != 1 {
t.Fatalf("learning active mismatch: %v", body["overview"])
}
code, body = e.do(t, "GET", "/api/admin/users/"+itoa(uid)+"/learning", admin, nil)
if code != 200 {
t.Fatalf("learning detail: %d %v", code, body)
}
user := body["learning"].(map[string]any)["user"].(map[string]any)
if user["name"] != "Active Student" {
t.Fatalf("detail user: %v", user)
}
code, body = e.do(t, "GET", "/api/admin/users/999999/learning", admin, nil)
if code != 404 {
t.Fatalf("missing user: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/learning/students?page=1&pageSize=10", admin, nil)
if code != 200 {
t.Fatalf("students page: %d %v", code, body)
}
if body["total"].(float64) != 1 || len(body["items"].([]any)) != 1 {
t.Fatalf("expected one page row, got %v", body)
}
}
func TestInviteStatusFilter(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
code, body := e.do(t, "POST", "/api/admin/invites", admin, map[string]any{"maxUses": 2, "label": "مهر"})
if code != 201 {
t.Fatalf("create invite: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=active", admin, nil)
if code != 200 {
t.Fatalf("active filter: %d %v", code, body)
}
if body["total"].(float64) < 1 {
t.Fatalf("expected active invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=revoked", admin, nil)
if code != 200 {
t.Fatalf("revoked filter: %d %v", code, body)
}
if body["total"].(float64) != 0 {
t.Fatalf("expected no revoked invites, got %v", body)
}
code, body = e.do(t, "GET", "/api/admin/invites?status=nope", admin, nil)
if code != 400 {
t.Fatalf("bad status: %d %v", code, body)
}
}
func TestPublicProfileOmitsPrivateFields(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Public Name", "public-name@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != 200 {
t.Fatalf("me: %d %v", code, me)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "GET", "/api/users/"+itoa(adminID), student, nil)
if code != 200 {
t.Fatalf("profile: %d %v", code, body)
}
user := body["user"].(map[string]any)
if user["name"] == nil || user["name"] == "" {
t.Fatalf("missing name: %v", user)
}
if _, ok := user["email"]; ok {
t.Fatalf("email must not be public: %v", user)
}
if _, ok := user["phone"]; ok {
t.Fatalf("phone must not be public: %v", user)
}
code, adminView := e.do(t, "GET", "/api/admin/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("admin user: %d %v", code, adminView)
}
managed := adminView["user"].(map[string]any)
if managed["email"] != "public-name@test.dev" {
t.Fatalf("admin should see email: %v", managed)
}
if _, ok := managed["passwordHash"]; ok {
t.Fatalf("password hash must stay hidden: %v", managed)
}
code, hidden := e.do(t, "GET", "/api/admin/users/"+itoa(adminID), student, nil)
if code != 403 {
t.Fatalf("student must not open admin user: %d %v", code, hidden)
}
code, body = e.do(t, "GET", "/api/users/"+itoa(studentID), admin, nil)
if code != 200 {
t.Fatalf("student profile: %d %v", code, body)
}
if body["user"].(map[string]any)["username"] != "public-name" {
t.Fatalf("username: %v", body["user"])
}
}
+22 -2
View File
@@ -57,6 +57,23 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
}
}
// Compute progress aggregates using only active+published lessons as the denominator so
// that frontend and backend always agree on the percentage.
totalActive, completedActive := 0, 0
for _, l := range lessons {
if !l.IsActive || !l.IsPublished {
continue
}
totalActive++
if p, ok := progress[l.ID]; ok && p.PassedQuiz {
completedActive++
}
}
var progressPct float64
if totalActive > 0 {
progressPct = float64(completedActive) / float64(totalActive) * 100
}
out := make([]chapterView, 0, len(chapters))
for _, ch := range chapters {
cv := chapterView{Chapter: ch, Lessons: []lessonView{}}
@@ -84,7 +101,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"chapters": out,
"chapters": out,
"totalLessons": totalActive,
"completedLessons": completedActive,
"progressPct": progressPct,
"me": map[string]any{
"xp": u.XP,
"streak": u.StreakCurrent,
@@ -244,7 +264,7 @@ func (s *Server) allowLessonAccess(w http.ResponseWriter, r *http.Request, lesso
writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error())
return false
}
if !lesson.IsActive && !u.Role.IsStaff() {
if (!lesson.IsActive || !lesson.IsPublished) && !u.Role.IsStaff() {
writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد")
return false
}
+31 -3
View File
@@ -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)
}
}
}
+17
View File
@@ -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)
}
+192
View File
@@ -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)
}
}
}
@@ -0,0 +1,3 @@
-- Own-message edits. Nullable so existing rows stay unedited.
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
@@ -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);
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS attachments JSONB;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,26 @@
-- 0029_lesson_delete_cascades.sql
-- Make deleting a lesson (directly, or via its chapter) work cleanly.
--
-- Previously only mcq_questions cascaded; watch/quiz history blocked the delete
-- with a 500. Progress/history rows now go away with the lesson, while lessons
-- that merely *required* the deleted one are unlinked (never deleted).
ALTER TABLE watch_sessions DROP CONSTRAINT IF EXISTS watch_sessions_lesson_id_fkey;
ALTER TABLE watch_sessions ADD CONSTRAINT watch_sessions_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE watch_heartbeats DROP CONSTRAINT IF EXISTS watch_heartbeats_lesson_id_fkey;
ALTER TABLE watch_heartbeats ADD CONSTRAINT watch_heartbeats_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE lesson_progress DROP CONSTRAINT IF EXISTS lesson_progress_lesson_id_fkey;
ALTER TABLE lesson_progress ADD CONSTRAINT lesson_progress_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE quiz_attempts DROP CONSTRAINT IF EXISTS quiz_attempts_lesson_id_fkey;
ALTER TABLE quiz_attempts ADD CONSTRAINT quiz_attempts_lesson_id_fkey
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE;
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS lessons_requires_lesson_id_fkey;
ALTER TABLE lessons ADD CONSTRAINT lessons_requires_lesson_id_fkey
FOREIGN KEY (requires_lesson_id) REFERENCES lessons(id) ON DELETE SET NULL;
@@ -0,0 +1,9 @@
-- Add draft/published state to chapters and lessons.
-- Existing content is published immediately so nothing goes dark on deploy.
-- New lessons created via the admin API default to is_published = false (draft).
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE chapters ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
UPDATE lessons SET is_published = true;
UPDATE chapters SET is_published = true;
@@ -0,0 +1,13 @@
-- Add manual approval flow for certificates.
-- Existing certs keep status='issued'. New completions create status='pending'
-- until an admin or mentor explicitly issues the certificate.
ALTER TABLE certificates ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'issued';
ALTER TABLE certificates ALTER COLUMN public_id DROP NOT NULL;
UPDATE certificates SET status = 'issued';
INSERT INTO app_settings (key, value) VALUES
('certificate_enabled', 'true'),
('certificate_requires_approval', 'true')
ON CONFLICT (key) DO NOTHING;
+100 -39
View File
@@ -21,41 +21,97 @@ 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 {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
PublicID string `json:"publicId"`
PublicID string `json:"publicId,omitempty"`
FullName string `json:"fullName"`
IssuedAt time.Time `json:"issuedAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
Status string `json:"status"`
}
type PhysicalOrderStatus string
@@ -109,6 +165,7 @@ type Chapter struct {
Description string `json:"description"`
Icon string `json:"icon"`
SortOrder int `json:"sortOrder"`
IsPublished bool `json:"isPublished"`
}
type Lesson struct {
@@ -125,6 +182,7 @@ type Lesson struct {
SortOrder int `json:"sortOrder"`
XPReward int `json:"xpReward"`
IsActive bool `json:"isActive"`
IsPublished bool `json:"isPublished"`
}
type MCQQuestion struct {
@@ -209,18 +267,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"`
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 {
@@ -77,10 +77,12 @@ func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error,
if err != nil {
return nil, err
}
// Schemaless: a non-empty but different schema URL here (from an older
// semconv pin) makes resource.Merge fail as soon as the SDK's
// resource.Default() schema URL changes across otel upgrades.
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
resource.NewSchemaless(
semconv.ServiceName(name),
),
)
+50 -6
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
package storage
import "testing"
func TestVoiceNoteContentType(t *testing.T) {
if got := mimeType("chats/2-1.weba"); got != "audio/webm" {
t.Fatalf("weba: %s", got)
}
if got := mediaContentType("chats/2-1.weba", "application/octet-stream"); got != "audio/webm" {
t.Fatalf("generic store type must not hide the voice type: %s", got)
}
if got := mediaContentType("chats/2-1.bin", "application/pdf"); got != "application/pdf" {
t.Fatalf("unknown extension keeps the stored type: %s", got)
}
}
+33 -3
View File
@@ -243,10 +243,11 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
}
defer result.Body.Close()
ct := mimeType(key)
if result.ContentType != nil && *result.ContentType != "" {
ct = *result.ContentType
stored := ""
if result.ContentType != nil {
stored = *result.ContentType
}
ct := mediaContentType(key, stored)
w.Header().Set("Content-Type", ct)
setInlineMediaHeaders(w, ct)
w.Header().Set("Accept-Ranges", "bytes")
@@ -290,8 +291,20 @@ func mimeType(key string) string {
return "video/mp4"
case ".webm":
return "video/webm"
case ".weba":
return "audio/webm"
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".ogg", ".opus":
return "audio/ogg"
case ".m4a", ".aac":
return "audio/mp4"
case ".mov":
return "video/quicktime"
case ".m4v":
return "video/mp4"
case ".pdf":
return "application/pdf"
default:
@@ -299,6 +312,23 @@ func mimeType(key string) string {
}
}
// mediaContentType keeps a known extension ahead of a generic object-store type.
// Voice notes are stored as .weba; a default octet-stream plus nosniff stops playback.
func mediaContentType(key, stored string) string {
guessed := mimeType(key)
if guessed != "application/octet-stream" {
return guessed
}
stored = strings.TrimSpace(stored)
if i := strings.Index(stored, ";"); i >= 0 {
stored = strings.TrimSpace(stored[:i])
}
if stored != "" {
return stored
}
return guessed
}
// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact.
func setInlineMediaHeaders(w http.ResponseWriter, contentType string) {
if contentType != "" {
+6 -3
View File
@@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite(
return scanInvite(row)
}
func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
func (s *Store) ListRegistrationInvites(ctx context.Context, query, status string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
if limit <= 0 {
limit = 10
}
@@ -157,18 +157,21 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit
offset = 0
}
query = strings.TrimSpace(query)
status = strings.TrimSpace(status)
var total int64
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)`, query, status).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT `+inviteCols+`
FROM registration_invites
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
AND ($2 = '' OR status = $2)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, query, limit, offset)
LIMIT $3 OFFSET $4`, query, status, limit, offset)
if err != nil {
return nil, 0, err
}
+85 -58
View File
@@ -7,17 +7,17 @@ import (
// LearningOverview is cohort-level learning analytics for staff.
type LearningOverview struct {
StudentsActive int64 `json:"studentsActive"`
StudentsLocked int64 `json:"studentsLocked"`
StudentsTotal int64 `json:"studentsTotal"`
LessonsTotal int64 `json:"lessonsTotal"`
Completions int64 `json:"completions"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
AvgWatchPct float64 `json:"avgWatchPct"`
XPAwarded7d int64 `json:"xpAwarded7d"`
StudentsStreaking int64 `json:"studentsStreaking"`
StudentsActive int64 `json:"studentsActive"`
StudentsLocked int64 `json:"studentsLocked"`
StudentsTotal int64 `json:"studentsTotal"`
LessonsTotal int64 `json:"lessonsTotal"`
Completions int64 `json:"completions"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
AvgWatchPct float64 `json:"avgWatchPct"`
XPAwarded7d int64 `json:"xpAwarded7d"`
StudentsStreaking int64 `json:"studentsStreaking"`
}
func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) {
@@ -81,10 +81,40 @@ type StudentLearningRow struct {
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
}
func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, error) {
func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string, limit, offset int) ([]StudentLearningRow, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
var lessonsTotal int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
return nil, err
return nil, 0, err
}
where := `u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')`
switch filter {
case "active":
where += ` AND u.is_active AND NOT u.is_locked`
case "locked":
where += ` AND u.is_locked`
}
order := `u.xp DESC, u.id ASC`
switch sort {
case "progress":
order = `COALESCE(p.passed, 0) DESC, u.id ASC`
case "streak":
order = `u.streak_current DESC, u.id ASC`
case "activity":
order = `u.last_activity_date DESC NULLS LAST, u.id ASC`
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users u WHERE `+where, q).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
@@ -105,12 +135,11 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
WHERE created_at >= now() - interval '7 days'
GROUP BY user_id
) a ON a.user_id = u.id
WHERE u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')
ORDER BY u.xp DESC, u.id ASC
LIMIT 500`, q)
WHERE `+where+`
ORDER BY `+order+`
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, err
return nil, 0, err
}
defer rows.Close()
out := make([]StudentLearningRow, 0)
@@ -122,7 +151,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
); err != nil {
return nil, err
return nil, 0, err
}
if r.LessonsTotal > 0 {
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
@@ -132,21 +161,21 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
}
out = append(out, r)
}
return out, rows.Err()
return out, total, rows.Err()
}
type LessonLearningDetail struct {
LessonID int64 `json:"lessonId"`
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
Title string `json:"title"`
SortOrder int `json:"sortOrder"`
WatchedPct float64 `json:"watchedPct"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
LessonID int64 `json:"lessonId"`
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
Title string `json:"title"`
SortOrder int `json:"sortOrder"`
WatchedPct float64 `json:"watchedPct"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
}
type AttemptLearningDetail struct {
@@ -178,11 +207,11 @@ type WatchSessionDetail struct {
}
type UserLearningDetail struct {
User StudentLearningRow `json:"user"`
Lessons []LessonLearningDetail `json:"lessons"`
Attempts []AttemptLearningDetail `json:"attempts"`
XPEvents []XPEventDetail `json:"xpEvents"`
Sessions []WatchSessionDetail `json:"sessions"`
User StudentLearningRow `json:"user"`
Lessons []LessonLearningDetail `json:"lessons"`
Attempts []AttemptLearningDetail `json:"attempts"`
XPEvents []XPEventDetail `json:"xpEvents"`
Sessions []WatchSessionDetail `json:"sessions"`
}
func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) {
@@ -190,30 +219,28 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
if err != nil {
return nil, err
}
list, err := s.ListStudentLearning(ctx, "")
if err != nil {
row := StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal); err != nil {
return nil, err
}
var row StudentLearningRow
found := false
for _, r := range list {
if r.ID == userID {
row = r
found = true
break
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed); err != nil {
return nil, err
}
if !found {
row = StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
}
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal)
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed)
if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
}
if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
}
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*), COUNT(*) FILTER (WHERE status='passed')
FROM quiz_attempts
WHERE user_id=$1 AND created_at >= now() - interval '7 days'`, userID).Scan(&row.Attempts7d, &row.Passes7d); err != nil {
return nil, err
}
if row.Attempts7d > 0 {
row.PassRate7d = float64(row.Passes7d) / float64(row.Attempts7d) * 100
}
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
@@ -221,7 +248,7 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
lrows, err := s.pool.Query(ctx, `
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
COALESCE(la.status, ''), COALESCE(la.score_pct, 0)
COALESCE(la.status::text, ''), COALESCE(la.score_pct, 0)
FROM lessons l
JOIN chapters c ON c.id = l.chapter_id
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
+1 -1
View File
@@ -28,7 +28,7 @@ func TestLearningOverviewSmoke(t *testing.T) {
if o == nil {
t.Fatal("nil overview")
}
students, err := st.ListStudentLearning(ctx, "")
students, _, err := st.ListStudentLearning(ctx, "", "", "xp", 10, 0)
if err != nil {
t.Fatal(err)
}
+277
View File
@@ -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
}
+522 -77
View File
@@ -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(&current, &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 == "" {
@@ -406,7 +618,7 @@ func IsUniqueViolation(err error) bool {
func (s *Store) CountActiveLessons(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active`).Scan(&n)
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active AND is_published`).Scan(&n)
return n, err
}
@@ -415,11 +627,16 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM lesson_progress lp
JOIN lessons l ON l.id = lp.lesson_id
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active`, userID).Scan(&n)
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active AND l.is_published`, userID).Scan(&n)
return n, err
}
func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
// An issued (non-revoked) certificate is permanent proof of completion regardless
// of subsequent curriculum changes (e.g. new lessons added after the student finished).
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil {
return true, nil
}
active, err := s.CountActiveLessons(ctx)
if err != nil {
return false, err
@@ -436,42 +653,106 @@ func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool,
func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
FROM certificates WHERE user_id=$1`, userID)
return scanCertificate(row)
}
func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
FROM certificates WHERE public_id=$1`, publicID)
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
FROM certificates WHERE public_id=$1 AND status='issued'`, publicID)
return scanCertificate(row)
}
func scanCertificate(row rowScanner) (*models.Certificate, error) {
var c models.Certificate
err := row.Scan(&c.ID, &c.UserID, &c.PublicID, &c.FullName, &c.IssuedAt, &c.RevokedAt)
var publicID *string
err := row.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if publicID != nil {
c.PublicID = *publicID
}
return &c, nil
}
func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
INSERT INTO certificates (user_id, public_id, full_name)
VALUES ($1, $2, $3)
INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, $2, $3, 'issued')
ON CONFLICT (user_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
public_id = EXCLUDED.public_id,
status = 'issued',
revoked_at = NULL
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at`,
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
userID, publicID, fullName)
return scanCertificate(row)
}
func (s *Store) CreatePendingCertificate(ctx context.Context, userID int64, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, NULL, $2, 'pending')
ON CONFLICT (user_id) DO NOTHING
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
userID, fullName)
cert, err := scanCertificate(row)
if errors.Is(err, ErrNotFound) {
return s.GetCertificateByUser(ctx, userID)
}
return cert, err
}
func (s *Store) IssuePendingCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
UPDATE certificates SET
public_id = $1,
full_name = $2,
status = 'issued',
revoked_at = NULL,
issued_at = NOW()
WHERE user_id = $3
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
publicID, fullName, userID)
return scanCertificate(row)
}
func (s *Store) RevokeCertificate(ctx context.Context, userID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE certificates SET revoked_at=NOW() WHERE user_id=$1`, userID)
return err
}
func (s *Store) ListCertificatesByStatus(ctx context.Context, status string) ([]models.Certificate, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.user_id, c.public_id, c.full_name, c.issued_at, c.revoked_at, c.status
FROM certificates c
WHERE c.status=$1 AND c.revoked_at IS NULL
ORDER BY c.issued_at DESC`, status)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Certificate
for rows.Next() {
var c models.Certificate
var publicID *string
if err := rows.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status); err != nil {
return nil, err
}
if publicID != nil {
c.PublicID = *publicID
}
out = append(out, c)
}
return out, rows.Err()
}
const physicalOrderCols = `id, certificate_id, user_id, status, note,
recipient_name, phone, address, city, postal_code, tracking_code,
window_ends_at, created_at, updated_at`
@@ -504,7 +785,26 @@ func (s *Store) GetPhysicalOrder(ctx context.Context, id int64) (*models.Certifi
return scanPhysicalOrder(row)
}
func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models.CertificatePhysicalOrder, error) {
func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, offset int) ([]models.CertificatePhysicalOrder, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
where := ""
args := []any{}
if status != "" {
where = ` WHERE o.status=$1`
args = append(args, status)
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM certificate_physical_orders o`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
@@ -512,16 +812,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
u.name, c.public_id
FROM certificate_physical_orders o
JOIN users u ON u.id = o.user_id
JOIN certificates c ON c.id = o.certificate_id`
args := []any{}
if status != "" {
q += ` WHERE o.status=$1`
args = append(args, status)
}
q += ` ORDER BY o.created_at DESC LIMIT 200`
JOIN certificates c ON c.id = o.certificate_id` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
args = append(args, limit, offset)
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
return nil, 0, err
}
defer rows.Close()
out := make([]models.CertificatePhysicalOrder, 0)
@@ -530,11 +825,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note,
&o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode,
&o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil {
return nil, err
return nil, 0, err
}
out = append(out, o)
}
return out, rows.Err()
return out, total, rows.Err()
}
func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) {
@@ -653,16 +948,22 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
// ---- Chapters / Lessons / Questions ----
func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) {
c.IsPublished = false
err := s.pool.QueryRow(ctx, `
INSERT INTO chapters (title, description, icon, sort_order) VALUES ($1,$2,$3,$4) RETURNING id`,
c.Title, c.Description, c.Icon, c.SortOrder).Scan(&c.ID)
INSERT INTO chapters (title, description, icon, sort_order, is_published) VALUES ($1,$2,$3,$4,$5) RETURNING id`,
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished).Scan(&c.ID)
return c.ID, err
}
func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error {
_, err := s.pool.Exec(ctx, `
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4 WHERE id=$5`,
c.Title, c.Description, c.Icon, c.SortOrder, id)
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4, is_published=$5 WHERE id=$6`,
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished, id)
return err
}
func (s *Store) PublishChapter(ctx context.Context, id int64, publish bool) error {
_, err := s.pool.Exec(ctx, `UPDATE chapters SET is_published=$1 WHERE id=$2`, publish, id)
return err
}
@@ -672,7 +973,7 @@ func (s *Store) DeleteChapter(ctx context.Context, id int64) error {
}
func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order FROM chapters ORDER BY sort_order, id`)
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order, is_published FROM chapters ORDER BY sort_order, id`)
if err != nil {
return nil, err
}
@@ -680,7 +981,7 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
var out []models.Chapter
for rows.Next() {
var c models.Chapter
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder); err != nil {
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder, &c.IsPublished); err != nil {
return nil, err
}
out = append(out, c)
@@ -689,22 +990,28 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
}
func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) {
l.IsPublished = false
err := s.pool.QueryRow(ctx, `
INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id`,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive).Scan(&l.ID)
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished).Scan(&l.ID)
return l.ID, err
}
func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error {
_, err := s.pool.Exec(ctx, `
UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5,
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12
WHERE id=$13`,
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12, is_published=$13
WHERE id=$14`,
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, id)
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished, id)
return err
}
func (s *Store) PublishLesson(ctx context.Context, id int64, publish bool) error {
_, err := s.pool.Exec(ctx, `UPDATE lessons SET is_published=$1 WHERE id=$2`, publish, id)
return err
}
@@ -716,11 +1023,11 @@ func (s *Store) DeleteLesson(ctx context.Context, id int64) error {
func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published
FROM lessons WHERE id=$1`, id)
var l models.Lesson
err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive)
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -729,9 +1036,9 @@ func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error)
func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) {
q := `SELECT id, chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active FROM lessons`
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons`
if !includeInactive {
q += ` WHERE is_active`
q += ` WHERE is_active AND is_published`
}
q += ` ORDER BY chapter_id, sort_order, id`
rows, err := s.pool.Query(ctx, q)
@@ -743,7 +1050,7 @@ func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models
for rows.Next() {
var l models.Lesson
if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive); err != nil {
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished); err != nil {
return nil, err
}
out = append(out, l)
@@ -1058,29 +1365,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`,
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()
@@ -1091,11 +1417,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`,
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)
}
@@ -1138,6 +1464,103 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in
return tag.RowsAffected() > 0, nil
}
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
// The new type must match the current attachment.
func (s *Store) ReplaceChatAttachment(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body, attType, attURL, attName string, attSize int64) (*models.ChatMessage, error) {
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,
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, 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
}
return m, err
}
// EditChatMessage replaces the body of one of my own messages in this conversation.
func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string) (*models.ChatMessage, error) {
row := s.pool.QueryRow(ctx, `
UPDATE chat_messages
SET body=$1, edited_at=now()
WHERE id=$2
AND ((user_id=$3 AND mentor_id=$4) OR (user_id=$4 AND mentor_id=$3))
AND sender_role=$5
AND (user_id=$3 OR mentor_id=$3)
AND ($1 <> '' OR attachment_url IS NOT NULL)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`,
body, msgID, meID, partnerID, meRole.String())
m, err := scanMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
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,
@@ -1160,7 +1583,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, msgID)
return scanMessage(out)
}
var n int
@@ -1188,7 +1611,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
out := s.pool.QueryRow(ctx, `
UPDATE chat_messages SET pinned_at=now() WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments`, msgID)
return scanMessage(out)
}
@@ -1196,7 +1619,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, 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))
@@ -1222,7 +1645,7 @@ func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int
func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, 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)
@@ -1270,7 +1693,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
older, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id <= $3
@@ -1302,7 +1725,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
newer, err := s.pool.Query(ctx, `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id > $3
@@ -1343,14 +1766,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,
&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),
}
@@ -1372,11 +1808,20 @@ func derefInt(p *int64) int64 {
return *p
}
func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`,
userID, mentorID)
return err
// MarkChatRead stamps messages the other person sent. Opening the thread must not
// mark my own messages as seen.
func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE chat_messages
SET read_at=now()
WHERE read_at IS NULL
AND sender_role <> $3
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`,
meID, partnerID, meRole.String())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// MessageInConversation reports whether a message belongs to a given pair.
@@ -1743,7 +2188,7 @@ func (s *Store) AllActiveUserIDs(ctx context.Context) ([]int64, error) {
func (s *Store) Stats(ctx context.Context) (map[string]any, error) {
var users, lessons, questions, events, completions int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users); err != nil {
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='student' AND is_active`).Scan(&users); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil {
+3
View File
@@ -63,6 +63,9 @@ services:
- --entrypoints.web.http.redirections.entrypoint.permanent=true
- --entrypoints.web.http.aliasHeadersStrategy=delete
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
# Dev: Traefik default certificate (browser warning expected on localhost).
- --api.dashboard=true
- --api.insecure=true
+3
View File
@@ -71,6 +71,9 @@ services:
- --entrypoints.web.http.redirections.entrypoint.permanent=true
- --entrypoints.web.http.aliasHeadersStrategy=delete
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL must be set}
- --certificatesresolvers.le.acme.storage=/data/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: pargar
description: Pargar gated LMS - Go API, Next.js frontend, Postgres, Redis, S3-compatible storage
type: application
version: 0.2.0
appVersion: "0.1.0"
version: 0.2.3
appVersion: "0.2.1"
keywords:
- lms
- pargar
+81
View File
@@ -0,0 +1,81 @@
backend:
autoscaling:
enabled: false
env:
devMode: "false"
donationEnabled: "true"
otelEndpoint: http://tempo.infra.svc.cluster.local:4318
physicalCertEnabled: "false"
seedCurriculum: "false"
existingSecret: pargar-app
image:
pullPolicy: IfNotPresent
repository: pargar-backend
tag: 0.2.1
metrics:
serviceMonitor:
enabled: true
interval: 30s
podDisruptionBudget:
enabled: true
minAvailable: 1
replicaCount: 3
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
backup:
enabled: true
persistence:
size: 2Gi
retentionDays: 7
schedule: 0 3 * * *
certManager:
enabled: false
frontend:
autoscaling:
enabled: false
image:
pullPolicy: IfNotPresent
repository: pargar-frontend
tag: 0.2.1
podDisruptionBudget:
enabled: false
replicaCount: 2
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
global:
domain: pargar.panahifar.ir
frontendOrigins: https://pargar.panahifar.ir
imageRegistry: ""
publicUrl: https://pargar.panahifar.ir
imagePullSecrets: []
ingress:
enabled: true
tls:
enabled: true
existingSecret: pargar-wildcard-tls
useIngressRoute: true
networkPolicy:
enabled: false
postgres:
enabled: true
persistence:
size: 10Gi
redis:
enabled: true
persistence:
size: 2Gi
rustfs:
consoleEnable: false
enabled: true
persistence:
size: 20Gi
+2 -2
View File
@@ -22,7 +22,7 @@ backend:
replicaCount: 2
image:
repository: backend
tag: "0.1.0"
tag: "0.2.1"
pullPolicy: IfNotPresent
existingSecret: pargar-app
env:
@@ -49,7 +49,7 @@ frontend:
replicaCount: 2
image:
repository: frontend
tag: "0.1.0"
tag: "0.2.1"
pullPolicy: IfNotPresent
resources:
requests:
+43
View File
@@ -0,0 +1,43 @@
# k3s Traefik customisation (HelmChartConfig for the built-in `traefik` chart).
#
# Apply with: kubectl apply -f deploy/k3s/traefik-helmchartconfig.yaml
#
# Why: Traefik's entrypoint `readTimeout` defaults to 60s and covers reading the
# *entire request including the body*. Admin video uploads (up to 200MB) that take
# longer than 60s were cut off with a 502 before reaching the backend. Raise it to
# 1 hour (use 0 to disable entirely).
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
failurePolicy: reinstall
valuesContent: |-
# ACCESSLOG tile -> ON; access logs go to container stdout (kubectl logs).
# Key is logs.access.enabled on the chart k3s ships (40.1.5);
# the top-level accessLog.enabled only exists on newer chart versions.
logs:
access:
enabled: true
# Allow IngressRoutes to reference Services living in other namespaces,
# e.g. argocd-server in `argocd`.
providers:
kubernetesCRD:
allowCrossNamespace: true
deployment:
podAnnotations:
# fix: metrics entrypoint listens on :9100, annotation said 8082
prometheus.io/port: "9100"
prometheus.io/scrape: "true"
# Allow slow, large uploads (admin video upload ≤ 200MB) to finish.
# Default is 60s; 3600s = 1h. Set to 0 to disable the cap.
ports:
web:
transport:
respondingTimeouts:
readTimeout: 3600
websecure:
transport:
respondingTimeouts:
readTimeout: 3600
+1004 -192
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -20,20 +20,28 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@tanstack/react-query": "^5.62.0",
"@tiptap/extension-placeholder": "^3.32.0",
"@tiptap/react": "^3.32.0",
"@tiptap/starter-kit": "^3.32.0",
"@types/dompurify": "^3.0.5",
"boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.17",
"emoji-datasource-apple": "^15.1.2",
"framer-motion": "^12.0.0",
"hls.js": "^1.7.3",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"marked": "^18.1.1",
"next": "15.5.27",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-multi-date-picker": "^4.5.2",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.2"
},
@@ -44,5 +52,10 @@
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
},
"overrides": {
"postcss": "8.5.28",
"sharp": "0.35.5",
"source-map-js": "1.2.2"
}
}
+115
View File
@@ -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 (
<div className="mx-auto max-w-3xl space-y-4">
<header className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
<Link href="/admin?tab=bots" className="underline-offset-4 hover:underline">
ربات
</Link>
</p>
<h1 className="text-2xl font-black tracking-tight">راهنمای ربات</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
با یک کلید API می‌توانید از بیرون پرگار به هنرجو پیام بفرستید و جواب‌های او را بخوانید.
</p>
</header>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">ساخت کلید</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
در مدیریت، بخش ربات، یک نام بگذارید و «ساخت ربات» را بزنید. پاسخ ساخت شامل فیلد <Code>token</Code> است.
توکن فقط یک بار نشان داده می‌شود. بعد از بستن یا بارگذاری دوبارهٔ صفحه دیگر برنمی‌گردد؛ سرور فقط هش آن را نگه می‌دارد.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
هر ردیف نام، زمان ساخت، وضعیت و تعداد درخواست‌های تأییدشده را نشان می‌دهد. با «توقف» کلید متوقف می‌شود و با «فعال‌کردن» دوباره کار می‌کند.
</p>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">احراز هویت</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
هر دو مسیر زیر هدر <Code>X-Bot-Token</Code> می‌خواهند. توکن خالی، ناشناس یا متوقف با وضعیت ۴۰۱ و پیام «توکن ربات نامعتبر است» رد می‌شود.
درخواست ناموفق در شمارنده حساب نمی‌شود. هر فراخوانی که توکن فعالش پذیرفته شود، یک واحد به شمار درخواست همان کلید اضافه می‌کند.
</p>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">ارسال پیام</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
<Code>POST /api/bot/send</Code> پیامی از طرف ربات در گفتگوی هنرجو می‌سازد. بدنه JSON:
</p>
<ul className="list-disc space-y-1 pe-5 text-sm leading-relaxed text-muted-foreground">
<li>
<Code>userId</Code> شناسهٔ عددی هنرجو. لازم است.
</li>
<li>
<Code>text</Code> متن پیام. لازم است و نباید خالی باشد.
</li>
<li>
<Code>buttons</Code> آرایهٔ دکمه‌ها. هر عضو شیئی با فیلد <Code>text</Code> است. در گفتگو، زدن دکمه همان متن را به عنوان پیام هنرجو برای ربات می‌فرستد.
</li>
</ul>
<p className="text-sm leading-relaxed text-muted-foreground">
پاسخ موفق: <Code>{`{ "messageId": 123 }`}</Code>. اگر <Code>userId</Code> یا <Code>text</Code> نباشد، وضعیت ۴۰۰ و پیام «userId و text لازم است».
</p>
<Example
title="نمونه"
code={`curl -X POST /api/bot/send \\
-H 'Content-Type: application/json' \\
-H 'X-Bot-Token: YOUR_TOKEN' \\
-d '{"userId":12,"text":"سلام","buttons":[{"text":"باشه"}]}'`}
/>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">خواندن پاسخ‌ها</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
<Code>GET /api/bot/updates</Code> صف پاسخ‌هایی را برمی‌گرداند که هنرجو برای این ربات فرستاده است؛ چه متن آزاد، چه متن یک دکمه.
پارامتر <Code>offset</Code> آخرین شناسه‌ای است که قبلاً دیده‌اید. فقط ردیف‌های با <Code>id</Code> بزرگ‌تر برمی‌گردند، حداکثر ۵۰ تا. اگر نفرستید، از صفر شروع می‌شود.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
پاسخ: <Code>{`{ "updates": [ { "id": 1, "payload": { "from": 12, "name": "نام", "text": "جواب" }, "createdAt": "..." } ] }`}</Code>.
<Code>from</Code> شناسهٔ فرستنده است، <Code>name</Code> نام او و <Code>text</Code> متن پیام. اگر چیزی نباشد، <Code>updates</Code> آرایهٔ خالی است.
</p>
</section>
</div>
);
}
function Code({ children }: { children: string }) {
return (
<code dir="ltr" className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.8em]">
{children}
</code>
);
}
function Example({ title, code }: { title: string; code: string }) {
return (
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">{title}</p>
<pre dir="ltr" className="overflow-x-auto rounded-2xl border border-border bg-muted/40 p-3 text-left font-mono text-xs leading-relaxed">
{code}
</pre>
</div>
);
}
+17 -163
View File
@@ -1,45 +1,23 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { Loader2, Package } from "lucide-react";
import { Loader2 } from "lucide-react";
import { CertificateCard } from "@/components/certificate-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types";
import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils";
import { clampPhoneInput, normalizePhone, PHONE_INPUT_MAX_LEN } from "@/lib/validation";
import { useEffect, useState } from "react";
import type { Certificate } from "@/lib/types";
type PublicCertResponse = {
certificate: { publicId: string; fullName: string; issuedAt: string };
course: string;
};
const STATUS_LABEL: Record<string, string> = {
requested: "درخواست‌شده — منتظر تأیید پرداخت",
paid: "پرداخت تأیید شد — در صف ارسال",
shipped: "ارسال شد",
cancelled: "لغو شده",
};
export default function CertificatePage() {
const params = useParams<{ id: string }>();
const publicId = params?.id ?? "";
const authUser = useAuth((s) => s.user);
const qc = useQueryClient();
const [requesting, setRequesting] = useState(false);
const [form, setForm] = useState({
recipientName: "",
phone: "",
address: "",
city: "",
postalCode: "",
});
const { data, isLoading, error } = useQuery({
queryKey: ["certificate", publicId],
@@ -50,42 +28,12 @@ export default function CertificatePage() {
const { data: mine } = useQuery({
queryKey: ["me", "certificate"],
queryFn: () =>
http.get<{
certificate: Certificate | null;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
}>("/api/me/certificate"),
queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
enabled: !!authUser,
});
const isOwner = !!authUser && mine?.certificate?.publicId === publicId;
useEffect(() => {
if (!isOwner || !authUser) return;
setForm((f) => ({
...f,
recipientName: f.recipientName || authUser.name || "",
phone: f.phone || authUser.phone || "",
}));
}, [isOwner, authUser]);
const requestPhysical = async () => {
setRequesting(true);
try {
await http.post("/api/me/certificate/physical", {
...form,
phone: normalizePhone(form.phone),
});
toast.success("درخواست ثبت شد — پس از واریز با ادمین هماهنگ کنید");
qc.invalidateQueries({ queryKey: ["me", "certificate"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setRequesting(false);
}
};
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
@@ -96,27 +44,20 @@ export default function CertificatePage() {
if (error || !data?.certificate) {
return (
<div className="mx-auto max-w-lg px-4 py-16 text-center">
<p className="text-lg font-bold">گواهینامه پیدا نشد</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/">بازگشت</Link>
</Button>
</div>
<NotFoundState
title="گواهینامه پیدا نشد"
description={notFoundSentence(
error ? toUserError(error, "") : "",
"این گواهینامه در سامانه نیست.",
"گواهینامه پیدا نشد",
)}
href="/"
actionLabel="بازگشت به خانه"
/>
);
}
const cert = data.certificate;
const order = isOwner ? mine?.physicalOrder : null;
const physical = isOwner ? mine?.physical : null;
const windowEndsAt = mine?.certificate?.issuedAt
? new Date(mine.certificate.issuedAt).getTime() + (physical?.windowDays ?? 30) * 86400000
: 0;
const windowStillOpen = !windowEndsAt || Date.now() < windowEndsAt;
const windowOpen =
physical?.enabled &&
windowStillOpen &&
(!order || order.status === "cancelled") &&
!!mine?.certificate;
return (
<CertificateCard
@@ -126,94 +67,7 @@ export default function CertificatePage() {
issuedAt: cert.issuedAt,
course: data.course,
}}
showOwnerLinks={!!authUser}
>
{isOwner && physical?.enabled && (
<div className="mt-8 space-y-3 border-t border-border pt-6">
<p className="flex items-center gap-2 text-sm font-bold">
<Package className="h-4 w-4" /> نسخه فیزیکی
</p>
<p className="text-xs text-muted-foreground">
قیمت حدودی {formatFaNumber(physical.priceIrr)} ریال · مهلت درخواست{" "}
{formatFaNumber(physical.windowDays)} روز از صدور
{windowEndsAt ? ` (تا ${new Date(windowEndsAt).toLocaleDateString("fa-IR")})` : ""}.
پرداخت آنلاین در سامانه نداریم. پس از واریز بیرونی با ادمین هماهنگ کنید تا وضعیت سفارش به‌روز شود.
</p>
{order && order.status !== "cancelled" ? (
<div className="space-y-2 rounded-xl border border-border bg-muted/30 p-3 text-sm">
<p>
وضعیت: <span className="font-medium">{STATUS_LABEL[order.status] ?? order.status}</span>
</p>
{order.trackingCode && (
<p className="font-mono text-xs" dir="ltr">
کد پیگیری: {order.trackingCode}
</p>
)}
{order.city && (
<p className="text-xs text-muted-foreground">
ارسال به {order.recipientName || "—"} · {order.city}
</p>
)}
{order.note && <p className="text-xs text-muted-foreground">یادداشت ادمین: {order.note}</p>}
<Button
size="sm"
variant="outline"
onClick={() =>
window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true } }))
}
>
هماهنگی با تیم
</Button>
</div>
) : windowOpen ? (
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="نام گیرنده"
value={form.recipientName}
onChange={(e) => setForm((f) => ({ ...f, recipientName: e.target.value }))}
/>
<Input
placeholder="موبایل"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: clampPhoneInput(e.target.value) }))}
dir="ltr"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
className="text-left"
/>
<Input
placeholder="شهر"
value={form.city}
onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))}
/>
<Input
placeholder="کد پستی"
value={form.postalCode}
onChange={(e) => setForm((f) => ({ ...f, postalCode: e.target.value }))}
dir="ltr"
/>
</div>
<textarea
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="آدرس کامل پستی"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
<Button
size="sm"
disabled={requesting || !form.address.trim() || !form.city.trim()}
onClick={() => void requestPhysical()}
>
{requesting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
ثبت درخواست نسخه فیزیکی
</Button>
</div>
) : !windowStillOpen ? (
<p className="text-sm text-muted-foreground">مهلت درخواست نسخه فیزیکی به پایان رسیده است.</p>
) : null}
</div>
)}
</CertificateCard>
showOwnerLinks={isOwner}
/>
);
}
+267 -46
View File
@@ -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,
@@ -13,7 +13,6 @@ import {
Heart,
Lock,
MessageSquare,
Package,
PlayCircle,
Sparkles,
TrendingUp,
@@ -22,12 +21,13 @@ import {
import { SkillTree } from "@/components/skill-tree";
import { Skeleton } from "@/components/ui/skeleton";
import { http, toUserError } from "@/lib/api";
import type { Certificate, CertificatePhysicalOrder, MeetingEvent, PhysicalCertSettings, TreeData, TreeLesson } from "@/lib/types";
import type { Certificate, MeetingEvent, TreeData, TreeLesson } from "@/lib/types";
import { Button } from "@/components/ui/button";
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 (
@@ -57,8 +57,8 @@ function CapPageInner() {
});
const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []);
const completedCount = allLessons.filter((l) => l.progress?.passedQuiz).length;
const curriculumDone = allLessons.length > 0 && completedCount === allLessons.length;
const completedCount = data?.completedLessons ?? allLessons.filter((l) => l.progress?.passedQuiz).length;
const curriculumDone = (data?.progressPct ?? 0) >= 100;
useEffect(() => {
const lesson = params.get("lesson");
@@ -69,12 +69,7 @@ function CapPageInner() {
const { data: certData } = useQuery({
queryKey: ["me", "certificate"],
queryFn: () =>
http.get<{
certificate: Certificate | null;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
}>("/api/me/certificate"),
queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
enabled: !!user && curriculumDone,
});
@@ -101,20 +96,10 @@ function CapPageInner() {
const firstLocked = all.find((l) => l.locked);
const done = curriculumDone;
const pct = all.length ? Math.round((completedCount / all.length) * 100) : 0;
const pct = Math.round(data?.progressPct ?? 0);
const hearts = clampHearts(me?.hearts ?? user?.hearts ?? MAX_HEARTS);
const lowHearts = !hasInfiniteHearts(user?.role) && hearts > 0 && hearts <= 1;
const nextEvent = (eventsData?.events ?? [])[0];
const physicalOpen =
done &&
certData?.physical?.enabled &&
!!certData.certificate &&
(!certData.physicalOrder || certData.physicalOrder.status === "cancelled") &&
(!certData.certificate.issuedAt ||
Date.now() <
new Date(certData.certificate.issuedAt).getTime() +
(certData.physical.windowDays || 30) * 24 * 60 * 60 * 1000);
const next = nextAction(inProgress, readyQuiz, openAndNew, firstLocked, done);
return (
@@ -150,6 +135,8 @@ function CapPageInner() {
</div>
</div>
<PinnedAnnouncement />
{lowHearts && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border-2 border-destructive/30 bg-destructive/5 px-5 py-4">
<div className="flex items-start gap-3">
@@ -157,7 +144,7 @@ function CapPageInner() {
<div>
<p className="text-sm font-extrabold text-destructive">فقط {formatFaNumber(hearts)} جان مانده</p>
<p className="text-xs text-muted-foreground">
یک اشتباه دیگر حساب را قفل می‌کند. قبل از آزمون مطمئن شوید؛ اگر گیر کردید با منتور حرف بزنید.
جان‌ها خودشان برمی‌گردند. تا برگشتن یک جان، آزمون تازه صبر می‌کند.
</p>
</div>
</div>
@@ -178,33 +165,29 @@ function CapPageInner() {
<div>
<p className="text-sm font-extrabold">دوره را تمام کردید</p>
<p className="text-xs text-muted-foreground">
گواهینامه دیجیتال آماده است
{physicalOpen ? " — می‌توانید نسخه فیزیکی هم درخواست کنید." : "."}
{certData?.certificate?.status === "pending"
? "درخواست گواهینامه ثبت شد — پس از تأیید تیم آموزش صادر می‌شود."
: certData?.certificate?.status === "issued"
? "گواهینامه دیجیتال آماده است."
: "گواهینامه دیجیتال آماده است."}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
const id = certData?.certificate?.publicId;
if (id) router.push(`/c/${id}`);
else {
void http
.post<{ certificate: { publicId: string } }>("/api/me/certificate/issue", {})
.then((r) => {
if (r.certificate?.publicId) router.push(`/c/${r.certificate.publicId}`);
})
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
}
}}
>
{certData?.certificate?.status === "issued" && certData.certificate.publicId ? (
<Button onClick={() => router.push(`/c/${certData.certificate!.publicId!}`)}>
<Award className="h-4 w-4" /> مشاهده گواهینامه
</Button>
{physicalOpen && certData?.certificate?.publicId && (
<Button variant="outline" onClick={() => router.push(`/c/${certData.certificate!.publicId}`)}>
<Package className="h-4 w-4" /> نسخه فیزیکی
</Button>
)}
</div>
) : certData?.certificate?.status !== "pending" ? (
<Button
onClick={() => {
void http
.post("/api/me/certificate/issue", {})
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
}}
>
<Award className="h-4 w-4" /> دریافت گواهینامه
</Button>
) : null}
</div>
)}
@@ -450,6 +433,244 @@ function CapSkeleton() {
);
}
type CapAnnouncement = { title: string; body: string; route: string };
function PinnedAnnouncement() {
const router = useRouter();
const scroller = useRef<HTMLDivElement>(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<ScrollBehavior>("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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
className="relative"
onPointerEnter={() => 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)}
>
<div
ref={scroller}
dir="ltr"
style={{ direction: "ltr" }}
className="flex gap-3 touch-pan-y select-none overflow-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onPointerDown={onTrackPointerDown}
onPointerMove={onTrackPointerMove}
onPointerUp={onTrackPointerUp}
onPointerCancel={onTrackPointerUp}
>
{items.map((a, i) => (
<button
key={`${a.title}-${i}`}
type="button"
dir="rtl"
draggable={false}
onClick={() => {
if (Math.abs(moved.current) > 12) return;
if (a.route) router.push(a.route);
}}
className="block w-[calc(100%-2rem)] min-w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] shrink-0 touch-pan-y rounded-2xl border-2 border-primary/30 bg-primary/5 px-5 py-4 text-start"
>
<p className="text-xs font-bold text-primary">اطلاعیه</p>
<p className="mt-1 text-base font-black">{a.title}</p>
<SafeMarkdown text={a.body} lines={3} className="mt-1 text-sm text-muted-foreground" />
</button>
))}
</div>
{items.length > 1 && (
<div className="mt-2 flex items-center justify-center gap-1.5">
{items.map((a, i) => (
<button
key={`${a.title}-dot-${i}`}
type="button"
aria-label={`اطلاعیه ${i + 1}`}
onClick={() => go(i)}
className={cn("h-1.5 rounded-full transition-all", i === index ? "w-4 bg-primary" : "w-1.5 bg-primary/30")}
/>
))}
</div>
)}
</div>
);
}
function ZapIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
+25
View File
@@ -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;
+8 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Providers } from "@/components/providers";
@@ -37,6 +37,13 @@ export const metadata: Metadata = {
description: "مسیر یادگیری واقعی پرگار",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
+2 -3
View File
@@ -89,8 +89,7 @@ export default function LockoutPage() {
<p className="text-xs font-bold tracking-wider text-destructive">حساب قفل شد</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight">جان‌هایتان تمام شد</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
هر پاسخ اشتباه در آزمون یک جان کم می‌کند. با تمام شدن سه جان، یادگیری و آزمون قفل می‌شود تا
تیم مسیر را بررسی کند — خودتان نمی‌توانید قفل را باز کنید.
جان‌ها معمولاً خودشان برمی‌گردند. این صفحه وقتی دیده می‌شود که مدیریت حساب را قفل کرده باشد.
</p>
<div className="mt-6 flex items-center gap-2">
@@ -144,7 +143,7 @@ export default function LockoutPage() {
className={cn("w-full text-muted-foreground")}
onClick={() => router.push("/unwrap?tab=community")}
>
جامعه و حمایت
شبکه‌های پرگار
</Button>
</div>
</div>
+26 -80
View File
@@ -1,13 +1,13 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2, RefreshCw, ShieldCheck, Timer, Zap } from "lucide-react";
import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
import { BrandMark } from "@/components/brand-mark";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
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";
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { CaptchaField, type CaptchaChallenge } from "@/components/captcha-field";
const perks = [
{ icon: Timer, text: "زمان تماشای راستی‌آزمایی‌شده — بدون میان‌بر" },
@@ -22,11 +23,6 @@ const perks = [
{ icon: CheckCircle2, text: "لیگ هفتگی، رویداد زنده و منتور یک‌به‌یک" },
];
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
export default function LoginPage() {
const router = useRouter();
const setSession = useAuth((s) => s.setSession);
@@ -34,39 +30,22 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(true);
const [captchaKey, setCaptchaKey] = useState(0);
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string | null>(null);
const { data: registerStatus } = useQuery({
queryKey: ["auth", "register-status"],
queryFn: () => http.get<{ enabled: boolean }>("/api/auth/register-status"),
});
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
void loadCaptcha();
}, [loadCaptcha]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha();
setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaKey((k) => k + 1);
return;
}
setLoading(true);
@@ -83,7 +62,10 @@ export default function LoginPage() {
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ورود ناموفق بود"));
void loadCaptcha();
// 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);
}
@@ -153,54 +135,14 @@ export default function LoginPage() {
placeholder="رمز عبور"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
<CaptchaField
key={captchaKey}
id="captcha"
value={captchaAnswer}
onChange={setCaptchaAnswer}
onChallenge={setChallenge}
onError={setCaptchaError}
/>
<label className="flex items-center justify-between gap-3 text-sm">
<span>مرا به یاد داشته باش</span>
<Switch
@@ -209,13 +151,17 @@ export default function LoginPage() {
aria-label="مرا به یاد داشته باش"
/>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
{(error || captchaError) && (
<p className="text-sm text-destructive" role="alert">
{error || captchaError}
</p>
)}
<Button
type="submit"
variant="gradient"
className="h-12 w-full"
size="lg"
disabled={loading || captchaLoading || !challenge}
disabled={loading || !challenge}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} ورود
</Button>
+7 -53
View File
@@ -1,58 +1,12 @@
"use client";
import Link from "next/link";
import { BrandMark } from "@/components/brand-mark";
import { Button } from "@/components/ui/button";
import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { Home, LogIn, Trees } from "lucide-react";
import { NotFoundState } from "@/components/not-found-state";
export default function NotFound() {
const hydrated = useAuthHydrated();
const user = useAuth((s) => s.user);
const loggedIn = hydrated && !!user;
return (
<div className="mx-auto flex max-w-lg flex-col items-center px-4 py-16 text-center">
<div className="relative w-full overflow-hidden rounded-2xl border-2 border-border bg-card px-8 py-14 shadow-offset">
<div className="dot-grid dot-grid-primary pointer-events-none absolute -end-10 -top-10 h-40 w-40 opacity-60" aria-hidden />
<p className="text-7xl font-black tabular-nums text-primary/25">۴۰۴</p>
<div className="mt-4 flex justify-center">
<BrandMark locale="fa" size="lg" />
</div>
<h1 className="mt-6 text-2xl font-black tracking-tight">این مسیر پیدا نشد</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{loggedIn
? "این آدرس در سامانه نیست. از درخت مهارت یا مرکز ادامه دهید."
: "صفحه‌ای که دنبالش بودید پیدا نشد. می‌توانید به خانه برگردید یا وارد حساب شوید."}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
{loggedIn ? (
<>
<Button asChild className="gap-2">
<Link href="/cap">
<Trees className="h-4 w-4" /> درخت مهارت
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/unwrap?tab=community">مرکز · حمایت</Link>
</Button>
</>
) : (
<>
<Button asChild className="gap-2">
<Link href="/">
<Home className="h-4 w-4" /> خانه
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/login">
<LogIn className="h-4 w-4" /> ورود
</Link>
</Button>
</>
)}
</div>
</div>
</div>
<NotFoundState
title="صفحه پیدا نشد"
description="این آدرس در سامانه نیست. از خانه می‌توانید دوباره شروع کنید."
href="/"
actionLabel="بازگشت به خانه"
/>
);
}
+24 -1
View File
@@ -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 (
<NotFoundState
title="درس پیدا نشد"
description="این درس در درخت مهارت نیست."
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
if (isLoading) {
return (
<div className="mx-auto max-w-4xl space-y-6">
@@ -79,6 +92,16 @@ export default function PlayerPage() {
if (isLessonLockedError(error)) {
return <LockedLessonState onBack={() => router.push("/cap")} />;
}
if (error instanceof ApiError && error.status === 404) {
return (
<NotFoundState
title="درس پیدا نشد"
description={notFoundSentence(toUserError(error, ""), "این درس در درخت مهارت نیست.", "درس پیدا نشد")}
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
return (
<div className="mx-auto max-w-lg space-y-4 py-16 text-center">
<p className="font-medium text-destructive">{toUserError(error, "بارگذاری درس ممکن نشد")}</p>
+142
View File
@@ -0,0 +1,142 @@
"use client";
import type { ReactNode } from "react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
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 { Skeleton } from "@/components/ui/skeleton";
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
const AdminProfileManage = dynamic(
() => import("@/components/admin/profile-manage").then((m) => m.AdminProfileManage),
{ ssr: false },
);
export default function PublicProfilePage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const enabled = Number.isFinite(id) && id > 0;
const isAdmin = useAuth((s) => s.user?.role === "admin");
const profileQ = useQuery({
queryKey: ["public-profile", id],
queryFn: () => http.get<{ user: PublicProfile }>(`/api/users/${id}`),
enabled,
retry: 1,
});
if (!enabled) {
return (
<NotFoundState
title="کاربر پیدا نشد"
description="این شناسه در سامانه نیست."
href="/unwrap"
actionLabel="بازگشت به مرکز"
/>
);
}
if (profileQ.isPending) {
return (
<div className="mx-auto max-w-2xl space-y-4" aria-busy="true" aria-label="در حال بارگذاری پروفایل">
<Skeleton className="h-40 rounded-2xl" />
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 rounded-2xl" />
))}
</div>
</div>
);
}
if (profileQ.isError || !profileQ.data?.user) {
return (
<NotFoundState
title="کاربر پیدا نشد"
description={notFoundSentence(
toUserError(profileQ.error, ""),
"این شناسه در سامانه نیست.",
"کاربر پیدا نشد",
)}
href="/unwrap"
actionLabel="بازگشت به مرکز"
/>
);
}
const person = profileQ.data.user;
return (
<div className="mx-auto max-w-2xl space-y-6">
<ProfileHeader
person={{
name: person.name,
role: person.role,
banner: person.banner,
createdAt: person.private ? undefined : person.createdAt,
avatarVariant: person.avatarVariant,
avatarPalette: person.avatarPalette,
avatarPhoto: person.avatarPhoto,
isFrozen: person.isFrozen,
isClosed: person.isClosed,
}}
secondary={person.private ? "این پروفایل خصوصی است" : undefined}
/>
{person.isLocked && <p className="text-xs font-bold text-destructive">حساب قفل است</p>}
{person.private ? (
<p className="text-sm text-muted-foreground">جز نام و آواتار چیزی نمایش داده نمی‌شود.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{person.xp != null && <Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />}
{person.hearts != null && <Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلب‌ها" value={formatFaNumber(person.hearts)} />}
{person.streakCurrent != null && (
<Stat
icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی"
value={formatStreakLabel(person.streakCurrent)}
/>
)}
{person.streakLongest != null && (
<Stat
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره"
value={formatStreakLabel(person.streakLongest)}
/>
)}
</div>
)}
{(person.socialLinks ?? []).length > 0 && (
<ul className="space-y-1 text-sm">
{person.socialLinks!.map((l) => (
<li key={l.url}>
<a href={l.url} className="text-primary underline" target="_blank" rel="noreferrer">
{l.name}
</a>
</li>
))}
</ul>
)}
{isAdmin && <AdminProfileManage userId={person.id} />}
</div>
);
}
function Stat({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3">
{icon}
<div>
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
<p className="text-lg font-black tabular-nums">{value}</p>
</div>
</div>
);
}
+176 -58
View File
@@ -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 (
<div className="mx-auto max-w-2xl space-y-6">
<Panel
title="پروفایل من"
description="هویت، آمار یادگیری، چالش‌ها و گواهینامه در یک نگاه."
icon={<UserIcon className="h-5 w-5" />}
tint="bg-primary/10 text-primary"
actions={
<Button variant="outline" size="sm" onClick={() => setStudioOpen(true)}>
ویرایش آواتار
</Button>
}
>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<UserAvatar name={user.name} className="h-20 w-20" {...avatarPropsOf(user)} />
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
<h1 className="text-2xl font-black tracking-tight">{user.name}</h1>
<dl className="mt-1 space-y-2 text-sm">
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">نام کاربری</dt>
<dd className="min-w-0 font-latin font-bold tracking-tight" dir="ltr">
@{user.username}
</dd>
</div>
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">ایمیل</dt>
<dd className="min-w-0 break-all font-latin font-medium" dir="ltr">
{user.email}
</dd>
</div>
{user.phone && (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">تلفن</dt>
<dd className="min-w-0 font-latin font-medium" dir="ltr">
{user.phone}
</dd>
</div>
)}
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">عضویت از</dt>
<dd className="min-w-0 font-medium">
{new Date(user.createdAt).toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
})}
</dd>
</div>
</dl>
</div>
</div>
</Panel>
<OwnerProfileHeader user={user} onEditAvatar={() => setStudioOpen(true)} />
<div className="grid gap-3 sm:grid-cols-2">
<StatCard icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز کل" value={formatFaNumber(user.xp)} />
@@ -139,14 +89,16 @@ export default function ProfilePage() {
<ChallengeCards />
</div>
<ProfileActivity />
<div>
<h2 className="mb-2 text-sm font-black text-muted-foreground">گواهینامه</h2>
<ProfileCertificateSlot />
</div>
<Dialog open={studioOpen} onOpenChange={setStudioOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogContent className="flex max-h-[min(85vh,40rem)] max-w-lg flex-col gap-3 overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
</DialogHeader>
<AvatarStudio user={user} onClose={() => 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 (
<div>
<h2 className="mb-2 text-sm font-black text-muted-foreground">فعالیت</h2>
<Card>
<CardContent className="space-y-6 p-4">
<div>
<h3 className="text-sm font-black">تاریخچهٔ ماهانه</h3>
<p className="mt-1 text-xs text-muted-foreground">هر بار یک ماه. پررنگ‌تر یعنی فعالیت بیشتر.</p>
{activityQ.isPending ? (
<Skeleton className="mt-3 h-24 rounded-xl" aria-busy="true" aria-label="در حال بارگذاری نمودار فعالیت" />
) : activityQ.isError ? (
<p className="mt-3 text-sm font-bold text-destructive">{toUserError(activityQ.error, "بارگذاری فعالیت ممکن نشد")}</p>
) : (
<ActivityGrid days={activityQ.data?.days ?? []} />
)}
</div>
<div>
<h3 className="text-sm font-black">رویدادها</h3>
{eventsQ.isPending ? (
<p className="mt-2 text-sm text-muted-foreground" aria-busy="true">
در حال بارگذاری…
</p>
) : eventsQ.isError ? (
<p className="mt-2 text-sm font-bold text-destructive">{toUserError(eventsQ.error, "بارگذاری رویدادها ممکن نشد")}</p>
) : (eventsQ.data?.events ?? []).length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">رویدادی نیست.</p>
) : (
<ul className="mt-3 divide-y divide-border rounded-xl border border-border">
{(eventsQ.data?.events ?? []).map((ev) => (
<li key={ev.id} className="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
<span className="font-bold">{ev.title}</span>
<time className="shrink-0 text-xs text-muted-foreground" dateTime={ev.startsAt}>
{formatJalaliStamp(ev.startsAt)}
</time>
</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
</div>
);
}
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<string, { label: string; cells: { day: string; count: number }[] }>();
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 (
<div className="mt-3" role="group" aria-label="تاریخچهٔ ماهانهٔ فعالیت، به تاریخ جلالی">
<div className="mb-3 flex items-center justify-between gap-3">
<Button type="button" variant="outline" size="sm" disabled={safeIndex <= 0} onClick={() => setIndex(safeIndex - 1)}>
ماه جدیدتر
</Button>
<h4 className="text-sm font-black">{month.label}</h4>
<Button
type="button"
variant="outline"
size="sm"
disabled={safeIndex >= newestFirst.length - 1}
onClick={() => setIndex(safeIndex + 1)}
>
ماه قدیمی‌تر
</Button>
</div>
<div className="grid grid-cols-7 gap-1" dir="ltr">
{WEEKDAYS.map((name) => (
<span key={name} className="text-center text-[10px] font-bold text-muted-foreground" dir="rtl">
{name}
</span>
))}
{Array.from({ length: pad }, (_, i) => (
<span key={`${id}-pad-${i}`} />
))}
{month.cells.map((c) => {
const date = jalaliDay(c.day);
const countLabel = activityCountLabel(c.count);
return (
<button
key={c.day}
type="button"
aria-label={`${date}، ${countLabel}`}
className="group relative z-0 aspect-square min-h-0 w-full min-w-0 cursor-default appearance-none rounded-sm border-0 p-0 outline-none hover:z-30 focus-visible:z-30 focus-visible:ring-2 focus-visible:ring-ring"
style={{ background: c.count === 0 ? "var(--muted)" : `color-mix(in srgb, var(--primary) ${30 + Math.min(c.count, 6) * 12}%, white)` }}
>
<span
role="tooltip"
dir="rtl"
className="pointer-events-none absolute bottom-[calc(100%+0.35rem)] left-1/2 z-30 hidden w-max -translate-x-1/2 rounded-lg border border-border bg-card px-2.5 py-1.5 text-start text-sm leading-snug text-card-foreground shadow-soft group-hover:block group-focus-visible:block"
>
<span className="block font-bold whitespace-nowrap">{date}</span>
<span className="block whitespace-nowrap text-muted-foreground">{countLabel}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
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 (
<Card>
+874
View File
@@ -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<Activity>("/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<SectionId>("account");
const [exportingId, setExportingId] = useState<number | null>(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<Extras | null>(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<Extras>) => {
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 (
<div className="mx-auto max-w-4xl">
<header className="mb-6 space-y-1">
<h1 className="text-2xl font-black tracking-tight">تنظیمات</h1>
<p className="text-sm text-muted-foreground">حساب، حریم و داده‌های همین حساب.</p>
</header>
<SectionColumns>
<SectionNav
label="بخش‌های تنظیمات"
groups={[{ items: SECTIONS.map((item) => ({ id: item.id, label: item.label })) }]}
activeId={section}
onSelect={setSection}
/>
<SectionPanel
id="settings-section"
titleId="settings-section-title"
title={active.title}
description={active.description}
>
{section === "account" && (
<div className="space-y-8">
<AccountIdentityFields
idPrefix="settings"
name={displayName}
email={email}
phone={phone}
onName={(value) => {
setIdentityDirty(true);
setDisplayName(value);
}}
onEmail={(value) => {
setIdentityDirty(true);
setEmail(value);
}}
onPhone={(value) => {
setIdentityDirty(true);
setPhone(value);
}}
action={
<Button type="button" disabled={savingIdentity} onClick={() => void saveIdentity()}>
ذخیره مشخصات
</Button>
}
/>
<AccountUsernameSection
mode="self"
idPrefix="settings"
current={user.username}
next={nextUsername}
onNext={setNextUsername}
disabled={usernameCooling}
hint={
usernameCooling && user.usernameCooldownUntil ? (
<p className="text-sm text-muted-foreground">
سه بار نام کاربری را عوض کرده‌اید. تغییر بعدی از {formatJalaliStamp(user.usernameCooldownUntil)} ممکن است.
</p>
) : usernameAllowanceLabel(usernameRemaining) ? (
<p className="text-xs font-bold text-muted-foreground">{usernameAllowanceLabel(usernameRemaining)}</p>
) : null
}
action={
<Button
type="button"
disabled={usernameCooling || savingUsername || usernameRemaining <= 0}
onClick={() => void changeUsername()}
>
تغییر نام کاربری
</Button>
}
/>
<section className="space-y-4 border-t border-border pt-6" aria-labelledby="settings-password-heading">
<div className="space-y-1">
<h3 id="settings-password-heading" className="text-sm font-black">گذرواژه</h3>
<p className="text-xs text-muted-foreground">برای ذخیره، گذرواژهٔ فعلی لازم است.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field id="settings-new-password" label="گذرواژهٔ جدید">
<PasswordInput
id="settings-new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
dir="ltr"
autoComplete="new-password"
/>
</Field>
<Field id="settings-current-password" label="گذرواژهٔ فعلی">
<PasswordInput
id="settings-current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
dir="ltr"
autoComplete="current-password"
/>
</Field>
</div>
<Button type="button" disabled={savingPassword} onClick={() => void savePassword()}>
ذخیره
</Button>
</section>
<section className="space-y-3 border-t border-border pt-6" aria-labelledby="settings-freeze-heading">
<h3 id="settings-freeze-heading" className="text-sm font-black">فریز حساب</h3>
<p className="text-sm text-muted-foreground">
فریز تا ۳۰ روز قابل بازگشت است، بعد از آن حساب بسته می‌شود و حذف دائمی فقط با مجوز ادمین است.
</p>
{user.isClosed ? (
<p className="text-sm font-bold">حساب بسته شده است. لغو فریز ممکن نیست.</p>
) : user.isFrozen ? (
<div className="space-y-3">
<p className="text-sm">
{closeDate ? `این حساب در ${closeDate} بسته می‌شود.` : "این حساب تا ۳۰ روز دیگر بسته می‌شود."}
</p>
<Button type="button" variant="outline" disabled={freezeBusy} onClick={() => void unfreezeAccount()}>
لغو فریز
</Button>
</div>
) : (
<div className="space-y-3">
{freezeConfirm && (
<>
<p className="text-sm font-bold">برای فریز، گذرواژهٔ فعلی را بنویسید.</p>
<Field id="settings-freeze-password" label="گذرواژهٔ فعلی">
<PasswordInput
id="settings-freeze-password"
value={freezePassword}
onChange={(e) => setFreezePassword(e.target.value)}
dir="ltr"
autoComplete="current-password"
/>
</Field>
</>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant={freezeConfirm ? "destructive" : "outline"} disabled={freezeBusy} onClick={() => void freezeAccount()}>
فریز حساب
</Button>
{freezeConfirm && (
<Button
type="button"
variant="ghost"
disabled={freezeBusy}
onClick={() => {
setFreezeConfirm(false);
setFreezePassword("");
}}
>
انصراف
</Button>
)}
</div>
</div>
)}
</section>
</div>
)}
{section === "privacy" && (
<PrivacyFields
form={form}
loading={profileQ.isPending}
error={profileQ.isError ? toUserError(profileQ.error, "بارگذاری حریم ممکن نشد") : null}
onPatch={patch}
onSave={() => void saveProfile()}
/>
)}
{section === "stats" && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatTile
icon={<Sparkles className="h-4 w-4 text-gold" />}
label="امتیاز"
value={formatFaNumber(user.xp)}
/>
<StatTile
icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی"
value={formatStreakLabel(user.streakCurrent)}
/>
<StatTile
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره"
value={formatStreakLabel(user.streakLongest)}
/>
<StatTile
icon={<CalendarDays className="h-4 w-4 text-primary" />}
label="روزهای این هفته"
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.weekDays)}
/>
<StatTile
icon={<CalendarDays className="h-4 w-4 text-secondary" />}
label="روزهای این ماه"
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.monthDays)}
/>
</div>
<div>
<h3 className="text-sm font-black">نشان‌ها</h3>
{badgesOn ? (
<div className="mt-3">
<p className="mb-3 text-xs text-muted-foreground">رنگ‌دارها باز شده‌اند و کمرنگ‌ها هنوز قفل‌اند.</p>
{badgesQ.isPending ? (
<div className="flex flex-wrap gap-2" aria-busy="true" aria-label="در حال بارگذاری نشان‌ها">
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-28 rounded-full" />
</div>
) : badgesQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(badgesQ.error, "بارگذاری نشان‌ها ممکن نشد")}</p>
) : (badgesQ.data?.achievements ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">نشانی ثبت نشده.</p>
) : (
<ul className="flex flex-wrap gap-2">
{(badgesQ.data?.achievements ?? []).map((b) => (
<li key={b.id}>
<Badge variant={b.unlocked ? "success" : "secondary"} className="rounded-full px-2.5 py-1">
{b.title}
</Badge>
</li>
))}
</ul>
)}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">نمایش نشان‌ها در بخش حریم خاموش است.</p>
)}
</div>
</div>
)}
{section === "export" && (
<div className="space-y-6">
{exportChatsQ.isPending ? (
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری گفتگوها">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
) : exportChatsQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(exportChatsQ.error, "بارگذاری گفتگوها ممکن نشد")}</p>
) : exportChats.length === 0 ? (
<p className="text-sm text-muted-foreground">گفتگویی نیست.</p>
) : (
<div className="space-y-4">
<ul className="divide-y divide-border">
{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 (
<li key={chat.partner.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-bold">{label}</p>
<p className="text-xs text-muted-foreground">
{last ? `آخرین فعالیت: ${formatJalaliStamp(last)}` : "هنوز پیامی نیست"}
</p>
<p className="text-xs font-bold text-foreground">
{size?.isPending
? "در حال برآورد حجم…"
: size?.isError
? "برآورد حجم ممکن نشد"
: `حجم تقریبی: ${formatApproxSize(size?.data?.bytes ?? 0)}`}
</p>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
disabled={exportingId === chat.partner.id}
onClick={() => {
setExportingId(chat.partner.id);
void downloadChatExport(chat.partner.id, exportFileName(label, chat.partner.id)).finally(() => {
setExportingId((cur) => (cur === chat.partner.id ? null : cur));
});
}}
>
دانلود
</Button>
</li>
);
})}
</ul>
{exportChats.length > EXPORT_PAGE_SIZE && (
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={exportPageSafe <= 1}
onClick={() => setExportPage(exportPageSafe - 1)}
>
قبلی
</Button>
<p className="text-sm font-bold tabular-nums text-muted-foreground">
{formatFaNumber(exportPageSafe)} از {formatFaNumber(exportPageCount)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={exportPageSafe >= exportPageCount}
onClick={() => setExportPage(exportPageSafe + 1)}
>
بعدی
</Button>
</div>
)}
</div>
)}
<ul className="divide-y divide-border border-t border-border pt-4">
<li className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-bold">حذف حافظه موقت</p>
<p className="text-xs leading-relaxed text-muted-foreground">عکس و صدای ذخیره‌شده روی همین دستگاه حذف می‌شود.</p>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
onClick={() => {
void clearMediaCache().then(() => toast.success("حافظه موقت حذف شد"));
}}
>
حذف
</Button>
</li>
</ul>
</div>
)}
</SectionPanel>
</SectionColumns>
</div>
);
}
function PrivacyFields({
form,
loading,
error,
onPatch,
onSave,
}: {
form: Extras | null;
loading: boolean;
error: string | null;
onPatch: (partial: Partial<Extras>) => void;
onSave: () => void;
}) {
if (loading || !form) {
if (error) return <p className="text-sm font-bold text-destructive">{error}</p>;
return (
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری حریم">
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
</div>
);
}
const links = form.socialLinks;
return (
<div className="space-y-6">
<div className="divide-y divide-border">
<PrivacyRow
id="privacy-public"
label="پروفایل عمومی"
hint="تا وقتی خاموش باشد، دیگران فقط نام و آواتار را می‌بینند."
checked={form.public}
onCheckedChange={(v) => onPatch({ public: v })}
/>
<PrivacyRow
id="privacy-activity"
label="نمایش فعالیت"
hint="روزهای فعال در پروفایل عمومی دیده می‌شود."
checked={form.showActivity}
onCheckedChange={(v) => onPatch({ showActivity: v })}
/>
<PrivacyRow
id="privacy-stats"
label="نمایش آمار"
hint="امتیاز و زنجیره در پروفایل عمومی دیده می‌شود."
checked={form.showStats}
onCheckedChange={(v) => onPatch({ showStats: v })}
/>
<PrivacyRow
id="privacy-path"
label="نمایش مسیر"
hint="مسیر یادگیری در پروفایل عمومی دیده می‌شود."
checked={form.showPath}
onCheckedChange={(v) => onPatch({ showPath: v })}
/>
<PrivacyRow
id="privacy-badges"
label="نمایش نشان‌ها"
hint="نشان‌ها در پروفایل عمومی دیده می‌شوند."
checked={form.showBadges}
onCheckedChange={(v) => onPatch({ showBadges: v })}
/>
</div>
<div className="space-y-3 border-t border-border pt-5">
<div>
<h3 className="text-sm font-black">لینک‌های شخصی</h3>
<p className="mt-1 text-xs text-muted-foreground">حداکثر پنج لینک. نام و نشانی را وارد کنید.</p>
</div>
{links.length === 0 ? (
<p className="text-sm text-muted-foreground">هنوز لینکی اضافه نشده.</p>
) : (
<ul className="space-y-3">
{links.map((link, i) => (
<li key={i} className="flex items-end gap-2">
<div className="w-28 shrink-0 space-y-1.5 sm:w-36">
<Label htmlFor={`settings-link-name-${i}`} className="text-xs font-bold text-muted-foreground">
نام
</Label>
<Input
id={`settings-link-name-${i}`}
value={link.name}
placeholder="تلگرام"
onChange={(e) => {
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row));
onPatch({ socialLinks });
}}
/>
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<Label htmlFor={`settings-link-url-${i}`} className="text-xs font-bold text-muted-foreground">
نشانی
</Label>
<Input
id={`settings-link-url-${i}`}
dir="ltr"
value={link.url}
placeholder="https://"
onChange={(e) => {
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row));
onPatch({ socialLinks });
}}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-11 shrink-0"
aria-label={`حذف لینک ${link.name || i + 1}`}
onClick={() => onPatch({ socialLinks: links.filter((_, idx) => idx !== i) })}
>
حذف
</Button>
</li>
))}
</ul>
)}
{links.length < 5 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onPatch({ socialLinks: [...links, { name: "", url: "" }] })}
>
افزودن لینک
</Button>
)}
</div>
<Button type="button" onClick={onSave}>
ذخیره حریم
</Button>
</div>
);
}
function PrivacyRow({
id,
label,
hint,
checked,
onCheckedChange,
}: {
id: string;
label: string;
hint: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) {
const hintId = `${id}-hint`;
return (
<div className="flex items-center justify-between gap-4 py-4 first:pt-0 last:pb-0">
<div className="min-w-0 space-y-1">
<Label htmlFor={id} className="text-sm font-bold text-foreground">
{label}
</Label>
<p id={hintId} className="text-xs leading-relaxed text-muted-foreground">
{hint}
</p>
</div>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} aria-describedby={hintId} />
</div>
);
}
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 (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
function StatTile({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/30 px-3 py-3 sm:px-4">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-card">{icon}</span>
<div className="min-w-0">
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
<p className="text-lg font-black tabular-nums">{value}</p>
</div>
</div>
);
}
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("دانلود خروجی گفتگو ممکن نشد");
}
}
+105 -50
View File
@@ -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() {
جامعه، <span className="text-gradient-brand">استمرار و منتورها</span>
</>
}
description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو با منتور، و هماهنگی حمایت مالی."
description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو، و شبکه‌های پرگار."
className="mb-0"
/>
@@ -88,26 +91,11 @@ function HubTabs() {
<span className="truncate">لیگ‌ها</span>
</TabsTrigger>
<TabsTrigger value="community" className="h-10 gap-1.5 px-2 sm:px-3">
<HeartHandshake className="h-4 w-4 shrink-0" />
<span className="truncate">حمایت</span>
<Share2 className="h-4 w-4 shrink-0" />
<span className="truncate">شبکه‌ها</span>
</TabsTrigger>
</TabsList>
<TabsContent value="events" className="mt-0 space-y-4">
<button
type="button"
onClick={() => onTab("community")}
className="flex w-full items-center gap-3 rounded-2xl border-2 border-accent/30 bg-accent/5 px-4 py-3.5 text-start transition hover:bg-accent/10"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-accent/15 text-accent">
<HeartHandshake className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-extrabold">حمایت مالی</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
هماهنگی دونیت از طریق گفتگو با تیم
</span>
</span>
</button>
<EventsPanel />
</TabsContent>
<TabsContent value="chats" className="mt-0">
@@ -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 (
<div className="grid gap-4 lg:grid-cols-2">
@@ -156,7 +167,7 @@ function ChatsLauncher() {
</div>
</Panel>
<Panel title="گفتگوهای شما" description="مکالمات جاری با منتورها — روی هر کدام بزنید تا باز شود.">
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
<div className="space-y-1.5 pt-1">
{convLoading && (
<div className="space-y-2">
@@ -183,9 +194,12 @@ function ChatsLauncher() {
<UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-bold">{c.partner.name}</p>
<p className="flex min-w-0 items-center gap-1 font-bold">
<span className="truncate">{c.partner.name}</span>
{isVerifiedRole(c.partner.role) && <VerifiedBadge role={c.partner.role} />}
</p>
{c.lastMessage && (
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo(c.lastMessage.createdAt)}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">{formatJalaliStamp(c.lastMessage.createdAt)}</span>
)}
</div>
<p
@@ -207,13 +221,38 @@ function ChatsLauncher() {
))}
{!convLoading && conversations.length === 0 && (
<p className="px-1 text-sm text-muted-foreground">
هنوز گفتگویی ندارید — از «شروع سریع» یک منتور انتخاب کنید.
هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید.
</p>
)}
{convTotal > (convData?.pageSize ?? 10) && (
<div className="flex items-center justify-between pt-2 text-xs">
<Button type="button" size="sm" variant="outline" disabled={convPage <= 1} onClick={() => setConvPage((p) => p - 1)}>
قبلی
</Button>
<span className="text-muted-foreground">{convPage}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={convPage * (convData?.pageSize ?? 10) >= convTotal}
onClick={() => setConvPage((p) => p + 1)}
>
بعدی
</Button>
</div>
)}
</div>
</Panel>
<Panel title="شروع سریع با منتور" description="یک گفتگو را مستقیم باز کنید.">
<Panel
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
description="نقش هر نفر روی ردیف نوشته شده است."
actions={
<Button type="button" size="sm" variant="outline" onClick={() => setAlpha((v) => !v)}>
{alpha ? "آنلاین بالا" : "الفبایی"}
</Button>
}
>
<div className="space-y-1 pt-1">
{isLoading && (
<div className="space-y-2">
@@ -229,24 +268,46 @@ function ChatsLauncher() {
))}
</div>
)}
{mentors.map((m) => (
<button
{people.map((m) => (
<motion.button
layout
key={m.id}
onClick={() => openChat(m.id)}
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<UserAvatar name={m.name} className="h-10 w-10" {...avatarPropsOf(m)} />
<div className="min-w-0 flex-1">
<p className="truncate font-bold">{m.name}</p>
<p className="text-xs text-muted-foreground">منتور</p>
<p className="flex min-w-0 items-center gap-1 font-bold">
<span className="truncate">{m.name}</span>
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
</p>
<p className="text-xs text-muted-foreground">{roleWord(m.role)}</p>
</div>
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" /> در دسترس
<span className={cn("inline-flex items-center gap-1.5 text-xs font-bold", m.online ? "text-success" : "text-muted-foreground")}>
<span className={cn("h-1.5 w-1.5 rounded-full", m.online ? "bg-success" : "bg-muted-foreground/40")} />
{m.online ? "آنلاین" : "آفلاین"}
</span>
</button>
</motion.button>
))}
{!isLoading && mentors.length === 0 && (
<p className="text-sm text-muted-foreground">هنوز منتوری در دسترس نیست.</p>
<p className="text-sm text-muted-foreground">{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}</p>
)}
{peopleTotal > (data?.pageSize ?? 20) && (
<div className="flex items-center justify-between pt-2 text-xs">
<Button type="button" size="sm" variant="outline" disabled={peoplePage <= 1} onClick={() => setPeoplePage((p) => p - 1)}>
قبلی
</Button>
<span className="text-muted-foreground">{peoplePage}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={peoplePage * (data?.pageSize ?? 20) >= peopleTotal}
onClick={() => setPeoplePage((p) => p + 1)}
>
بعدی
</Button>
</div>
)}
</div>
</Panel>
@@ -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<string, string> = { 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");
}
@@ -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 (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
/** نام، ایمیل، تلفن — 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 (
<section className="space-y-4" aria-labelledby={`${idPrefix}-identity-heading`}>
<h3 id={`${idPrefix}-identity-heading`} className="text-sm font-black">هویت</h3>
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${idPrefix}-name`} label="نام">
<Input id={`${idPrefix}-name`} value={name} onChange={(e) => onName(e.target.value)} autoComplete="name" />
</Field>
<Field id={`${idPrefix}-email`} label="ایمیل">
<Input
id={`${idPrefix}-email`}
value={email}
onChange={(e) => onEmail(e.target.value)}
dir="ltr"
autoComplete="email"
inputMode="email"
/>
</Field>
<Field id={`${idPrefix}-phone`} label="تلفن">
<Input
id={`${idPrefix}-phone`}
value={phone}
onChange={(e) => onPhone(e.target.value)}
dir="ltr"
autoComplete="tel"
inputMode="tel"
maxLength={phoneMaxLength}
/>
</Field>
</div>
{action}
</section>
);
}
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 (
<section className="space-y-3 border-t border-border pt-6" aria-labelledby={headingId}>
<h3 id={headingId} className="text-sm font-black">نام کاربری</h3>
{props.mode === "self" && props.hint}
{props.mode === "self" ? (
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${props.idPrefix}-username-current`} label="نام کاربری فعلی">
<Input
id={`${props.idPrefix}-username-current`}
value={props.current}
readOnly
dir="ltr"
autoComplete="username"
/>
</Field>
<Field id={`${props.idPrefix}-username`} label="نام کاربری جدید">
<Input
id={`${props.idPrefix}-username`}
value={props.next}
onChange={(e) => props.onNext(e.target.value.toLowerCase())}
dir="ltr"
autoComplete="off"
disabled={props.disabled}
/>
</Field>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<Field id={`${props.idPrefix}-username`} label="نام کاربری">
<Input
id={`${props.idPrefix}-username`}
value={props.value}
onChange={(e) => props.onChange(e.target.value.toLowerCase())}
dir="ltr"
autoComplete="off"
placeholder="مثلاً ahp"
/>
</Field>
</div>
)}
{props.mode === "self" ? props.action : null}
</section>
);
}
+109 -220
View File
@@ -1,6 +1,7 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
@@ -16,7 +17,6 @@ import {
Globe,
GripVertical,
Heart,
KeyRound,
Layers,
Loader2,
Lock,
@@ -25,7 +25,6 @@ import {
Plus,
Rocket,
Save,
ShieldCheck,
Sparkles,
Target,
Terminal,
@@ -65,6 +64,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { RichEditor } from "@/components/ui/rich-editor";
import {
Dialog,
DialogContent,
@@ -82,13 +82,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 +97,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 +198,32 @@ export function AdminStudio() {
})).filter((g) => g.items.length > 0);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="section-kicker">{isAdmin ? "استودیو ادمین" : "استودیو منتور"}</p>
<h1 className="mt-1.5 text-2xl font-bold tracking-tight sm:text-3xl">
{isAdmin ? (
<>
پنل <span className="text-gradient-brand">مدیریت</span>
</>
) : (
<>
پنل <span className="text-gradient-brand">منتور</span>
</>
)}
</h1>
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
{isAdmin
? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت — گروه‌بندی‌شده تا چیزی گم نشود."
: MENTOR_HINT}
</p>
</div>
<Badge variant="accent" className="gap-1 rounded-lg px-3 py-1.5">
<ShieldCheck className="h-4 w-4" /> {isAdmin ? "ادمین" : "منتور"}
</Badge>
</div>
<div>
<header className="mb-6 space-y-1">
<h1 className="text-2xl font-black tracking-tight">{isAdmin ? "مدیریت" : "منتور"}</h1>
<p className="text-sm text-muted-foreground">
{isAdmin
? "افراد، ثبت‌نام، محتوای دوره، ارتباط و تنظیمات سایت."
: MENTOR_HINT}
</p>
</header>
{/* Mobile: horizontal grouped chips */}
<nav className="lg:hidden" aria-label={isAdmin ? "بخش‌های مدیریت" : "بخش‌های منتور"}>
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
{visibleGroups.map((group) => (
<div key={group.title} className="flex shrink-0 flex-col gap-1.5">
<p className="px-1 text-[10px] font-bold text-muted-foreground">{group.title}</p>
<div className="flex gap-1.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl px-3 py-2 text-xs font-bold transition-colors",
active
? "bg-primary text-primary-foreground shadow-offset-sm"
: "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{item.label}
</button>
);
})}
</div>
</div>
))}
</div>
</nav>
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
<aside className="hidden w-52 shrink-0 lg:sticky lg:top-24 lg:block">
<nav
className="space-y-3 rounded-xl border border-border bg-card p-2.5"
aria-label={isAdmin ? "منوی مدیریت" : "منوی منتور"}
>
{visibleGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-2 text-[11px] font-bold text-muted-foreground">
{group.title}
</p>
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = tab === item.id;
return (
<li key={item.id}>
<button
type="button"
onClick={() => setTab(item.id)}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-bold transition-colors",
active
? "bg-primary text-primary-foreground shadow-offset-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{item.label}
</button>
</li>
);
})}
</ul>
</div>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">
{navItem && (
<AdminPanelHeader
title={navItem.label}
description={navItem.description}
icon={navItem.icon}
/>
)}
<SectionColumns>
<SectionNav
label={isAdmin ? "بخش‌های مدیریت" : "بخش‌های منتور"}
groups={visibleGroups.map((group) => ({
title: group.title,
items: group.items.map((item) => ({ id: item.id, label: item.label })),
}))}
activeId={tab}
onSelect={setTab}
/>
<SectionPanel
id="admin-section"
titleId="admin-section-title"
title={navItem?.label ?? ""}
description={navItem?.description ?? ""}
>
{tab === "stats" && isAdmin && <StatsPanel />}
{tab === "learning" && <LearningPanel />}
{tab === "users" && isAdmin && <UsersPanel />}
@@ -318,9 +239,10 @@ export function AdminStudio() {
{tab === "events" && <AdminEventsPanel />}
{tab === "challenges" && isAdmin && <ChallengesPanel />}
{tab === "announce" && isAdmin && <AnnouncePanel />}
{tab === "bots" && isAdmin && <BotPanel />}
{tab === "chat-export" && isAdmin && <ChatExportPanel />}
</div>
</div>
</SectionPanel>
</SectionColumns>
</div>
);
}
@@ -374,7 +296,7 @@ function UsersPanel() {
const del = async (u: User) => {
if (
!(await confirm(
"حذف کاربر",
"حذف دائمی",
`حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`
))
) {
@@ -454,6 +376,10 @@ function UsersPanel() {
(currentUser?.role === "mentor" && u.role === "student");
return (
<div key={u.id} className="flex items-center gap-3 px-3 py-2.5">
<Link
href={`/profile/${u.id}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-md outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm font-medium">
@@ -475,6 +401,7 @@ function UsersPanel() {
{u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)}
</span>
</div>
</Link>
<div className="flex shrink-0 gap-1.5">
{canMutate && u.id !== currentUser?.id && (
u.isLocked ? (
@@ -492,8 +419,8 @@ function UsersPanel() {
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{canMutate && u.id !== currentUser?.id && (
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف">
{canMutate && u.id !== currentUser?.id && (u.isFrozen || u.isClosed) && (
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف دائمی" aria-label="حذف دائمی">
{busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
)}
@@ -515,7 +442,7 @@ function UsersPanel() {
);
}
function UserDialog({
export function UserDialog({
user,
onClose,
onSaved,
@@ -529,18 +456,13 @@ function UserDialog({
const [form, setForm] = useState<User>(user ?? ({} as User));
const [password, setPassword] = useState("");
const [securityAnswer, setSecurityAnswer] = useState("");
const [resetAnswer, setResetAnswer] = useState("");
const [resetPassword, setResetPassword] = useState("");
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (user) {
setForm(user);
setPassword("");
setSecurityAnswer("");
setResetAnswer("");
setResetPassword("");
}
}, [user]);
@@ -633,29 +555,6 @@ function UserDialog({
}
};
const doReset = async () => {
if (!form?.id) return;
const pwErr = validatePassword(resetPassword);
if (!resetAnswer.trim() || pwErr) {
toast.error(pwErr ?? "جواب امنیتی و رمز جدید (حداقل ۸ کاراکتر) لازم است");
return;
}
setResetting(true);
try {
await http.post(`/api/admin/users/${form.id}/reset-password`, {
securityAnswer: resetAnswer,
newPassword: resetPassword,
});
toast.success("رمز عبور ریست شد");
setResetAnswer("");
setResetPassword("");
} catch (e) {
toast.error(toUserError(e));
} finally {
setResetting(false);
}
};
return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -664,40 +563,27 @@ function UserDialog({
<DialogDescription>
{isNew
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
: "اطلاعات کاربر را به‌روزرسانی کنید. ریست رمز فقط با جواب امنیتی."}
: "اطلاعات کاربر را به‌روزرسانی کنید."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label>نام</Label>
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>شناسه</Label>
<Input
value={form.username ?? ""}
onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase() })}
dir="ltr"
className="text-left"
placeholder="مثلاً ahp"
/>
</div>
<div className="space-y-1.5">
<Label>ایمیل</Label>
<Input type="email" value={form.email ?? ""} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>تلفن</Label>
<Input
value={form.phone ?? ""}
onChange={(e) => setForm({ ...form, phone: clampPhoneInput(e.target.value) })}
dir="ltr"
className="text-left"
placeholder="09xxxxxxxxx"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
/>
</div>
<div className="space-y-8">
<AccountIdentityFields
idPrefix="admin-user"
name={form.name ?? ""}
email={form.email ?? ""}
phone={form.phone ?? ""}
onName={(value) => setForm({ ...form, name: value })}
onEmail={(value) => setForm({ ...form, email: value })}
onPhone={(value) => setForm({ ...form, phone: clampPhoneInput(value) })}
phoneMaxLength={PHONE_INPUT_MAX_LEN}
/>
<AccountUsernameSection
mode="admin"
idPrefix="admin-user"
value={form.username ?? ""}
onChange={(value) => setForm({ ...form, username: value })}
/>
<section className="space-y-4 border-t border-border pt-6">
<div className="space-y-1.5">
<Label>نقش</Label>
<select
@@ -768,40 +654,7 @@ function UserDialog({
/>
</div>
)}
{!isNew && (
<div className="space-y-2 rounded-md border border-dashed border-border p-3">
<p className="text-sm font-medium">ریست رمز با سوال امنیتی</p>
{form.securityQuestion ? (
<p className="text-xs text-muted-foreground">سوال: {form.securityQuestion}</p>
) : (
<p className="text-xs text-destructive">سوال امنیتی تعریف نشده است.</p>
)}
<PasswordInput
value={resetAnswer}
onChange={(e) => setResetAnswer(e.target.value)}
placeholder="جواب امنیتی"
dir="rtl"
autoComplete="off"
/>
<PasswordInput
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
placeholder="رمز جدید (حداقل ۸ کاراکتر)"
minLength={8}
autoComplete="new-password"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={resetting || !form.hasSecurityAnswer}
onClick={doReset}
>
{resetting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
ریست رمز
</Button>
</div>
)}
</section>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>انصراف</Button>
@@ -899,11 +752,31 @@ function CurriculumPanel() {
}
};
const publishLesson = async (l: Lesson, publish: boolean) => {
try {
await http.post(`/api/admin/lessons/${l.id}/publish`, { publish });
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
toast.success(publish ? "درس منتشر شد" : "درس به پیش‌نویس برگشت");
} catch (e) {
toast.error(toUserError(e));
}
};
const publishChapter = async (ch: Chapter, publish: boolean) => {
try {
await http.post(`/api/admin/chapters/${ch.id}/publish`, { publish });
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
toast.success(publish ? "فصل منتشر شد" : "فصل به پیش‌نویس برگشت");
} catch (e) {
toast.error(toUserError(e));
}
};
const newLesson = (chapterId: number) =>
setLessonModal({
id: 0, chapterId, title: "", description: "", videoKey: "",
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true,
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
});
const duplicateLesson = async (l: Lesson) => {
@@ -958,7 +831,7 @@ function CurriculumPanel() {
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
</p>
</div>
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}>
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}>
<Plus className="h-4 w-4" /> فصل جدید
</Button>
</CardHeader>
@@ -978,8 +851,8 @@ function CurriculumPanel() {
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
</div>
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
{ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو
<Badge variant={ch.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{ch.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
<GroupNav
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
@@ -988,6 +861,15 @@ function CurriculumPanel() {
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
<Pencil className="h-3 w-3" /> فصل
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
onClick={() => publishChapter(ch, !ch.isPublished)}
title={ch.isPublished ? "برگشت به پیش‌نویس" : "انتشار فصل"}
>
{ch.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
<Plus className="h-3.5 w-3.5" /> درس
</Button>
@@ -1043,8 +925,8 @@ function CurriculumPanel() {
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
</div>
</div>
<Badge variant={l.isActive ? "success" : "secondary"} className="px-1.5 text-[10px]">
{l.isActive ? "فعال" : "پیش‌نویس"}
<Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{l.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
<div className="flex shrink-0 gap-0.5">
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
@@ -1054,6 +936,15 @@ function CurriculumPanel() {
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-1.5 text-[10px]"
title={l.isPublished ? "برگشت به پیش‌نویس" : "انتشار درس"}
onClick={() => publishLesson(l, !l.isPublished)}
>
{l.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -1204,7 +1095,7 @@ function LessonDialog({
onClose: () => void;
onSave: (l: Lesson) => void;
}) {
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true });
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false });
useEffect(() => {
if (lesson) setDraft(lesson);
}, [lesson]);
@@ -1273,12 +1164,10 @@ function LessonDialog({
</div>
<div className="space-y-1.5">
<Label>توضیحات</Label>
<textarea
<RichEditor
value={draft.description}
onChange={(e) => set({ description: e.target.value })}
rows={3}
onChange={(v) => set({ description: v })}
placeholder="چرا این درس مهم است و چه چیزی می‌آموزد؟"
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="space-y-1.5">
+145 -20
View File
@@ -1,14 +1,19 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Bell,
BookOpen,
Calendar,
Eye,
Gamepad2,
Loader2,
Megaphone,
Route,
Settings2,
Trash2,
TrendingUp,
Users,
} from "lucide-react";
@@ -28,14 +33,28 @@ import {
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { toast } from "@/components/providers";
import { SafeMarkdown } from "@/components/safe-markdown";
const CATEGORIES = [
{ id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" },
{ id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" },
{ id: "gamification", label: "بازی‌سازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" },
{ id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" },
{ id: "urgent", label: "فوری", icon: AlertTriangle, hint: "نیاز به توجه همین حالا" },
{ id: "curriculum", label: "درس", icon: BookOpen, hint: "محتوای آموزشی" },
{ id: "community", label: "جامعه", icon: Users, hint: "فضای مشترک دوره" },
{ id: "system", label: "سیستم", icon: Settings2, hint: "وضعیت خود پرگار" },
] as const;
type Announcement = {
id: number;
title: string;
body: string;
category: string;
route: string;
pinned: boolean;
};
const ROUTES = [
{ path: "/unwrap", label: "نقشه مسیر" },
{ path: "/notifications", label: "اعلان‌ها" },
@@ -43,6 +62,20 @@ const ROUTES = [
{ path: "/cap", label: "داشبورد" },
];
function announcementExcerpt(body: string) {
const plain = body
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]*)`/g, "$1")
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)]\([^)]*\)/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/[*_~>]+/g, "")
.replace(/\s+/g, " ")
.trim();
if (plain.length <= 180) return plain;
return `${plain.slice(0, 180).trimEnd()}…`;
}
function PreviewCard({
title,
body,
@@ -66,9 +99,10 @@ function PreviewCard({
</Badge>
</div>
<p className="mt-2.5 text-sm font-black leading-snug">{title.trim() || "عنوان اطلاعیه"}</p>
<p className="mt-1 min-h-[2.5rem] text-xs leading-relaxed text-muted-foreground">
{body.trim() || "متن پیام اینجا دیده می‌شود…"}
</p>
<SafeMarkdown
text={body.trim() || "متن پیام اینجا دیده می‌شود…"}
className="mt-1 min-h-[2.5rem] space-y-1 text-xs leading-relaxed text-muted-foreground"
/>
<p className="mt-2.5 truncate text-[10px] font-mono text-primary" dir="ltr">
→ {route || "/"}
</p>
@@ -80,8 +114,15 @@ export function AnnouncePanel() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [category, setCategory] = useState<string>("event");
const [route, setRoute] = useState("/unwrap");
const [route, setRoute] = useState("");
const [pinned, setPinned] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const qc = useQueryClient();
const listQ = useQuery({
queryKey: ["admin-announcements"],
queryFn: () => http.get<{ announcements: Announcement[] }>("/api/admin/announcements"),
});
const [confirmOpen, setConfirmOpen] = useState(false);
const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null);
@@ -93,17 +134,21 @@ export function AnnouncePanel() {
if (!valid) return;
setBusy(true);
try {
const res = await http.post<{ recipients: number }>("/api/admin/announce", {
title: title.trim(),
body: body.trim(),
category,
route,
});
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
setLastSent({ title: title.trim(), recipients: res.recipients });
const payload = { title: title.trim(), body: body.trim(), category, route: route.trim(), pinned };
if (editingId) {
await http.put(`/api/admin/announcements/${editingId}`, payload);
toast.success("اطلاعیه به‌روز شد");
} else {
const res = await http.post<{ recipients: number }>("/api/admin/announce", payload);
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
setLastSent({ title: title.trim(), recipients: res.recipients });
}
setTitle("");
setBody("");
setPinned(false);
setEditingId(null);
setConfirmOpen(false);
void qc.invalidateQueries({ queryKey: ["admin-announcements"] });
} catch (e) {
toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد"));
} finally {
@@ -113,7 +158,8 @@ export function AnnouncePanel() {
return (
<>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="flex flex-col gap-6">
<div data-announce-compose className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-base">نگارش اعلان</CardTitle>
@@ -136,13 +182,13 @@ export function AnnouncePanel() {
<div className="space-y-2">
<div className="flex h-5 items-center justify-between gap-3">
<Label className="text-sm font-bold">پیام</Label>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۵۰۰</span>
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۴۰۰۰</span>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value.slice(0, 500))}
onChange={(e) => setBody(e.target.value.slice(0, 4000))}
rows={5}
maxLength={500}
maxLength={4000}
className={cn(
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
!bodyOk && body.length > 0 ? "border-destructive/50" : "border-input"
@@ -190,8 +236,9 @@ export function AnnouncePanel() {
<div className="space-y-2">
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
<Route className="h-3.5 w-3.5" /> مقصد کلیک
<Route className="h-3.5 w-3.5" /> با زدن اعلان این صفحه باز می‌شود
</Label>
<p className="text-[11px] text-muted-foreground">خالی بگذارید تا اعلان فقط خوانده شود و صفحه‌ای باز نکند.</p>
<div className="flex flex-wrap gap-2">
{ROUTES.map((r) => (
<button
@@ -214,8 +261,12 @@ export function AnnouncePanel() {
onChange={(e) => setRoute(e.target.value)}
dir="ltr"
className="h-10 rounded-2xl font-mono text-xs"
placeholder="/unwrap"
placeholder="خالی مجاز است"
/>
<label className="flex items-center gap-2 text-sm font-bold">
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
سنجاق روی درخت مهارت
</label>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
@@ -235,7 +286,7 @@ export function AnnouncePanel() {
</CardContent>
</Card>
<div className="space-y-4 lg:sticky lg:top-24">
<div className="space-y-4">
<Card className="border bg-muted/20">
<CardHeader className="space-y-1 pb-3">
<CardTitle className="flex h-6 items-center gap-2 text-sm">
@@ -260,6 +311,80 @@ export function AnnouncePanel() {
</div>
</div>
<Card data-announce-list className="rounded-2xl border border-border">
<CardHeader>
<CardTitle className="text-base">اطلاعیه‌های ثبت‌شده</CardTitle>
</CardHeader>
<CardContent>
{(listQ.data?.announcements ?? []).length > 0 ? (
<ul className="flex list-none flex-col gap-3 p-0">
{(listQ.data?.announcements ?? []).map((a) => {
const cat = CATEGORIES.find((c) => c.id === a.category);
const excerpt = announcementExcerpt(a.body);
return (
<li key={a.id} className="rounded-2xl border border-border bg-background p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 text-sm font-bold leading-snug">{a.title}</p>
<Badge variant="secondary" className="text-[10px]">
{cat?.label ?? a.category}
</Badge>
{a.pinned ? (
<Badge variant="gold" className="text-[10px]">
سنجاق
</Badge>
) : null}
</div>
{excerpt ? (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{excerpt}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingId(a.id);
setTitle(a.title);
setBody(a.body);
setCategory(a.category);
setRoute(a.route);
setPinned(a.pinned);
}}
>
ویرایش
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label="حذف"
onClick={() => {
void http
.del(`/api/admin/announcements/${a.id}`)
.then(() => qc.invalidateQueries({ queryKey: ["admin-announcements"] }))
.catch((e) => toast.error(toUserError(e, "حذف ممکن نشد")));
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</li>
);
})}
</ul>
) : !listQ.isPending ? (
<p className="rounded-2xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
هنوز اطلاعیه‌ای ثبت نشده است.
</p>
) : null}
</CardContent>
</Card>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 sm:rounded-2xl">
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
@@ -275,7 +400,7 @@ export function AnnouncePanel() {
</Button>
<Button className="h-10 gap-2" disabled={busy || !valid} onClick={() => void send()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
تأیید و ارسال
{editingId ? "تأیید و ذخیره" : "تأیید و ارسال"}
</Button>
</DialogFooter>
</DialogContent>
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/components/providers";
import { formatFaNumber, formatJalaliStamp } from "@/lib/utils";
const PAGE_SIZE = 8;
type BotKey = {
id: number;
botId: number;
name: string;
createdAt: string;
active: boolean;
requestCount: number;
};
type BotList = {
items: BotKey[];
total: number;
page: number;
pageSize: number;
};
export function BotPanel() {
const qc = useQueryClient();
const [name, setName] = useState("");
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const [page, setPage] = useState(1);
const [toggling, setToggling] = useState<number | null>(null);
const listQ = useQuery({
queryKey: ["admin-bots", page],
queryFn: () => http.get<BotList>(`/api/admin/bots?page=${page}&pageSize=${PAGE_SIZE}`),
});
const items = listQ.data?.items ?? [];
const total = listQ.data?.total ?? 0;
const pageSize = listQ.data?.pageSize ?? PAGE_SIZE;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageSafe = Math.min(Math.max(page, 1), totalPages);
async function copyToken() {
try {
await navigator.clipboard.writeText(token);
toast.success("توکن کپی شد");
} catch {
toast.error("کپی ممکن نشد");
}
}
function setActive(row: BotKey, active: boolean) {
setToggling(row.id);
void http
.put(`/api/admin/bots/${row.id}`, { active })
.then(() => {
toast.success(active ? "کلید فعال شد" : "کلید متوقف شد");
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
})
.catch((e) => toast.error(toUserError(e, "تغییر وضعیت ممکن نشد")))
.finally(() => setToggling(null));
}
return (
<div className="space-y-4">
<Card className="rounded-2xl">
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">کلید API</CardTitle>
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده می‌شود.</p>
</div>
<Button type="button" variant="outline" size="sm" asChild>
<Link href="/admin/bot-docs">
<BookOpen className="h-3.5 w-3.5" />
راهنما
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="نام ربات" />
<Button
type="button"
disabled={busy}
onClick={() => {
setBusy(true);
void http
.post<{ token: string }>("/api/admin/bots", { name })
.then((res) => {
setToken(res.token);
setName("");
setPage(1);
toast.success("ربات ساخته شد");
return qc.invalidateQueries({ queryKey: ["admin-bots"] });
})
.catch((e) => toast.error(toUserError(e, "ساخت ربات ممکن نشد")))
.finally(() => setBusy(false));
}}
>
ساخت ربات
</Button>
{token && (
<div className="space-y-2 rounded-2xl border border-border bg-muted/30 p-3">
<p className="text-sm text-muted-foreground">توکن فقط یک بار نشان داده می‌شود.</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={token} dir="ltr" className="font-mono text-xs" aria-label="توکن ربات" />
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => void copyToken()}>
کپی
</Button>
<Button type="button" variant="ghost" onClick={() => setToken("")}>
بستن
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="rounded-2xl">
<CardHeader>
<CardTitle className="text-base">کلیدها</CardTitle>
</CardHeader>
<CardContent>
{listQ.isPending ? (
<p className="text-sm text-muted-foreground">در حال بارگذاری…</p>
) : listQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(listQ.error, "بارگذاری کلیدها ممکن نشد")}</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">هنوز کلیدی نیست.</p>
) : (
<div className="space-y-3">
<ul className="divide-y divide-border">
{items.map((row) => (
<li key={row.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-bold">{row.name || "ربات"}</p>
<p className="text-xs text-muted-foreground">{formatJalaliStamp(row.createdAt)}</p>
<p className="text-xs">
<span className={row.active ? "font-bold text-success" : "font-bold text-muted-foreground"}>
{row.active ? "فعال" : "متوقف"}
</span>
<span className="text-muted-foreground"> · {formatFaNumber(row.requestCount)} درخواست</span>
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={toggling === row.id}
onClick={() => setActive(row, !row.active)}
>
{row.active ? "توقف" : "فعال‌کردن"}
</Button>
</li>
))}
</ul>
{totalPages > 1 && (
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<Button type="button" variant="outline" size="sm" disabled={pageSafe <= 1} onClick={() => setPage(pageSafe - 1)}>
قبلی
</Button>
<p className="text-sm font-bold tabular-nums text-muted-foreground">
{formatFaNumber(pageSafe)} از {formatFaNumber(totalPages)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={pageSafe >= totalPages}
onClick={() => setPage(pageSafe + 1)}
>
بعدی
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -68,16 +68,18 @@ export function InvitesPanel() {
const [editBusy, setEditBusy] = useState(false);
const [page, setPage] = useState(1);
const [q, setQ] = useState("");
const [status, setStatus] = useState<"" | RegistrationInvite["status"]>("");
useEffect(() => {
setPage(1);
}, [q]);
}, [q, status]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "invites", q, page],
queryKey: ["admin", "invites", q, status, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim());
if (status) params.set("status", status);
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
},
});
@@ -296,14 +298,43 @@ export function InvitesPanel() {
</CardHeader>
<CardContent className="space-y-4">
<ListToolbar>
<div className="flex flex-wrap items-center gap-2">
{(
[
["", "همه"],
["active", "فعال"],
["paused", "متوقف"],
["exhausted", "تکمیل‌شده"],
["revoked", "لغو شده"],
] as const
).map(([value, label]) => (
<Button
key={value || "all"}
type="button"
size="sm"
variant={status === value ? "default" : "outline"}
onClick={() => setStatus(value)}
>
{label}
</Button>
))}
</div>
<Input
placeholder="جستجو با برچسب…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="max-w-xs"
/>
{q && (
<Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}>
{(q || status) && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setQ("");
setStatus("");
}}
>
پاک کردن
</Button>
)}
@@ -315,7 +346,7 @@ export function InvitesPanel() {
)}
{!isLoading && invites.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{q.trim() ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
{q.trim() || status ? "نتیجه‌ای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
</p>
)}
<div className="space-y-2">
@@ -1,9 +1,10 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Award,
Flame,
Heart,
Loader2,
@@ -34,6 +35,12 @@ import {
} from "@/components/ui/dialog";
import { toast } from "@/components/providers";
import { cn, formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
type Paginated,
} from "@/components/admin/list-pagination";
type SortKey = "xp" | "progress" | "activity" | "streak";
type FilterKey = "all" | "active" | "locked";
@@ -58,6 +65,12 @@ export function LearningPanel() {
const [sort, setSort] = useState<SortKey>("xp");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [unlocking, setUnlocking] = useState(false);
const [issuingCertFor, setIssuingCertFor] = useState<number | null>(null);
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [q, filter, sort]);
const overviewQ = useQuery({
queryKey: ["admin", "learning", "overview"],
@@ -66,11 +79,17 @@ export function LearningPanel() {
});
const studentsQ = useQuery({
queryKey: ["admin", "learning", "students", q],
queryFn: () =>
http.get<{ students: StudentLearningRow[] }>(
`/api/admin/learning/students?q=${encodeURIComponent(q)}`
),
queryKey: ["admin", "learning", "students", q, filter, sort, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (q.trim()) params.set("q", q.trim());
if (filter !== "all") params.set("filter", filter);
params.set("sort", sort);
return http.get<Paginated<StudentLearningRow>>(
`/api/admin/learning/students?${params.toString()}`,
);
},
placeholderData: keepPreviousData,
retry: 1,
});
@@ -97,28 +116,23 @@ export function LearningPanel() {
}
};
const issueCertificate = async (id: number) => {
setIssuingCertFor(id);
try {
await http.post(`/api/admin/users/${id}/certificate/issue`, {});
toast.success("گواهینامه با موفقیت صادر شد");
await qc.invalidateQueries({ queryKey: ["admin", "learning", "user", id] });
} catch (e) {
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
} finally {
setIssuingCertFor(null);
}
};
const overview = overviewQ.data?.overview;
const students = useMemo(() => {
let rows = [...(studentsQ.data?.students ?? [])];
if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked);
if (filter === "locked") rows = rows.filter((r) => r.isLocked);
rows.sort((a, b) => {
switch (sort) {
case "progress":
return b.progressPct - a.progressPct;
case "streak":
return b.streakCurrent - a.streakCurrent;
case "activity": {
const at = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0;
const bt = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0;
return bt - at;
}
default:
return b.xp - a.xp;
}
});
return rows;
}, [studentsQ.data?.students, filter, sort]);
const students = studentsQ.data?.items ?? [];
const studentsTotal = studentsQ.data?.total ?? 0;
const studentsPageSize = studentsQ.data?.pageSize ?? DEFAULT_PAGE_SIZE;
const cards = overview
? [
@@ -177,7 +191,7 @@ export function LearningPanel() {
<div>
<CardTitle className="text-base">جدول هنرجویان</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{formatFaNumber(students.length)} نفر · برای جزئیات روی ردیف بزنید
{formatFaNumber(studentsTotal)} نفر · برای جزئیات روی ردیف بزنید
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -295,6 +309,12 @@ export function LearningPanel() {
<p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p>
)}
</div>
<ListPagination
page={page}
pageSize={studentsPageSize}
total={studentsTotal}
onPageChange={setPage}
/>
</CardContent>
</Card>
@@ -305,9 +325,11 @@ export function LearningPanel() {
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
</DialogTitle>
<DialogDescription>
{detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"}
{detailQ.isError
? toUserError(detailQ.error, "بارگذاری جزئیات ممکن نشد")
: detailQ.data
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
: "در حال بارگذاری…"}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 px-6 py-5">
@@ -362,6 +384,29 @@ export function LearningPanel() {
))}
</div>
{detailQ.data.learning.user.progressPct >= 100 && (
<div className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-3 py-2.5">
<div className="flex items-center gap-2">
<Award className="h-4 w-4 shrink-0 text-primary" />
<p className="text-xs font-bold">دوره تکمیل شده — گواهینامه قابل صدور است</p>
</div>
<Button
size="sm"
variant="default"
disabled={issuingCertFor === detailQ.data.learning.user.id}
onClick={() => issueCertificate(detailQ.data!.learning.user.id)}
className="gap-1.5"
>
{issuingCertFor === detailQ.data.learning.user.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Award className="h-3.5 w-3.5" />
)}
صدور گواهینامه
</Button>
</div>
)}
<section className="space-y-2">
<h3 className="text-sm font-black">دروس</h3>
<div className="max-h-64 space-y-1.5 overflow-y-auto">
+10 -1
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
Activity,
Bot,
BookOpen,
CalendarPlus,
DatabaseBackup,
@@ -33,6 +34,7 @@ export type AdminTabId =
| "events"
| "challenges"
| "announce"
| "bots"
| "chat-export"
| "settings";
@@ -166,6 +168,13 @@ export const ADMIN_NAV: AdminNavGroup[] = [
icon: Megaphone,
adminOnly: true,
},
{
id: "bots",
label: "ربات",
description: "کلیدهای API، وضعیت، شمار درخواست و راهنمای اتصال.",
icon: Bot,
adminOnly: true,
},
{
id: "chat-export",
label: "آرشیو گفتگوها",
@@ -181,7 +190,7 @@ export const ADMIN_NAV: AdminNavGroup[] = [
{
id: "settings",
label: "تنظیمات سایت",
description: "حمایت مالی، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
description: "شبکه‌ها، قلب‌ها، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
icon: Settings2,
adminOnly: true,
},
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } from "@/lib/types";
@@ -11,6 +11,11 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils";
import {
DEFAULT_PAGE_SIZE,
ListPagination,
buildPageQuery,
} from "@/components/admin/list-pagination";
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
requested: "درخواست‌شده",
@@ -27,16 +32,29 @@ const NEXT: Partial<Record<PhysicalOrderStatus, PhysicalOrderStatus>> = {
export function PhysicalOrdersPanel() {
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const [notes, setNotes] = useState<Record<number, string>>({});
const [tracking, setTracking] = useState<Record<number, string>>({});
const [busy, setBusy] = useState<number | null>(null);
useEffect(() => {
setPage(1);
}, [statusFilter]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "physical-orders", statusFilter],
queryFn: () =>
http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>(
`/api/admin/physical-orders?status=${encodeURIComponent(statusFilter)}`,
),
queryKey: ["admin", "physical-orders", statusFilter, page],
queryFn: () => {
const params = new URLSearchParams(buildPageQuery(page));
if (statusFilter) params.set("status", statusFilter);
return http.get<{
orders: CertificatePhysicalOrder[];
physical: PhysicalCertSettings;
total: number;
page: number;
pageSize: number;
}>(`/api/admin/physical-orders?${params.toString()}`);
},
placeholderData: keepPreviousData,
});
const update = async (id: number, status: PhysicalOrderStatus) => {
@@ -151,6 +169,12 @@ export function PhysicalOrdersPanel() {
{!isLoading && (data?.orders ?? []).length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
)}
<ListPagination
page={page}
pageSize={data?.pageSize ?? DEFAULT_PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</CardContent>
</Card>
);
@@ -0,0 +1,246 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, Lock, Mail, Pencil, Phone, Shield, ShieldQuestion, Trash2, Unlock } from "lucide-react";
import { UserDialog } from "@/components/admin-studio";
import { useConfirm } from "@/components/admin/use-confirm";
import { Panel } from "@/components/panel";
import { toast } from "@/components/providers";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Skeleton } from "@/components/ui/skeleton";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import { validatePassword } from "@/lib/validation";
import { cn } from "@/lib/utils";
import type { User } from "@/lib/types";
export function AdminProfileManage({ userId }: { userId: number }) {
const me = useAuth((s) => s.user);
const router = useRouter();
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const [busy, setBusy] = useState<"lock" | "delete" | "reset" | null>(null);
const [editing, setEditing] = useState<User | null>(null);
const [resetOpen, setResetOpen] = useState(false);
const [nextPassword, setNextPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const accountQ = useQuery({
queryKey: ["admin", "user", userId],
queryFn: () => http.get<{ user: User }>(`/api/admin/users/${userId}`),
retry: 1,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin", "user", userId] });
qc.invalidateQueries({ queryKey: ["public-profile", userId] });
qc.invalidateQueries({ queryKey: ["admin", "users"] });
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
};
const account = accountQ.data?.user;
const isSelf = account?.id === me?.id;
const canDelete = !!account && !isSelf && (account.isFrozen || account.isClosed);
const act = async (kind: "lock" | "unlock") => {
setBusy("lock");
try {
await http.post(`/api/admin/users/${userId}/${kind}`);
toast.success(kind === "lock" ? "حساب قفل شد" : "حساب فعال شد");
refresh();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const closeReset = () => {
setResetOpen(false);
setNextPassword("");
setConfirmPassword("");
};
const resetPassword = async () => {
const pwErr = validatePassword(nextPassword, confirmPassword);
if (pwErr) {
toast.error(pwErr);
return;
}
setBusy("reset");
try {
await http.post(`/api/admin/users/${userId}/reset-password`, { newPassword: nextPassword });
toast.success("رمز عبور عوض شد");
closeReset();
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
const del = async () => {
if (!account) {
return;
}
if (
!(await confirm(
"حذف دائمی",
`حساب «${account.name}» (${account.email}) برای همیشه حذف شود؟ این عمل بازگشت‌ناپذیر است.`,
))
) {
return;
}
setBusy("delete");
try {
await http.del(`/api/admin/users/${account.id}`);
toast.success("کاربر حذف شد");
router.push("/admin?tab=users");
} catch (e) {
toast.error(toUserError(e));
} finally {
setBusy(null);
}
};
return (
<>
{dialog}
<Panel
title="مدیریت حساب"
description="فقط ادمین این بخش را می‌بیند."
icon={<Shield className="h-5 w-5" />}
tint="bg-destructive/10 text-destructive"
>
{accountQ.isPending && (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-9 w-full" />
</div>
)}
{accountQ.isError && (
<p className="text-sm font-bold text-destructive">{toUserError(accountQ.error, "حساب پیدا نشد")}</p>
)}
{account && (
<div className="space-y-4">
<dl className="grid gap-3 sm:grid-cols-2">
<AccountFact icon={<Mail className="h-3.5 w-3.5" />} label="ایمیل" value={account.email} ltr />
<AccountFact
icon={<Phone className="h-3.5 w-3.5" />}
label="تلفن"
value={account.phone || "ثبت نشده"}
ltr
/>
<AccountFact
className="sm:col-span-2"
icon={<ShieldQuestion className="h-3.5 w-3.5" />}
label="سوال امنیتی"
value={account.securityQuestion || "تعریف نشده"}
/>
</dl>
<div className="space-y-2 border-t border-border/70 pt-4">
{!isSelf &&
(account.isLocked ? (
<Button size="sm" variant="success" disabled={busy !== null} onClick={() => act("unlock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Unlock className="h-3.5 w-3.5" />}
فعال‌سازی
</Button>
) : (
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => act("lock")}>
{busy === "lock" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lock className="h-3.5 w-3.5" />}
قفل
</Button>
))}
<div className={cn("grid gap-2", canDelete ? "sm:grid-cols-3" : "sm:grid-cols-2")}>
<Button size="sm" variant="outline" className="w-full" onClick={() => setEditing(account)}>
<Pencil className="h-3.5 w-3.5" /> ویرایش
</Button>
<Button size="sm" variant="outline" className="w-full" disabled={busy !== null} onClick={() => setResetOpen(true)}>
<KeyRound className="h-3.5 w-3.5" /> ریست رمز
</Button>
{canDelete && (
<Button size="sm" variant="destructive" className="w-full" disabled={busy !== null} onClick={del}>
{busy === "delete" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
حذف دائمی
</Button>
)}
</div>
</div>
</div>
)}
</Panel>
<UserDialog user={editing} onClose={() => setEditing(null)} onSaved={refresh} />
<Dialog open={resetOpen} onOpenChange={(open) => !open && closeReset()}>
<DialogContent>
<DialogHeader>
<DialogTitle>ریست رمز</DialogTitle>
<DialogDescription>رمز جدید همین حساب را بنویسید.</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="reset-password">رمز جدید</Label>
<PasswordInput
id="reset-password"
value={nextPassword}
onChange={(e) => setNextPassword(e.target.value)}
autoComplete="new-password"
placeholder="حداقل ۸ کاراکتر"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="reset-password-confirm">تکرار رمز</Label>
<PasswordInput
id="reset-password-confirm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="همان رمز را دوباره بنویسید"
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={closeReset}>
انصراف
</Button>
<Button onClick={() => void resetPassword()} disabled={busy === "reset"}>
{busy === "reset" ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
ذخیره رمز
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function AccountFact({
icon,
label,
value,
ltr,
className,
}: {
icon: ReactNode;
label: string;
value: string;
ltr?: boolean;
className?: string;
}) {
return (
<div className={cn("rounded-xl border border-border/80 bg-muted/25 px-3.5 py-3", className)}>
<dt className="flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
{icon}
{label}
</dt>
<dd className={cn("mt-1.5 text-sm font-semibold leading-relaxed [overflow-wrap:anywhere]", ltr && "font-latin")} dir={ltr ? "ltr" : undefined}>
{value}
</dd>
</div>
);
}
@@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { HeartHandshake, Loader2, Package, Save, Settings2, Sparkles } from "lucide-react";
import { Heart, Loader2, Package, Save, Settings2, Share2, Sparkles } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -15,6 +15,7 @@ import type { AdminTabId } from "@/components/admin/nav";
type SettingsMap = Record<string, string>;
type SponsorDraft = { name: string; url: string; blurb: string };
type SocialDraft = { name: string; url: string };
function SectionHeading({
icon: Icon,
@@ -44,6 +45,7 @@ export function SiteSettingsPanel({
const qc = useQueryClient();
const [draft, setDraft] = useState<SettingsMap>({});
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
const [socials, setSocials] = useState<SocialDraft[]>([]);
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
@@ -68,15 +70,17 @@ export function SiteSettingsPanel({
} catch {
setSponsors([]);
}
try {
const parsed = JSON.parse(data.settings.social_links || "[]") as SocialDraft[];
setSocials(Array.isArray(parsed) ? parsed.map((s) => ({ name: s.name ?? "", url: s.url ?? "" })) : []);
} catch {
setSocials([]);
}
}, [data]);
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
const save = async () => {
if ((draft.donation_note ?? "").trim().length > 2000) {
toast.error("یادداشت دونیت خیلی طولانی است");
return;
}
const price = Number(draft.physical_cert_price_irr);
const days = Number(draft.physical_cert_window_days);
if (!Number.isFinite(price) || price < 0) {
@@ -110,6 +114,9 @@ export function SiteSettingsPanel({
try {
const settings = {
...draft,
social_links: JSON.stringify(
socials.filter((s) => s.name.trim() && s.url.trim()).map((s) => ({ name: s.name.trim(), url: s.url.trim() })),
),
sponsors: JSON.stringify(
sponsors
.filter((s) => s.name.trim())
@@ -173,24 +180,60 @@ export function SiteSettingsPanel({
<section className="space-y-3">
<SectionHeading
icon={HeartHandshake}
title="حمایت / دونیت"
hint="متن راهنما برای گفتگو با تیم دربارهٔ حمایت مالی"
icon={Share2}
title="شبکه‌های پرگار"
hint="حداکثر ۸ لینک رسمی. در فوتر و زبانه شبکه‌ها دیده می‌شود."
/>
{socials.map((s, i) => (
<div key={i} className="grid gap-2 sm:grid-cols-2">
<Input
value={s.name}
placeholder="نام"
onChange={(e) =>
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row)))
}
/>
<Input
dir="ltr"
value={s.url}
placeholder="https://"
onChange={(e) =>
setSocials((list) => list.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row)))
}
/>
</div>
))}
{socials.length < 8 && (
<Button type="button" size="sm" variant="outline" onClick={() => setSocials((list) => [...list, { name: "", url: "" }])}>
افزودن شبکه
</Button>
)}
</section>
<section className="space-y-3">
<SectionHeading icon={Heart} title="قلب‌ها" hint="سقف، فاصله بازگشت، و قفل در صفر." />
<div className="grid gap-2 sm:grid-cols-2">
<Input
type="number"
value={draft.hearts_max ?? "5"}
onChange={(e) => set("hearts_max", e.target.value)}
aria-label="سقف قلب"
/>
<Input
type="number"
value={draft.hearts_regen_minutes ?? "240"}
onChange={(e) => set("hearts_regen_minutes", e.target.value)}
aria-label="فاصله بازگشت به دقیقه"
/>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span>فعال باشد</span>
<span>صفر قلب حساب را قفل کند</span>
<Switch
checked={(draft.donation_enabled ?? "true") === "true"}
onCheckedChange={(v) => set("donation_enabled", v ? "true" : "false")}
aria-label="حمایت مالی"
checked={(draft.hearts_lock_on_empty ?? "false") === "true"}
onCheckedChange={(v) => set("hearts_lock_on_empty", v ? "true" : "false")}
aria-label="قفل در صفر"
/>
</label>
<Textarea
className="min-h-[96px]"
value={draft.donation_note ?? ""}
onChange={(e) => set("donation_note", e.target.value)}
placeholder="متن راهنما برای دانشجو"
/>
</section>
<section className="space-y-3">
@@ -142,7 +142,7 @@ export function StatsPanel() {
const s = data?.stats;
const cards = [
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "حساب‌های فعال در سامانه" },
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "هنرجویان با حساب فعال" },
{ label: "درس‌ها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درس‌های ثبت‌شده در درخت" },
{ label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
{ label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاه‌ها" },
+42 -51
View File
@@ -5,6 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
ChevronLeft,
ChevronRight,
Eye,
Film,
Link2,
@@ -32,7 +34,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { cn, formatFaNumber } from "@/lib/utils";
import { toast } from "@/components/providers";
import { Skeleton } from "@/components/ui/skeleton";
@@ -59,59 +61,19 @@ function VideoThumb({
className?: string;
onOpen?: () => void;
}) {
const [url, setUrl] = useState<string | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let cancelled = false;
setState("loading");
setUrl(null);
signedMediaUrl(videoKey)
.then((u) => {
if (!cancelled) {
setUrl(u);
setState("ready");
}
})
.catch(() => {
if (!cancelled) setState("error");
});
return () => {
cancelled = true;
};
}, [videoKey]);
return (
<button
type="button"
onClick={onOpen}
className={cn(
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
className
className,
)}
title="پیش‌نمایش"
>
{state === "loading" && (
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
)}
{state === "error" && (
<span className="absolute inset-0 grid place-items-center gap-1 text-muted-foreground">
<Film className="h-5 w-5" />
<span className="text-[10px] font-bold">پیش‌نمایش نیست</span>
</span>
)}
{url && state === "ready" && (
<video
src={url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onError={() => setState("error")}
/>
)}
<span className="absolute inset-0 grid place-items-center text-muted-foreground/30">
<Film className="h-10 w-10" />
</span>
<span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35">
<span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100">
<Play className="h-4 w-4 fill-current" />
@@ -169,7 +131,7 @@ function VideoPreviewDialog({
</div>
)}
{url && (
<video src={url} controls playsInline className="max-h-[70vh] w-full bg-black" autoPlay />
<video src={url} controls playsInline preload="none" className="max-h-[70vh] w-full bg-black" />
)}
</div>
<DialogFooter className="border-t border-border px-6 py-3 sm:justify-end">
@@ -182,12 +144,15 @@ function VideoPreviewDialog({
);
}
const PAGE_SIZE = 24;
export function VideosPanel() {
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
const [attachKey, setAttachKey] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState<string | null>(null);
@@ -230,10 +195,13 @@ export function VideosPanel() {
const filtered = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return videos;
return videos.filter((v) => v.key.toLowerCase().includes(term));
return term ? videos.filter((v) => v.key.toLowerCase().includes(term)) : videos;
}, [videos, q]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const pick = (file: File | undefined) => {
if (!file) return;
const invalid = validateVideoFile(file);
@@ -333,14 +301,14 @@ export function VideosPanel() {
{isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{videos.length} فایل · روی تصویر بزنید تا پخش شود
{videos.length} فایل · برای پخش روی دکمه کلیک کنید
</p>
</div>
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={(e) => { setQ(e.target.value); setPage(1); }}
placeholder="جستجوی نام فایل…"
className="h-10 ps-9"
/>
@@ -355,7 +323,7 @@ export function VideosPanel() {
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((v) => {
{paginated.map((v) => {
const used = usedByKey.get(v.key) ?? [];
return (
<div
@@ -431,6 +399,29 @@ export function VideosPanel() {
{q ? "نتیجه‌ای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
</p>
)}
{totalPages > 1 && (
<div className="col-span-full flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
disabled={safePage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">
صفحه {formatFaNumber(safePage)} از {formatFaNumber(totalPages)}
</span>
<Button
size="sm"
variant="outline"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
+23 -8
View File
@@ -10,6 +10,16 @@ import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { http } from "@/lib/api";
import type { User } from "@/lib/types";
import { hasInfiniteHearts } from "@/lib/hearts";
import { BottomNav } from "@/components/bottom-nav";
function SiteFooter() {
return (
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
<BrandMark size="sm" logoOnly />
<p className="text-xs text-muted-foreground">بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر</p>
</div>
);
}
function isPublic(p: string) {
return p === "/" || p === "/login";
@@ -24,6 +34,8 @@ const PROTECTED_PREFIXES = [
"/quiz",
"/notifications",
"/lockout",
"/profile",
"/settings",
];
function needsAuth(p: string) {
@@ -136,18 +148,21 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</button>
</div>
)}
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6">
<main
className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 md:pb-10"
// Bottom nav is 56px + safe area; only shown for authed non-admin on mobile
style={user && !pathname.startsWith("/admin") ? { paddingBottom: "calc(4.5rem + env(safe-area-inset-bottom, 0px))" } : undefined}
>
{allowChildren ? children : null}
</main>
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
<BrandMark size="sm" logoOnly />
<p className="text-xs text-muted-foreground">
بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر
</p>
</div>
<footer
className="border-t border-border/70 bg-muted/20 backdrop-blur"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<SiteFooter />
</footer>
{user && <ChatWidget />}
{user && !pathname.startsWith("/admin") && <BottomNav />}
</div>
);
}
+72 -19
View File
@@ -20,12 +20,16 @@ function clamp(v: number, lo: number, hi: number) {
export function AvatarCropDialog({
file,
busy,
error,
onCancel,
onInvalid,
onCrop,
}: {
file: File | null;
busy?: boolean;
error?: string | null;
onCancel: () => void;
onInvalid?: () => void;
onCrop: (blob: Blob) => Promise<void> | void;
}) {
const boxRef = useRef<HTMLDivElement>(null);
@@ -33,25 +37,68 @@ export function AvatarCropDialog({
const [C, setC] = useState(280);
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [localError, setLocalError] = useState<string | null>(null);
const drag = useRef<{ sx: number; sy: number; px: number; py: number } | null>(null);
const onInvalidRef = useRef(onInvalid);
onInvalidRef.current = onInvalid;
const fittedFor = useRef<HTMLImageElement | null>(null);
const fittedSize = useRef(0);
useEffect(() => {
if (!file) return;
if (!file) {
setImg(null);
return;
}
let cancel = false;
setLocalError(null);
const url = URL.createObjectURL(file);
const el = new Image();
el.onload = () => setImg({ el, w: el.naturalWidth, h: el.naturalHeight });
el.onload = () => {
if (cancel) return;
setImg({ el, w: el.naturalWidth, h: el.naturalHeight });
};
el.onerror = () => {
if (cancel) return;
setImg(null);
onInvalidRef.current?.();
};
el.src = url;
return () => URL.revokeObjectURL(url);
return () => {
cancel = true;
URL.revokeObjectURL(url);
};
}, [file]);
useEffect(() => {
if (!img) return;
const box = boxRef.current;
const size = Math.min(box?.clientWidth ?? 280, 320);
setC(size);
const minScale = size / Math.min(img.w, img.h);
setScale(minScale);
setPan({ x: (size - img.w * minScale) / 2, y: (size - img.h * minScale) / 2 });
let ro: ResizeObserver | null = null;
let frame = 0;
let tries = 0;
const fit = () => {
const box = boxRef.current;
if (!box) {
if (tries++ < 40) frame = requestAnimationFrame(fit);
return;
}
if (!ro) {
ro = new ResizeObserver(fit);
ro.observe(box);
}
const size = Math.round(Math.min(box.clientWidth, 320));
if (size < 8) return;
setC(size);
if (fittedFor.current === img.el && Math.abs(fittedSize.current - size) < 2) return;
fittedFor.current = img.el;
fittedSize.current = size;
const min = size / Math.min(img.w, img.h);
setScale(min);
setPan({ x: (size - img.w * min) / 2, y: (size - img.h * min) / 2 });
};
fit();
return () => {
cancelAnimationFrame(frame);
ro?.disconnect();
};
}, [img]);
const minScale = img ? C / Math.min(img.w, img.h) : 1;
@@ -94,12 +141,13 @@ export function AvatarCropDialog({
};
const confirm = async () => {
if (!img) return;
if (!img || !(scale > 0) || C < 8) return;
const cropSize = C / scale;
let cx = -pan.x / scale;
let cy = -pan.y / scale;
cx = clamp(cx, 0, img.w - cropSize);
cy = clamp(cy, 0, img.h - cropSize);
if (!(cropSize > 0) || !Number.isFinite(cx) || !Number.isFinite(cy)) return;
cx = clamp(cx, 0, Math.max(0, img.w - cropSize));
cy = clamp(cy, 0, Math.max(0, img.h - cropSize));
const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE;
@@ -113,10 +161,14 @@ export function AvatarCropDialog({
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
if (blob) await onCrop(blob);
if (!blob) {
setLocalError("ساخت تصویر آواتار ممکن نشد");
return;
}
await onCrop(blob);
};
const zoomPct = Math.round((scale / maxScale) * 100);
const zoomPct = maxScale > 0 ? Math.round((scale / maxScale) * 100) : 17;
return (
<Dialog open={!!file && !!img} onOpenChange={(o) => !o && !busy && onCancel()}>
@@ -145,10 +197,9 @@ export function AvatarCropDialog({
position: "absolute",
left: 0,
top: 0,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: "0 0",
width: img.w,
height: img.h,
transform: `translate(${pan.x}px, ${pan.y}px)`,
width: img.w * scale,
height: img.h * scale,
maxWidth: "none",
maxHeight: "none",
}}
@@ -184,6 +235,8 @@ export function AvatarCropDialog({
/>
</div>
{(error || localError) && <p className="text-[11px] font-bold text-destructive">{error || localError}</p>}
<DialogFooter className="sm:space-x-0 sm:space-x-reverse sm:justify-between">
<Button variant="outline" onClick={onCancel} disabled={busy}>
<X /> انصراف
@@ -192,7 +245,7 @@ export function AvatarCropDialog({
<Button variant="ghost" size="sm" onClick={setZoomIn} disabled={busy} title="تنظیم خودکار">
<Scan /> برازش
</Button>
<Button onClick={confirm} disabled={busy || !img}>
<Button onClick={confirm} disabled={busy || !img || !(scale > 0) || C < 8}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
{busy ? "در حال آپلود…" : "تأیید و ذخیره"}
</Button>
+24 -18
View File
@@ -6,7 +6,7 @@ import { useAuth } from "@/lib/auth-store";
import { queryClient } from "@/lib/query-client";
import { apiForm, http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types";
import { AVATAR_COLORS, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
import { AVATAR_PALETTES, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
import { AvatarCropDialog } from "@/components/avatar-crop";
import {
Dialog,
@@ -18,15 +18,6 @@ import {
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const PALETTES: { label: string; colors: string[] }[] = [
{ label: "پیش‌فرض", colors: AVATAR_COLORS },
{ label: "رزین", colors: ["#0f172a", "#38bdf8", "#f0abfc", "#a5f3fc", "#7c3aed", "#f5d0fe"] },
{ label: "صحرایی", colors: ["#1c1917", "#fbbf24", "#fb923c", "#fde68a", "#f97316", "#fed7aa"] },
{ label: "اسکاندی", colors: ["#022c22", "#34d399", "#fef3c7", "#a7f3d0", "#166534", "#d1fae5"] },
{ label: "نئون", colors: ["#18181b", "#a3e635", "#22d3ee", "#f97316", "#e879f9", "#fb7185"] },
{ label: "سلطنتی", colors: ["#20123a", "#9370db", "#e6e6fa", "#6b21a8", "#c084fc", "#f3e8ff"] },
];
export function AvatarStudio({ user, onClose }: { user: User; onClose: () => void }) {
const setUser = useAuth((s) => s.setUser);
const fileRef = useRef<HTMLInputElement>(null);
@@ -54,13 +45,18 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
void queryClient.invalidateQueries({ queryKey: ["leaderboard"] });
};
const publish = (fresh: User) => {
queryClient.setQueryData<{ user: User }>(["me"], { user: fresh });
setUser(fresh);
invalidate();
};
const doSave = async (body: { avatarVariant?: string; avatarPalette?: string; avatarPhoto?: string }) => {
setBusy(true);
setError(null);
try {
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", body);
setUser(fresh);
invalidate();
publish(fresh);
} catch (e) {
setError(toUserError(e, "خطا در ذخیره"));
} finally {
@@ -95,8 +91,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png");
const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form);
const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", { avatarPhoto: url });
setUser(fresh);
invalidate();
publish(fresh);
setPending(null);
} catch (e) {
setError(toUserError(e, "خطا در آپلود"));
@@ -106,7 +101,8 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
};
return (
<div className="space-y-4">
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pe-1">
<div className="flex items-center gap-4">
<div className="relative shrink-0">
<UserAvatar
@@ -162,7 +158,7 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p>
<div className="space-y-1">
{PALETTES.map((p) => {
{AVATAR_PALETTES.map((p) => {
const active = JSON.stringify(p.colors) === draftP;
return (
<button
@@ -224,8 +220,9 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
</div>
{error && <p className="text-[11px] font-bold text-destructive">{error}</p>}
</div>
<div className="sticky -bottom-3 z-10 -mx-2.5 flex items-center justify-between gap-2 bg-popover px-2.5 pb-1 pt-2">
<div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border bg-popover pt-3">
<div className="flex items-center gap-2">
{dirty && (
<Button variant="ghost" size="sm" onClick={reset} disabled={busy}>
@@ -250,7 +247,16 @@ export function AvatarStudio({ user, onClose }: { user: User; onClose: () => voi
<AvatarCropDialog
file={pending}
busy={busy}
onCancel={() => !busy && setPending(null)}
error={error}
onCancel={() => {
if (busy) return;
setPending(null);
setError(null);
}}
onInvalid={() => {
setPending(null);
setError("خواندن این عکس ممکن نشد. JPEG، PNG یا WebP را انتخاب کنید.");
}}
onCrop={(blob) => onUpload(blob)}
/>
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AVATAR_PALETTES } from "@/components/ui/user-avatar";
import { BANNER_FILLS, BANNER_HEIGHT, BANNER_WIDTH, drawBannerFill, type BannerFill } from "@/lib/banner-pattern";
import { toast } from "@/components/providers";
import { cn } from "@/lib/utils";
function FillThumb({
colors,
fill,
seed,
label,
active,
onClick,
}: {
colors: string[];
fill: BannerFill;
seed: string;
label: string;
active: boolean;
onClick: () => void;
}) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
drawBannerFill(ctx, canvas.width, canvas.height, colors, fill, seed);
}, [colors, fill, seed]);
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"flex flex-col gap-1 rounded-lg border p-1 text-center transition-colors",
active ? "border-primary bg-primary/10 ring-2 ring-primary" : "border-border hover:bg-muted",
)}
>
<canvas ref={ref} width={240} height={80} className="h-10 w-full rounded-md" />
<span className={cn("text-[10px] font-bold", active ? "text-primary" : "text-muted-foreground")}>{label}</span>
</button>
);
}
function PatternCanvas({
canvasRef,
colors,
seed,
fill,
onReady,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
colors: string[];
seed: string;
fill: BannerFill;
onReady: (ready: boolean) => void;
}) {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) {
onReady(false);
return;
}
drawBannerFill(ctx, BANNER_WIDTH, BANNER_HEIGHT, colors, fill, seed);
onReady(true);
}, [canvasRef, colors, seed, fill, onReady]);
return <canvas ref={canvasRef} width={BANNER_WIDTH} height={BANNER_HEIGHT} className="absolute inset-0 h-full w-full" />;
}
function HeaderPreview({ children }: { children: React.ReactNode }) {
return (
<div className="relative aspect-[3/1] w-full overflow-hidden rounded-xl border-2 border-border bg-muted">
{children}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-card via-card/25 to-transparent" />
<div
aria-hidden
className="pointer-events-none absolute bottom-0 left-1/2 aspect-square w-[18%] -translate-x-1/2 translate-y-1/2 rounded-full bg-muted ring-4 ring-card"
/>
</div>
);
}
export function BannerEditor({
open,
busy,
onOpenChange,
onSave,
onRemove,
}: {
open: boolean;
busy?: boolean;
onOpenChange: (open: boolean) => void;
onSave: (blob: Blob) => Promise<void>;
onRemove?: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [paletteIndex, setPaletteIndex] = useState(0);
const [fill, setFill] = useState<BannerFill>("gradient");
const [ready, setReady] = useState(false);
const palette = AVATAR_PALETTES[paletteIndex] ?? AVATAR_PALETTES[0];
const save = async () => {
try {
const canvas = canvasRef.current;
if (!canvas || !ready) return;
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) {
toast.error("ساخت تصویر بنر ممکن نشد");
return;
}
await onSave(blob);
} catch {
toast.error("ذخیره بنر ممکن نشد");
}
};
return (
<Dialog open={open} onOpenChange={(next) => !busy && onOpenChange(next)}>
<DialogContent className="flex max-h-[min(92dvh,44rem)] w-[calc(100%-1.5rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="shrink-0 space-y-1 px-4 pe-12 pt-4 text-start sm:px-6">
<DialogTitle>ویرایش بنر</DialogTitle>
<DialogDescription>تصویر عریض پشت نام و آواتار در هدر پروفایل. نسبت حدود ۳ به ۱.</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 py-3 sm:px-6">
<div className="space-y-1.5">
<HeaderPreview>
<PatternCanvas
canvasRef={canvasRef}
colors={palette.colors}
seed={palette.label}
fill={fill}
onReady={setReady}
/>
</HeaderPreview>
<p className="text-[11px] text-muted-foreground">پیش‌نمایش هدر — سایه و جای آواتار فقط برای دیدن نتیجه‌اند و داخل تصویر ذخیره نمی‌شوند.</p>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p>
<div className="space-y-1">
{AVATAR_PALETTES.map((p, i) => {
const active = i === paletteIndex;
return (
<button
key={p.label}
type="button"
onClick={() => setPaletteIndex(i)}
aria-pressed={active}
className={cn(
"flex w-full items-center justify-between rounded-lg border px-2.5 py-1.5 transition-colors",
active ? "border-primary bg-primary/10" : "border-border hover:bg-muted",
)}
>
<span className="text-xs font-bold">{p.label}</span>
<span className="flex items-center gap-0.5">
{p.colors.slice(0, 5).map((c) => (
<span
key={c}
className="h-3.5 w-3.5 rounded-full ring-1 ring-black/10 dark:ring-white/10"
style={{ background: c }}
/>
))}
{active && <Check className="ms-1 h-3.5 w-3.5 text-primary" />}
</span>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">پس‌زمینه</p>
<div className="grid grid-cols-3 gap-2">
{BANNER_FILLS.map((item) => (
<FillThumb
key={item.id}
colors={palette.colors}
fill={item.id}
seed={palette.label}
label={item.label}
active={fill === item.id}
onClick={() => setFill(item.id)}
/>
))}
</div>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-border bg-popover px-4 py-3 sm:flex-row sm:justify-between sm:space-x-0 sm:px-6">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
انصراف
</Button>
{onRemove && (
<Button type="button" variant="outline" onClick={onRemove} disabled={busy}>
برداشتن بنر
</Button>
)}
</div>
<Button type="button" onClick={() => void save()} disabled={!ready || busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
{busy ? "در حال ذخیره…" : "ذخیره بنر"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutGrid, Trophy, TreePine } from "lucide-react";
import { cn } from "@/lib/utils";
const items = [
{ href: "/cap", label: "مسیر", icon: TreePine },
{ href: "/unwrap", label: "مرکز", icon: LayoutGrid },
{ href: "/leaderboard", label: "لیگ", icon: Trophy },
];
export function BottomNav() {
const pathname = usePathname();
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-card/95 backdrop-blur md:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<div className="flex">
{items.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-0.5 py-2 text-[11px] font-bold transition-colors min-h-[56px] touch-manipulation",
active ? "text-primary" : "text-muted-foreground",
)}
>
<Icon
className={cn(
"h-5 w-5 transition-transform",
active && "scale-110",
)}
strokeWidth={active ? 2.5 : 2}
/>
{label}
</Link>
);
})}
</div>
</nav>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
expiresIn?: number;
};
const FALLBACK_SECONDS = 120;
export function CaptchaField({
id,
value,
onChange,
onChallenge,
onError,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onChallenge: (challenge: CaptchaChallenge | null) => void;
onError?: (message: string | null) => void;
}) {
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [loading, setLoading] = useState(true);
const [left, setLeft] = useState(FALLBACK_SECONDS);
const gen = useRef(0);
const rotating = useRef(false);
const onChallengeRef = useRef(onChallenge);
const onChangeRef = useRef(onChange);
const onErrorRef = useRef(onError);
onChallengeRef.current = onChallenge;
onChangeRef.current = onChange;
onErrorRef.current = onError;
const load = useCallback(async (silent: boolean) => {
if (silent && rotating.current) return;
if (silent) rotating.current = true;
const ticket = ++gen.current;
if (!silent) setLoading(true);
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
if (ticket !== gen.current) return;
setChallenge(data);
onChallengeRef.current(data);
setLeft(Math.max(15, Math.round(data.expiresIn || FALLBACK_SECONDS)));
onChangeRef.current("");
onErrorRef.current?.(null);
} catch (err) {
if (ticket !== gen.current) return;
if (!silent) {
setChallenge(null);
onChallengeRef.current(null);
}
onErrorRef.current?.(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
rotating.current = false;
if (ticket === gen.current && !silent) setLoading(false);
}
}, []);
useEffect(() => {
void load(false);
}, [load]);
useEffect(() => {
if (!challenge) return;
const timer = window.setInterval(() => {
setLeft((n) => {
if (n <= 1) {
void load(true);
return n;
}
return n - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [challenge, load]);
const minutes = String(Math.floor(left / 60)).padStart(1, "0");
const seconds = String(left % 60).padStart(2, "0");
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={id}>کد امنیتی</Label>
<button
type="button"
onClick={() => void load(false)}
disabled={loading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
تازه‌سازی
{challenge && (
<span className="tabular-nums text-muted-foreground" dir="ltr">
{minutes}:{seconds}
</span>
)}
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{challenge?.imageBase64 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
) : (
<span className="text-sm text-muted-foreground">{loading ? "…" : "—"}</span>
)}
{challenge && (
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-0.5 bg-primary/15"
>
<span
className="block h-full bg-primary transition-[width] duration-1000 ease-linear"
style={{ width: `${Math.min(100, (left / Math.max(challenge.expiresIn || FALLBACK_SECONDS, 1)) * 100)}%` }}
/>
</span>
)}
</div>
<Input
id={id}
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
);
}
+28 -51
View File
@@ -1,42 +1,25 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { HeartHandshake, LogIn, MessageSquareHeart } from "lucide-react";
import Link from "next/link";
import { ExternalLink, Share2 } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button";
export type SocialLink = { name: string; url: string };
export type CommunityPayload = {
donation: {
enabled: boolean;
note: string;
mode: "chat";
contactId?: number;
draft: string;
};
socials: SocialLink[];
};
function openDonationChat(contactId: number | undefined, draft: string) {
window.dispatchEvent(
new CustomEvent("pargar:chat", {
detail: { open: true, with: contactId, draft },
}),
);
}
export function CommunityPanel() {
const user = useAuth((s) => s.user);
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["community", user?.id ?? "guest"],
queryKey: ["community"],
queryFn: () => http.get<CommunityPayload>("/api/community"),
});
if (isLoading) {
return (
<div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
);
return <div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />;
}
if (isError) {
@@ -50,40 +33,34 @@ export function CommunityPanel() {
);
}
const donation = data?.donation;
const socials = data?.socials ?? [];
return (
<div className="mx-auto max-w-xl">
<Panel
title="حمایت مالی"
description="بدون کارت عمومی — اگر لاگین باشید، گفتگو با تیم باز می‌شود."
icon={<HeartHandshake className="h-5 w-5" />}
tint="bg-accent/15 text-accent"
title="شبکه‌های پرگار"
description="راه‌های رسمی ارتباط با برنامه. لینک شخصی هر کاربر روی پروفایل خودش است."
icon={<Share2 className="h-5 w-5" />}
tint="bg-primary/15 text-primary"
>
{donation?.enabled ? (
<div className="space-y-4 pt-1">
<p className="text-sm leading-relaxed text-muted-foreground">{donation.note}</p>
<div className="rounded-2xl border border-border bg-muted/20 p-5 text-sm leading-relaxed">
جزئیات واریز فقط در پیام خصوصی رد و بدل می‌شود؛ اینجا چیزی عمومی نیست.
</div>
{user ? (
<Button
className="w-full gap-2"
size="lg"
onClick={() => openDonationChat(donation.contactId, donation.draft)}
>
<MessageSquareHeart className="h-4 w-4" /> پیام دربارهٔ دونیت
</Button>
) : (
<Button asChild className="w-full gap-2" size="lg">
<Link href="/login">
<LogIn className="h-4 w-4" /> ورود برای هماهنگی دونیت
</Link>
</Button>
)}
</div>
{socials.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">هنوز شبکه‌ای ثبت نشده است.</p>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">حمایت فعلاً غیرفعال است.</p>
<ul className="space-y-2 pt-1">
{socials.map((s) => (
<li key={s.url}>
<a
href={s.url}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-sm font-bold transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<span>{s.name}</span>
<ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
</a>
</li>
))}
</ul>
)}
</Panel>
</div>

Some files were not shown because too many files have changed in this diff Show More