Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924cb52a38
|
||
|
|
3c5a32394d
|
||
|
|
f6ce2bd340
|
||
|
|
96b10c9148
|
||
|
|
7716c1677c
|
||
|
|
161610049c
|
||
|
|
617d8ff680
|
||
|
|
88c86ac810
|
||
|
|
f59ff54256
|
||
|
|
12ecbf991e | ||
|
|
951610cda2 | ||
|
|
943897044a | ||
|
|
53c8c8e94c | ||
|
|
c4c9f37bbc | ||
|
|
a1c429782f | ||
|
|
0dcc7bbf76 | ||
|
|
77748bcba2 | ||
|
|
994879749b | ||
|
|
41e40c9ab2 | ||
|
|
42871c51cd | ||
|
|
6dc04de222 | ||
|
|
265fb8dde4
|
||
|
|
3bf6dae724
|
||
|
|
6228e66d2c
|
||
|
|
089c114019
|
||
|
|
602d3f2f4d
|
||
|
|
40fa0b8581
|
||
|
|
b4a7e7e1d3
|
||
|
|
03610bc3d3
|
||
|
|
75e2892e36 | ||
|
|
22d92c133d
|
||
|
|
8586bbafcc | ||
|
|
e05c4327d9 | ||
|
|
9f5b3b5593
|
||
|
|
e222a7dcd7
|
||
|
|
d9a2abb44f
|
||
|
|
3cce2158e9
|
@@ -23,6 +23,8 @@ FRONTEND_ORIGINS=https://localhost,https://127.0.0.1
|
||||
# Keep false unless you intentionally want fail-open rate limits / captcha.
|
||||
DEV_MODE=false
|
||||
SEED_CURRICULUM=false
|
||||
# One registration per client IP. Keep false behind NAT / shared egress (default).
|
||||
REGISTRATION_ENFORCE_IP=false
|
||||
# METRICS_TOKEN=
|
||||
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ FRONTEND_ORIGINS=https://app.example.com
|
||||
|
||||
DEV_MODE=false
|
||||
SEED_CURRICULUM=false
|
||||
# Keep false behind NAT until the edge forwards real client IPs.
|
||||
REGISTRATION_ENFORCE_IP=false
|
||||
METRICS_TOKEN=replace-with-metrics-scrape-token
|
||||
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# CI job image for the Gitea Actions runners.
|
||||
#
|
||||
# JS actions (actions/checkout, docker/build-push-action) execute INSIDE the
|
||||
# job container and need node; builds talk to the runner's daemon through the
|
||||
# mounted /var/run/docker.sock, so this image needs the docker CLI + buildx
|
||||
# but no daemon. Baking them in means jobs never download binaries at runtime:
|
||||
# download.docker.com and github.com releases are unreachable (connection
|
||||
# reset) from the cluster's network.
|
||||
#
|
||||
# Tagged as src.panahifar.ir/ahp/act-job:20 (node 20 / bookworm).
|
||||
FROM node:20-bookworm
|
||||
|
||||
COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker
|
||||
COPY --from=docker:27-cli /usr/local/libexec/docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||
@@ -0,0 +1,90 @@
|
||||
name: build-and-push
|
||||
|
||||
# On a version tag push (v*), build both images and publish them to the
|
||||
# Gitea container registry. Argo CD then rolls them out via
|
||||
# deploy/helm/pargar (values-apollo.yaml pins the matching image tags).
|
||||
#
|
||||
# Requires a registered Gitea Actions runner, a `REGISTRY_TOKEN` secret
|
||||
# (Gitea access token with write:package scope) so docker login can push to
|
||||
# the registry.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: src.panahifar.ir
|
||||
BACKEND_IMAGE: ahp/pargar-backend
|
||||
FRONTEND_IMAGE: ahp/pargar-frontend
|
||||
# Single credential location shared by login + build steps (the job runs
|
||||
# in a container whose HOME can differ between steps).
|
||||
DOCKER_CONFIG: /tmp/.docker
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Gitea runners match on their registered labels (default: linux-amd64)
|
||||
runs-on: linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Image tag (strip leading v)
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Builds run docker-outside-of-docker through the runner-mounted
|
||||
# /var/run/docker.sock. The apollo runner's job image (act-job:20,
|
||||
# .gitea/job-image) already ships the docker CLI + buildx in system
|
||||
# plugin paths; runners on other networks fall back to installing them
|
||||
# at runtime (needs access to download.docker.com + github.com, and the
|
||||
# plugin must land where docker looks with DOCKER_CONFIG set).
|
||||
- name: Set up Docker CLI (preinstalled on apollo; install elsewhere)
|
||||
run: |
|
||||
set -eu
|
||||
if [ ! -S /var/run/docker.sock ]; then
|
||||
echo "::error::/var/run/docker.sock is not mounted in the job container - the act_runner config must mount it (docker_host: \"\" auto-mounts it)."
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v docker >/dev/null 2>&1 || ! docker buildx version >/dev/null 2>&1; then
|
||||
curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz" | tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||
mkdir -p /root/.docker/cli-plugins /usr/local/libexec/docker/cli-plugins
|
||||
curl -fsSL "https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64" -o /root/.docker/cli-plugins/docker-buildx
|
||||
cp /root/.docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||
chmod +x /root/.docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||
fi
|
||||
docker version
|
||||
docker buildx version
|
||||
|
||||
- name: Login to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest
|
||||
|
||||
- name: Build and push frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
# Empty = same-origin API/WS through the Traefik reverse proxy
|
||||
# (the production topology). Override via repo variables if needed.
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_WS_URL=${{ vars.NEXT_PUBLIC_WS_URL }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest
|
||||
@@ -1,67 +1,86 @@
|
||||
# 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="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).
|
||||
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 842 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<title>Compass master</title>
|
||||
<image width="1024" height="1024" href="png/1024x1024.png" xlink:href="png/1024x1024.png"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// certRequiresApproval always returns true — certificates are never auto-issued.
|
||||
// The DB setting is kept for observability but cannot override the hard gate.
|
||||
func (s *Server) certRequiresApproval(_ context.Context) bool { return true }
|
||||
|
||||
// issueCertificateIfEligible creates a pending certificate row when the user has
|
||||
// completed the curriculum. It never issues (publishes) the certificate directly;
|
||||
// that step requires explicit admin action via handleAdminIssueCertificate.
|
||||
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,15 +53,12 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us
|
||||
} else if err != store.ErrNotFound {
|
||||
return nil, false, err
|
||||
}
|
||||
publicID, err := newPublicID()
|
||||
// Always create a pending row — never auto-issue.
|
||||
cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
cert, err := s.store.CreateCertificate(ctx, user.ID, publicID, user.Name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return cert, true, nil
|
||||
return cert, cert.Status == "pending", nil
|
||||
}
|
||||
|
||||
func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -101,7 +114,7 @@ func (s *Server) handlePublicCertificate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID)
|
||||
if err != nil || cert.RevokedAt != nil {
|
||||
if err != nil || cert.RevokedAt != nil || cert.Status != "issued" {
|
||||
writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد")
|
||||
return
|
||||
}
|
||||
@@ -140,100 +153,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) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPendingCertDoesNotShortCircuitCurriculumCheck ensures that a pending
|
||||
// certificate does not satisfy HasCompletedCurriculum. Only an issued
|
||||
// (non-revoked) certificate should bypass the lesson-completion check.
|
||||
func TestPendingCertDoesNotShortCircuitCurriculumCheck(t *testing.T) {
|
||||
e := setup(t)
|
||||
token, uid := register(t, e, "CertPending", "cert-pending@test.dev")
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
|
||||
// Forcibly create a pending certificate in the DB (simulating a student
|
||||
// who triggered issueCertificateIfEligible via quiz submit).
|
||||
_, err := e.pool.Exec(context.Background(),
|
||||
`INSERT INTO certificates (user_id, full_name, status)
|
||||
VALUES ($1, 'CertPending', 'pending')
|
||||
ON CONFLICT DO NOTHING`, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("insert pending cert: %v", err)
|
||||
}
|
||||
|
||||
// The student has NOT passed any lessons — eligible must be false.
|
||||
code, body := e.do(t, "GET", "/api/me/certificate", token, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("get cert: %d %v", code, body)
|
||||
}
|
||||
if body["eligible"] != false {
|
||||
t.Fatalf("pending cert must not make student eligible; got eligible=%v", body["eligible"])
|
||||
}
|
||||
|
||||
// Admin issues the certificate manually.
|
||||
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/certificate/issue", admin, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("admin issue: %d", code)
|
||||
}
|
||||
|
||||
// Physical order endpoint must return 410 Gone.
|
||||
code, _ = e.do(t, "POST", "/api/me/certificate/physical", token, map[string]any{
|
||||
"recipientName": "Test", "phone": "09123456789", "address": "addr",
|
||||
"city": "tehran", "postalCode": "1234567890",
|
||||
})
|
||||
if code != http.StatusGone {
|
||||
t.Fatalf("physical endpoint must be disabled (410), got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,65 +1,13 @@
|
||||
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(ctx),
|
||||
"contributors": s.loadContributors(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -130,16 +130,8 @@ func (s *Server) handleRegisterInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := clientAddress(r)
|
||||
if ip == "" || ip == "unknown" {
|
||||
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
||||
return
|
||||
}
|
||||
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
||||
return
|
||||
} else if taken {
|
||||
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
||||
ip, ok := s.registrationClientIP(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -292,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
|
||||
|
||||
@@ -130,6 +130,7 @@ func TestInviteRevoke(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInviteDuplicatePhoneAndIP(t *testing.T) {
|
||||
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||
e := setup(t)
|
||||
inv := createInvite(t, e, 5, "", nil)
|
||||
token := inv["token"].(string)
|
||||
|
||||
@@ -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,56 @@ 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
|
||||
})
|
||||
|
||||
ids := make([]int64, len(scoredList))
|
||||
for i, sc := range scoredList {
|
||||
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
|
||||
if err != nil {
|
||||
ids[i] = sc.userID
|
||||
}
|
||||
usersMap, _ := s.store.GetUsersByIDs(context.Background(), ids)
|
||||
|
||||
entries := make([]lbEntry, 0, len(scoredList))
|
||||
for i, sc := range scoredList {
|
||||
usr, ok := usersMap[sc.userID]
|
||||
if !ok {
|
||||
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 +138,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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "<b>راز</b>") {
|
||||
t.Fatal("user text was not escaped")
|
||||
}
|
||||
if !strings.Contains(htmlDoc, "<b>راز</b>\nخط دوم") {
|
||||
t.Fatal("newline was not kept in the bubble")
|
||||
}
|
||||
if !strings.Contains(htmlDoc, "white-space: pre-wrap") {
|
||||
t.Fatal("bubble text does not keep whitespace")
|
||||
}
|
||||
if !strings.Contains(htmlDoc, `class="msg mine"`) || !strings.Contains(htmlDoc, `class="msg theirs"`) {
|
||||
t.Fatal("own and partner bubbles are missing")
|
||||
}
|
||||
if !strings.Contains(htmlDoc, "#6d63f0") {
|
||||
t.Fatal("own bubble is not the chat primary color")
|
||||
}
|
||||
if !strings.Contains(htmlDoc, ">Mentor Export</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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
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) 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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -154,16 +158,13 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}()
|
||||
observability.Activity("certificate.pending", "user_id", u.ID)
|
||||
_ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_pending",
|
||||
"درخواست گواهینامه ثبت شد", "دوره را با موفقیت به پایان رساندید. درخواست گواهینامه شما ثبت شد و پس از تأیید مدیریت صادر خواهد شد.", "/profile", nil)
|
||||
_ = cert // pending; no publicId yet
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -282,8 +282,11 @@ func (s *Server) perUserRateLimit(limit int64, scope string, next http.HandlerFu
|
||||
// allowWSHandshake enforces a per-IP budget on WebSocket upgrades.
|
||||
// Returns false when the caller should receive 429 (already written by caller).
|
||||
func (s *Server) allowWSHandshake(r *http.Request) bool {
|
||||
if s.cfg.DevMode {
|
||||
return true
|
||||
}
|
||||
if s.redis == nil {
|
||||
return s.cfg.DevMode
|
||||
return false
|
||||
}
|
||||
ip := clientAddress(r)
|
||||
if ip == "" {
|
||||
|
||||
@@ -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, "بازیابی رمز عمومی غیرفعال است")
|
||||
|
||||
@@ -27,6 +27,29 @@ func (s *Server) registrationRequireWhitelist(r *http.Request) bool {
|
||||
return s.settingTruthy(r, "registration_require_whitelist", true)
|
||||
}
|
||||
|
||||
// registrationClientIP returns the IP to bind to a registration when enforcement is on.
|
||||
// When REGISTRATION_ENFORCE_IP is false (default), returns "" so NAT / shared egress is ignored.
|
||||
func (s *Server) registrationClientIP(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if s.cfg == nil || !s.cfg.RegistrationEnforceIP {
|
||||
return "", true
|
||||
}
|
||||
ip := clientAddress(r)
|
||||
if ip == "" || ip == "unknown" {
|
||||
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
||||
return "", false
|
||||
}
|
||||
taken, err := s.store.HasRegistrationIP(r.Context(), ip)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
||||
return "", false
|
||||
}
|
||||
if taken {
|
||||
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
||||
return "", false
|
||||
}
|
||||
return ip, true
|
||||
}
|
||||
|
||||
func (s *Server) handleRegisterStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.registrationEnabled(r),
|
||||
@@ -83,16 +106,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := clientAddress(r)
|
||||
if ip == "" || ip == "unknown" {
|
||||
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
||||
return
|
||||
}
|
||||
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
||||
return
|
||||
} else if taken {
|
||||
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
||||
ip, ok := s.registrationClientIP(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,7 @@ func TestRegisterDuplicatePhone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegisterSameIPRejected(t *testing.T) {
|
||||
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||
e := setup(t)
|
||||
enableRegistration(t, e)
|
||||
whitelistPhones(t, e, "09121110007", "09121110008")
|
||||
@@ -278,7 +279,27 @@ func TestRegisterSameIPRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSameIPAllowedWhenEnforcementOff(t *testing.T) {
|
||||
t.Setenv("REGISTRATION_ENFORCE_IP", "false")
|
||||
e := setup(t)
|
||||
enableRegistration(t, e)
|
||||
whitelistPhones(t, e, "09121110007", "09121110008")
|
||||
|
||||
headers := map[string]string{"X-Forwarded-For": "203.0.113.40"}
|
||||
code, body := e.doHeaders(t, "POST", "/api/auth/register", "", headers,
|
||||
registerBody(t, e, "09121110007", "reg-ipoff1@test.dev", "regipoff1"))
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("first: %d %v", code, body)
|
||||
}
|
||||
code, body = e.doHeaders(t, "POST", "/api/auth/register", "", headers,
|
||||
registerBody(t, e, "09121110008", "reg-ipoff2@test.dev", "regipoff2"))
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("same IP must be allowed when REGISTRATION_ENFORCE_IP=false: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUsesForwardedIP(t *testing.T) {
|
||||
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||
e := setup(t)
|
||||
enableRegistration(t, e)
|
||||
whitelistPhones(t, e, "09121110009", "09121110010", "09121110011")
|
||||
|
||||
@@ -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,8 +101,12 @@ 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("POST /api/admin/contributor-avatar", admin(s.handleAdminContributorAvatarUpload))
|
||||
mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
|
||||
mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats))
|
||||
mux.HandleFunc("POST /api/admin/chat-export/restore", admin(s.handleAdminChatExportRestore))
|
||||
@@ -124,11 +131,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 +158,33 @@ 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/meta", s.handleMeta)
|
||||
mux.HandleFunc("POST /api/bot/send", s.handleBotSend)
|
||||
mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -3,12 +3,15 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"pargar/backend/internal/observability"
|
||||
"pargar/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetSponsors(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -41,12 +44,192 @@ 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) loadContributors(ctx context.Context) []map[string]any {
|
||||
raw, err := s.store.GetSetting(ctx, "contributors")
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for _, item := range list {
|
||||
name, _ := item["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
continue
|
||||
}
|
||||
role, _ := item["role"].(string)
|
||||
bio, _ := item["bio"].(string)
|
||||
url, _ := item["url"].(string)
|
||||
avatar, _ := item["avatar"].(string)
|
||||
contributions, _ := item["contributions"].([]any)
|
||||
cats := make([]string, 0, len(contributions))
|
||||
for _, c := range contributions {
|
||||
if s, ok := c.(string); ok && s != "" {
|
||||
cats = append(cats, s)
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"name": strings.TrimSpace(name),
|
||||
"role": strings.TrimSpace(role),
|
||||
"bio": strings.TrimSpace(bio),
|
||||
"url": strings.TrimSpace(url),
|
||||
"avatar": strings.TrimSpace(avatar),
|
||||
"contributions": cats,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateContributors(v string) string {
|
||||
if v == "" || v == "[]" {
|
||||
return ""
|
||||
}
|
||||
var tmp []map[string]any
|
||||
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
|
||||
return "فرمت مشارکتکنندگان نامعتبر است"
|
||||
}
|
||||
if len(tmp) > 50 {
|
||||
return "حداکثر ۵۰ مشارکتکننده مجاز است"
|
||||
}
|
||||
for _, item := range tmp {
|
||||
name, _ := item["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return "نام مشارکتکننده الزامی است"
|
||||
}
|
||||
if utf8.RuneCountInString(strings.TrimSpace(name)) > 100 {
|
||||
return "نام مشارکتکننده خیلی طولانی است"
|
||||
}
|
||||
if role, _ := item["role"].(string); utf8.RuneCountInString(strings.TrimSpace(role)) > 100 {
|
||||
return "نقش مشارکتکننده خیلی طولانی است"
|
||||
}
|
||||
if bio, _ := item["bio"].(string); utf8.RuneCountInString(strings.TrimSpace(bio)) > 300 {
|
||||
return "بیوگرافی خیلی طولانی است"
|
||||
}
|
||||
if url, _ := item["url"].(string); url != "" {
|
||||
u := strings.TrimSpace(url)
|
||||
if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
|
||||
return "لینک پروفایل باید با http شروع شود"
|
||||
}
|
||||
if utf8.RuneCountInString(u) > 500 {
|
||||
return "لینک پروفایل خیلی طولانی است"
|
||||
}
|
||||
}
|
||||
if contributions, ok := item["contributions"].([]any); ok {
|
||||
if len(contributions) > 20 {
|
||||
return "حداکثر ۲۰ دسته مشارکت برای هر نفر مجاز است"
|
||||
}
|
||||
for _, c := range contributions {
|
||||
s, ok := c.(string)
|
||||
if !ok {
|
||||
return "دسته مشارکت باید رشته باشد"
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "دسته مشارکت نمیتواند خالی باشد"
|
||||
}
|
||||
if utf8.RuneCountInString(s) > 50 {
|
||||
return "دسته مشارکت خیلی طولانی است"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminContributorAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
const maxSize = 6 << 20
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSize+1<<20)
|
||||
if err := r.ParseMultipartForm(maxSize + 1<<20); err != nil {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است")
|
||||
return
|
||||
}
|
||||
file, h, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if h.Size > maxSize {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد")
|
||||
return
|
||||
}
|
||||
accepted, err := acceptAvatarImage(h, file)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("contributor-avatars/%d%s", time.Now().UnixNano(), accepted.Ext)
|
||||
rawURL, err := s.storage.Save(key, file, h.Size)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)})
|
||||
}
|
||||
|
||||
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",
|
||||
"contributors",
|
||||
"hearts_max",
|
||||
"hearts_regen_minutes",
|
||||
"hearts_lock_on_empty",
|
||||
"registration_enabled",
|
||||
"registration_require_whitelist",
|
||||
}
|
||||
@@ -58,15 +241,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 +284,17 @@ 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,
|
||||
"contributors": true,
|
||||
"hearts_max": true,
|
||||
"hearts_regen_minutes": true,
|
||||
"hearts_lock_on_empty": true,
|
||||
}
|
||||
for k, v := range req.Settings {
|
||||
if !allowed[k] {
|
||||
@@ -114,7 +302,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 +350,29 @@ 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 "contributors":
|
||||
if v == "" {
|
||||
v = "[]"
|
||||
}
|
||||
if msg := validateContributors(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ type Config struct {
|
||||
PhysicalCertEnabled bool
|
||||
PhysicalCertPriceIRR int
|
||||
PhysicalCertWindowDays int
|
||||
// RegistrationEnforceIP limits one successful registration per client IP.
|
||||
// Default false: behind NAT / shared egress the peer IP is not a real client identity.
|
||||
RegistrationEnforceIP bool
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
@@ -140,6 +143,7 @@ func Load() *Config {
|
||||
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
|
||||
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
|
||||
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
|
||||
RegistrationEnforceIP: getEnv("REGISTRATION_ENFORCE_IP", "false") == "true",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add contributors setting key. Seed one verified entry (sole git author).
|
||||
-- Admins can update via the site settings panel.
|
||||
INSERT INTO app_settings (key, value) VALUES (
|
||||
'contributors',
|
||||
'[{"name":"Amir Husayn Panahifar","role":"بنیانگذار و توسعهدهنده","bio":"","url":"","avatar":"","contributions":["code"]}]'
|
||||
) ON CONFLICT (key) DO NOTHING;
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVoiceNoteContentType(t *testing.T) {
|
||||
if got := mimeType("chats/2-1.weba"); got != "audio/webm" {
|
||||
t.Fatalf("weba: %s", got)
|
||||
}
|
||||
if got := mediaContentType("chats/2-1.weba", "application/octet-stream"); got != "audio/webm" {
|
||||
t.Fatalf("generic store type must not hide the voice type: %s", got)
|
||||
}
|
||||
if got := mediaContentType("chats/2-1.bin", "application/pdf"); got != "application/pdf" {
|
||||
t.Fatalf("unknown extension keeps the stored type: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -243,10 +243,11 @@ func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
|
||||
}
|
||||
defer result.Body.Close()
|
||||
|
||||
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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -344,16 +347,14 @@ func EvaluateInviteAvailability(inv *models.RegistrationInvite, now time.Time) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterInviteStudent creates a student, consumes the IP, and atomically consumes one invite seat.
|
||||
// RegisterInviteStudent creates a student, optionally records the client IP, and consumes one invite seat.
|
||||
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
|
||||
func (s *Store) RegisterInviteStudent(
|
||||
ctx context.Context,
|
||||
inviteID int64,
|
||||
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
||||
) (*models.User, error) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return nil, errors.New("empty registration ip")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -366,12 +367,14 @@ func (s *Store) RegisterInviteStudent(
|
||||
return nil, ErrPhoneBlacklisted
|
||||
}
|
||||
|
||||
var taken bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
if ip != "" {
|
||||
var taken bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
@@ -436,12 +439,14 @@ func (s *Store) RegisterInviteStudent(
|
||||
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
if ip != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -129,6 +148,27 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetUsersByIDs fetches multiple users in a single query. Missing IDs are silently skipped.
|
||||
func (s *Store) GetUsersByIDs(ctx context.Context, ids []int64) (map[int64]*models.User, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[int64]*models.User{}, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[int64]*models.User, len(ids))
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[u.ID] = u
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserStats(ctx context.Context, userID int64, xp int, streak int, streakLongest int, lastActivity *time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE users SET xp=$1, streak_current=$2, streak_longest=$3, last_activity_date=$4
|
||||
@@ -195,7 +235,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 +244,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 +313,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 +345,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 +432,101 @@ func (s *Store) UpdateUser(ctx context.Context, id int64, name, email, username,
|
||||
return u, err
|
||||
}
|
||||
|
||||
// ChangeUsername sets a new username and consumes one allowance slot.
|
||||
// The same username is a no-op. The third change in a window starts a cooldown;
|
||||
// after that cooldown the allowance returns to the limit.
|
||||
func (s *Store) ChangeUsername(ctx context.Context, id int64, username string, now time.Time) (*models.User, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var current string
|
||||
var count int
|
||||
var until *time.Time
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT username, username_change_count, username_cooldown_until
|
||||
FROM users WHERE id=$1 FOR UPDATE`, id).Scan(¤t, &count, &until)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reset := false
|
||||
if until != nil && !now.Before(*until) {
|
||||
count = 0
|
||||
until = nil
|
||||
reset = true
|
||||
}
|
||||
|
||||
if strings.EqualFold(current, username) {
|
||||
if reset {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET username_change_count=0, username_cooldown_until=NULL WHERE id=$1`, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
if count >= models.UsernameChangeLimit {
|
||||
if until == nil {
|
||||
t := now.Add(models.UsernameChangeCooldown)
|
||||
until = &t
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET username_change_count=$2, username_cooldown_until=$3 WHERE id=$1`,
|
||||
id, count, until); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, &UsernameCooldownError{Until: *until}
|
||||
}
|
||||
|
||||
var taken int64
|
||||
err = tx.QueryRow(ctx, `SELECT id FROM users WHERE lower(username)=lower($1) AND id<>$2`, username, id).Scan(&taken)
|
||||
if err == nil {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count++
|
||||
var nextUntil *time.Time
|
||||
if count >= models.UsernameChangeLimit {
|
||||
t := now.Add(models.UsernameChangeCooldown)
|
||||
nextUntil = &t
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET username=$2, username_change_count=$3, username_cooldown_until=$4
|
||||
WHERE id=$1
|
||||
RETURNING `+userCols, id, username, count, nextUntil)
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByPhone(ctx context.Context, phone string) (*models.User, error) {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if phone == "" {
|
||||
@@ -331,18 +564,15 @@ func (s *Store) HasRegistrationIP(ctx context.Context, ip string) (bool, error)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// RegisterPublicStudent creates a student and consumes the IP atomically.
|
||||
// RegisterPublicStudent creates a student and optionally records the client IP.
|
||||
// When requireWhitelist is true, the phone must be an available whitelist entry.
|
||||
// Concurrent same-IP registrations: one commits; the other rolls back on unique IP conflict.
|
||||
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
|
||||
func (s *Store) RegisterPublicStudent(
|
||||
ctx context.Context,
|
||||
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
||||
requireWhitelist bool,
|
||||
) (*models.User, error) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return nil, errors.New("empty registration ip")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -355,12 +585,14 @@ func (s *Store) RegisterPublicStudent(
|
||||
return nil, ErrPhoneBlacklisted
|
||||
}
|
||||
|
||||
var taken bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
if ip != "" {
|
||||
var taken bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
}
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
@@ -380,12 +612,14 @@ func (s *Store) RegisterPublicStudent(
|
||||
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
if ip != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrRegistrationIPTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
@@ -405,7 +639,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
|
||||
}
|
||||
|
||||
@@ -414,11 +648,17 @@ 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).
|
||||
// Pending certs do NOT count — they haven't been approved yet.
|
||||
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil && cert.Status == "issued" {
|
||||
return true, nil
|
||||
}
|
||||
active, err := s.CountActiveLessons(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -435,42 +675,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`
|
||||
@@ -503,7 +807,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,
|
||||
@@ -511,16 +834,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)
|
||||
@@ -529,11 +847,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) {
|
||||
@@ -652,16 +970,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
|
||||
}
|
||||
|
||||
@@ -671,7 +995,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
|
||||
}
|
||||
@@ -679,7 +1003,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)
|
||||
@@ -688,22 +1012,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
|
||||
}
|
||||
|
||||
@@ -715,11 +1045,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
|
||||
}
|
||||
@@ -728,9 +1058,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)
|
||||
@@ -742,7 +1072,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)
|
||||
@@ -1057,29 +1387,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()
|
||||
@@ -1090,11 +1439,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)
|
||||
}
|
||||
|
||||
@@ -1137,6 +1486,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,
|
||||
@@ -1159,7 +1605,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
|
||||
@@ -1187,7 +1633,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)
|
||||
}
|
||||
|
||||
@@ -1195,7 +1641,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))
|
||||
@@ -1221,7 +1667,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)
|
||||
@@ -1269,7 +1715,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
|
||||
@@ -1301,7 +1747,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
|
||||
@@ -1342,14 +1788,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),
|
||||
}
|
||||
@@ -1371,11 +1830,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.
|
||||
@@ -1742,7 +2210,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 {
|
||||
|
||||
@@ -33,6 +33,7 @@ services:
|
||||
environment:
|
||||
DEV_MODE: ${DEV_MODE:-false}
|
||||
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
||||
CAPTCHA_EXPOSE_ANSWER: ${CAPTCHA_EXPOSE_ANSWER:-false}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -63,6 +64,11 @@ 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
|
||||
# Disable HTTP/2 so WebSocket upgrades work from Chromium (RFC 8441 not enabled).
|
||||
- --entrypoints.websecure.http2.maxConcurrentStreams=0
|
||||
# Dev: Traefik default certificate (browser warning expected on localhost).
|
||||
- --api.dashboard=true
|
||||
- --api.insecure=true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ x-backend-env: &backend-env
|
||||
STORAGE_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY must be set}
|
||||
STORAGE_BUCKET: ${STORAGE_BUCKET:-pargar}
|
||||
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
||||
REGISTRATION_ENFORCE_IP: ${REGISTRATION_ENFORCE_IP:-false}
|
||||
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
||||
|
||||
@@ -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.4
|
||||
appVersion: "0.4.0"
|
||||
keywords:
|
||||
- lms
|
||||
- pargar
|
||||
|
||||
@@ -66,6 +66,8 @@ spec:
|
||||
value: {{ .Values.backend.env.devMode | quote }}
|
||||
- name: SEED_CURRICULUM
|
||||
value: {{ .Values.backend.env.seedCurriculum | quote }}
|
||||
- name: REGISTRATION_ENFORCE_IP
|
||||
value: {{ .Values.backend.env.registrationEnforceIP | quote }}
|
||||
- name: ADMIN_NAME
|
||||
value: {{ .Values.backend.admin.name | quote }}
|
||||
- name: ADMIN_USERNAME
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
{{- else if and .Values.postgres.auth.password (not (hasPrefix "change-me" .Values.postgres.auth.password)) (ne .Values.postgres.auth.password "") }}
|
||||
{{- $pgPass = .Values.postgres.auth.password }}
|
||||
{{- else }}
|
||||
{{- $pgPass = randAlphaNum 32 }}
|
||||
{{- /* Fail loudly instead of silently minting a random password: under
|
||||
Argo CD (helm template) `lookup` is always empty, and a random
|
||||
value would diverge from the live service on every render. */}}
|
||||
{{- fail "postgres.auth.password must be set: no existing pargar-postgres secret was found and .Values.postgres.auth.password is empty or a change-me placeholder" }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -30,7 +33,7 @@ stringData:
|
||||
{{- else if and .Values.redis.auth.password (not (hasPrefix "change-me" .Values.redis.auth.password)) (ne .Values.redis.auth.password "") }}
|
||||
{{- $rdPass = .Values.redis.auth.password }}
|
||||
{{- else }}
|
||||
{{- $rdPass = randAlphaNum 32 }}
|
||||
{{- fail "redis.auth.password must be set: no existing pargar-redis secret was found and .Values.redis.auth.password is empty or a change-me placeholder" }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -56,7 +59,7 @@ stringData:
|
||||
{{- else if and .Values.rustfs.auth.secretKey (not (hasPrefix "change-me" .Values.rustfs.auth.secretKey)) (ne .Values.rustfs.auth.secretKey "") }}
|
||||
{{- $secret = .Values.rustfs.auth.secretKey }}
|
||||
{{- else }}
|
||||
{{- $secret = randAlphaNum 40 }}
|
||||
{{- fail "rustfs.auth.secretKey must be set: no existing pargar-rustfs secret was found and .Values.rustfs.auth.secretKey is empty or a change-me placeholder" }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{{- if .Values.backend.metrics.serviceMonitor.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "pargar.fullname" . }}-backend
|
||||
labels:
|
||||
{{- include "pargar.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backend
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "pargar.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: backend
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- {{ .Release.Namespace }}
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /metrics
|
||||
interval: {{ .Values.backend.metrics.serviceMonitor.interval | default "30s" }}
|
||||
scheme: http
|
||||
# /metrics answers 401 without it; Prometheus sends it as
|
||||
# Authorization: Bearer <token>.
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials:
|
||||
name: {{ include "pargar.appSecretName" . }}
|
||||
key: metrics-token
|
||||
{{- end }}
|
||||
@@ -0,0 +1,85 @@
|
||||
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.4.0
|
||||
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.4.0
|
||||
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
|
||||
# Images published by .gitea/workflows/build-and-push.yaml on tag push.
|
||||
imageRegistry: src.panahifar.ir/ahp
|
||||
publicUrl: https://pargar.panahifar.ir
|
||||
# Gitea packages are private: kubelet pulls need secret `gitea-pull`
|
||||
# (docker-registry secret for src.panahifar.ir, created in ns pargar).
|
||||
imagePullSecrets:
|
||||
- name: gitea-pull
|
||||
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
|
||||
@@ -3,26 +3,27 @@
|
||||
# -f deploy/helm/pargar/values-prod.yaml
|
||||
#
|
||||
# Prerequisites:
|
||||
# - images pushed to ghcr.io/pargar/{backend,frontend}:<tag>
|
||||
# - images pushed to src.panahifar.ir/ahp/{pargar-backend,pargar-frontend}:<tag>
|
||||
# (by .gitea/workflows/build-and-push.yaml on a v* tag push)
|
||||
# - Secret pargar-app (jwt-secret, admin-password, metrics-token, database-url, redis-url,
|
||||
# storage-access-key, storage-secret-key)
|
||||
# - cert-manager ClusterIssuer letsencrypt-prod
|
||||
# - imagePullSecrets if the registry is private
|
||||
# - imagePullSecrets if the Gitea package registry visibility is private
|
||||
|
||||
global:
|
||||
domain: app.example.com
|
||||
publicUrl: https://app.example.com
|
||||
frontendOrigins: "https://app.example.com"
|
||||
imageRegistry: ghcr.io/pargar
|
||||
imageRegistry: src.panahifar.ir/ahp
|
||||
|
||||
imagePullSecrets:
|
||||
- name: ghcr-pull
|
||||
- name: gitea-pull
|
||||
|
||||
backend:
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: backend
|
||||
tag: "0.1.0"
|
||||
repository: pargar-backend
|
||||
tag: "0.2.1"
|
||||
pullPolicy: IfNotPresent
|
||||
existingSecret: pargar-app
|
||||
env:
|
||||
@@ -48,8 +49,8 @@ backend:
|
||||
frontend:
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: frontend
|
||||
tag: "0.1.0"
|
||||
repository: pargar-frontend
|
||||
tag: "0.2.1"
|
||||
pullPolicy: IfNotPresent
|
||||
resources:
|
||||
requests:
|
||||
|
||||
@@ -25,6 +25,13 @@ backend:
|
||||
pullPolicy: IfNotPresent
|
||||
service:
|
||||
port: 8080
|
||||
metrics:
|
||||
# Prometheus Operator ServiceMonitor. /metrics is bearer-protected by
|
||||
# METRICS_TOKEN, so the endpoint carries authorization credentials
|
||||
# (pargar-app secret) instead of scraping anonymously.
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
interval: 30s
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
@@ -44,6 +51,8 @@ backend:
|
||||
env:
|
||||
devMode: "false"
|
||||
seedCurriculum: "false"
|
||||
# false behind NAT / shared egress (peer IP is not a real client).
|
||||
registrationEnforceIP: "false"
|
||||
donationEnabled: "true"
|
||||
# Offline payment only — leave off until ops process is ready; enable in admin settings.
|
||||
physicalCertEnabled: "false"
|
||||
|
||||
@@ -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
|
||||
@@ -63,6 +63,7 @@ One of two patterns:
|
||||
|----------|-----------------|--------|
|
||||
| `DEV_MODE` | false | Open metrics without token, limited fail-open behavior |
|
||||
| `SEED_CURRICULUM` | false (empty in DevMode → true) | Seed lessons/events |
|
||||
| `REGISTRATION_ENFORCE_IP` | false | One registration per client IP; keep off behind NAT |
|
||||
| `CAPTCHA_EXPOSE_ANSWER` | DevMode only | For tests; never in production |
|
||||
| `DONATION_ENABLED` | true | Support card |
|
||||
| `DONATION_NOTE` | Copy text | |
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
- Whitelist and blacklist are **mutually exclusive**; import resolves conflicts.
|
||||
- A phone already tied to an account cannot re-enter either list as a fresh entry.
|
||||
- One account per phone and per IP on registration paths.
|
||||
- One account per phone. Optional one-account-per-IP (`REGISTRATION_ENFORCE_IP`, default off — unsuitable behind NAT).
|
||||
- Blacklist blocks all registration paths (public + invite) and keeps a failed-attempt history tab.
|
||||
|
||||
## Certificates
|
||||
|
||||
@@ -25,6 +25,10 @@ Public password recovery (`/api/auth/recovery/*` and `/recovery`) is disabled fo
|
||||
- Only peers in `TRUSTED_PROXY_CIDRS` are trusted for `X-Forwarded-*` / `X-Real-IP`.
|
||||
- CORS: `FRONTEND_ORIGINS` + `PUBLIC_URL` with `AllowCredentials: true`.
|
||||
|
||||
## Registration IP binding
|
||||
|
||||
`REGISTRATION_ENFORCE_IP` (default **false**) optionally limits one successful public/invite registration per client IP. Leave it off when the app sits behind NAT or shared egress and cannot see real client addresses. Enable only after the edge forwards trustworthy `X-Forwarded-For` / `X-Real-IP` via `TRUSTED_PROXY_CIDRS`.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
| Scope | Approximate behavior |
|
||||
@@ -34,7 +38,7 @@ Public password recovery (`/api/auth/recovery/*` and `/recovery`) is disabled fo
|
||||
| External chat export | ~10/min/IP |
|
||||
| Per-user uploads | Separately limited |
|
||||
|
||||
`/api/health` is excluded from the global ceiling.
|
||||
`/api/health` is excluded from the global ceiling. Note: per-IP rate limits still use the peer/forwarded address — configure trusted proxies when you need per-client fairness behind NAT.
|
||||
|
||||
## Uploads
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"available":true,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInJvbGUiOiJhZG1pbiIsInN1YiI6IjEiLCJleHAiOjE3OTI0OTc0MjcsImlhdCI6MTc5MjQ5NjUyN30.0RuGRaPhMeQ36aHrn_x2iAgJPpyH7pfXGr_cOOOeG1c","refreshToken":"e4059f725d0648ae77b0147835296228c5bba0d0bcda638ebb9a65adeed6c5f72e56d0da268de11d34393e9d4ea96acb","user":{"id":1,"email":"admin@example.com","username":"admin","name":"Admin","role":"admin","xp":0,"hearts":5,"heartsUpdatedAt":"2026-10-18T06:49:58.329935Z","streakCurrent":0,"streakLongest":0,"isLocked":false,"isActive":true,"avatarVariant":"beam","avatarPalette":"","phone":"","hasSecurityAnswer":false,"createdAt":"2026-10-18T06:49:58.329935Z","usernameChangesRemaining":3,"isFrozen":false,"isClosed":false}}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE, requireAuth, injectAuth, apiLogin } from "./helpers";
|
||||
|
||||
test.describe("Auth — public endpoints", () => {
|
||||
test("login page renders", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveTitle(/.+/);
|
||||
const hasForm = await page.locator("form, input[type=password]").count();
|
||||
expect(hasForm).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("protected routes reject unauthenticated requests", async ({ page }) => {
|
||||
const routes = ["/api/tree", "/api/leaderboard/weekly", "/api/notifications"];
|
||||
for (const route of routes) {
|
||||
const res = await page.request.get(`${BASE}${route}`);
|
||||
expect(res.status(), `${route} should be 401`).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid credentials: bad captcha → 400, bad password → 401", async ({ page }) => {
|
||||
// Bad captcha answer returns 400
|
||||
const r1 = await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { username: "x", password: "x", challengeId: "fake", captchaAnswer: "wrong" },
|
||||
});
|
||||
expect([400, 429]).toContain(r1.status());
|
||||
|
||||
// Correct captcha, wrong password → 401
|
||||
const auth = requireAuth();
|
||||
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||
const { challengeId, answer } = await captchaRes.json();
|
||||
const r2 = await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { username: ADMIN_EMAIL, password: "definitely-wrong", challengeId, captchaAnswer: answer },
|
||||
});
|
||||
expect(r2.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Auth — requires working CAPTCHA", () => {
|
||||
test("admin login via API succeeds and /me returns admin role", async ({ page }) => {
|
||||
const auth = requireAuth();
|
||||
const res = await page.request.get(`${BASE}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.user.role).toBe("admin");
|
||||
});
|
||||
|
||||
test("logout invalidates refresh token", async ({ page }) => {
|
||||
requireAuth();
|
||||
// Issue a fresh session for this test only (don't use the shared token)
|
||||
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||
const { challengeId, answer } = await captchaRes.json();
|
||||
if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
|
||||
|
||||
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD, challengeId, captchaAnswer: answer },
|
||||
});
|
||||
const { accessToken, refreshToken } = await loginRes.json();
|
||||
|
||||
const logout = await page.request.post(`${BASE}/api/auth/logout`, {
|
||||
data: { refreshToken },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
expect(logout.status()).toBe(200);
|
||||
|
||||
const refresh = await page.request.post(`${BASE}/api/auth/refresh`, {
|
||||
data: { refreshToken },
|
||||
});
|
||||
expect(refresh.status()).toBe(401);
|
||||
});
|
||||
|
||||
test("cap page loads and stays off /login", async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await page.goto("/cap");
|
||||
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||
expect(page.url()).not.toContain("/login");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { BASE, requireAuth, injectAuth } from "./helpers";
|
||||
|
||||
test.describe("Chat", () => {
|
||||
test("conversations endpoint returns list", async ({ page }) => {
|
||||
const auth = requireAuth();
|
||||
const res = await page.request.get(`${BASE}/api/chats/conversations`, {
|
||||
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveProperty("conversations");
|
||||
expect(Array.isArray(data.conversations)).toBe(true);
|
||||
});
|
||||
|
||||
test("student→student chat is rejected (IDOR guard)", async ({ page }) => {
|
||||
const auth = requireAuth();
|
||||
const adminId = auth.user!.id;
|
||||
|
||||
// Create a student
|
||||
const u1 = await page.request.post(`${BASE}/api/admin/users`, {
|
||||
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
||||
data: {
|
||||
name: "ChatTest", email: "chat-idor@e2e.test", username: "chat_idor_e2e",
|
||||
password: "TestPass123!", role: "student",
|
||||
securityQuestion: "Q", securityAnswer: "A",
|
||||
},
|
||||
});
|
||||
// 201 = created, 409 = already exists from prior run — both are fine
|
||||
if (u1.status() !== 201 && u1.status() !== 409) {
|
||||
test.skip(true, `Unexpected status creating test user: ${u1.status()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const studentId = u1.status() === 201
|
||||
? (await u1.json()).user.id
|
||||
: null;
|
||||
|
||||
if (!studentId) { test.skip(true, "Could not get student ID"); return; }
|
||||
|
||||
// Log in as the student
|
||||
const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
|
||||
const { challengeId, answer } = await captchaRes.json();
|
||||
if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
|
||||
|
||||
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { username: "chat_idor_e2e", password: "TestPass123!", challengeId, captchaAnswer: answer },
|
||||
});
|
||||
const studentSession = await loginRes.json();
|
||||
if (!studentSession.accessToken) { test.skip(true, "Student login failed"); return; }
|
||||
|
||||
// Student tries to read another student's conversations — must fail
|
||||
const forbidden = await page.request.get(`${BASE}/api/chats/${studentId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${studentSession.accessToken}` },
|
||||
});
|
||||
expect([403, 404]).toContain(forbidden.status());
|
||||
});
|
||||
|
||||
test("chat page renders without redirect to /login", async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await page.goto("/unwrap?tab=chats");
|
||||
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||
expect(page.url()).not.toContain("/login");
|
||||
});
|
||||
});
|
||||