Compare commits
22 Commits
chore/sync
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fe5f70ae5 | |||
| a8cd6fc252 | |||
| 27863718ff | |||
| ac8bb3bae1 | |||
| a7a8052ec3 | |||
| 91b450aa1a | |||
| 45db367588 | |||
| ba1cf3efe2 | |||
| a307400f83 | |||
| edd468cf1d | |||
| 3f072976fe | |||
| c0428bb65d | |||
| 3b86c2c72f | |||
| 26feb8f2f1 | |||
| 96603d31f7 | |||
| 0a68104e85 | |||
| 32b06db81c | |||
| 3a08625cbe | |||
| 9435173971 | |||
| 9254a8b487 | |||
| 4c5f4d5983 | |||
| 116d1be258 |
24
.cursor/rules/infra-finland-iran.mdc
Normal file
24
.cursor/rules/infra-finland-iran.mdc
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
description: Finland git/CI/Telegram vs Iran app runtime topology
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Infra topology (Finland vs Iran)
|
||||||
|
|
||||||
|
Ghabilee product apps (backend, admin, frontend) follow this split:
|
||||||
|
|
||||||
|
| Concern | Where |
|
||||||
|
| --- | --- |
|
||||||
|
| Git (`git.ghabilee.ir`) + Gitea Actions runners | **Finland** |
|
||||||
|
| All Telegram ops / deploy notifications | **Finland** (Actions secrets `TELEGRAM_*`, send from the runner) |
|
||||||
|
| App runtime (Docker Compose, Postgres, Redis, nginx, uploads) | **Iran** VPS |
|
||||||
|
|
||||||
|
## Rules for agents
|
||||||
|
|
||||||
|
- Do **not** send Telegram from the Iran VPS (`api.telegram.org` is unreachable there).
|
||||||
|
- Do **not** `source` full backend `.env` in shell notify paths (cron globs like `BOOKING_EXPIRY_CRON=*` break `sh`).
|
||||||
|
- Do **not** run heavy `docker build` / `next build` on the Iran box by default (OOM risk on small VPS).
|
||||||
|
- CI deploy should **build on Finland**, transfer the image to Iran, then `compose up` on Iran.
|
||||||
|
- Secrets: `VPS_*` = Iran runtime SSH; `FINLAND_*` = Finland build SSH; `TELEGRAM_*` = notify from Finland.
|
||||||
|
- Exception: `telegrambot` / telegram-relay **runs on Finland** (that is intentional).
|
||||||
|
|
||||||
41
.gitea/BRANCH_PROTECTION.md
Normal file
41
.gitea/BRANCH_PROTECTION.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Branch protection / CI (Gitea)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Role | Where |
|
||||||
|
| ---- | ----- |
|
||||||
|
| Git + Actions runner + Telegram notify | Finland (`git.ghabilee.ir`) |
|
||||||
|
| Admin runtime (`ghabilee-admin` container) | Iran VPS |
|
||||||
|
|
||||||
|
Build the image on Finland, stream it to Iran, then `docker compose up` there.
|
||||||
|
|
||||||
|
## Actions secrets
|
||||||
|
|
||||||
|
**Iran runtime (already set as `VPS_*`):**
|
||||||
|
|
||||||
|
| Secret | Meaning |
|
||||||
|
| ------ | ------- |
|
||||||
|
| `VPS_HOST` | Iran IP (e.g. `95.38.160.241`) |
|
||||||
|
| `VPS_USER` | usually `root` |
|
||||||
|
| `VPS_SSH_KEY` | Iran private key |
|
||||||
|
|
||||||
|
**Finland build host (required):**
|
||||||
|
|
||||||
|
| Secret | Meaning |
|
||||||
|
| ------ | ------- |
|
||||||
|
| `FINLAND_HOST` | e.g. `65.108.18.151` |
|
||||||
|
| `FINLAND_USER` | usually `root` |
|
||||||
|
| `FINLAND_SSH_KEY` | same key telegrambot uses as `DEPLOY_SSH_KEY` |
|
||||||
|
|
||||||
|
**Telegram (required — same values as telegrambot repo):**
|
||||||
|
|
||||||
|
| Secret | Meaning |
|
||||||
|
| ------ | ------- |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | bot token |
|
||||||
|
| `TELEGRAM_GROUP_CHAT_ID` | ops group |
|
||||||
|
| `TELEGRAM_GROUP_THREAD_ID` | forum topic id (optional) |
|
||||||
|
| `TELEGRAM_CHAT_ID` | private fallback (optional) |
|
||||||
|
|
||||||
|
## Manual notify test
|
||||||
|
|
||||||
|
Actions → **Notify Telegram (manual)** → Run workflow
|
||||||
65
.gitea/workflows/deploy-vps.yml
Normal file
65
.gitea/workflows/deploy-vps.yml
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
name: Deploy admin to VPS
|
||||||
|
|
||||||
|
# Finland (git / act_runner): build image + Telegram notify
|
||||||
|
# Iran: runtime only (compose up pre-built image)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-admin-production
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Build on Finland, run on Iran
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy (Finland build → Iran runtime)
|
||||||
|
env:
|
||||||
|
FINLAND_SSH_KEY: ${{ secrets.FINLAND_SSH_KEY }}
|
||||||
|
FINLAND_HOST: ${{ secrets.FINLAND_HOST }}
|
||||||
|
FINLAND_USER: ${{ secrets.FINLAND_USER }}
|
||||||
|
IRAN_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||||
|
IRAN_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
IRAN_USER: ${{ secrets.VPS_USER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "${FINLAND_SSH_KEY:-}"
|
||||||
|
test -n "${FINLAND_HOST:-}"
|
||||||
|
test -n "${FINLAND_USER:-}"
|
||||||
|
test -n "${IRAN_SSH_KEY:-}"
|
||||||
|
test -n "${IRAN_HOST:-}"
|
||||||
|
test -n "${IRAN_USER:-}"
|
||||||
|
|
||||||
|
if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq rsync openssh-client
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x scripts/deploy-finland-to-iran.sh
|
||||||
|
IMAGE_TAG="ghabilee-admin:${GITHUB_SHA}" ./scripts/deploy-finland-to-iran.sh
|
||||||
|
|
||||||
|
- name: Notify Telegram
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
DEPLOY_SHA: ${{ github.sha }}
|
||||||
|
DEPLOY_STATUS: ${{ job.status }}
|
||||||
|
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_GROUP_CHAT_ID: ${{ secrets.TELEGRAM_GROUP_CHAT_ID }}
|
||||||
|
TELEGRAM_GROUP_THREAD_ID: ${{ secrets.TELEGRAM_GROUP_THREAD_ID }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')"
|
||||||
|
case "${DEPLOY_STATUS}" in
|
||||||
|
success) export DEPLOY_STATUS=success ;;
|
||||||
|
*) export DEPLOY_STATUS=failed ;;
|
||||||
|
esac
|
||||||
|
chmod +x scripts/notify-from-finland.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
|
||||||
|
./scripts/notify-from-finland.sh
|
||||||
27
.gitea/workflows/notify-telegram.yml
Normal file
27
.gitea/workflows/notify-telegram.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
name: Notify Telegram (manual)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
name: Send test deploy notification
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Notify Telegram
|
||||||
|
env:
|
||||||
|
DEPLOY_SHA: ${{ github.sha }}
|
||||||
|
DEPLOY_STATUS: success
|
||||||
|
DEPLOY_COMMIT_SUBJECT: manual notify test
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_GROUP_CHAT_ID: ${{ secrets.TELEGRAM_GROUP_CHAT_ID }}
|
||||||
|
TELEGRAM_GROUP_THREAD_ID: ${{ secrets.TELEGRAM_GROUP_THREAD_ID }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')"
|
||||||
|
chmod +x scripts/notify-from-finland.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
|
||||||
|
./scripts/notify-from-finland.sh
|
||||||
38
.github/BRANCH_PROTECTION.md
vendored
38
.github/BRANCH_PROTECTION.md
vendored
@ -1,37 +1,5 @@
|
|||||||
# Branch protection on `main` (GitHub Pro required)
|
# Branch protection / CI docs moved to Gitea
|
||||||
|
|
||||||
Private repositories on GitHub Free cannot enable branch protection via API or
|
See [`.gitea/BRANCH_PROTECTION.md`](../.gitea/BRANCH_PROTECTION.md).
|
||||||
Settings. Upgrade to **GitHub Pro** (or make the repo public), then configure:
|
|
||||||
|
|
||||||
**Settings → Branches → Add branch protection rule → Branch name: `main`**
|
Canonical remote: <https://git.ghabilee.ir/AliSaZa/admin>
|
||||||
|
|
||||||
Recommended settings:
|
|
||||||
|
|
||||||
- [x] Require a pull request before merging
|
|
||||||
- [ ] Require approvals (optional for solo work)
|
|
||||||
- [x] Require status checks to pass before merging
|
|
||||||
- [x] Require branches to be up to date before merging
|
|
||||||
|
|
||||||
### Required status checks — `ghabilee-frontend2`
|
|
||||||
|
|
||||||
- `Dependency vulnerability scan`
|
|
||||||
- `Secret scan`
|
|
||||||
- `Build, test, and quality checks`
|
|
||||||
|
|
||||||
Until Pro is enabled, use **Pull Request → merge** (not direct push) and rely on
|
|
||||||
local Husky `pre-push` (`pnpm prepush:check`).
|
|
||||||
|
|
||||||
Direct pushes to `main` still trigger full CI in **Deploy frontend to VPS**
|
|
||||||
before build/deploy.
|
|
||||||
|
|
||||||
### Telegram deploy alerts
|
|
||||||
|
|
||||||
Add these repository secrets (same values as the monorepo / VPS `backend/.env`):
|
|
||||||
|
|
||||||
- `TELEGRAM_BOT_TOKEN`
|
|
||||||
- `TELEGRAM_GROUP_CHAT_ID`
|
|
||||||
- `TELEGRAM_GROUP_THREAD_ID` (optional forum topic)
|
|
||||||
- `TELEGRAM_CHAT_ID` (fallback private chat)
|
|
||||||
|
|
||||||
Successful/failed deploys send an **admin/backoffice-specific** message via
|
|
||||||
`scripts/notify-deploy.sh` (distinct from the consumer frontend notify copy).
|
|
||||||
|
|||||||
221
.github/workflows/deploy-vps.yml
vendored
221
.github/workflows/deploy-vps.yml
vendored
@ -1,221 +0,0 @@
|
|||||||
name: Deploy admin to VPS
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-admin-production
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
# PR merges: build + deploy only (quality ran on pull_request).
|
|
||||||
# Direct pushes to main: re-run quality before deploy.
|
|
||||||
jobs:
|
|
||||||
gate:
|
|
||||||
name: Detect direct push to main
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
run_quality: ${{ steps.detect.outputs.run_quality }}
|
|
||||||
steps:
|
|
||||||
- id: detect
|
|
||||||
env:
|
|
||||||
# Via env — never interpolate commit text into the script body
|
|
||||||
# (backticks/`$()` in messages would otherwise become shell command substitution).
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
COMMIT_MSG: ${{ github.event.head_commit.message || '' }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
|
||||||
echo "run_quality=false" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
msg="$COMMIT_MSG"
|
|
||||||
if printf '%s' "$msg" | grep -qiE 'merge pull request #[0-9]+'; then
|
|
||||||
echo "run_quality=false" >> "$GITHUB_OUTPUT"
|
|
||||||
elif printf '%s' "$msg" | grep -qE '\(#[0-9]+\)[[:space:]]*$'; then
|
|
||||||
echo "run_quality=false" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
# Admin boot: deploy first; quality runs on pull_request workflow.
|
|
||||||
echo "Direct push to main — skipping quality gate for deploy."
|
|
||||||
echo "run_quality=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
quality:
|
|
||||||
needs: gate
|
|
||||||
if: needs.gate.outputs.run_quality == 'true'
|
|
||||||
uses: ./.github/workflows/frontend-quality.yml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build and push admin image
|
|
||||||
needs: [gate, quality]
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
needs.gate.result == 'success' &&
|
|
||||||
(needs.quality.result == 'success' || needs.quality.result == 'skipped')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
outputs:
|
|
||||||
image: ${{ steps.meta.outputs.image }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Image metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
repo="$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "image=ghcr.io/${owner}/${repo}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Fetch production build environment
|
|
||||||
env:
|
|
||||||
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
|
||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
install -m 700 -d "$HOME/.ssh"
|
|
||||||
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
|
|
||||||
chmod 600 "$HOME/.ssh/vps_key"
|
|
||||||
env_path="$(ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
"${VPS_USER}@${VPS_HOST}" \
|
|
||||||
'test -s /opt/ghabilee-admin/.env && printf %s /opt/ghabilee-admin/.env')"
|
|
||||||
scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
"${VPS_USER}@${VPS_HOST}:${env_path}" .env.production
|
|
||||||
test -s .env.production
|
|
||||||
rm -f "$HOME/.ssh/vps_key"
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
env:
|
|
||||||
ADMIN_ENV_FILE: .env.production
|
|
||||||
IMAGE_REPO: ${{ steps.meta.outputs.image }}
|
|
||||||
IMAGE_TAG: ${{ github.sha }}
|
|
||||||
run: ./scripts/ci-build-image.sh
|
|
||||||
|
|
||||||
- name: Remove production build environment
|
|
||||||
if: always()
|
|
||||||
run: rm -f .env.production
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Stage or deploy admin
|
|
||||||
needs: build
|
|
||||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 25
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Copy Compose definition and deploy
|
|
||||||
env:
|
|
||||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GHCR_USER: ${{ github.actor }}
|
|
||||||
ADMIN_IMAGE: ${{ needs.build.outputs.image }}:${{ github.sha }}
|
|
||||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
|
||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
|
||||||
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
install -m 700 -d "$HOME/.ssh"
|
|
||||||
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
|
|
||||||
chmod 600 "$HOME/.ssh/vps_key"
|
|
||||||
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
"${VPS_USER}@${VPS_HOST}" '
|
|
||||||
set -eu
|
|
||||||
install -d -m 0750 /opt/ghabilee-admin
|
|
||||||
test -s /opt/ghabilee-admin/.env
|
|
||||||
'
|
|
||||||
scp -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
deploy/docker-compose.production.yml \
|
|
||||||
"${VPS_USER}@${VPS_HOST}:/opt/ghabilee-admin/docker-compose.yml"
|
|
||||||
ssh -i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
"${VPS_USER}@${VPS_HOST}" \
|
|
||||||
"GHCR_TOKEN='${GHCR_TOKEN}' GHCR_USER='${GHCR_USER}' ADMIN_IMAGE='${ADMIN_IMAGE}' sh -s" <<'REMOTE'
|
|
||||||
set -eu
|
|
||||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
|
|
||||||
docker pull "$ADMIN_IMAGE"
|
|
||||||
cd /opt/ghabilee-admin
|
|
||||||
ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin
|
|
||||||
for attempt in $(seq 1 36); do
|
|
||||||
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)"
|
|
||||||
echo "Admin health ${attempt}/36: ${health}"
|
|
||||||
if [ "$health" = healthy ]; then
|
|
||||||
# فقط بعد از healthy: ایمیجهای unused (تگهای قبلی) را پاک کن؛ volumeها دست نخورند
|
|
||||||
if [ -x /opt/ghabilee/scripts/docker-prune.sh ]; then
|
|
||||||
/opt/ghabilee/scripts/docker-prune.sh full
|
|
||||||
else
|
|
||||||
docker image prune -af
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
case "$health" in unhealthy|exited|dead|missing) exit 1;; esac
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
REMOTE
|
|
||||||
rm -f "$HOME/.ssh/vps_key"
|
|
||||||
|
|
||||||
notify-success:
|
|
||||||
name: Notify Telegram (success)
|
|
||||||
needs: deploy
|
|
||||||
# `quality` is intentionally skipped after PR merges. `success()` treats
|
|
||||||
# that skipped upstream job as non-success and would skip this job too.
|
|
||||||
if: ${{ always() && needs.deploy.result == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Notify ops group of successful admin deploy
|
|
||||||
env:
|
|
||||||
DEPLOY_SHA: ${{ github.sha }}
|
|
||||||
DEPLOY_STATUS: success
|
|
||||||
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
|
|
||||||
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
|
||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
|
||||||
run: |
|
|
||||||
export DEPLOY_VERSION="$(node -p "require('./package.json').version")"
|
|
||||||
chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
|
|
||||||
./scripts/notify-via-vps.sh
|
|
||||||
|
|
||||||
notify-failed:
|
|
||||||
name: Notify Telegram (failed)
|
|
||||||
needs: [gate, quality, build, deploy]
|
|
||||||
if: failure()
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Notify ops group of failed admin deploy
|
|
||||||
env:
|
|
||||||
DEPLOY_SHA: ${{ github.sha }}
|
|
||||||
DEPLOY_STATUS: failed
|
|
||||||
DEPLOY_COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
|
|
||||||
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
|
||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
|
||||||
run: |
|
|
||||||
# Do not gate on -x: notify-via-vps.sh may be 100644 in git; chmod first
|
|
||||||
# (the old `if [[ -x ... ]]` skipped the whole notify and still exited 0).
|
|
||||||
export DEPLOY_VERSION="$(node -p "require('./package.json').version" 2>/dev/null || echo '?')"
|
|
||||||
chmod +x scripts/notify-via-vps.sh scripts/notify-ops-telegram.sh scripts/notify-deploy.sh
|
|
||||||
./scripts/notify-via-vps.sh
|
|
||||||
@ -56,6 +56,13 @@ const columns: PaginationListColumnType[] = [
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
filterItems: BOOKING_STATUS_FILTER_ITEMS,
|
filterItems: BOOKING_STATUS_FILTER_ITEMS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'cancellationReason',
|
||||||
|
label: 'دلیل لغو',
|
||||||
|
filterable: false,
|
||||||
|
sortable: false,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'checkedInAt',
|
field: 'checkedInAt',
|
||||||
label: 'چکاین',
|
label: 'چکاین',
|
||||||
@ -123,6 +130,14 @@ const BookingsPage = () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
cancellationReason: (row, cellValue) => {
|
||||||
|
const reason = coerceToString(cellValue).trim()
|
||||||
|
const isCancelledLike = row.status === 'cancelled' || row.status === 'refunded'
|
||||||
|
|
||||||
|
if (!isCancelledLike || !reason) return '—'
|
||||||
|
|
||||||
|
return <span className="max-w-[220px] whitespace-pre-wrap break-words text-xs text-secondary-20">{reason}</span>
|
||||||
|
},
|
||||||
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
|
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||||||
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||||||
actions: (row) => {
|
actions: (row) => {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
|||||||
import StatusChip from '@/components/ui/StatusChip'
|
import StatusChip from '@/components/ui/StatusChip'
|
||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
import useAdminMutation from '@/hooks/useAdminMutation'
|
import useAdminMutation from '@/hooks/useAdminMutation'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
import { coerceToString } from '@/helpers'
|
import { coerceToString } from '@/helpers'
|
||||||
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
|
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
|
||||||
import { getBooleanStatus } from '@/constants/status'
|
import { getBooleanStatus } from '@/constants/status'
|
||||||
@ -79,9 +80,16 @@ const columns: PaginationListColumnType[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const ContactMessagesPage = () => {
|
const ContactMessagesPage = () => {
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
|
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
|
||||||
const [selected, setSelected] = useState<ContactMessageRow | null>(null)
|
const [selected, setSelected] = useState<ContactMessageRow | null>(null)
|
||||||
|
|
||||||
|
const handleMarkRead = (message: ContactMessageRow) => {
|
||||||
|
showAlert('این پیام بهعنوان خواندهشده علامتگذاری شود؟', () =>
|
||||||
|
runAction(message.id, () => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)), 'پیام خوانده شد')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="h-full w-full text-right">
|
<section className="h-full w-full text-right">
|
||||||
<PageNavbar pageTitle="پیامهای تماس" />
|
<PageNavbar pageTitle="پیامهای تماس" />
|
||||||
@ -150,13 +158,9 @@ const ContactMessagesPage = () => {
|
|||||||
isLoading={pendingId === message.id}
|
isLoading={pendingId === message.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
void runAction(
|
handleMarkRead(message)
|
||||||
message.id,
|
}}
|
||||||
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
|
|
||||||
'پیام خوانده شد'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FileCheckIcon className="size-5" />
|
<FileCheckIcon className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -16,7 +16,12 @@ vi.mock('@/config/axios', () => ({
|
|||||||
patch: axiosMocks.patch,
|
patch: axiosMocks.patch,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
|
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
|
||||||
|
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||||
vi.mock('@/components/formElements/Input', () => ({
|
vi.mock('@/components/formElements/Input', () => ({
|
||||||
default: ({
|
default: ({
|
||||||
description,
|
description,
|
||||||
@ -109,6 +114,9 @@ describe('NotificationRulesPanel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
axiosMocks.get.mockReset()
|
axiosMocks.get.mockReset()
|
||||||
axiosMocks.patch.mockReset()
|
axiosMocks.patch.mockReset()
|
||||||
axiosMocks.get.mockResolvedValue({
|
axiosMocks.get.mockResolvedValue({
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import Button from '@/components/formElements/Button'
|
|||||||
import Input from '@/components/formElements/Input'
|
import Input from '@/components/formElements/Input'
|
||||||
import AdminState from '@/components/feedback/AdminState'
|
import AdminState from '@/components/feedback/AdminState'
|
||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
|
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
|
||||||
import { API_ROUTES } from '@/services/config'
|
import { API_ROUTES } from '@/services/config'
|
||||||
import { extractServerErrorDetail } from '@/services/errorHandler'
|
import { extractServerErrorDetail } from '@/services/errorHandler'
|
||||||
@ -52,6 +53,7 @@ const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
|
|||||||
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
|
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
|
||||||
|
|
||||||
const NotificationRulesPanel = () => {
|
const NotificationRulesPanel = () => {
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
const [rules, setRules] = useState<Rule[]>([])
|
const [rules, setRules] = useState<Rule[]>([])
|
||||||
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
|
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -111,6 +113,12 @@ const NotificationRulesPanel = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmSave = (rule: Rule) => {
|
||||||
|
showAlert(`تغییرات قاعدهٔ «${rule.displayName}» ذخیره شود؟`, () => {
|
||||||
|
void save(rule)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
|
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
|
||||||
@ -142,7 +150,9 @@ const NotificationRulesPanel = () => {
|
|||||||
rule={rule}
|
rule={rule}
|
||||||
saving={saving === rule.eventKey}
|
saving={saving === rule.eventKey}
|
||||||
onChange={change}
|
onChange={change}
|
||||||
onSave={() => void save(rule)}
|
onSave={() => {
|
||||||
|
confirmSave(rule)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -115,7 +115,9 @@ const ReviewsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRestore = (row: ReviewRow) => {
|
const handleRestore = (row: ReviewRow) => {
|
||||||
void runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
|
showAlert('این نظر دوباره در نمایش عمومی قرار گیرد؟', () =>
|
||||||
|
runAction(row.id, () => axiosInstance.patch(API_ROUTES.REVIEWS.ADMIN_RESTORE(row.id)), 'نظر بازگردانده شد')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (row: ReviewRow) => {
|
const handleDelete = (row: ReviewRow) => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import PageNavbar from '@/components/layouts/PageNavbar'
|
|||||||
import Button from '@/components/formElements/Button'
|
import Button from '@/components/formElements/Button'
|
||||||
import Input from '@/components/formElements/Input'
|
import Input from '@/components/formElements/Input'
|
||||||
import { APP_ROUTES } from '@/constants/routes'
|
import { APP_ROUTES } from '@/constants/routes'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
import { addToast } from '@/lib/toast'
|
import { addToast } from '@/lib/toast'
|
||||||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||||||
import {
|
import {
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
|
|
||||||
const AdminSupportTicketDetail = () => {
|
const AdminSupportTicketDetail = () => {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
const [ticket, setTicket] = useState<SupportTicket | null>(null)
|
const [ticket, setTicket] = useState<SupportTicket | null>(null)
|
||||||
const [reply, setReply] = useState('')
|
const [reply, setReply] = useState('')
|
||||||
const [pending, setPending] = useState(false)
|
const [pending, setPending] = useState(false)
|
||||||
@ -56,6 +58,41 @@ const AdminSupportTicketDetail = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmSend = () => {
|
||||||
|
if (!reply.trim()) return
|
||||||
|
|
||||||
|
showAlert('این پاسخ ثبت و پیامک اطلاعرسانی برای کاربر ارسال شود؟', () => {
|
||||||
|
void send()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmStatusChange = (nextStatus: string) => {
|
||||||
|
if (!ticket || nextStatus === ticket.status) return
|
||||||
|
|
||||||
|
if (nextStatus === 'closed') {
|
||||||
|
showAlert(
|
||||||
|
'این تیکت بسته شود؟ کاربر دیگر نمیتواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمیتواند دوباره باز کند.',
|
||||||
|
() => {
|
||||||
|
void changeStatus('closed')
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ dangerAccept: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = SUPPORT_ADMIN_STATUS_LABELS[nextStatus] ?? nextStatus
|
||||||
|
|
||||||
|
showAlert(`وضعیت تیکت به «${label}» تغییر کند؟`, () => {
|
||||||
|
void changeStatus(nextStatus)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmClose = () => {
|
||||||
|
confirmStatusChange('closed')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="h-full w-full text-right">
|
<section className="h-full w-full text-right">
|
||||||
<PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} />
|
<PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} />
|
||||||
@ -91,7 +128,9 @@ const AdminSupportTicketDetail = () => {
|
|||||||
<select
|
<select
|
||||||
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
|
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
|
||||||
value={ticket.status}
|
value={ticket.status}
|
||||||
onChange={(event) => void changeStatus(event.target.value)}
|
onChange={(event) => {
|
||||||
|
confirmStatusChange(event.target.value)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
|
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
|
||||||
<option
|
<option
|
||||||
@ -104,6 +143,24 @@ const AdminSupportTicketDetail = () => {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{ticket.status !== 'closed' ? (
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onClick={confirmClose}
|
||||||
|
>
|
||||||
|
بستن تیکت
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm text-text-muted">
|
||||||
|
{ticket.closedBy === 'user'
|
||||||
|
? 'این تیکت توسط کاربر بسته شده است.'
|
||||||
|
: 'این تیکت توسط پشتیبانی بسته شده است؛ کاربر نمیتواند دوباره باز کند.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 rounded-2xl border border-default-200 bg-white p-5">
|
<div className="space-y-3 rounded-2xl border border-default-200 bg-white p-5">
|
||||||
{ticket.messages.map((message) => (
|
{ticket.messages.map((message) => (
|
||||||
@ -129,10 +186,13 @@ const AdminSupportTicketDetail = () => {
|
|||||||
setReply(String(value))
|
setReply(String(value))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="my-3 text-xs text-text-muted">پس از ثبت پاسخ، برای کاربر پیامک اطلاعرسانی ارسال میشود.</p>
|
<p className="my-3 text-xs text-text-muted">
|
||||||
|
میتوانید چند پیام پشتسرهم بفرستید. کاربر فقط بعد از پاسخ شما یک پیام میتواند بفرستد. پس از ثبت پاسخ، برای کاربر پیامک
|
||||||
|
اطلاعرسانی ارسال میشود.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
isLoading={pending}
|
isLoading={pending}
|
||||||
onClick={() => void send()}
|
onClick={confirmSend}
|
||||||
>
|
>
|
||||||
ثبت پاسخ و ارسال پیامک
|
ثبت پاسخ و ارسال پیامک
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -140,16 +140,29 @@ const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (values: AdminUserEditValues) => {
|
const handleSubmit = (values: AdminUserEditValues) => {
|
||||||
// Suspending an account is destructive-ish for the user, so confirm first.
|
|
||||||
const initial = toFormValues(user)
|
const initial = toFormValues(user)
|
||||||
|
const payload = buildDiffPayload(values)
|
||||||
|
|
||||||
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
|
if (Object.keys(payload).length === 0) {
|
||||||
showAlert('این کاربر معلق شود؟ کاربر تا فعالسازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
|
onOpenChange(false)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void submitUpdate(values)
|
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
|
||||||
|
showAlert(
|
||||||
|
'این کاربر معلق شود؟ کاربر تا فعالسازی مجدد امکان استفاده از حساب را نخواهد داشت.',
|
||||||
|
() => submitUpdate(values),
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
dangerAccept: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert('تغییرات این کاربر ذخیره شود؟', () => submitUpdate(values))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import axiosInstance, { getOrRefreshAccessToken, resetAxiosAuthModuleState } from '@/config/axios'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
expireRefreshSession: vi.fn().mockResolvedValue(undefined),
|
expireRefreshSession: vi.fn().mockResolvedValue(undefined),
|
||||||
}))
|
}))
|
||||||
@ -23,18 +25,15 @@ const writeStoredToken = (accessToken: string): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('admin access-token refresh races', () => {
|
describe('admin access-token refresh races', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
window.localStorage.clear()
|
window.localStorage.clear()
|
||||||
const { resetAxiosAuthModuleState } = await import('@/config/axios')
|
|
||||||
|
|
||||||
resetAxiosAuthModuleState()
|
resetAxiosAuthModuleState()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
|
it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
|
||||||
writeStoredToken('access-a')
|
writeStoredToken('access-a')
|
||||||
const { default: axiosInstance } = await import('@/config/axios')
|
|
||||||
const refreshRequest = vi.spyOn(axios, 'post')
|
const refreshRequest = vi.spyOn(axios, 'post')
|
||||||
const authorizationHeaders: string[] = []
|
const authorizationHeaders: string[] = []
|
||||||
let requestCount = 0
|
let requestCount = 0
|
||||||
@ -85,7 +84,6 @@ describe('admin access-token refresh races', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { getOrRefreshAccessToken } = await import('@/config/axios')
|
|
||||||
const refreshRequest = vi.spyOn(axios, 'post')
|
const refreshRequest = vi.spyOn(axios, 'post')
|
||||||
|
|
||||||
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')
|
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')
|
||||||
|
|||||||
@ -1,10 +1,38 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from 'react'
|
||||||
|
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { AlertModalProvider } from '@/context/AlertModalContext'
|
import { AlertModalProvider } from '@/context/AlertModalContext'
|
||||||
import useAlertModal from '@/hooks/useAlertModal'
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
|
|
||||||
afterEach(cleanup)
|
vi.mock('@/components/formElements/Button', () => ({
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
isLoading,
|
||||||
|
fullWidth: _fullWidth,
|
||||||
|
color: _color,
|
||||||
|
variant: _variant,
|
||||||
|
...props
|
||||||
|
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
isLoading?: boolean
|
||||||
|
fullWidth?: boolean
|
||||||
|
color?: string
|
||||||
|
variant?: string
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
disabled={props.disabled || isLoading}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
function AlertHarness({ onConfirm }: { onConfirm: () => void | Promise<void> }) {
|
function AlertHarness({ onConfirm }: { onConfirm: () => void | Promise<void> }) {
|
||||||
const { showAlert } = useAlertModal()
|
const { showAlert } = useAlertModal()
|
||||||
|
|||||||
35
deploy/nginx/backoffice.conf
Normal file
35
deploy/nginx/backoffice.conf
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Admin backoffice — TLS terminated by Nginx, app on 127.0.0.1:3009
|
||||||
|
# DNS: point backoffice.ghabilee.ir at this VPS, then:
|
||||||
|
# certbot --nginx -d backoffice.ghabilee.ir
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream ghabilee_admin {
|
||||||
|
server 127.0.0.1:3009;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name backoffice.ghabilee.ir;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://ghabilee_admin;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -143,7 +143,10 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
|
|||||||
|
|
||||||
const [updateRequest] = await Promise.all([
|
const [updateRequest] = await Promise.all([
|
||||||
page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')),
|
page.waitForRequest((request) => request.method() === 'PATCH' && new URL(request.url()).pathname.endsWith('/admin/events/event-1')),
|
||||||
page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true }),
|
(async () => {
|
||||||
|
await page.getByRole('switch', { name: 'نمایش در جستجو' }).click({ force: true })
|
||||||
|
await page.getByRole('button', { name: 'تأیید' }).click()
|
||||||
|
})(),
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } })
|
expect(updateRequest.postDataJSON()).toEqual({ settings: { isDiscoverable: true } })
|
||||||
@ -153,6 +156,7 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'انتشار فوری' }).click()
|
await page.getByRole('button', { name: 'انتشار فوری' }).click()
|
||||||
|
await page.getByRole('button', { name: 'تأیید' }).click()
|
||||||
await publishRequest
|
await publishRequest
|
||||||
await expect(page.getByText('منتشرشده', { exact: true })).toBeVisible()
|
await expect(page.getByText('منتشرشده', { exact: true })).toBeVisible()
|
||||||
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
|
||||||
|
|||||||
@ -127,6 +127,7 @@ test('admin approval publishes a pending-review event exactly once', async ({ pa
|
|||||||
await page.goto('/manage-events/event-1')
|
await page.goto('/manage-events/event-1')
|
||||||
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
|
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
|
||||||
await page.getByRole('button', { name: 'تأیید و انتشار' }).click()
|
await page.getByRole('button', { name: 'تأیید و انتشار' }).click()
|
||||||
|
await page.getByRole('button', { name: 'تأیید' }).click()
|
||||||
|
|
||||||
await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }])
|
await expect.poll(() => requests).toEqual([{ path: '/api/v1/admin/events/event-1/approve', payload: null }])
|
||||||
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)
|
||||||
|
|||||||
@ -197,7 +197,10 @@ const AdminEventDetail = () => {
|
|||||||
const cityName = useMemo(() => cities.find((item) => item.id === event?.cityId)?.name ?? '—', [cities, event?.cityId])
|
const cityName = useMemo(() => cities.find((item) => item.id === event?.cityId)?.name ?? '—', [cities, event?.cityId])
|
||||||
const provinceName = useMemo(() => provinces.find((item) => item.id === event?.provinceId)?.name ?? '—', [event?.provinceId, provinces])
|
const provinceName = useMemo(() => provinces.find((item) => item.id === event?.provinceId)?.name ?? '—', [event?.provinceId, provinces])
|
||||||
|
|
||||||
const handleApprove = () =>
|
const handleApprove = () => {
|
||||||
|
const isPendingReview = event?.status === 'pending_review'
|
||||||
|
|
||||||
|
showAlert(isPendingReview ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
|
||||||
runAction(
|
runAction(
|
||||||
'approve',
|
'approve',
|
||||||
async () => {
|
async () => {
|
||||||
@ -205,13 +208,16 @@ const AdminEventDetail = () => {
|
|||||||
|
|
||||||
mergeEvent(updated)
|
mergeEvent(updated)
|
||||||
},
|
},
|
||||||
event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
|
isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Bypasses the whole request/approve flow — publishes immediately
|
// Bypasses the whole request/approve flow — publishes immediately
|
||||||
// regardless of whether the host has requested publication or an admin
|
// regardless of whether the host has requested publication or an admin
|
||||||
// has approved yet. Kept as a separate action from "approve" on purpose.
|
// has approved yet. Kept as a separate action from "approve" on purpose.
|
||||||
const handleForcePublish = () =>
|
const handleForcePublish = () => {
|
||||||
|
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
|
||||||
runAction(
|
runAction(
|
||||||
'force-publish',
|
'force-publish',
|
||||||
async () => {
|
async () => {
|
||||||
@ -221,6 +227,8 @@ const AdminEventDetail = () => {
|
|||||||
},
|
},
|
||||||
'رویداد فورا منتشر شد'
|
'رویداد فورا منتشر شد'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
|
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
|
||||||
@ -276,7 +284,8 @@ const AdminEventDetail = () => {
|
|||||||
const handleApproveRevision = () => {
|
const handleApproveRevision = () => {
|
||||||
if (!pendingRevision) return
|
if (!pendingRevision) return
|
||||||
|
|
||||||
void runAction(
|
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () =>
|
||||||
|
runAction(
|
||||||
'approve-revision',
|
'approve-revision',
|
||||||
async () => {
|
async () => {
|
||||||
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
|
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
|
||||||
@ -287,6 +296,7 @@ const AdminEventDetail = () => {
|
|||||||
},
|
},
|
||||||
texts.events.revisionApproveSuccess
|
texts.events.revisionApproveSuccess
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRejectRevision = (rejectionReason: string) => {
|
const handleRejectRevision = (rejectionReason: string) => {
|
||||||
@ -303,12 +313,13 @@ const AdminEventDetail = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDiscoverable = async (nextValue: boolean) => {
|
const handleToggleDiscoverable = (nextValue: boolean) => {
|
||||||
if (!event || isTogglingDiscoverable) return
|
if (!event || isTogglingDiscoverable) return
|
||||||
|
|
||||||
const previous = event.settings.isDiscoverable
|
const previous = event.settings.isDiscoverable
|
||||||
|
|
||||||
setEvent({ ...event, settings: { ...event.settings, isDiscoverable: nextValue } })
|
showAlert(nextValue ? 'نمایش این رویداد در جستجوی عمومی فعال شود؟' : 'نمایش این رویداد در جستجوی عمومی غیرفعال شود؟', async () => {
|
||||||
|
setEvent((prev) => (prev ? { ...prev, settings: { ...prev.settings, isDiscoverable: nextValue } } : prev))
|
||||||
setIsTogglingDiscoverable(true)
|
setIsTogglingDiscoverable(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -326,6 +337,7 @@ const AdminEventDetail = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsTogglingDiscoverable(false)
|
setIsTogglingDiscoverable(false)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -336,11 +348,11 @@ const AdminEventDetail = () => {
|
|||||||
<AdminEventLifecycleActions
|
<AdminEventLifecycleActions
|
||||||
event={event}
|
event={event}
|
||||||
pendingId={pendingId}
|
pendingId={pendingId}
|
||||||
onApprove={() => void handleApprove()}
|
onApprove={handleApprove}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onForcePublish={() => void handleForcePublish()}
|
onForcePublish={handleForcePublish}
|
||||||
onOpenReject={() => {
|
onOpenReject={() => {
|
||||||
setHasOpenedRejectModal(true)
|
setHasOpenedRejectModal(true)
|
||||||
setIsRejectModalOpen(true)
|
setIsRejectModalOpen(true)
|
||||||
|
|||||||
@ -11,8 +11,12 @@ const bulkCreate = vi.fn()
|
|||||||
const setActive = vi.fn()
|
const setActive = vi.fn()
|
||||||
const removeCode = vi.fn()
|
const removeCode = vi.fn()
|
||||||
const addToast = vi.fn()
|
const addToast = vi.fn()
|
||||||
|
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
||||||
|
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||||
vi.mock('@/services/discountCodes', () => ({
|
vi.mock('@/services/discountCodes', () => ({
|
||||||
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
|
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
|
||||||
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
|
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
|
||||||
@ -67,10 +71,16 @@ const freshCode = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('EventDiscountsPanel', () => {
|
describe('EventDiscountsPanel', () => {
|
||||||
afterEach(cleanup)
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } })
|
listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } })
|
||||||
listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } })
|
listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } })
|
||||||
getReport.mockResolvedValue({
|
getReport.mockResolvedValue({
|
||||||
@ -136,6 +146,7 @@ describe('EventDiscountsPanel', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await screen.findByText('WELCOME20')
|
await screen.findByText('WELCOME20')
|
||||||
|
bulkCreate.mockClear()
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } })
|
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } })
|
||||||
fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } })
|
fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } })
|
||||||
@ -157,6 +168,8 @@ describe('EventDiscountsPanel', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await screen.findByText('WELCOME20')
|
await screen.findByText('WELCOME20')
|
||||||
|
bulkCreate.mockClear()
|
||||||
|
addToast.mockClear()
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } })
|
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' }))
|
fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' }))
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
|
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
|
||||||
SET_DISCOUNT_CODE_ACTIVE,
|
SET_DISCOUNT_CODE_ACTIVE,
|
||||||
} from '@/services/discountCodes'
|
} from '@/services/discountCodes'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
|
|
||||||
interface EventDiscountsPanelProps {
|
interface EventDiscountsPanelProps {
|
||||||
eventId: string
|
eventId: string
|
||||||
@ -51,6 +52,7 @@ const DISCOUNT_BEARER_OPTIONS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
|
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
const [codes, setCodes] = useState<DiscountCode[]>([])
|
const [codes, setCodes] = useState<DiscountCode[]>([])
|
||||||
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
|
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
|
||||||
const [report, setReport] = useState<DiscountReport | null>(null)
|
const [report, setReport] = useState<DiscountReport | null>(null)
|
||||||
@ -96,26 +98,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
const canCreate = !isFree && !isEnded
|
const canCreate = !isFree && !isEnded
|
||||||
const canMutateCodes = !isEnded
|
const canMutateCodes = !isEnded
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const executeCreate = async (numericValue: number, numericQuantity: number) => {
|
||||||
const numericValue = toInt(value)
|
|
||||||
const numericQuantity = toInt(quantity)
|
|
||||||
|
|
||||||
if (!numericValue) {
|
|
||||||
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
|
|
||||||
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!numericQuantity) {
|
|
||||||
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
|
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
|
||||||
type,
|
type,
|
||||||
@ -143,13 +126,39 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleActive = async (code: DiscountCode, nextActive: boolean) => {
|
const handleCreate = () => {
|
||||||
|
const numericValue = toInt(value)
|
||||||
|
const numericQuantity = toInt(quantity)
|
||||||
|
|
||||||
|
if (!numericValue) {
|
||||||
|
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
|
||||||
|
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!numericQuantity) {
|
||||||
|
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert(`${number(numericQuantity)} کد تخفیف ساخته شود؟`, () => {
|
||||||
|
void executeCreate(numericValue, numericQuantity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleActive = (code: DiscountCode, nextActive: boolean) => {
|
||||||
if (!canMutateCodes) {
|
if (!canMutateCodes) {
|
||||||
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
|
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
|
||||||
setPendingCodeId(code.id)
|
setPendingCodeId(code.id)
|
||||||
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
|
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
|
||||||
|
|
||||||
@ -162,9 +171,10 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item)))
|
setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item)))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (code: DiscountCode) => {
|
const handleDelete = (code: DiscountCode) => {
|
||||||
if (!canMutateCodes) {
|
if (!canMutateCodes) {
|
||||||
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
|
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
|
||||||
|
|
||||||
@ -177,6 +187,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAlert(
|
||||||
|
`کد تخفیف «${code.code}» حذف شود؟`,
|
||||||
|
async () => {
|
||||||
setPendingCodeId(code.id)
|
setPendingCodeId(code.id)
|
||||||
const result = await DELETE_DISCOUNT_CODE(code.id)
|
const result = await DELETE_DISCOUNT_CODE(code.id)
|
||||||
|
|
||||||
@ -190,6 +203,10 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
|
|
||||||
addToast({ title: texts.events.discountDeleted, color: 'success' })
|
addToast({ title: texts.events.discountDeleted, color: 'success' })
|
||||||
setCodes((current) => current.filter((item) => item.id !== code.id))
|
setCodes((current) => current.filter((item) => item.id !== code.id))
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ dangerAccept: true }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyCodes = async (list: string[]) => {
|
const copyCodes = async (list: string[]) => {
|
||||||
@ -458,7 +475,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
label=""
|
label=""
|
||||||
name={`code-active-${code.id}`}
|
name={`code-active-${code.id}`}
|
||||||
value={code.isActive}
|
value={code.isActive}
|
||||||
onValueChange={(next) => void handleToggleActive(code, Boolean(next))}
|
onValueChange={(next) => {
|
||||||
|
handleToggleActive(code, Boolean(next))
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-secondary-30">
|
<span className="text-xs text-secondary-30">
|
||||||
{isEnded ? texts.events.eventEndedShort : code.isActive ? texts.events.availableToGuests : texts.events.deactivated}
|
{isEnded ? texts.events.eventEndedShort : code.isActive ? texts.events.availableToGuests : texts.events.deactivated}
|
||||||
@ -469,7 +488,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
|
|||||||
disabled={!canMutateCodes || code.redeemedCount > 0 || pendingCodeId === code.id}
|
disabled={!canMutateCodes || code.redeemedCount > 0 || pendingCodeId === code.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onClick={() => void handleDelete(code)}
|
onClick={() => {
|
||||||
|
handleDelete(code)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{texts.common.delete}
|
{texts.common.delete}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { checkInBookingAsAdmin } from '@/services/eventManagement'
|
|||||||
import { API_ROUTES } from '@/services/config'
|
import { API_ROUTES } from '@/services/config'
|
||||||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||||||
import useAdminAction from '@/hooks/useAdminAction'
|
import useAdminAction from '@/hooks/useAdminAction'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
|
|
||||||
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
|
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
|
||||||
// minus the `eventId` column (this list is already scoped to one event via
|
// minus the `eventId` column (this list is already scoped to one event via
|
||||||
@ -34,6 +35,7 @@ const bookingsColumns: PaginationListColumnType[] = [
|
|||||||
{ field: 'bookingCode', label: 'کد رزرو', filterable: false, sortable: false, type: 'text' },
|
{ field: 'bookingCode', label: 'کد رزرو', filterable: false, sortable: false, type: 'text' },
|
||||||
{ field: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' },
|
{ field: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' },
|
||||||
{ field: 'status', label: 'وضعیت', filterable: true, sortable: true, type: 'select', filterItems: BOOKING_STATUS_FILTER_ITEMS },
|
{ field: 'status', label: 'وضعیت', filterable: true, sortable: true, type: 'select', filterItems: BOOKING_STATUS_FILTER_ITEMS },
|
||||||
|
{ field: 'cancellationReason', label: 'دلیل لغو', filterable: false, sortable: false, type: 'text' },
|
||||||
{ field: 'checkedInAt', label: 'چکاین', filterable: false, sortable: false, type: 'date' },
|
{ field: 'checkedInAt', label: 'چکاین', filterable: false, sortable: false, type: 'date' },
|
||||||
{ field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' },
|
{ field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' },
|
||||||
{ field: 'actions', label: 'عملیات' },
|
{ field: 'actions', label: 'عملیات' },
|
||||||
@ -48,8 +50,10 @@ interface AdminEventBookingsTabProps {
|
|||||||
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
|
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
|
||||||
const bookingsListRef = useRef<PaginatedListHandle>(null)
|
const bookingsListRef = useRef<PaginatedListHandle>(null)
|
||||||
const { pendingId, runAction } = useAdminAction()
|
const { pendingId, runAction } = useAdminAction()
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
|
|
||||||
const handleCheckIn = (bookingId: string) =>
|
const handleCheckIn = (bookingId: string) => {
|
||||||
|
showAlert('حضور این مهمان ثبت شود؟', () =>
|
||||||
runAction(
|
runAction(
|
||||||
bookingId,
|
bookingId,
|
||||||
async () => {
|
async () => {
|
||||||
@ -59,6 +63,8 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
|
|||||||
},
|
},
|
||||||
'حضور مهمان ثبت شد'
|
'حضور مهمان ثبت شد'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@ -113,6 +119,14 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
cancellationReason: (row, cellValue) => {
|
||||||
|
const reason = coerceToString(cellValue).trim()
|
||||||
|
const isCancelledLike = row.status === 'cancelled' || row.status === 'refunded'
|
||||||
|
|
||||||
|
if (!isCancelledLike || !reason) return '—'
|
||||||
|
|
||||||
|
return <span className="max-w-[220px] whitespace-pre-wrap break-words text-xs text-secondary-20">{reason}</span>
|
||||||
|
},
|
||||||
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
|
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||||||
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||||||
actions: (row) => {
|
actions: (row) => {
|
||||||
@ -137,7 +151,9 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
|
|||||||
isLoading={pendingId === bookingId}
|
isLoading={pendingId === bookingId}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onClick={() => void handleCheckIn(bookingId)}
|
onClick={() => {
|
||||||
|
handleCheckIn(bookingId)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FileCheckIcon className="size-4" />
|
<FileCheckIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -7,8 +7,12 @@ import AdminEventCommissionModal from '@/features/events/detail/admin-event-deta
|
|||||||
|
|
||||||
const updateCommission = vi.fn()
|
const updateCommission = vi.fn()
|
||||||
const addToast = vi.fn()
|
const addToast = vi.fn()
|
||||||
|
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
||||||
|
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
|
||||||
vi.mock('@/services/events', () => ({
|
vi.mock('@/services/events', () => ({
|
||||||
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
|
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
|
||||||
}))
|
}))
|
||||||
@ -73,6 +77,9 @@ describe('AdminEventCommissionModal', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
|
||||||
|
void onConfirm?.()
|
||||||
|
})
|
||||||
updateCommission.mockResolvedValue({
|
updateCommission.mockResolvedValue({
|
||||||
id: 'event-1',
|
id: 'event-1',
|
||||||
commissionPercent: 12,
|
commissionPercent: 12,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import Modal from '@/components/modals/Modal'
|
|||||||
import { coerceToString } from '@/helpers'
|
import { coerceToString } from '@/helpers'
|
||||||
import { updateEventCommissionAsAdmin } from '@/services/events'
|
import { updateEventCommissionAsAdmin } from '@/services/events'
|
||||||
import useAdminAction from '@/hooks/useAdminAction'
|
import useAdminAction from '@/hooks/useAdminAction'
|
||||||
|
import useAlertModal from '@/hooks/useAlertModal'
|
||||||
|
|
||||||
interface AdminEventCommissionModalProps {
|
interface AdminEventCommissionModalProps {
|
||||||
eventId: string
|
eventId: string
|
||||||
@ -23,6 +24,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
const [commissionInput, setCommissionInput] = useState('')
|
const [commissionInput, setCommissionInput] = useState('')
|
||||||
const [commissionError, setCommissionError] = useState<string | null>(null)
|
const [commissionError, setCommissionError] = useState<string | null>(null)
|
||||||
const { pendingId, runAction } = useAdminAction()
|
const { pendingId, runAction } = useAdminAction()
|
||||||
|
const { showAlert } = useAlertModal()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@ -40,7 +42,8 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void runAction(
|
showAlert(`کمیسیون این رویداد به ${parsed}٪ تغییر کند؟`, () =>
|
||||||
|
runAction(
|
||||||
'commission',
|
'commission',
|
||||||
async () => {
|
async () => {
|
||||||
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
|
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
|
||||||
@ -50,9 +53,11 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
},
|
},
|
||||||
'کمیسیون رویداد بهروزرسانی شد'
|
'کمیسیون رویداد بهروزرسانی شد'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetCommission = () =>
|
const handleResetCommission = () => {
|
||||||
|
showAlert('کمیسیون این رویداد به پیشفرض پلتفرم بازگردد؟', () =>
|
||||||
runAction(
|
runAction(
|
||||||
'commission',
|
'commission',
|
||||||
async () => {
|
async () => {
|
||||||
@ -63,6 +68,8 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
},
|
},
|
||||||
'کمیسیون رویداد به پیشفرض پلتفرم بازگشت'
|
'کمیسیون رویداد به پیشفرض پلتفرم بازگشت'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -75,7 +82,9 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
|
|||||||
isLoading={pendingId === 'commission'}
|
isLoading={pendingId === 'commission'}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onClick={() => void handleResetCommission()}
|
onClick={() => {
|
||||||
|
handleResetCommission()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
بازگشت به پیشفرض پلتفرم
|
بازگشت به پیشفرض پلتفرم
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import type { AdminEventDetailData } from './types'
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
import AdminEventLifecycleActions from './AdminEventLifecycleActions'
|
import AdminEventLifecycleActions from './AdminEventLifecycleActions'
|
||||||
|
|
||||||
vi.mock('@/components/formElements/Button', () => ({
|
vi.mock('@/components/formElements/Button', () => ({
|
||||||
@ -28,10 +30,9 @@ const baseEvent = {
|
|||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
describe('AdminEventLifecycleActions', () => {
|
describe('AdminEventLifecycleActions', () => {
|
||||||
it('exposes approve/reject/force-publish for pending_review events', () => {
|
it('exposes approve/reject for pending_review and hides force-publish', () => {
|
||||||
const onApprove = vi.fn()
|
const onApprove = vi.fn()
|
||||||
const onOpenReject = vi.fn()
|
const onOpenReject = vi.fn()
|
||||||
const onForcePublish = vi.fn()
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<AdminEventLifecycleActions
|
<AdminEventLifecycleActions
|
||||||
@ -41,18 +42,63 @@ describe('AdminEventLifecycleActions', () => {
|
|||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
onComplete={vi.fn()}
|
onComplete={vi.fn()}
|
||||||
onDelete={vi.fn()}
|
onDelete={vi.fn()}
|
||||||
onForcePublish={onForcePublish}
|
onForcePublish={vi.fn()}
|
||||||
onOpenReject={onOpenReject}
|
onOpenReject={onOpenReject}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'رد' }))
|
fireEvent.click(screen.getByRole('button', { name: 'رد' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
|
|
||||||
|
|
||||||
expect(onApprove).toHaveBeenCalledTimes(1)
|
expect(onApprove).toHaveBeenCalledTimes(1)
|
||||||
expect(onOpenReject).toHaveBeenCalledTimes(1)
|
expect(onOpenReject).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
|
||||||
|
expect(screen.getByText(texts.events.adminLifecycleHintPendingReview)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes approve and force-publish for unapproved draft events', () => {
|
||||||
|
const onApprove = vi.fn()
|
||||||
|
const onForcePublish = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminEventLifecycleActions
|
||||||
|
event={{ ...baseEvent, status: 'draft', adminApprovedAt: null }}
|
||||||
|
pendingId={null}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onForcePublish={onForcePublish}
|
||||||
|
onOpenReject={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید برای انتشار' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
|
||||||
|
|
||||||
|
expect(onApprove).toHaveBeenCalledTimes(1)
|
||||||
expect(onForcePublish).toHaveBeenCalledTimes(1)
|
expect(onForcePublish).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
|
||||||
|
expect(screen.getByText(texts.events.adminLifecycleHintDraftUnapproved)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps force-publish for approved drafts waiting on the host', () => {
|
||||||
|
render(
|
||||||
|
<AdminEventLifecycleActions
|
||||||
|
event={{ ...baseEvent, status: 'draft', adminApprovedAt: '2026-01-01T00:00:00.000Z' }}
|
||||||
|
pendingId={null}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onForcePublish={vi.fn()}
|
||||||
|
onOpenReject={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('تأییدشده؛ منتظر میزبان')).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'انتشار فوری' })).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: 'تأیید برای انتشار' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes complete/cancel for published events and hides reject', () => {
|
it('exposes complete/cancel for published events and hides reject', () => {
|
||||||
@ -73,5 +119,6 @@ describe('AdminEventLifecycleActions', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
|
||||||
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
|
||||||
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
|
||||||
|
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import type { AdminEventDetailData } from './types'
|
|||||||
|
|
||||||
import Button from '@/components/formElements/Button'
|
import Button from '@/components/formElements/Button'
|
||||||
import { APP_ROUTES } from '@/constants/routes'
|
import { APP_ROUTES } from '@/constants/routes'
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
|
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
|
||||||
|
|
||||||
interface AdminEventLifecycleActionsProps {
|
interface AdminEventLifecycleActionsProps {
|
||||||
event: AdminEventDetailData
|
event: AdminEventDetailData
|
||||||
@ -25,7 +28,11 @@ const AdminEventLifecycleActions = ({
|
|||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AdminEventLifecycleActionsProps) => (
|
}: AdminEventLifecycleActionsProps) => {
|
||||||
|
const hint = resolveAdminEventLifecycleHint(event)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-full flex-col items-end gap-1">
|
||||||
<div className="flex max-w-full flex-nowrap items-center justify-end gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div className="flex max-w-full flex-nowrap items-center justify-end gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
|
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
|
||||||
<Button
|
<Button
|
||||||
@ -71,9 +78,9 @@ const AdminEventLifecycleActions = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{event.status === 'draft' || event.status === 'pending_review' ? (
|
{event.status === 'draft' ? (
|
||||||
<Button
|
<Button
|
||||||
aria-label="انتشار فوری بدون تایید میزبان"
|
aria-label={texts.events.adminLifecycleTitleForcePublish}
|
||||||
isLoading={pendingId === 'force-publish'}
|
isLoading={pendingId === 'force-publish'}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@ -117,6 +124,9 @@ const AdminEventLifecycleActions = ({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
{hint ? <p className="max-w-[min(72vw,28rem)] text-right text-xs leading-5 text-text-muted">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default AdminEventLifecycleActions
|
export default AdminEventLifecycleActions
|
||||||
|
|||||||
@ -48,10 +48,6 @@ const AdminEventOverviewTab = ({ insights }: AdminEventOverviewTabProps) => {
|
|||||||
label="بازپرداختشده"
|
label="بازپرداختشده"
|
||||||
value={number(insights.registrations.refundedCount)}
|
value={number(insights.registrations.refundedCount)}
|
||||||
/>
|
/>
|
||||||
<InsightStat
|
|
||||||
label="غایب"
|
|
||||||
value={number(insights.registrations.noShowCount)}
|
|
||||||
/>
|
|
||||||
<InsightStat
|
<InsightStat
|
||||||
label="ظرفیت باقیمانده"
|
label="ظرفیت باقیمانده"
|
||||||
value={number(insights.registrations.remainingCapacity)}
|
value={number(insights.registrations.remainingCapacity)}
|
||||||
|
|||||||
@ -13,7 +13,8 @@ interface AdminEventPendingRevisionCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdminEventPendingRevisionCard = ({ pendingId, revision, onApprove, onReject }: AdminEventPendingRevisionCardProps) => {
|
const AdminEventPendingRevisionCard = ({ pendingId, revision, onApprove, onReject }: AdminEventPendingRevisionCardProps) => {
|
||||||
const { title, slug } = revision.payload
|
const { title, slug, faqs } = revision.payload
|
||||||
|
const proposedFaqs = Array.isArray(faqs) ? faqs : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="admin-surface">
|
<Card className="admin-surface">
|
||||||
@ -43,6 +44,21 @@ const AdminEventPendingRevisionCard = ({ pendingId, revision, onApprove, onRejec
|
|||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{proposedFaqs.length > 0 ? (
|
||||||
|
<div className="space-y-2 text-right">
|
||||||
|
<p className="text-xs text-secondary-30">سوالات متداول پیشنهادی ({proposedFaqs.length.toLocaleString('fa-IR')})</p>
|
||||||
|
{proposedFaqs.map((faq, index) => (
|
||||||
|
<div
|
||||||
|
key={`${faq.question}-${index}`}
|
||||||
|
className="rounded-xl border border-secondary-40 bg-secondary-50/60 p-3"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-bold text-secondary-20">{faq.question}</h3>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-xs leading-6 text-secondary-30">{faq.answer}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
isLoading={pendingId === 'approve-revision'}
|
isLoading={pendingId === 'approve-revision'}
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import type { AdminEventDetailData } from './types'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
|
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
|
||||||
|
|
||||||
|
const baseEvent = {
|
||||||
|
id: 'event-1',
|
||||||
|
adminApprovedAt: null,
|
||||||
|
bookedCount: 0,
|
||||||
|
} as AdminEventDetailData
|
||||||
|
|
||||||
|
describe('resolveAdminEventLifecycleHint', () => {
|
||||||
|
it('explains approve vs force-publish on unapproved drafts', () => {
|
||||||
|
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'draft', adminApprovedAt: null })).toBe(
|
||||||
|
texts.events.adminLifecycleHintDraftUnapproved
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains waiting on the host after admin approval', () => {
|
||||||
|
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'draft', adminApprovedAt: '2026-01-01T00:00:00.000Z' })).toBe(
|
||||||
|
texts.events.adminLifecycleHintDraftApproved
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains pending-review approval', () => {
|
||||||
|
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'pending_review' })).toBe(texts.events.adminLifecycleHintPendingReview)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains complete and cancel on live events', () => {
|
||||||
|
expect(resolveAdminEventLifecycleHint({ ...baseEvent, status: 'published', bookedCount: 2 })).toBe(
|
||||||
|
texts.events.adminLifecycleHintPublished
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import type { AdminEventDetailData } from './types'
|
||||||
|
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
|
/** راهنمای کوتاه اکشنهای lifecycle — بسته به وضعیت فعلی رویداد */
|
||||||
|
export const resolveAdminEventLifecycleHint = (event: AdminEventDetailData): string | null => {
|
||||||
|
if (event.status === 'draft' && !event.adminApprovedAt) {
|
||||||
|
return texts.events.adminLifecycleHintDraftUnapproved
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'draft' && event.adminApprovedAt) {
|
||||||
|
return texts.events.adminLifecycleHintDraftApproved
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'pending_review') {
|
||||||
|
return texts.events.adminLifecycleHintPendingReview
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'published' || event.status === 'full') {
|
||||||
|
return texts.events.adminLifecycleHintPublished
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.bookedCount === 0) {
|
||||||
|
return texts.events.adminLifecycleHintDeleteOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
2524
openapi.json
2524
openapi.json
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ghabilee-admin",
|
"name": "ghabilee-admin",
|
||||||
"version": "0.1.14",
|
"version": "0.1.18",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build and publish the production admin image in GitHub Actions.
|
# Build and publish the production admin image in Gitea Actions.
|
||||||
# Build-time public variables are read from the VPS .env file and never logged.
|
# Build-time public variables are read from the VPS .env file and never logged.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
141
scripts/deploy-finland-to-iran.sh
Executable file
141
scripts/deploy-finland-to-iran.sh
Executable file
@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build admin image on the Finland host, then load+run it on Iran.
|
||||||
|
#
|
||||||
|
# Architecture:
|
||||||
|
# - Finland (git.ghabilee.ir / act_runner): build + Telegram notify
|
||||||
|
# - Iran: runtime only (/opt/ghabilee-admin)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${FINLAND_SSH_KEY:?}"
|
||||||
|
: "${FINLAND_HOST:?}"
|
||||||
|
: "${FINLAND_USER:?}"
|
||||||
|
: "${IRAN_SSH_KEY:?}"
|
||||||
|
: "${IRAN_HOST:?}"
|
||||||
|
: "${IRAN_USER:?}"
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-ghabilee-admin:$(git rev-parse --short HEAD 2>/dev/null || date +%s)}"
|
||||||
|
FINLAND_APP="/opt/ghabilee-admin-ci"
|
||||||
|
IRAN_APP="/opt/ghabilee-admin"
|
||||||
|
|
||||||
|
install -m 700 -d "$HOME/.ssh"
|
||||||
|
printf '%s\n' "$FINLAND_SSH_KEY" > "$HOME/.ssh/finland_key"
|
||||||
|
printf '%s\n' "$IRAN_SSH_KEY" > "$HOME/.ssh/iran_key"
|
||||||
|
chmod 600 "$HOME/.ssh/finland_key" "$HOME/.ssh/iran_key"
|
||||||
|
trap 'rm -f "$HOME/.ssh/finland_key" "$HOME/.ssh/iran_key"' EXIT
|
||||||
|
|
||||||
|
FSSH=(ssh -i "$HOME/.ssh/finland_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new)
|
||||||
|
ISSH=(ssh -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new)
|
||||||
|
FRSYNC=(-e "ssh -i $HOME/.ssh/finland_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new")
|
||||||
|
|
||||||
|
# --- build env from Iran (runtime .env holds NEXT_PUBLIC_* bake inputs) ---
|
||||||
|
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" "test -s ${IRAN_APP}/.env"
|
||||||
|
scp -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
||||||
|
"${IRAN_USER}@${IRAN_HOST}:${IRAN_APP}/.env" .env.production
|
||||||
|
test -s .env.production
|
||||||
|
|
||||||
|
# --- sync sources + env to Finland build dir ---
|
||||||
|
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" "install -d -m 0750 '${FINLAND_APP}/src'"
|
||||||
|
rsync -az --delete "${FRSYNC[@]}" \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.next' \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude '.env.*' \
|
||||||
|
--exclude 'test-results' \
|
||||||
|
--exclude 'playwright-report' \
|
||||||
|
./ "${FINLAND_USER}@${FINLAND_HOST}:${FINLAND_APP}/src/"
|
||||||
|
|
||||||
|
scp -i "$HOME/.ssh/finland_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
||||||
|
.env.production "${FINLAND_USER}@${FINLAND_HOST}:${FINLAND_APP}/.env"
|
||||||
|
rm -f .env.production
|
||||||
|
|
||||||
|
# --- build on Finland (enough RAM; Telegram/git live here too) ---
|
||||||
|
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" \
|
||||||
|
"IMAGE_TAG='${IMAGE_TAG}' APP_DIR='${FINLAND_APP}' SRC_DIR='${FINLAND_APP}/src' bash -s" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$APP_DIR"
|
||||||
|
# noglob: backend-style cron values must never expand if present in .env
|
||||||
|
set -f
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$APP_DIR/.env"
|
||||||
|
set +a
|
||||||
|
set +f
|
||||||
|
|
||||||
|
env_or_empty() { printf '%s' "${!1-}"; }
|
||||||
|
|
||||||
|
sanitize_api_proxy_target() {
|
||||||
|
local target
|
||||||
|
target="$(env_or_empty API_PROXY_TARGET)"
|
||||||
|
case "$target" in
|
||||||
|
*127.0.0.1*|*localhost*|*'::1'*) printf '' ;;
|
||||||
|
*) printf '%s' "$target" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Avoid glob expansion when sourcing cron-like values already done; build args only.
|
||||||
|
docker build --platform linux/amd64 -t "$IMAGE_TAG" \
|
||||||
|
--build-arg "NEXT_PUBLIC_API_URL=$(env_or_empty NEXT_PUBLIC_API_URL)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_FILE_SERVER_URL=$(env_or_empty NEXT_PUBLIC_FILE_SERVER_URL)" \
|
||||||
|
--build-arg "MAP_API_KEY=$(env_or_empty MAP_API_KEY)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_MAP_API_KEY=$(env_or_empty NEXT_PUBLIC_MAP_API_KEY)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_VAPID_PUBLIC_KEY=$(env_or_empty NEXT_PUBLIC_VAPID_PUBLIC_KEY)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_BASE_PATH=$(env_or_empty NEXT_PUBLIC_BASE_PATH)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_SITE_URL=$(env_or_empty NEXT_PUBLIC_SITE_URL)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_OBSERVABILITY_ENDPOINT=$(env_or_empty NEXT_PUBLIC_OBSERVABILITY_ENDPOINT)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$(env_or_empty NEXT_PUBLIC_SENTRY_DSN)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=$(env_or_empty NEXT_PUBLIC_SENTRY_ENVIRONMENT)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$(env_or_empty NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)" \
|
||||||
|
--build-arg "NEXT_PUBLIC_ARCAPTCHA_SITE_KEY=$(env_or_empty NEXT_PUBLIC_ARCAPTCHA_SITE_KEY)" \
|
||||||
|
--build-arg "SENTRY_AUTH_TOKEN=$(env_or_empty SENTRY_AUTH_TOKEN)" \
|
||||||
|
--build-arg "SENTRY_ORG=$(env_or_empty SENTRY_ORG)" \
|
||||||
|
--build-arg "SENTRY_PROJECT=$(env_or_empty SENTRY_PROJECT)" \
|
||||||
|
--build-arg "API_PROXY_TARGET=$(sanitize_api_proxy_target)" \
|
||||||
|
"$SRC_DIR"
|
||||||
|
|
||||||
|
docker image prune -af >/dev/null 2>&1 || true
|
||||||
|
echo "BUILT $IMAGE_TAG"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
# --- stream image Finland → Iran, then compose up ---
|
||||||
|
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" "install -d -m 0750 '${IRAN_APP}'"
|
||||||
|
scp -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
||||||
|
deploy/docker-compose.production.yml \
|
||||||
|
"${IRAN_USER}@${IRAN_HOST}:${IRAN_APP}/docker-compose.yml"
|
||||||
|
if [[ -f deploy/nginx/backoffice.conf ]]; then
|
||||||
|
scp -i "$HOME/.ssh/iran_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
||||||
|
deploy/nginx/backoffice.conf \
|
||||||
|
"${IRAN_USER}@${IRAN_HOST}:/etc/nginx/sites-available/backoffice"
|
||||||
|
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" '
|
||||||
|
ln -sfn /etc/nginx/sites-available/backoffice /etc/nginx/sites-enabled/backoffice
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Transferring ${IMAGE_TAG} Finland → Iran..."
|
||||||
|
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" "docker save '${IMAGE_TAG}'" \
|
||||||
|
| "${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" "docker load"
|
||||||
|
|
||||||
|
"${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" \
|
||||||
|
"ADMIN_IMAGE='${IMAGE_TAG}' bash -s" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
cd /opt/ghabilee-admin
|
||||||
|
test -s .env
|
||||||
|
ADMIN_IMAGE="$ADMIN_IMAGE" docker compose -f docker-compose.yml up -d --no-deps ghabilee-admin
|
||||||
|
for attempt in $(seq 1 36); do
|
||||||
|
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)"
|
||||||
|
echo "Admin health ${attempt}/36: ${health}"
|
||||||
|
if [ "$health" = healthy ]; then
|
||||||
|
docker image prune -af >/dev/null 2>&1 || true
|
||||||
|
echo "Deploy OK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
case "$health" in unhealthy|exited|dead|missing) exit 1 ;; esac
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
REMOTE
|
||||||
98
scripts/deploy-on-vps.sh
Executable file
98
scripts/deploy-on-vps.sh
Executable file
@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build and run admin on the VPS (idempotent).
|
||||||
|
# Expects app sources in APP_DIR (CI rsync) and a filled APP_DIR/.env.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/ghabilee-admin}"
|
||||||
|
SRC_DIR="${SRC_DIR:-$APP_DIR/src}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-ghabilee-admin:local}"
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$APP_DIR/.env" ]; then
|
||||||
|
echo "Missing $APP_DIR/.env — copy from .env.example and fill production values." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$SRC_DIR/Dockerfile" ]; then
|
||||||
|
echo "Missing $SRC_DIR/Dockerfile — rsync the repo before deploy." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0644 "$SRC_DIR/deploy/docker-compose.production.yml" "$APP_DIR/docker-compose.yml"
|
||||||
|
if [ -f "$SRC_DIR/deploy/nginx/backoffice.conf" ]; then
|
||||||
|
install -m 0644 "$SRC_DIR/deploy/nginx/backoffice.conf" /etc/nginx/sites-available/backoffice
|
||||||
|
ln -sfn /etc/nginx/sites-available/backoffice /etc/nginx/sites-enabled/backoffice
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$APP_DIR/.env"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
env_or_empty() {
|
||||||
|
local key="$1"
|
||||||
|
printf '%s' "${!key-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitize_api_proxy_target() {
|
||||||
|
local target
|
||||||
|
target="$(env_or_empty API_PROXY_TARGET)"
|
||||||
|
case "$target" in
|
||||||
|
*127.0.0.1*|*localhost*|*'::1'*)
|
||||||
|
echo "Warning: refusing loopback API_PROXY_TARGET for production image build" >&2
|
||||||
|
printf ''
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s' "$target"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
BUILD_ARGS=(
|
||||||
|
--build-arg "NEXT_PUBLIC_API_URL=$(env_or_empty NEXT_PUBLIC_API_URL)"
|
||||||
|
--build-arg "NEXT_PUBLIC_FILE_SERVER_URL=$(env_or_empty NEXT_PUBLIC_FILE_SERVER_URL)"
|
||||||
|
--build-arg "MAP_API_KEY=$(env_or_empty MAP_API_KEY)"
|
||||||
|
--build-arg "NEXT_PUBLIC_MAP_API_KEY=$(env_or_empty NEXT_PUBLIC_MAP_API_KEY)"
|
||||||
|
--build-arg "NEXT_PUBLIC_VAPID_PUBLIC_KEY=$(env_or_empty NEXT_PUBLIC_VAPID_PUBLIC_KEY)"
|
||||||
|
--build-arg "NEXT_PUBLIC_BASE_PATH=$(env_or_empty NEXT_PUBLIC_BASE_PATH)"
|
||||||
|
--build-arg "NEXT_PUBLIC_SITE_URL=$(env_or_empty NEXT_PUBLIC_SITE_URL)"
|
||||||
|
--build-arg "NEXT_PUBLIC_OBSERVABILITY_ENDPOINT=$(env_or_empty NEXT_PUBLIC_OBSERVABILITY_ENDPOINT)"
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$(env_or_empty NEXT_PUBLIC_SENTRY_DSN)"
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=$(env_or_empty NEXT_PUBLIC_SENTRY_ENVIRONMENT)"
|
||||||
|
--build-arg "NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=$(env_or_empty NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)"
|
||||||
|
--build-arg "NEXT_PUBLIC_ARCAPTCHA_SITE_KEY=$(env_or_empty NEXT_PUBLIC_ARCAPTCHA_SITE_KEY)"
|
||||||
|
--build-arg "SENTRY_AUTH_TOKEN=$(env_or_empty SENTRY_AUTH_TOKEN)"
|
||||||
|
--build-arg "SENTRY_ORG=$(env_or_empty SENTRY_ORG)"
|
||||||
|
--build-arg "SENTRY_PROJECT=$(env_or_empty SENTRY_PROJECT)"
|
||||||
|
--build-arg "API_PROXY_TARGET=$(sanitize_api_proxy_target)"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Building ${IMAGE_TAG} on VPS (build arguments redacted)"
|
||||||
|
docker build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
-t "$IMAGE_TAG" \
|
||||||
|
"${BUILD_ARGS[@]}" \
|
||||||
|
"$SRC_DIR"
|
||||||
|
|
||||||
|
ADMIN_IMAGE="$IMAGE_TAG" docker compose -f "$APP_DIR/docker-compose.yml" up -d --no-deps ghabilee-admin
|
||||||
|
|
||||||
|
for attempt in $(seq 1 36); do
|
||||||
|
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' ghabilee-admin 2>/dev/null || echo missing)"
|
||||||
|
echo "Admin health ${attempt}/36: ${health}"
|
||||||
|
if [ "$health" = "healthy" ]; then
|
||||||
|
docker image prune -af >/dev/null 2>&1 || true
|
||||||
|
echo "Deploy OK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
case "$health" in
|
||||||
|
unhealthy|exited|dead|missing) exit 1 ;;
|
||||||
|
esac
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timed out waiting for healthy admin container" >&2
|
||||||
|
exit 1
|
||||||
50
scripts/extract-telegram-env.py
Normal file
50
scripts/extract-telegram-env.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Print TELEGRAM_* assignments from known VPS env files (stdout only)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WANTED = (
|
||||||
|
'TELEGRAM_BOT_TOKEN',
|
||||||
|
'TELEGRAM_GROUP_CHAT_ID',
|
||||||
|
'TELEGRAM_GROUP_THREAD_ID',
|
||||||
|
'TELEGRAM_CHAT_ID',
|
||||||
|
)
|
||||||
|
PATHS = (
|
||||||
|
Path('/opt/ghabilee-backend/.env'),
|
||||||
|
Path('/opt/ghabilee/backend/.env'),
|
||||||
|
Path('/opt/ghabilee-admin/.env'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
found: dict[str, str] = {}
|
||||||
|
for path in PATHS:
|
||||||
|
if not path.is_file() or path.stat().st_size == 0:
|
||||||
|
continue
|
||||||
|
for raw in path.read_text(encoding='utf-8', errors='replace').splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith('#') or '=' not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
key = key.strip()
|
||||||
|
if key not in WANTED or key in found:
|
||||||
|
continue
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||||
|
value = value[1:-1]
|
||||||
|
# Inline comments in .env (e.g. THREAD_ID=8 # ops)
|
||||||
|
if ' #' in f' {value}':
|
||||||
|
value = value.split('#', 1)[0].rstrip()
|
||||||
|
value = value.replace('\n', '').replace('\r', '')
|
||||||
|
found[key] = value
|
||||||
|
if 'TELEGRAM_BOT_TOKEN' in found:
|
||||||
|
break
|
||||||
|
|
||||||
|
for key in WANTED:
|
||||||
|
if key in found and found[key]:
|
||||||
|
print(f'{key}={found[key]}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
16
scripts/notify-from-finland.sh
Executable file
16
scripts/notify-from-finland.sh
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Notify ops Telegram from the Finland Actions runner (not from Iran).
|
||||||
|
# Credentials come from Gitea Actions secrets — same model as telegrambot.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
: "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN secret required}"
|
||||||
|
|
||||||
|
if [[ -z "${TELEGRAM_GROUP_CHAT_ID:-}${TELEGRAM_CHAT_ID:-}" ]]; then
|
||||||
|
echo "[ghabilee-admin-notify] failed: set TELEGRAM_GROUP_CHAT_ID or TELEGRAM_CHAT_ID" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "${SCRIPT_DIR}/notify-ops-telegram.sh" "${SCRIPT_DIR}/notify-deploy.sh"
|
||||||
|
bash "${SCRIPT_DIR}/notify-deploy.sh"
|
||||||
@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Send deploy notification from VPS (Telegram creds from /opt/ghabilee-backend/.env).
|
# Load Telegram credentials from the Iran VPS .env, then send from this host.
|
||||||
# GitHub runners may not reach api.telegram.org; VPS can.
|
#
|
||||||
|
# Why not notify on the VPS?
|
||||||
|
# - Iran egress often cannot reach api.telegram.org
|
||||||
|
# - Sourcing the full backend .env breaks on cron globs (BOOKING_EXPIRY_CRON=*)
|
||||||
|
#
|
||||||
|
# Gitea act_runner (foreign) can reach Telegram; we only SSH to fetch TELEGRAM_* keys.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${SSH_KEY:?SSH_KEY required}"
|
: "${SSH_KEY:?SSH_KEY required}"
|
||||||
@ -8,8 +13,7 @@ set -euo pipefail
|
|||||||
: "${VPS_USER:?VPS_USER required}"
|
: "${VPS_USER:?VPS_USER required}"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REMOTE_DIR="/tmp/ghabilee-notify-$$"
|
EXTRACTOR="${SCRIPT_DIR}/extract-telegram-env.py"
|
||||||
COMMIT_SUBJECT_B64="$(printf '%s' "${DEPLOY_COMMIT_SUBJECT:-}" | base64 | tr -d '\n')"
|
|
||||||
|
|
||||||
install -m 700 -d "$HOME/.ssh"
|
install -m 700 -d "$HOME/.ssh"
|
||||||
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
|
printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/vps_key"
|
||||||
@ -17,32 +21,26 @@ chmod 600 "$HOME/.ssh/vps_key"
|
|||||||
trap 'rm -f "$HOME/.ssh/vps_key"' EXIT
|
trap 'rm -f "$HOME/.ssh/vps_key"' EXIT
|
||||||
|
|
||||||
SSH_OPTS=(-i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new)
|
SSH_OPTS=(-i "$HOME/.ssh/vps_key" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new)
|
||||||
SCP_OPTS=("${SSH_OPTS[@]}")
|
REMOTE_EXTRACT="/tmp/ghabilee-extract-telegram-env-$$.py"
|
||||||
|
|
||||||
ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" "mkdir -p '${REMOTE_DIR}'"
|
scp "${SSH_OPTS[@]}" "$EXTRACTOR" "${VPS_USER}@${VPS_HOST}:${REMOTE_EXTRACT}"
|
||||||
scp "${SCP_OPTS[@]}" \
|
|
||||||
"${SCRIPT_DIR}/notify-ops-telegram.sh" \
|
|
||||||
"${SCRIPT_DIR}/notify-deploy.sh" \
|
|
||||||
"${VPS_USER}@${VPS_HOST}:${REMOTE_DIR}/"
|
|
||||||
|
|
||||||
ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" \
|
while IFS= read -r line; do
|
||||||
"DEPLOY_SHA='${DEPLOY_SHA:-}' DEPLOY_STATUS='${DEPLOY_STATUS:-success}' DEPLOY_VERSION='${DEPLOY_VERSION:-}' COMMIT_SUBJECT_B64='${COMMIT_SUBJECT_B64}' REMOTE_DIR='${REMOTE_DIR}' sh -s" <<'REMOTE'
|
[[ -z "$line" || "$line" != TELEGRAM_*=* ]] && continue
|
||||||
set -eu
|
key="${line%%=*}"
|
||||||
if [ -n "${COMMIT_SUBJECT_B64:-}" ]; then
|
value="${line#*=}"
|
||||||
DEPLOY_COMMIT_SUBJECT="$(printf '%s' "$COMMIT_SUBJECT_B64" | base64 -d 2>/dev/null || true)"
|
export "${key}=${value}"
|
||||||
export DEPLOY_COMMIT_SUBJECT
|
done < <(ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" "python3 '${REMOTE_EXTRACT}'; rm -f '${REMOTE_EXTRACT}'")
|
||||||
|
|
||||||
|
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
|
||||||
|
echo "[ghabilee-admin-notify] failed: TELEGRAM_BOT_TOKEN missing on VPS" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
set -a
|
if [[ -z "${TELEGRAM_GROUP_CHAT_ID:-}${TELEGRAM_CHAT_ID:-}" ]]; then
|
||||||
if [ -s /opt/ghabilee-backend/.env ]; then
|
echo "[ghabilee-admin-notify] failed: no Telegram chat id on VPS" >&2
|
||||||
# shellcheck disable=SC1091
|
exit 1
|
||||||
. /opt/ghabilee-backend/.env
|
|
||||||
elif [ -s /opt/ghabilee/backend/.env ]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
. /opt/ghabilee/backend/.env
|
|
||||||
fi
|
fi
|
||||||
set +a
|
|
||||||
export DEPLOY_SHA DEPLOY_STATUS DEPLOY_COMMIT_SUBJECT DEPLOY_VERSION
|
chmod +x "${SCRIPT_DIR}/notify-ops-telegram.sh" "${SCRIPT_DIR}/notify-deploy.sh"
|
||||||
chmod +x "${REMOTE_DIR}/notify-ops-telegram.sh" "${REMOTE_DIR}/notify-deploy.sh"
|
# Run on the Actions runner (foreign) — not on the Iran VPS.
|
||||||
bash "${REMOTE_DIR}/notify-deploy.sh"
|
bash "${SCRIPT_DIR}/notify-deploy.sh"
|
||||||
rm -rf "${REMOTE_DIR}"
|
|
||||||
REMOTE
|
|
||||||
|
|||||||
@ -9,8 +9,9 @@ export type EventFaq = EventFaqResponseDto
|
|||||||
const eventExtrasApi = getEventExtras()
|
const eventExtrasApi = getEventExtras()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared public list endpoints — backend allows admin reads for event detail/edit.
|
* Shared public list endpoints — after backend admin bypass on list reads,
|
||||||
* No dedicated admin media/FAQ list routes exist in OpenAPI.
|
* authenticated admins can load media/FAQs for any non-deleted event
|
||||||
|
* (detail + edit pages for another host's event).
|
||||||
*/
|
*/
|
||||||
export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> {
|
export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> {
|
||||||
const response = await eventExtrasApi.eventMediaControllerList(eventId)
|
const response = await eventExtrasApi.eventMediaControllerList(eventId)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export interface SupportTicket {
|
|||||||
status: string
|
status: string
|
||||||
lastMessageAt: string
|
lastMessageAt: string
|
||||||
closedAt: string | null
|
closedAt: string | null
|
||||||
|
closedBy?: 'user' | 'admin' | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
user: { mobile: string; displayName: string | null }
|
user: { mobile: string; displayName: string | null }
|
||||||
messages: SupportTicketMessage[]
|
messages: SupportTicketMessage[]
|
||||||
|
|||||||
@ -126,6 +126,12 @@ export const events = {
|
|||||||
revisionReject: 'رد ویرایش',
|
revisionReject: 'رد ویرایش',
|
||||||
revisionRejectReasonLabel: 'دلیل رد',
|
revisionRejectReasonLabel: 'دلیل رد',
|
||||||
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
||||||
|
adminLifecycleHintDraftUnapproved: 'تأیید برای انتشار: فقط اجازه میدهد میزبان خودش منتشر کند · انتشار فوری: همین الان عمومی میشود',
|
||||||
|
adminLifecycleHintDraftApproved: 'ادمین تأیید کرده؛ منتظر Publish میزبان بمانید یا با «انتشار فوری» همین الان منتشر کنید',
|
||||||
|
adminLifecycleHintPendingReview: 'میزبان درخواست انتشار داده؛ «تأیید و انتشار» رویداد را عمومی میکند',
|
||||||
|
adminLifecycleHintPublished: 'پایان: پس از برگزاری · لغو: همهٔ رزروها لغو میشوند',
|
||||||
|
adminLifecycleHintDeleteOnly: 'حذف فقط برای رویدادهای بدون رزرو فعال',
|
||||||
|
adminLifecycleTitleForcePublish: 'انتشار فوری بدون انتظار برای میزبان',
|
||||||
genderOpen: 'آزاد برای عموم',
|
genderOpen: 'آزاد برای عموم',
|
||||||
genderFemaleOnly: 'خانمها',
|
genderFemaleOnly: 'خانمها',
|
||||||
genderMaleOnly: 'آقایان',
|
genderMaleOnly: 'آقایان',
|
||||||
|
|||||||
@ -12,6 +12,11 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['**/*.test.{ts,tsx}'],
|
include: ['**/*.test.{ts,tsx}'],
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
// Default 5s flakes under pre-push load (jsdom + HeroUI transform); aborted
|
||||||
|
// tests then leak async work into the next case in the same file.
|
||||||
|
testTimeout: 20_000,
|
||||||
|
hookTimeout: 20_000,
|
||||||
|
maxWorkers: '50%',
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'],
|
include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user