Compare commits

..

25 Commits

Author SHA1 Message Date
d2222fe73e chore: bump version to 0.1.20
All checks were successful
Deploy admin to VPS / Build on Finland, run on Iran (push) Successful in 3m48s
2026-09-13 18:54:57 +03:30
22567c1611 chore: bump version to 0.1.19
All checks were successful
Deploy admin to VPS / Build on Finland, run on Iran (push) Successful in 4m11s
2026-09-13 18:47:56 +03:30
bd8bc31ee0 fix: optimize Docker image management during deployment
Enhance the deployment scripts for Finland and Iran by refining Docker image pruning and transfer processes. Implement conditional transfer compression using zstd or gzip based on availability, and ensure only unused admin tags are removed without affecting build cache. This improves efficiency and reduces unnecessary image deletions during deployments.
2026-09-13 18:47:38 +03:30
8fe5f70ae5 docs: add Finland/Iran infra topology Cursor rule [skip ci] 2026-09-13 18:41:47 +03:30
a8cd6fc252 ci: build admin on Finland, run on Iran, notify from Finland [skip ci]
Align with ops topology: act_runner/Telegram on Finland, runtime on Iran.
2026-09-13 18:34:21 +03:30
27863718ff fix: strip inline comments from extracted Telegram env [skip ci] 2026-09-13 18:31:29 +03:30
ac8bb3bae1 fix: send admin deploy Telegram alerts from Actions runner [skip ci]
Iran VPS cannot reach api.telegram.org, and sourcing backend .env broke
on BOOKING_EXPIRY_CRON globs. Extract TELEGRAM_* safely over SSH and
notify from the foreign act_runner instead.
2026-09-13 18:31:13 +03:30
a7a8052ec3 fix
All checks were successful
Deploy admin to VPS / Rsync and deploy admin (push) Successful in 43m44s
2026-09-13 17:08:56 +03:30
91b450aa1a ci: install rsync/openssh in Gitea deploy job
Some checks failed
Deploy admin to VPS / Rsync and deploy admin (push) Has been cancelled
act_runner containers lack rsync, which caused the first VPS-build
deploy workflow to fail before syncing sources.
2026-09-13 16:28:22 +03:30
45db367588 ci: deploy admin by building on the VPS
Some checks failed
Deploy admin to VPS / Rsync and deploy admin (push) Failing after 10s
act_runner job images have no Docker daemon, so rsync sources and build
with scripts/deploy-on-vps.sh on the target host instead of GHCR/buildx.
2026-09-13 15:42:58 +03:30
ba1cf3efe2 ci: simplify Gitea admin deploy for act_runner
Some checks failed
Deploy admin to VPS / Build, push, and deploy admin (push) Has been cancelled
Single-job build/push/deploy plus nginx site install so self-hosted
runners do not stall on multi-job graphs.
2026-09-13 15:39:29 +03:30
a307400f83 ci: migrate Actions from GitHub to Gitea registry [skip ci]
Move workflows under .gitea/workflows and publish images to
git.ghabilee.ir instead of ghcr.io so deploy runs on self-hosted Gitea.
2026-09-13 14:49:37 +03:30
edd468cf1d test: enhance test configurations and cleanup procedures
Updated the Vitest configuration to increase test and hook timeouts to 20 seconds, addressing flaky tests under pre-push load. Additionally, improved test cleanup procedures across multiple test files by ensuring mocks are cleared after each test, enhancing test reliability and maintainability.
2026-09-13 13:42:22 +03:30
3f072976fe test(axios): refactor axios functional tests for improved clarity
Refactored the axios functional tests by removing unnecessary asynchronous imports and simplifying the setup in the `beforeEach` hook. This change enhances the readability and maintainability of the test code, ensuring it aligns with the project's coding standards.
2026-09-13 13:31:19 +03:30
c0428bb65d refactor(UserEditModal): format alert message for better readability
Updated the alert message in the UserEditModal component to improve code readability by formatting the function call across multiple lines. This change enhances maintainability and aligns with the project's coding standards.
2026-09-13 13:09:10 +03:30
3b86c2c72f feat(api): add notifications endpoints for user notifications
Introduced new API endpoints for managing in-app notifications. The `/api/v1/notifications/me` endpoint retrieves a paginated list of notifications for the authenticated user, while the `/api/v1/notifications/me/unread-count` endpoint returns the count of unread notifications. Enhanced the OpenAPI documentation to reflect these changes, including detailed parameter descriptions and response schemas for better clarity and usability.
2026-09-13 13:04:02 +03:30
26feb8f2f1 chore(version): bump version to 0.1.17
Updated the project version in package.json from 0.1.16 to 0.1.17 to reflect the latest changes and improvements.
2026-09-13 12:54:43 +03:30
96603d31f7 feat(alert-modal): integrate alert modal for confirmation actions
Enhanced various components to utilize the alert modal for user confirmations before executing critical actions. This includes marking messages as read, saving notification rules, restoring reviews, sending replies, changing ticket statuses, and managing event commissions. The integration improves user experience by ensuring actions are intentional and provides clear feedback on the outcomes.
2026-09-13 10:14:18 +03:30
0a68104e85 chore(openapi): format tags in API documentation for consistency
Updated the OpenAPI specification to format the 'tags' property as an array on multiple endpoints, ensuring consistent styling across the documentation. Additionally, simplified JSX return statements in the BookingsPage and AdminEventBookingsTab components for improved readability.
2026-09-11 23:00:12 +03:30
32b06db81c fix 2026-09-11 22:56:47 +03:30
3a08625cbe feat(bookings): add cancellation reason column to bookings table
Enhanced the bookings page and admin event bookings tab by adding a new column for 'cancellationReason'. This column displays the reason for cancellations when applicable, improving the visibility of booking statuses. Additionally, removed the 'غایب' (no-show) insight stat from the event overview tab for a cleaner presentation of event insights.
2026-09-11 22:56:04 +03:30
9435173971 chore(openapi): sync previous-attendees summary with confirmed guests 2026-09-11 21:55:34 +03:30
9254a8b487 feat(support-tickets): add close action and turn-based reply guidance
Let admins close tickets with confirmation and surface closedBy so consumer reopen rules stay clear.
2026-09-11 21:31:16 +03:30
4c5f4d5983 chore(version): bump version to 0.1.15
Updated the project version in package.json from 0.1.14 to 0.1.15 to reflect the latest changes and improvements.
2026-09-11 18:15:14 +03:30
116d1be258 fix(events): show proposed FAQs on pending revision review
Surface revision-payload FAQs on the pending card so admins can review
another host's FAQ changes alongside title/slug.
2026-09-11 18:03:08 +03:30
43 changed files with 2466 additions and 1762 deletions

View 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).

View 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

View 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

View 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

View File

@ -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
Settings. Upgrade to **GitHub Pro** (or make the repo public), then configure:
See [`.gitea/BRANCH_PROTECTION.md`](../.gitea/BRANCH_PROTECTION.md).
**Settings → Branches → Add branch protection rule → Branch name: `main`**
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).
Canonical remote: <https://git.ghabilee.ir/AliSaZa/admin>

View File

@ -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

View File

@ -56,6 +56,13 @@ const columns: PaginationListColumnType[] = [
type: 'select',
filterItems: BOOKING_STATUS_FILTER_ITEMS,
},
{
field: 'cancellationReason',
label: 'دلیل لغو',
filterable: false,
sortable: false,
type: 'text',
},
{
field: 'checkedInAt',
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),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {

View File

@ -13,6 +13,7 @@ import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import axiosInstance from '@/config/axios'
import useAdminMutation from '@/hooks/useAdminMutation'
import useAlertModal from '@/hooks/useAlertModal'
import { coerceToString } from '@/helpers'
import { formatIranianMobile, formatPersianDate, truncateValue } from '@/lib/formatters'
import { getBooleanStatus } from '@/constants/status'
@ -79,9 +80,16 @@ const columns: PaginationListColumnType[] = [
]
const ContactMessagesPage = () => {
const { showAlert } = useAlertModal()
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.CONTACT_MESSAGES.ADMIN_LIST })
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 (
<section className="h-full w-full text-right">
<PageNavbar pageTitle="پیام‌های تماس" />
@ -150,13 +158,9 @@ const ContactMessagesPage = () => {
isLoading={pendingId === message.id}
size="sm"
variant="light"
onClick={() =>
void runAction(
message.id,
() => axiosInstance.patch(API_ROUTES.CONTACT_MESSAGES.ADMIN_READ(message.id)),
'پیام خوانده شد'
)
}
onClick={() => {
handleMarkRead(message)
}}
>
<FileCheckIcon className="size-5" />
</Button>

View File

@ -16,7 +16,12 @@ vi.mock('@/config/axios', () => ({
patch: axiosMocks.patch,
},
}))
const showAlert = vi.fn((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
vi.mock('@/lib/toast', () => ({ addToast: vi.fn() }))
vi.mock('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/components/formElements/Input', () => ({
default: ({
description,
@ -109,6 +114,9 @@ describe('NotificationRulesPanel', () => {
})
beforeEach(() => {
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
axiosMocks.get.mockReset()
axiosMocks.patch.mockReset()
axiosMocks.get.mockResolvedValue({

View File

@ -10,6 +10,7 @@ import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import AdminState from '@/components/feedback/AdminState'
import axiosInstance from '@/config/axios'
import useAlertModal from '@/hooks/useAlertModal'
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler'
@ -52,6 +53,7 @@ const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
const NotificationRulesPanel = () => {
const { showAlert } = useAlertModal()
const [rules, setRules] = useState<Rule[]>([])
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
const [loading, setLoading] = useState(true)
@ -111,6 +113,12 @@ const NotificationRulesPanel = () => {
}
}
const confirmSave = (rule: Rule) => {
showAlert(`تغییرات قاعدهٔ «${rule.displayName}» ذخیره شود؟`, () => {
void save(rule)
})
}
return (
<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">
@ -142,7 +150,9 @@ const NotificationRulesPanel = () => {
rule={rule}
saving={saving === rule.eventKey}
onChange={change}
onSave={() => void save(rule)}
onSave={() => {
confirmSave(rule)
}}
/>
))}
</div>

View File

@ -115,7 +115,9 @@ const ReviewsPage = () => {
}
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) => {

View File

@ -7,6 +7,7 @@ import PageNavbar from '@/components/layouts/PageNavbar'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import { APP_ROUTES } from '@/constants/routes'
import useAlertModal from '@/hooks/useAlertModal'
import { addToast } from '@/lib/toast'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import {
@ -21,6 +22,7 @@ import {
const AdminSupportTicketDetail = () => {
const { id } = useParams<{ id: string }>()
const { showAlert } = useAlertModal()
const [ticket, setTicket] = useState<SupportTicket | null>(null)
const [reply, setReply] = useState('')
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 (
<section className="h-full w-full text-right">
<PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} />
@ -91,7 +128,9 @@ const AdminSupportTicketDetail = () => {
<select
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
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]) => (
<option
@ -104,6 +143,24 @@ const AdminSupportTicketDetail = () => {
</select>
</label>
</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 className="space-y-3 rounded-2xl border border-default-200 bg-white p-5">
{ticket.messages.map((message) => (
@ -129,10 +186,13 @@ const AdminSupportTicketDetail = () => {
setReply(String(value))
}}
/>
<p className="my-3 text-xs text-text-muted">پس از ثبت پاسخ، برای کاربر پیامک اطلاعرسانی ارسال میشود.</p>
<p className="my-3 text-xs text-text-muted">
میتوانید چند پیام پشتسرهم بفرستید. کاربر فقط بعد از پاسخ شما یک پیام میتواند بفرستد. پس از ثبت پاسخ، برای کاربر پیامک
اطلاعرسانی ارسال میشود.
</p>
<Button
isLoading={pending}
onClick={() => void send()}
onClick={confirmSend}
>
ثبت پاسخ و ارسال پیامک
</Button>

View File

@ -140,16 +140,29 @@ const UserEditModal = ({ isOpen, onOpenChange, user, currentAdminId, onSuccess }
}
const handleSubmit = (values: AdminUserEditValues) => {
// Suspending an account is destructive-ish for the user, so confirm first.
const initial = toFormValues(user)
const payload = buildDiffPayload(values)
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
showAlert('این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.', () => submitUpdate(values))
if (Object.keys(payload).length === 0) {
onOpenChange(false)
return
}
void submitUpdate(values)
if (!isSelfEdit && values.status === 'suspended' && initial.status !== 'suspended') {
showAlert(
'این کاربر معلق شود؟ کاربر تا فعال‌سازی مجدد امکان استفاده از حساب را نخواهد داشت.',
() => submitUpdate(values),
undefined,
{
dangerAccept: true,
}
)
return
}
showAlert('تغییرات این کاربر ذخیره شود؟', () => submitUpdate(values))
}
return (

View File

@ -1,6 +1,8 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import axiosInstance, { getOrRefreshAccessToken, resetAxiosAuthModuleState } from '@/config/axios'
const mocks = vi.hoisted(() => ({
expireRefreshSession: vi.fn().mockResolvedValue(undefined),
}))
@ -23,18 +25,15 @@ const writeStoredToken = (accessToken: string): void => {
}
describe('admin access-token refresh races', () => {
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
window.localStorage.clear()
const { resetAxiosAuthModuleState } = await import('@/config/axios')
resetAxiosAuthModuleState()
})
it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
writeStoredToken('access-a')
const { default: axiosInstance } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post')
const authorizationHeaders: string[] = []
let requestCount = 0
@ -85,7 +84,6 @@ describe('admin access-token refresh races', () => {
})
try {
const { getOrRefreshAccessToken } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post')
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')

View File

@ -1,10 +1,38 @@
import type { ButtonHTMLAttributes } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AlertModalProvider } from '@/context/AlertModalContext'
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> }) {
const { showAlert } = useAlertModal()

View 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;
}
}

View File

@ -143,7 +143,10 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
const [updateRequest] = await Promise.all([
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 } })
@ -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 publishRequest
await expect(page.getByText('منتشر‌شده', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)

View File

@ -127,6 +127,7 @@ test('admin approval publishes a pending-review event exactly once', async ({ pa
await page.goto('/manage-events/event-1')
await expect(page.getByRole('button', { name: 'تأیید و انتشار' })).toBeEnabled()
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(page.getByRole('button', { name: 'تأیید و انتشار' })).toHaveCount(0)

View File

@ -197,7 +197,10 @@ const AdminEventDetail = () => {
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 handleApprove = () =>
const handleApprove = () => {
const isPendingReview = event?.status === 'pending_review'
showAlert(isPendingReview ? 'این رویداد تأیید و منتشر شود؟' : 'این رویداد برای انتشار تأیید شود؟', () =>
runAction(
'approve',
async () => {
@ -205,13 +208,16 @@ const AdminEventDetail = () => {
mergeEvent(updated)
},
event?.status === 'pending_review' ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
isPendingReview ? 'رویداد تأیید و منتشر شد' : 'رویداد برای انتشار تأیید شد'
)
)
}
// Bypasses the whole request/approve flow — publishes immediately
// regardless of whether the host has requested publication or an admin
// has approved yet. Kept as a separate action from "approve" on purpose.
const handleForcePublish = () =>
const handleForcePublish = () => {
showAlert('این رویداد فوراً و بدون انتظار برای میزبان منتشر شود؟', () =>
runAction(
'force-publish',
async () => {
@ -221,6 +227,8 @@ const AdminEventDetail = () => {
},
'رویداد فورا منتشر شد'
)
)
}
const handleComplete = () => {
showAlert('آیا این رویداد به پایان رسیده است؟', () =>
@ -276,7 +284,8 @@ const AdminEventDetail = () => {
const handleApproveRevision = () => {
if (!pendingRevision) return
void runAction(
showAlert('این ویرایش تأیید و روی رویداد اعمال شود؟', () =>
runAction(
'approve-revision',
async () => {
const updated = (await approveEventRevisionAsAdmin(eventId, pendingRevision.id)) as AdminEventDetailData
@ -287,6 +296,7 @@ const AdminEventDetail = () => {
},
texts.events.revisionApproveSuccess
)
)
}
const handleRejectRevision = (rejectionReason: string) => {
@ -303,12 +313,13 @@ const AdminEventDetail = () => {
)
}
const handleToggleDiscoverable = async (nextValue: boolean) => {
const handleToggleDiscoverable = (nextValue: boolean) => {
if (!event || isTogglingDiscoverable) return
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)
try {
@ -326,6 +337,7 @@ const AdminEventDetail = () => {
} finally {
setIsTogglingDiscoverable(false)
}
})
}
return (
@ -336,11 +348,11 @@ const AdminEventDetail = () => {
<AdminEventLifecycleActions
event={event}
pendingId={pendingId}
onApprove={() => void handleApprove()}
onApprove={handleApprove}
onCancel={handleCancel}
onComplete={handleComplete}
onDelete={handleDelete}
onForcePublish={() => void handleForcePublish()}
onForcePublish={handleForcePublish}
onOpenReject={() => {
setHasOpenedRejectModal(true)
setIsRejectModalOpen(true)

View File

@ -11,8 +11,12 @@ const bulkCreate = vi.fn()
const setActive = vi.fn()
const removeCode = 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('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/services/discountCodes', () => ({
LIST_DISCOUNT_CODES: (...args: unknown[]) => listCodes(...args),
LIST_DISCOUNT_REDEMPTIONS: (...args: unknown[]) => listRedemptions(...args),
@ -67,10 +71,16 @@ const freshCode = {
}
describe('EventDiscountsPanel', () => {
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
beforeEach(() => {
vi.clearAllMocks()
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
listCodes.mockResolvedValue({ ok: true, data: { items: [usedCode, freshCode], totalItemsCount: 2, totalPages: 1 } })
listRedemptions.mockResolvedValue({ ok: true, data: { items: [], totalItemsCount: 0, totalPages: 0 } })
getReport.mockResolvedValue({
@ -136,6 +146,7 @@ describe('EventDiscountsPanel', () => {
)
await screen.findByText('WELCOME20')
bulkCreate.mockClear()
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '25' } })
fireEvent.change(screen.getByLabelText('تعداد کد یکتا'), { target: { value: '2' } })
@ -157,6 +168,8 @@ describe('EventDiscountsPanel', () => {
)
await screen.findByText('WELCOME20')
bulkCreate.mockClear()
addToast.mockClear()
fireEvent.change(screen.getByLabelText('درصد تخفیف (۱ تا ۹۹)'), { target: { value: '150' } })
fireEvent.click(screen.getByRole('button', { name: 'ساخت کد تخفیف' }))

View File

@ -18,6 +18,7 @@ import {
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
SET_DISCOUNT_CODE_ACTIVE,
} from '@/services/discountCodes'
import useAlertModal from '@/hooks/useAlertModal'
interface EventDiscountsPanelProps {
eventId: string
@ -51,6 +52,7 @@ const DISCOUNT_BEARER_OPTIONS = [
] as const
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
const { showAlert } = useAlertModal()
const [codes, setCodes] = useState<DiscountCode[]>([])
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
const [report, setReport] = useState<DiscountReport | null>(null)
@ -96,26 +98,7 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
const canCreate = !isFree && !isEnded
const canMutateCodes = !isEnded
const handleCreate = async () => {
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
}
const executeCreate = async (numericValue: number, numericQuantity: number) => {
setIsCreating(true)
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
type,
@ -143,13 +126,39 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
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) {
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
return
}
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
setPendingCodeId(code.id)
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)))
})
}
const handleDelete = async (code: DiscountCode) => {
const handleDelete = (code: DiscountCode) => {
if (!canMutateCodes) {
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
@ -177,6 +187,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
return
}
showAlert(
`کد تخفیف «${code.code}» حذف شود؟`,
async () => {
setPendingCodeId(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' })
setCodes((current) => current.filter((item) => item.id !== code.id))
},
undefined,
{ dangerAccept: true }
)
}
const copyCodes = async (list: string[]) => {
@ -458,7 +475,9 @@ const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false
label=""
name={`code-active-${code.id}`}
value={code.isActive}
onValueChange={(next) => void handleToggleActive(code, Boolean(next))}
onValueChange={(next) => {
handleToggleActive(code, Boolean(next))
}}
/>
<span className="text-xs text-secondary-30">
{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}
size="sm"
variant="flat"
onClick={() => void handleDelete(code)}
onClick={() => {
handleDelete(code)
}}
>
{texts.common.delete}
</Button>

View File

@ -19,6 +19,7 @@ import { checkInBookingAsAdmin } from '@/services/eventManagement'
import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import useAdminAction from '@/hooks/useAdminAction'
import useAlertModal from '@/hooks/useAlertModal'
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
// 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: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' },
{ 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: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' },
{ field: 'actions', label: 'عملیات' },
@ -48,8 +50,10 @@ interface AdminEventBookingsTabProps {
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
const bookingsListRef = useRef<PaginatedListHandle>(null)
const { pendingId, runAction } = useAdminAction()
const { showAlert } = useAlertModal()
const handleCheckIn = (bookingId: string) =>
const handleCheckIn = (bookingId: string) => {
showAlert('حضور این مهمان ثبت شود؟', () =>
runAction(
bookingId,
async () => {
@ -59,6 +63,8 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
},
'حضور مهمان ثبت شد'
)
)
}
return (
<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),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
@ -137,7 +151,9 @@ const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEvent
isLoading={pendingId === bookingId}
size="sm"
variant="flat"
onClick={() => void handleCheckIn(bookingId)}
onClick={() => {
handleCheckIn(bookingId)
}}
>
<FileCheckIcon className="size-4" />
</Button>

View File

@ -7,8 +7,12 @@ import AdminEventCommissionModal from '@/features/events/detail/admin-event-deta
const updateCommission = 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('@/hooks/useAlertModal', () => ({ default: () => ({ showAlert }) }))
vi.mock('@/services/events', () => ({
updateEventCommissionAsAdmin: (...args: unknown[]) => updateCommission(...args),
}))
@ -73,6 +77,9 @@ describe('AdminEventCommissionModal', () => {
beforeEach(() => {
vi.clearAllMocks()
showAlert.mockImplementation((_message: string, onConfirm?: () => unknown) => {
void onConfirm?.()
})
updateCommission.mockResolvedValue({
id: 'event-1',
commissionPercent: 12,

View File

@ -10,6 +10,7 @@ import Modal from '@/components/modals/Modal'
import { coerceToString } from '@/helpers'
import { updateEventCommissionAsAdmin } from '@/services/events'
import useAdminAction from '@/hooks/useAdminAction'
import useAlertModal from '@/hooks/useAlertModal'
interface AdminEventCommissionModalProps {
eventId: string
@ -23,6 +24,7 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
const [commissionInput, setCommissionInput] = useState('')
const [commissionError, setCommissionError] = useState<string | null>(null)
const { pendingId, runAction } = useAdminAction()
const { showAlert } = useAlertModal()
useEffect(() => {
if (!isOpen) return
@ -40,7 +42,8 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
return
}
void runAction(
showAlert(`کمیسیون این رویداد به ${parsed}٪ تغییر کند؟`, () =>
runAction(
'commission',
async () => {
const updated = (await updateEventCommissionAsAdmin(eventId, parsed)) as AdminEventDetailData
@ -50,9 +53,11 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
},
'کمیسیون رویداد به‌روزرسانی شد'
)
)
}
const handleResetCommission = () =>
const handleResetCommission = () => {
showAlert('کمیسیون این رویداد به پیش‌فرض پلتفرم بازگردد؟', () =>
runAction(
'commission',
async () => {
@ -63,6 +68,8 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
},
'کمیسیون رویداد به پیش‌فرض پلتفرم بازگشت'
)
)
}
return (
<Modal
@ -75,7 +82,9 @@ const AdminEventCommissionModal = ({ eventId, commissionPercent, isOpen, onOpenC
isLoading={pendingId === 'commission'}
size="sm"
variant="flat"
onClick={() => void handleResetCommission()}
onClick={() => {
handleResetCommission()
}}
>
بازگشت به پیشفرض پلتفرم
</Button>

View File

@ -4,6 +4,8 @@ import type { AdminEventDetailData } from './types'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { texts } from '@/texts'
import AdminEventLifecycleActions from './AdminEventLifecycleActions'
vi.mock('@/components/formElements/Button', () => ({
@ -28,10 +30,9 @@ const baseEvent = {
afterEach(cleanup)
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 onOpenReject = vi.fn()
const onForcePublish = vi.fn()
render(
<AdminEventLifecycleActions
@ -41,18 +42,63 @@ describe('AdminEventLifecycleActions', () => {
onCancel={vi.fn()}
onComplete={vi.fn()}
onDelete={vi.fn()}
onForcePublish={onForcePublish}
onForcePublish={vi.fn()}
onOpenReject={onOpenReject}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'تأیید و انتشار' }))
fireEvent.click(screen.getByRole('button', { name: 'رد' }))
fireEvent.click(screen.getByRole('button', { name: 'انتشار فوری' }))
expect(onApprove).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(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', () => {
@ -73,5 +119,6 @@ describe('AdminEventLifecycleActions', () => {
expect(screen.getByRole('button', { name: 'لغو' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'رد' })).toBeNull()
expect(screen.queryByRole('button', { name: 'حذف' })).toBeNull()
expect(screen.queryByRole('button', { name: 'انتشار فوری' })).toBeNull()
})
})

View File

@ -4,6 +4,9 @@ import type { AdminEventDetailData } from './types'
import Button from '@/components/formElements/Button'
import { APP_ROUTES } from '@/constants/routes'
import { texts } from '@/texts'
import { resolveAdminEventLifecycleHint } from './adminEventLifecycleHints'
interface AdminEventLifecycleActionsProps {
event: AdminEventDetailData
@ -25,7 +28,11 @@ const AdminEventLifecycleActions = ({
onComplete,
onCancel,
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">
{['draft', 'pending_review', 'published', 'full'].includes(event.status) ? (
<Button
@ -71,9 +78,9 @@ const AdminEventLifecycleActions = ({
</Button>
</>
) : null}
{event.status === 'draft' || event.status === 'pending_review' ? (
{event.status === 'draft' ? (
<Button
aria-label="انتشار فوری بدون تایید میزبان"
aria-label={texts.events.adminLifecycleTitleForcePublish}
isLoading={pendingId === 'force-publish'}
size="sm"
variant="flat"
@ -117,6 +124,9 @@ const AdminEventLifecycleActions = ({
</Button>
) : null}
</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

View File

@ -48,10 +48,6 @@ const AdminEventOverviewTab = ({ insights }: AdminEventOverviewTabProps) => {
label="بازپرداخت‌شده"
value={number(insights.registrations.refundedCount)}
/>
<InsightStat
label="غایب"
value={number(insights.registrations.noShowCount)}
/>
<InsightStat
label="ظرفیت باقی‌مانده"
value={number(insights.registrations.remainingCapacity)}

View File

@ -13,7 +13,8 @@ interface 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 (
<Card className="admin-surface">
@ -43,6 +44,21 @@ const AdminEventPendingRevisionCard = ({ pendingId, revision, onApprove, onRejec
) : null}
</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">
<Button
isLoading={pendingId === 'approve-revision'}

View File

@ -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
)
})
})

View File

@ -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
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ghabilee-admin",
"version": "0.1.14",
"version": "0.1.20",
"license": "MIT",
"private": true,
"scripts": {

View File

@ -1,5 +1,5 @@
#!/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.
set -euo pipefail

173
scripts/deploy-finland-to-iran.sh Executable file
View File

@ -0,0 +1,173 @@
#!/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
}
# DOCKER_BUILDKIT keeps layer/build cache across deploys; never prune -af here.
export DOCKER_BUILDKIT=1
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"
# فقط dangling؛ prune -a/-af کش build را می‌کشد و هر deploy را cold می‌کند
docker image prune -f >/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
# فشرده‌سازی transfer؛ zstd اگر روی هر دو host باشد، وگرنه gzip
TRANSFER_CODEC=gzip
if "${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" 'command -v zstd >/dev/null 2>&1' \
&& "${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" 'command -v zstd >/dev/null 2>&1'; then
TRANSFER_CODEC=zstd
fi
echo "Transferring ${IMAGE_TAG} Finland → Iran (${TRANSFER_CODEC})..."
if [[ "$TRANSFER_CODEC" == zstd ]]; then
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" "docker save '${IMAGE_TAG}' | zstd -T0 -3" \
| "${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" 'zstd -d | docker load'
else
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" "docker save '${IMAGE_TAG}' | gzip -1" \
| "${ISSH[@]}" "${IRAN_USER}@${IRAN_HOST}" 'gzip -d | docker load'
fi
"${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
# آزاد کردن دیسک بدون نابود کردن کش: فقط tagهای قدیمی admin که در حال استفاده نیستند
docker images --format '{{.Repository}}:{{.Tag}}' \
| awk -v keep="$ADMIN_IMAGE" '$0 ~ /^ghabilee-admin:/ && $0 != keep { print }' \
| while IFS= read -r old; do
docker rmi "$old" >/dev/null 2>&1 || true
done
docker image prune -f >/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
# روی Finland هم tagهای قدیمی admin را بردار؛ BuildKit cache دست‌نخورده می‌ماند
"${FSSH[@]}" "${FINLAND_USER}@${FINLAND_HOST}" \
"IMAGE_TAG='${IMAGE_TAG}' bash -s" <<'REMOTE'
set -euo pipefail
docker images --format '{{.Repository}}:{{.Tag}}' \
| awk -v keep="$IMAGE_TAG" '$0 ~ /^ghabilee-admin:/ && $0 != keep { print }' \
| while IFS= read -r old; do
docker rmi "$old" >/dev/null 2>&1 || true
done
docker image prune -f >/dev/null 2>&1 || true
REMOTE

104
scripts/deploy-on-vps.sh Executable file
View File

@ -0,0 +1,104 @@
#!/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
# فقط tagهای قدیمی admin؛ prune -af کش/لایه‌های قابل‌استفاده مجدد را پاک می‌کند
docker images --format '{{.Repository}}:{{.Tag}}' \
| awk -v keep="$IMAGE_TAG" '$0 ~ /^ghabilee-admin:/ && $0 != keep { print }' \
| while IFS= read -r old; do
docker rmi "$old" >/dev/null 2>&1 || true
done
docker image prune -f >/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

View 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
View 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"

View File

@ -1,6 +1,11 @@
#!/usr/bin/env bash
# Send deploy notification from VPS (Telegram creds from /opt/ghabilee-backend/.env).
# GitHub runners may not reach api.telegram.org; VPS can.
# Load Telegram credentials from the Iran VPS .env, then send from this host.
#
# 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
: "${SSH_KEY:?SSH_KEY required}"
@ -8,8 +13,7 @@ set -euo pipefail
: "${VPS_USER:?VPS_USER required}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REMOTE_DIR="/tmp/ghabilee-notify-$$"
COMMIT_SUBJECT_B64="$(printf '%s' "${DEPLOY_COMMIT_SUBJECT:-}" | base64 | tr -d '\n')"
EXTRACTOR="${SCRIPT_DIR}/extract-telegram-env.py"
install -m 700 -d "$HOME/.ssh"
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
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 "${SCP_OPTS[@]}" \
"${SCRIPT_DIR}/notify-ops-telegram.sh" \
"${SCRIPT_DIR}/notify-deploy.sh" \
"${VPS_USER}@${VPS_HOST}:${REMOTE_DIR}/"
scp "${SSH_OPTS[@]}" "$EXTRACTOR" "${VPS_USER}@${VPS_HOST}:${REMOTE_EXTRACT}"
ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" \
"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'
set -eu
if [ -n "${COMMIT_SUBJECT_B64:-}" ]; then
DEPLOY_COMMIT_SUBJECT="$(printf '%s' "$COMMIT_SUBJECT_B64" | base64 -d 2>/dev/null || true)"
export DEPLOY_COMMIT_SUBJECT
while IFS= read -r line; do
[[ -z "$line" || "$line" != TELEGRAM_*=* ]] && continue
key="${line%%=*}"
value="${line#*=}"
export "${key}=${value}"
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
set -a
if [ -s /opt/ghabilee-backend/.env ]; then
# shellcheck disable=SC1091
. /opt/ghabilee-backend/.env
elif [ -s /opt/ghabilee/backend/.env ]; then
# shellcheck disable=SC1091
. /opt/ghabilee/backend/.env
if [[ -z "${TELEGRAM_GROUP_CHAT_ID:-}${TELEGRAM_CHAT_ID:-}" ]]; then
echo "[ghabilee-admin-notify] failed: no Telegram chat id on VPS" >&2
exit 1
fi
set +a
export DEPLOY_SHA DEPLOY_STATUS DEPLOY_COMMIT_SUBJECT DEPLOY_VERSION
chmod +x "${REMOTE_DIR}/notify-ops-telegram.sh" "${REMOTE_DIR}/notify-deploy.sh"
bash "${REMOTE_DIR}/notify-deploy.sh"
rm -rf "${REMOTE_DIR}"
REMOTE
chmod +x "${SCRIPT_DIR}/notify-ops-telegram.sh" "${SCRIPT_DIR}/notify-deploy.sh"
# Run on the Actions runner (foreign) — not on the Iran VPS.
bash "${SCRIPT_DIR}/notify-deploy.sh"

View File

@ -9,8 +9,9 @@ export type EventFaq = EventFaqResponseDto
const eventExtrasApi = getEventExtras()
/**
* Shared public list endpoints backend allows admin reads for event detail/edit.
* No dedicated admin media/FAQ list routes exist in OpenAPI.
* Shared public list endpoints after backend admin bypass on list reads,
* 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[]> {
const response = await eventExtrasApi.eventMediaControllerList(eventId)

View File

@ -19,6 +19,7 @@ export interface SupportTicket {
status: string
lastMessageAt: string
closedAt: string | null
closedBy?: 'user' | 'admin' | null
createdAt: string
user: { mobile: string; displayName: string | null }
messages: SupportTicketMessage[]

View File

@ -126,6 +126,12 @@ export const events = {
revisionReject: 'رد ویرایش',
revisionRejectReasonLabel: 'دلیل رد',
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
adminLifecycleHintDraftUnapproved: 'تأیید برای انتشار: فقط اجازه می‌دهد میزبان خودش منتشر کند · انتشار فوری: همین الان عمومی می‌شود',
adminLifecycleHintDraftApproved: 'ادمین تأیید کرده؛ منتظر Publish میزبان بمانید یا با «انتشار فوری» همین الان منتشر کنید',
adminLifecycleHintPendingReview: 'میزبان درخواست انتشار داده؛ «تأیید و انتشار» رویداد را عمومی می‌کند',
adminLifecycleHintPublished: 'پایان: پس از برگزاری · لغو: همهٔ رزروها لغو می‌شوند',
adminLifecycleHintDeleteOnly: 'حذف فقط برای رویدادهای بدون رزرو فعال',
adminLifecycleTitleForcePublish: 'انتشار فوری بدون انتظار برای میزبان',
genderOpen: 'آزاد برای عموم',
genderFemaleOnly: 'خانم‌ها',
genderMaleOnly: 'آقایان',

View File

@ -12,6 +12,11 @@ export default defineConfig({
environment: 'jsdom',
include: ['**/*.test.{ts,tsx}'],
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: {
provider: 'v8',
include: ['lib/authRouting.ts', 'helpers/listResponse.ts', 'validation/auth.ts'],