бэкап-снимок: прод-правки (перед пушем SMS-лимитера)
This commit is contained in:
parent
5170c4fc93
commit
b713bdcf52
38
deploy.sh
38
deploy.sh
|
|
@ -1,12 +1,32 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# SuperSam — прод-деплой фронта.
|
||||||
|
# Реальная схема: /opt/deployer (порт 9900) на push в main пересобирает образ
|
||||||
|
# из compose-контекста (/opt/supersam-dev, ветка dev) и пересоздаёт контейнер.
|
||||||
|
# Поэтому docker cp в контейнер ЖИВЁТ ДО ПЕРВОГО ПЕРЕСОЗДАНИЯ — правки должны
|
||||||
|
# попадать в git (ветка dev) и собираться в образ.
|
||||||
set -e
|
set -e
|
||||||
cd /opt/supersam
|
SRC=/opt/supersam-dev
|
||||||
git pull origin main
|
echo "── 1. подтянуть dev ──"
|
||||||
docker compose -f docker-compose.app.yml up -d --build
|
cd "$SRC"
|
||||||
echo 'Deploy completed at' $(date)
|
git fetch gitea-public dev
|
||||||
|
git merge --ff-only gitea-public/dev
|
||||||
|
git log --oneline -1
|
||||||
|
|
||||||
# ── Backup code to S3 after deploy ─────────────────────────────────────────
|
echo "── 2. пересобрать образ и пересоздать контейнер ──"
|
||||||
if [ -x ./scripts/backup-code-s3.sh ]; then
|
cd /opt/supersam
|
||||||
echo "Running code backup to S3..."
|
docker compose -f docker-compose.app.yml up -d --build
|
||||||
./scripts/backup-code-s3.sh || echo "WARNING: code backup failed, deploy OK"
|
|
||||||
fi
|
echo "── 3. проверка ──"
|
||||||
|
IDX=$(docker exec supersam-app sh -c 'grep -o "assets/index-[A-Za-z0-9_.-]*\.js" /usr/share/caddy/index.html | head -1')
|
||||||
|
DP=$(docker exec supersam-app sh -c "grep -o 'DashboardPage-[A-Za-z0-9_-]*\.js' /usr/share/caddy/$IDX | sort -u | head -1")
|
||||||
|
echo "index=$IDX dashboard=$DP"
|
||||||
|
echo -n "маркер «Автодозвон»: "
|
||||||
|
docker exec supersam-app sh -c "grep -c Автодозвон /usr/share/caddy/assets/$DP" || true
|
||||||
|
echo -n "маркер «шаблоны самовывоза»: "
|
||||||
|
docker exec supersam-app sh -c "grep -rl sms_text_template_pickup /usr/share/caddy/assets/*.js | wc -l"
|
||||||
|
echo -n "SW: "
|
||||||
|
docker exec supersam-app grep -o 'construction-delivery-static-v[0-9]*' /usr/share/caddy/service-worker.js
|
||||||
|
|
||||||
|
echo "── 4. бэкап кода в S3 ──"
|
||||||
|
/opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1 && echo "backup ok" || echo "backup WARN"
|
||||||
|
echo "Deploy completed at $(date)"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
services:
|
services:
|
||||||
supersam-app:
|
supersam-app:
|
||||||
build:
|
build:
|
||||||
context: /opt/supersam
|
context: /opt/supersam-dev
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v116";
|
const STATIC_CACHE = "construction-delivery-static-v117";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v116";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v117";
|
||||||
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
// Only these are treated as cacheable static assets. Everything else
|
// Only these are treated as cacheable static assets. Everything else
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#
|
#
|
||||||
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
|
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
|
||||||
# Also triggered after deploy via deploy.sh
|
# Also triggered after deploy via deploy.sh
|
||||||
|
# Каждая операция (успех или провал) логируется в public.backup_logs.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -18,9 +19,41 @@ SOURCE_DIR="/opt/supersam"
|
||||||
SOURCE_DIR_DEV="/opt/supersam-dev"
|
SOURCE_DIR_DEV="/opt/supersam-dev"
|
||||||
RETENTION_DAYS=30
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
DB_CONTAINER="supabase-db"
|
||||||
|
DB_USER="supabase_admin"
|
||||||
|
DB_NAME="postgres"
|
||||||
|
DB_PASS="${POSTGRES_PASSWORD:-$(grep -m1 '^POSTGRES_PASSWORD=' /opt/supabase/docker/.env 2>/dev/null | cut -d= -f2- || true)}"
|
||||||
|
DB_PASS="${DB_PASS:-4fe80bb21c7c3d17a8d8b226adf7a479}"
|
||||||
|
|
||||||
|
BACKUP_TYPE="code"
|
||||||
|
|
||||||
|
# ── Logging в public.backup_logs ──────────────────────────────────────────
|
||||||
|
LOGGED=0
|
||||||
|
log_backup() {
|
||||||
|
local status="$1" size="$2" bytes="$3" s3_path="$4" tables="$5"
|
||||||
|
printf "INSERT INTO public.backup_logs (backup_type, status, file_size, file_size_bytes, s3_path, tables_included) VALUES ('%s', '%s', '%s', %s, '%s', '%s');\n" \
|
||||||
|
"${BACKUP_TYPE}" "${status}" "${size}" "${bytes:-0}" "${s3_path}" "${tables}" \
|
||||||
|
| docker exec -i -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
|
||||||
|
psql -U "${DB_USER}" -d "${DB_NAME}" -q -t -A >/dev/null \
|
||||||
|
|| echo "[$(date)] WARN: не удалось записать backup_logs"
|
||||||
|
LOGGED=1
|
||||||
|
}
|
||||||
|
|
||||||
|
on_exit() {
|
||||||
|
local rc=$?
|
||||||
|
if [ "${rc}" -ne 0 ] && [ "${LOGGED}" -eq 0 ]; then
|
||||||
|
echo "[$(date)] FAILED: скрипт завершился с кодом ${rc}"
|
||||||
|
log_backup "failed" "" 0 "" "exit code ${rc}"
|
||||||
|
fi
|
||||||
|
exit "${rc}"
|
||||||
|
}
|
||||||
|
trap on_exit EXIT
|
||||||
|
|
||||||
# ── Runtime ───────────────────────────────────────────────────────────────
|
# ── Runtime ───────────────────────────────────────────────────────────────
|
||||||
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
||||||
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
|
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
|
||||||
|
S3_KEY="${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
|
||||||
|
S3_PATH="s3://${S3_BUCKET}/${S3_KEY}"
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
|
@ -34,18 +67,34 @@ tar -czf "${ARCHIVE_FILE}" \
|
||||||
--exclude="node_modules" \
|
--exclude="node_modules" \
|
||||||
--exclude=".git" \
|
--exclude=".git" \
|
||||||
--exclude="dist" \
|
--exclude="dist" \
|
||||||
--exclude="volumes" \
|
--exclude="volumes/db" \
|
||||||
|
--exclude="volumes/storage" \
|
||||||
|
--exclude="volumes/logs" \
|
||||||
|
--exclude="volumes/api" \
|
||||||
|
--exclude="volumes/snippets" \
|
||||||
--exclude="__pycache__" \
|
--exclude="__pycache__" \
|
||||||
"${SOURCE_DIR#/}" "${SOURCE_DIR_DEV#/}" 2>/dev/null
|
"${SOURCE_DIR#/}" "${SOURCE_DIR_DEV#/}" 2>/dev/null
|
||||||
|
|
||||||
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
|
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
|
||||||
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
|
ARCHIVE_BYTES=$(stat -c%s "${ARCHIVE_FILE}")
|
||||||
|
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE}, ${ARCHIVE_BYTES} bytes)"
|
||||||
|
|
||||||
# ── Upload via mc ─────────────────────────────────────────────────────────
|
# ── Upload via mc ─────────────────────────────────────────────────────────
|
||||||
echo "[$(date)] Uploading to S3..."
|
echo "[$(date)] Uploading to S3..."
|
||||||
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz" --quiet 2>&1
|
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_KEY}" --quiet 2>&1
|
||||||
|
|
||||||
echo "[$(date)] Uploaded to s3://${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
|
echo "[$(date)] Uploaded to ${S3_PATH}"
|
||||||
|
|
||||||
|
# ── Verify: объект существует в S3 ────────────────────────────────────────
|
||||||
|
S3_BYTES=$(mc stat --json "beget/${S3_BUCKET}/${S3_KEY}" 2>/dev/null | grep -o '"size":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "${S3_BYTES}" ]; then
|
||||||
|
echo "[$(date)] FAILED: объект не найден в S3 после загрузки: ${S3_PATH}"
|
||||||
|
log_backup "failed" "${ARCHIVE_SIZE}" "${ARCHIVE_BYTES}" "${S3_PATH}" "upload not verified"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${S3_BYTES}" != "${ARCHIVE_BYTES}" ]; then
|
||||||
|
echo "[$(date)] WARN: размер в S3 (${S3_BYTES}) не совпадает с локальным (${ARCHIVE_BYTES})"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Cleanup local ─────────────────────────────────────────────────────────
|
# ── Cleanup local ─────────────────────────────────────────────────────────
|
||||||
rm -f "${ARCHIVE_FILE}"
|
rm -f "${ARCHIVE_FILE}"
|
||||||
|
|
@ -54,7 +103,7 @@ echo "[$(date)] Local cleanup done"
|
||||||
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
|
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
|
||||||
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
|
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
|
||||||
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
||||||
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
|
{ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null || true; } \
|
||||||
| awk '{print $NF}' \
|
| awk '{print $NF}' \
|
||||||
| while read -r fname; do
|
| while read -r fname; do
|
||||||
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
||||||
|
|
@ -64,4 +113,5 @@ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
log_backup "success" "${ARCHIVE_SIZE}" "${ARCHIVE_BYTES}" "${S3_PATH}" "source code"
|
||||||
echo "[$(date)] Code backup complete."
|
echo "[$(date)] Code backup complete."
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Supersam app code backup to S3 (Beget Cloud Storage)
|
||||||
|
# Archives /opt/supersam source code (excluding node_modules, .git, dist)
|
||||||
|
# and uploads to S3. Retention: keeps last 30 days.
|
||||||
|
#
|
||||||
|
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
|
||||||
|
# Also triggered after deploy via deploy.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
|
||||||
|
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
||||||
|
S3_BUCKET="02f162ff4a18-supersam-s3"
|
||||||
|
S3_PREFIX="backups"
|
||||||
|
|
||||||
|
SOURCE_DIR="/opt/supersam"
|
||||||
|
SOURCE_DIR_DEV="/opt/supersam-dev"
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────────────────────────────
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
||||||
|
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
|
||||||
|
|
||||||
|
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
echo "[$(date)] Starting code backup..."
|
||||||
|
|
||||||
|
# ── Archive ──────────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Archiving ${SOURCE_DIR}..."
|
||||||
|
# Рантайм-чекаут + актуальный dev-чекаут (источник прод-фронта)
|
||||||
|
tar -czf "${ARCHIVE_FILE}" \
|
||||||
|
--directory="/" \
|
||||||
|
--exclude="node_modules" \
|
||||||
|
--exclude=".git" \
|
||||||
|
--exclude="dist" \
|
||||||
|
--exclude="volumes" \
|
||||||
|
--exclude="__pycache__" \
|
||||||
|
"${SOURCE_DIR#/}" "${SOURCE_DIR_DEV#/}" 2>/dev/null
|
||||||
|
|
||||||
|
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
|
||||||
|
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
|
||||||
|
|
||||||
|
# ── Upload via mc ─────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Uploading to S3..."
|
||||||
|
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz" --quiet 2>&1
|
||||||
|
|
||||||
|
echo "[$(date)] Uploaded to s3://${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
|
||||||
|
|
||||||
|
# ── Cleanup local ─────────────────────────────────────────────────────────
|
||||||
|
rm -f "${ARCHIVE_FILE}"
|
||||||
|
echo "[$(date)] Local cleanup done"
|
||||||
|
|
||||||
|
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
|
||||||
|
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
|
||||||
|
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
||||||
|
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
|
||||||
|
| awk '{print $NF}' \
|
||||||
|
| while read -r fname; do
|
||||||
|
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
||||||
|
if [ -n "$file_date" ] && [[ "$file_date" < "$CUTOFF" ]]; then
|
||||||
|
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/code/${fname}" --quiet 2>/dev/null
|
||||||
|
echo "[$(date)] Deleted old backup: ${fname}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[$(date)] Code backup complete."
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
# Dumps the complete supabase postgres database and uploads to S3.
|
# Dumps the complete supabase postgres database and uploads to S3.
|
||||||
# Retention: keeps last 30 days.
|
# Retention: keeps last 30 days.
|
||||||
#
|
#
|
||||||
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
|
# Cron: 0 6,18 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
|
||||||
|
# Каждая операция (успех или провал) логируется в public.backup_logs.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -17,14 +18,40 @@ S3_PREFIX="backups"
|
||||||
DB_CONTAINER="supabase-db"
|
DB_CONTAINER="supabase-db"
|
||||||
DB_USER="supabase_admin"
|
DB_USER="supabase_admin"
|
||||||
DB_NAME="postgres"
|
DB_NAME="postgres"
|
||||||
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
# Пароль берём из /opt/supabase/docker/.env, фолбэк — известное значение
|
||||||
|
DB_PASS="${POSTGRES_PASSWORD:-$(grep -m1 '^POSTGRES_PASSWORD=' /opt/supabase/docker/.env 2>/dev/null | cut -d= -f2- || true)}"
|
||||||
|
DB_PASS="${DB_PASS:-4fe80bb21c7c3d17a8d8b226adf7a479}"
|
||||||
|
|
||||||
|
BACKUP_TYPE="pgdump"
|
||||||
RETENTION_DAYS=30
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ── Logging в public.backup_logs ──────────────────────────────────────────
|
||||||
|
LOGGED=0
|
||||||
|
log_backup() {
|
||||||
|
local status="$1" size="$2" bytes="$3" s3_path="$4" tables="$5"
|
||||||
|
printf "INSERT INTO public.backup_logs (backup_type, status, file_size, file_size_bytes, s3_path, tables_included) VALUES ('%s', '%s', '%s', %s, '%s', '%s');\n" \
|
||||||
|
"${BACKUP_TYPE}" "${status}" "${size}" "${bytes:-0}" "${s3_path}" "${tables}" \
|
||||||
|
| docker exec -i -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
|
||||||
|
psql -U "${DB_USER}" -d "${DB_NAME}" -q -t -A >/dev/null \
|
||||||
|
|| echo "[$(date)] WARN: не удалось записать backup_logs"
|
||||||
|
LOGGED=1
|
||||||
|
}
|
||||||
|
|
||||||
|
on_exit() {
|
||||||
|
local rc=$?
|
||||||
|
if [ "${rc}" -ne 0 ] && [ "${LOGGED}" -eq 0 ]; then
|
||||||
|
echo "[$(date)] FAILED: скрипт завершился с кодом ${rc}"
|
||||||
|
log_backup "failed" "" 0 "" "exit code ${rc}"
|
||||||
|
fi
|
||||||
|
exit "${rc}"
|
||||||
|
}
|
||||||
|
trap on_exit EXIT
|
||||||
|
|
||||||
# ── Runtime ───────────────────────────────────────────────────────────────
|
# ── Runtime ───────────────────────────────────────────────────────────────
|
||||||
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
||||||
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
|
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
|
||||||
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
|
S3_KEY="${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
|
||||||
|
S3_PATH="s3://${S3_BUCKET}/${S3_KEY}"
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
|
@ -37,14 +64,26 @@ docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
|
||||||
| gzip -9 > "${DUMP_FILE}"
|
| gzip -9 > "${DUMP_FILE}"
|
||||||
|
|
||||||
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
|
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
|
||||||
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
|
DUMP_BYTES=$(stat -c%s "${DUMP_FILE}")
|
||||||
|
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE}, ${DUMP_BYTES} bytes)"
|
||||||
|
|
||||||
# ── Upload via mc ─────────────────────────────────────────────────────────
|
# ── Upload via mc ─────────────────────────────────────────────────────────
|
||||||
echo "[$(date)] Uploading to S3..."
|
echo "[$(date)] Uploading to S3..."
|
||||||
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
|
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_KEY}" --quiet 2>&1
|
||||||
|
|
||||||
echo "[$(date)] Uploaded to ${S3_PATH}"
|
echo "[$(date)] Uploaded to ${S3_PATH}"
|
||||||
|
|
||||||
|
# ── Verify: объект существует в S3 и размер совпадает ─────────────────────
|
||||||
|
S3_BYTES=$(mc stat --json "beget/${S3_BUCKET}/${S3_KEY}" 2>/dev/null | grep -o '"size":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "${S3_BYTES}" ]; then
|
||||||
|
echo "[$(date)] FAILED: объект не найден в S3 после загрузки: ${S3_PATH}"
|
||||||
|
log_backup "failed" "${DUMP_SIZE}" "${DUMP_BYTES}" "${S3_PATH}" "upload not verified"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${S3_BYTES}" != "${DUMP_BYTES}" ]; then
|
||||||
|
echo "[$(date)] WARN: размер в S3 (${S3_BYTES}) не совпадает с локальным (${DUMP_BYTES})"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Cleanup local ─────────────────────────────────────────────────────────
|
# ── Cleanup local ─────────────────────────────────────────────────────────
|
||||||
rm -f "${DUMP_FILE}"
|
rm -f "${DUMP_FILE}"
|
||||||
echo "[$(date)] Local cleanup done"
|
echo "[$(date)] Local cleanup done"
|
||||||
|
|
@ -52,7 +91,7 @@ echo "[$(date)] Local cleanup done"
|
||||||
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
|
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
|
||||||
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
|
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
|
||||||
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
||||||
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
|
{ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null || true; } \
|
||||||
| awk '{print $NF}' \
|
| awk '{print $NF}' \
|
||||||
| while read -r fname; do
|
| while read -r fname; do
|
||||||
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
||||||
|
|
@ -62,4 +101,5 @@ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
log_backup "success" "${DUMP_SIZE}" "${DUMP_BYTES}" "${S3_PATH}" "all (full pg_dump)"
|
||||||
echo "[$(date)] Backup complete."
|
echo "[$(date)] Backup complete."
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Supersam full DB backup (pg_dump) to S3 (Beget Cloud Storage)
|
||||||
|
# Dumps the complete supabase postgres database and uploads to S3.
|
||||||
|
# Retention: keeps last 30 days.
|
||||||
|
#
|
||||||
|
# Cron: 0 */12 * * * /opt/supersam/scripts/backup-pgdump-s3.sh >> /var/log/supersam-backup-pgdump.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────
|
||||||
|
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
|
||||||
|
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
||||||
|
S3_ENDPOINT="https://s3.ru1.storage.beget.cloud"
|
||||||
|
S3_BUCKET="02f162ff4a18-supersam-s3"
|
||||||
|
S3_PREFIX="backups"
|
||||||
|
|
||||||
|
DB_CONTAINER="supabase-db"
|
||||||
|
DB_USER="supabase_admin"
|
||||||
|
DB_NAME="postgres"
|
||||||
|
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
||||||
|
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────────────────────────────
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
|
||||||
|
DUMP_FILE="/tmp/supersam-dump-${TIMESTAMP}.sql.gz"
|
||||||
|
S3_PATH="s3://${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
|
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
echo "[$(date)] Starting pg_dump backup..."
|
||||||
|
|
||||||
|
# ── Dump ──────────────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Dumping database..."
|
||||||
|
docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \
|
||||||
|
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
|
||||||
|
| gzip -9 > "${DUMP_FILE}"
|
||||||
|
|
||||||
|
DUMP_SIZE=$(du -h "${DUMP_FILE}" | cut -f1)
|
||||||
|
echo "[$(date)] Dump created: ${DUMP_FILE} (${DUMP_SIZE})"
|
||||||
|
|
||||||
|
# ── Upload via mc ─────────────────────────────────────────────────────────
|
||||||
|
echo "[$(date)] Uploading to S3..."
|
||||||
|
mc cp "${DUMP_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/supersam-db-${TIMESTAMP}.sql.gz" --quiet 2>&1
|
||||||
|
|
||||||
|
echo "[$(date)] Uploaded to ${S3_PATH}"
|
||||||
|
|
||||||
|
# ── Cleanup local ─────────────────────────────────────────────────────────
|
||||||
|
rm -f "${DUMP_FILE}"
|
||||||
|
echo "[$(date)] Local cleanup done"
|
||||||
|
|
||||||
|
# ── Retention: delete S3 pgdump backups older than RETENTION_DAYS ─────────
|
||||||
|
echo "[$(date)] Cleaning S3 pgdump backups older than ${RETENTION_DAYS} days..."
|
||||||
|
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
|
||||||
|
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null \
|
||||||
|
| awk '{print $NF}' \
|
||||||
|
| while read -r fname; do
|
||||||
|
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
|
||||||
|
if [ -n "$file_date" ] && [ "$file_date" \< "$CUTOFF" ]; then
|
||||||
|
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/${fname}" --quiet 2>/dev/null
|
||||||
|
echo "[$(date)] Deleted old backup: ${fname}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[$(date)] Backup complete."
|
||||||
|
|
@ -24,6 +24,40 @@ DB_HOST = "supabase-db"
|
||||||
DB_USER = "supabase_admin"
|
DB_USER = "supabase_admin"
|
||||||
DB_NAME = "postgres"
|
DB_NAME = "postgres"
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_pass():
|
||||||
|
"""Пароль из окружения или /opt/supabase/docker/.env, иначе известное значение."""
|
||||||
|
if os.environ.get("POSTGRES_PASSWORD"):
|
||||||
|
return os.environ["POSTGRES_PASSWORD"]
|
||||||
|
try:
|
||||||
|
with open("/opt/supabase/docker/.env") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("POSTGRES_PASSWORD="):
|
||||||
|
return line.split("=", 1)[1].strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "4fe80bb21c7c3d17a8d8b226adf7a479"
|
||||||
|
|
||||||
|
|
||||||
|
DB_PASS = get_db_pass()
|
||||||
|
|
||||||
|
|
||||||
|
def log_backup(status, size, size_bytes, s3_path, tables):
|
||||||
|
"""Запись об операции в public.backup_logs (SQL через stdin, не через -c)."""
|
||||||
|
sql = (
|
||||||
|
"INSERT INTO public.backup_logs "
|
||||||
|
"(backup_type, status, file_size, file_size_bytes, s3_path, tables_included) "
|
||||||
|
f"VALUES ('csv', '{status}', '{size}', {int(size_bytes)}, '{s3_path}', '{tables}');\n"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", "-i", "-e", f"PGPASSWORD={DB_PASS}", DB_HOST,
|
||||||
|
"psql", "-U", DB_USER, "-d", DB_NAME, "-q", "-t", "-A"],
|
||||||
|
input=sql, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"WARN: не удалось записать backup_logs: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def get_tables():
|
def get_tables():
|
||||||
"""Get list of public tables with data."""
|
"""Get list of public tables with data."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|
@ -38,6 +72,7 @@ def get_tables():
|
||||||
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||||||
return tables
|
return tables
|
||||||
|
|
||||||
|
|
||||||
def export_table_csv(table_name, out_dir):
|
def export_table_csv(table_name, out_dir):
|
||||||
"""Export a single table to CSV via psql \\copy."""
|
"""Export a single table to CSV via psql \\copy."""
|
||||||
csv_path = os.path.join(out_dir, f"{table_name}.csv")
|
csv_path = os.path.join(out_dir, f"{table_name}.csv")
|
||||||
|
|
@ -73,6 +108,7 @@ def export_table_csv(table_name, out_dir):
|
||||||
print(f" {table_name}: {len(lines)-1} rows")
|
print(f" {table_name}: {len(lines)-1} rows")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def upload_to_s3(file_path, key):
|
def upload_to_s3(file_path, key):
|
||||||
"""Upload file to S3."""
|
"""Upload file to S3."""
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
|
|
@ -86,41 +122,58 @@ def upload_to_s3(file_path, key):
|
||||||
s3.upload_file(file_path, S3_BUCKET, key)
|
s3.upload_file(file_path, S3_BUCKET, key)
|
||||||
print(f" Uploaded: s3://{S3_BUCKET}/{key}")
|
print(f" Uploaded: s3://{S3_BUCKET}/{key}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
archive_name = f"supersam-backup-{date_str}.tar.gz"
|
archive_name = f"supersam-backup-{date_str}.tar.gz"
|
||||||
s3_key = f"backups/{archive_name}"
|
s3_key = f"backups/{archive_name}"
|
||||||
|
s3_path = f"s3://{S3_BUCKET}/{s3_key}"
|
||||||
|
|
||||||
print(f"=== SuperSam Backup {date_str} ===")
|
print(f"=== SuperSam Backup {date_str} ===")
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
try:
|
||||||
tables = get_tables()
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
print(f"Found {len(tables)} tables")
|
tables = get_tables()
|
||||||
|
print(f"Found {len(tables)} tables")
|
||||||
|
|
||||||
exported = []
|
exported = []
|
||||||
for table in tables:
|
for table in tables:
|
||||||
if export_table_csv(table, tmpdir):
|
if export_table_csv(table, tmpdir):
|
||||||
exported.append(table)
|
exported.append(table)
|
||||||
|
|
||||||
if not exported:
|
if not exported:
|
||||||
print("No tables with data — nothing to backup")
|
print("No tables with data — nothing to backup")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# Create tar.gz
|
# Create tar.gz
|
||||||
archive_path = os.path.join(tmpdir, archive_name)
|
archive_path = os.path.join(tmpdir, archive_name)
|
||||||
with tarfile.open(archive_path, "w:gz") as tar:
|
with tarfile.open(archive_path, "w:gz") as tar:
|
||||||
for table in exported:
|
for table in exported:
|
||||||
csv_path = os.path.join(tmpdir, f"{table}.csv")
|
csv_path = os.path.join(tmpdir, f"{table}.csv")
|
||||||
tar.add(csv_path, arcname=f"{table}.csv")
|
tar.add(csv_path, arcname=f"{table}.csv")
|
||||||
|
|
||||||
# Get size
|
# Get size
|
||||||
size_mb = os.path.getsize(archive_path) / (1024 * 1024)
|
size_bytes = os.path.getsize(archive_path)
|
||||||
print(f"Archive: {archive_name} ({size_mb:.2f} MB)")
|
size_mb = size_bytes / (1024 * 1024)
|
||||||
|
print(f"Archive: {archive_name} ({size_mb:.2f} MB)")
|
||||||
|
|
||||||
# Upload
|
# Upload
|
||||||
upload_to_s3(archive_path, s3_key)
|
upload_to_s3(archive_path, s3_key)
|
||||||
|
|
||||||
|
log_backup(
|
||||||
|
"success",
|
||||||
|
f"{size_mb:.2f} MB",
|
||||||
|
size_bytes,
|
||||||
|
s3_path,
|
||||||
|
", ".join(exported),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — лог провала, затем проброс
|
||||||
|
print(f"FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
log_backup("failed", "", 0, s3_path, f"{type(exc).__name__}: {exc}"[:200])
|
||||||
|
raise
|
||||||
|
|
||||||
print(f"=== Backup complete ===")
|
print(f"=== Backup complete ===")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SuperSam daily backup — export public tables to CSV, tar.gz, upload to S3."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
# S3 config
|
||||||
|
S3_ENDPOINT = "https://s3.ru1.storage.beget.cloud"
|
||||||
|
S3_KEY = "YG4MQNKAPNL65200MBUY"
|
||||||
|
S3_SECRET = "8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
|
||||||
|
S3_BUCKET = "02f162ff4a18-supersam-s3"
|
||||||
|
|
||||||
|
# DB config
|
||||||
|
DB_HOST = "supabase-db"
|
||||||
|
DB_USER = "supabase_admin"
|
||||||
|
DB_NAME = "postgres"
|
||||||
|
|
||||||
|
def get_tables():
|
||||||
|
"""Get list of public tables with data."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", "-i", DB_HOST, "psql", "-U", DB_USER, "-d", DB_NAME,
|
||||||
|
"-t", "-A", "-c",
|
||||||
|
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"ERROR getting tables: {result.stderr}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def export_table_csv(table_name, out_dir):
|
||||||
|
"""Export a single table to CSV via psql \\copy."""
|
||||||
|
csv_path = os.path.join(out_dir, f"{table_name}.csv")
|
||||||
|
# Use psql \copy (client-side) to avoid needing superuser for COPY
|
||||||
|
cmd = [
|
||||||
|
"docker", "exec", "-i", DB_HOST,
|
||||||
|
"psql", "-U", DB_USER, "-d", DB_NAME,
|
||||||
|
"-c", f"\\copy (SELECT * FROM public.{table_name}) TO '/tmp/{table_name}.csv' WITH CSV HEADER;"
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"WARN: {table_name} export failed: {result.stderr}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Copy from container to host
|
||||||
|
cmd2 = ["docker", "cp", f"{DB_HOST}:/tmp/{table_name}.csv", csv_path]
|
||||||
|
result2 = subprocess.run(cmd2, capture_output=True, text=True)
|
||||||
|
if result2.returncode != 0:
|
||||||
|
print(f"WARN: {table_name} docker cp failed: {result2.stderr}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Clean up temp file in container
|
||||||
|
subprocess.run(["docker", "exec", "-i", DB_HOST, "rm", "-f", f"/tmp/{table_name}.csv"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
# Check if file has data (more than just header)
|
||||||
|
with open(csv_path, "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
if len(lines) <= 1:
|
||||||
|
print(f" {table_name}: empty (skipping)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f" {table_name}: {len(lines)-1} rows")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def upload_to_s3(file_path, key):
|
||||||
|
"""Upload file to S3."""
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=S3_ENDPOINT,
|
||||||
|
aws_access_key_id=S3_KEY,
|
||||||
|
aws_secret_access_key=S3_SECRET,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
region_name="ru-1"
|
||||||
|
)
|
||||||
|
s3.upload_file(file_path, S3_BUCKET, key)
|
||||||
|
print(f" Uploaded: s3://{S3_BUCKET}/{key}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
archive_name = f"supersam-backup-{date_str}.tar.gz"
|
||||||
|
s3_key = f"backups/{archive_name}"
|
||||||
|
|
||||||
|
print(f"=== SuperSam Backup {date_str} ===")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tables = get_tables()
|
||||||
|
print(f"Found {len(tables)} tables")
|
||||||
|
|
||||||
|
exported = []
|
||||||
|
for table in tables:
|
||||||
|
if export_table_csv(table, tmpdir):
|
||||||
|
exported.append(table)
|
||||||
|
|
||||||
|
if not exported:
|
||||||
|
print("No tables with data — nothing to backup")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Create tar.gz
|
||||||
|
archive_path = os.path.join(tmpdir, archive_name)
|
||||||
|
with tarfile.open(archive_path, "w:gz") as tar:
|
||||||
|
for table in exported:
|
||||||
|
csv_path = os.path.join(tmpdir, f"{table}.csv")
|
||||||
|
tar.add(csv_path, arcname=f"{table}.csv")
|
||||||
|
|
||||||
|
# Get size
|
||||||
|
size_mb = os.path.getsize(archive_path) / (1024 * 1024)
|
||||||
|
print(f"Archive: {archive_name} ({size_mb:.2f} MB)")
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
upload_to_s3(archive_path, s3_key)
|
||||||
|
|
||||||
|
print(f"=== Backup complete ===")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -21,6 +21,15 @@ const CAMPAIGNS = [
|
||||||
|
|
||||||
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
|
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
|
||||||
|
|
||||||
|
// ── SMS text templates (доставка / самовывоз) ────────────────────────────────
|
||||||
|
const SMS_LINK_PREVIEW = "https://dost.supersamsev.ru/d/XXXXXX";
|
||||||
|
|
||||||
|
const renderSmsPreview = (template, emptyLabel) => {
|
||||||
|
const text = (template || "").trim();
|
||||||
|
if (!text) return emptyLabel;
|
||||||
|
return text.replace("{link}", SMS_LINK_PREVIEW);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Days of week ─────────────────────────────────────────────────────────────
|
// ── Days of week ─────────────────────────────────────────────────────────────
|
||||||
const DAYS = [
|
const DAYS = [
|
||||||
{ num: 1, short: "Пн" },
|
{ num: 1, short: "Пн" },
|
||||||
|
|
@ -479,19 +488,43 @@ export const SmsCampaignPanel = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── SMS text template ──────────────────────────────────────────── */}
|
{/* ── SMS text templates: доставка / самовывоз ───────────────────── */}
|
||||||
{!isManualCampaign && (
|
{!isManualCampaign && (
|
||||||
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
|
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
|
||||||
<textarea
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
value={settings.sms_text_template || ""}
|
<div>
|
||||||
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
|
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">📦 Доставка</div>
|
||||||
rows={3}
|
<textarea
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
|
value={settings.sms_text_template || ""}
|
||||||
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
|
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
|
||||||
/>
|
rows={3}
|
||||||
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
|
||||||
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
|
placeholder="Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Текущая длина: {(settings.sms_text_template || "").replace("{link}", SMS_LINK_PREVIEW).length} символов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">🏪 Самовывоз</div>
|
||||||
|
<textarea
|
||||||
|
value={settings.sms_text_template_pickup || ""}
|
||||||
|
onChange={(e) => updateSetting("sms_text_template_pickup", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
|
||||||
|
placeholder="Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Текущая длина: {(settings.sms_text_template_pickup || "").replace("{link}", SMS_LINK_PREVIEW).length} символов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-lg bg-[var(--color-surface-strong)] px-3 py-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Текст выбирается по типу отгрузки: самовывоз → правый шаблон, доставка → левый. {"{link}"} заменяется ссылкой согласования.
|
||||||
|
<div className="mt-1">📦 Доставка: {renderSmsPreview(settings.sms_text_template, "— пусто, уйдёт стандартный текст кампании")}</div>
|
||||||
|
<div>🏪 Самовывоз: {renderSmsPreview(settings.sms_text_template_pickup, "— пусто, уйдёт стандартный текст кампании")}</div>
|
||||||
|
<div className="mt-1">Если у города в «SMS по городам» задан свой текст в режиме «Уведомление» — уходит он, и тогда шаблон применяется только если в нём нет {"{link}"}.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,830 @@
|
||||||
|
/**
|
||||||
|
* @file SmsCampaignPanel.jsx
|
||||||
|
* @description SMS Campaign management for mega_admin.
|
||||||
|
* Campaign selector cards + settings + log table + "Проверить снова" button
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||||||
|
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
||||||
|
|
||||||
|
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||||
|
const CAMPAIGNS = [
|
||||||
|
{ key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" },
|
||||||
|
{ key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" },
|
||||||
|
{ key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" },
|
||||||
|
{ key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
|
||||||
|
|
||||||
|
// ── Days of week ─────────────────────────────────────────────────────────────
|
||||||
|
const DAYS = [
|
||||||
|
{ num: 1, short: "Пн" },
|
||||||
|
{ num: 2, short: "Вт" },
|
||||||
|
{ num: 3, short: "Ср" },
|
||||||
|
{ num: 4, short: "Чт" },
|
||||||
|
{ num: 5, short: "Пт" },
|
||||||
|
{ num: 6, short: "Сб" },
|
||||||
|
{ num: 7, short: "Вс" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Status labels ────────────────────────────────────────────────────────────
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
sent: "Отправлено",
|
||||||
|
checking: "Проверяется",
|
||||||
|
delivered: "Доставлено",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
error: "Ошибка доставки",
|
||||||
|
expired: "Истёк срок",
|
||||||
|
limit_exceeded: "Лимит превышен",
|
||||||
|
manual_override: "Ручной режим",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TONES = {
|
||||||
|
sent: "info",
|
||||||
|
checking: "neutral",
|
||||||
|
delivered: "accent",
|
||||||
|
send_failed: "danger",
|
||||||
|
error: "danger",
|
||||||
|
expired: "warning",
|
||||||
|
limit_exceeded: "danger",
|
||||||
|
manual_override: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── SMS code labels ──────────────────────────────────────────────────────────
|
||||||
|
const SMS_CODE_LABELS = {
|
||||||
|
"100": "В очереди", "101": "Оператору", "102": "В пути",
|
||||||
|
"103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором",
|
||||||
|
"106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено",
|
||||||
|
"130": "Лимит на номер/день", "131": "Лимит одинаковых/мин",
|
||||||
|
"132": "Лимит одинаковых/день", "200": "Неправильный api_id",
|
||||||
|
"201": "Недостаточно средств", "202": "Неправильный получатель",
|
||||||
|
"230": "Общий лимит/день", "231": "Лимит одинаковых/мин",
|
||||||
|
"232": "Лимит одинаковых/день",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch { return ts; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtPhone = (phone) => {
|
||||||
|
if (!phone) return "—";
|
||||||
|
const digits = String(phone).replace(/\D/g, "");
|
||||||
|
if (digits.length === 11 && digits.startsWith("7")) {
|
||||||
|
return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`;
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtElapsed = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
const diff = Date.now() - new Date(ts).getTime();
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
if (m < 1) return "только что";
|
||||||
|
if (m < 60) return `${m} мин назад`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rest = m % 60;
|
||||||
|
return `${h}ч ${rest}м назад`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseWorkDays = (str) => {
|
||||||
|
if (!str) return new Set([1, 2, 3, 4, 5]);
|
||||||
|
return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7));
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeWorkDays = (set) => [...set].sort().join(",");
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export const SmsCampaignPanel = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeCampaign, setActiveCampaign] = useState("first_sms");
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [manualGroups, setManualGroups] = useState([]);
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
|
const [allSettings, setAllSettings] = useState({});
|
||||||
|
const [campaignCounts, setCampaignCounts] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [filter, setFilter] = useState("all");
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
|
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||||
|
const [checkingIds, setCheckingIds] = useState(new Set());
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [togglingCampaign, setTogglingCampaign] = useState(null);
|
||||||
|
const [showCityRules, setShowCityRules] = useState(false);
|
||||||
|
const [logPage, setLogPage] = useState(1);
|
||||||
|
const [manualPage, setManualPage] = useState(1);
|
||||||
|
const refreshTimer = useRef(null);
|
||||||
|
const PER_PAGE = 20;
|
||||||
|
|
||||||
|
const handleOpenGroup = useCallback((groupId) => {
|
||||||
|
if (groupId) navigate("/dashboard/group/" + groupId);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// ── Load data ──────────────────────────────────────────────────────────────
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Load all settings for cards
|
||||||
|
const { data: allSettingsData, error: allErr } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.select("*");
|
||||||
|
if (allErr) throw allErr;
|
||||||
|
const settingsMap = {};
|
||||||
|
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
||||||
|
setAllSettings(settingsMap);
|
||||||
|
|
||||||
|
// Load counts per campaign (for cards)
|
||||||
|
const { data: countsData, error: countsErr } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("campaign_type, status");
|
||||||
|
if (countsErr) throw countsErr;
|
||||||
|
const counts = {};
|
||||||
|
(countsData || []).forEach(r => {
|
||||||
|
if (!counts[r.campaign_type]) counts[r.campaign_type] = { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||||||
|
counts[r.campaign_type].total++;
|
||||||
|
if (r.status === "delivered") counts[r.campaign_type].delivered++;
|
||||||
|
if (r.status === "sent" || r.status === "checking") counts[r.campaign_type].sent++;
|
||||||
|
if (["send_failed", "error", "limit_exceeded"].includes(r.status)) counts[r.campaign_type].errors++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also count manual_required groups for the manual card
|
||||||
|
const { count: manualCount, error: manualCountErr } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.eq("notification_status", "manual_required");
|
||||||
|
if (!manualCountErr && manualCount != null) {
|
||||||
|
counts["manual"] = counts["manual"] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||||||
|
counts["manual"].total = manualCount;
|
||||||
|
}
|
||||||
|
setCampaignCounts(counts);
|
||||||
|
|
||||||
|
// For "manual" campaign: load order_groups with manual_required status
|
||||||
|
if (activeCampaign === "manual") {
|
||||||
|
const { data: manualData, error: manualErr } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id, customer_name, customer_phone, notification_status, first_sms_sent_at, second_sms_sent_at, next_notification_check_at, sms_attempts, last_sms_error, delivery_status, delivery_link, created_at, updated_at")
|
||||||
|
.eq("notification_status", "manual_required")
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
if (manualErr) throw manualErr;
|
||||||
|
setManualGroups(manualData || []);
|
||||||
|
setLogs([]); // No SMS logs for manual campaign
|
||||||
|
} else {
|
||||||
|
setManualGroups([]);
|
||||||
|
// Load logs for active campaign
|
||||||
|
let query = supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("*")
|
||||||
|
.eq("campaign_type", activeCampaign)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
if (filter !== "all") query = query.eq("status", filter);
|
||||||
|
const { data: logData, error: logError } = await query;
|
||||||
|
if (logError) throw logError;
|
||||||
|
setLogs(logData || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load settings for active campaign
|
||||||
|
if (HAS_SETTINGS.includes(activeCampaign)) {
|
||||||
|
setSettings(settingsMap[activeCampaign] || null);
|
||||||
|
} else {
|
||||||
|
setSettings(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || String(e));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeCampaign, filter]);
|
||||||
|
|
||||||
|
useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
refreshTimer.current = setInterval(() => loadData(), 30000);
|
||||||
|
return () => clearInterval(refreshTimer.current);
|
||||||
|
}
|
||||||
|
}, [autoRefresh, loadData]);
|
||||||
|
|
||||||
|
// ── Save settings ──────────────────────────────────────────────────────────
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
const { id, ...updates } = settings;
|
||||||
|
updates.updated_at = new Date().toISOString();
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setSettingsSaved(true);
|
||||||
|
setTimeout(() => setSettingsSaved(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка сохранения: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = (key, value) => {
|
||||||
|
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Quick toggle campaign on/off from card ────────────────────────────────
|
||||||
|
const toggleCampaignEnabled = async (campaignKey) => {
|
||||||
|
const s = allSettings[campaignKey];
|
||||||
|
if (!s) return;
|
||||||
|
setTogglingCampaign(campaignKey);
|
||||||
|
const newVal = !(s.enabled ?? true);
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.update({ enabled: newVal, updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", s.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
// Update local state
|
||||||
|
setAllSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[campaignKey]: { ...prev[campaignKey], enabled: newVal }
|
||||||
|
}));
|
||||||
|
if (campaignKey === activeCampaign) {
|
||||||
|
setSettings(prev => prev ? { ...prev, enabled: newVal } : prev);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setTogglingCampaign(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWorkDay = (dayNum) => {
|
||||||
|
const current = parseWorkDays(settings?.work_days);
|
||||||
|
if (current.has(dayNum)) current.delete(dayNum);
|
||||||
|
else current.add(dayNum);
|
||||||
|
updateSetting("work_days", serializeWorkDays(current));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Recheck ────────────────────────────────────────────────────────────────
|
||||||
|
const handleRecheck = async (logId) => {
|
||||||
|
setCheckingIds(prev => new Set([...prev, logId]));
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.update({ needs_check: true, updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", logId);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setLogs(prev => prev.map(l => l.id === logId ? { ...l, needs_check: true } : l));
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; });
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||||
|
const stats = logs.reduce((acc, log) => {
|
||||||
|
acc[log.status] = (acc[log.status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const showSettings = HAS_SETTINGS.includes(activeCampaign);
|
||||||
|
const isManualCampaign = activeCampaign === "manual";
|
||||||
|
const isPaidStorageCampaign = activeCampaign === "paid_storage";
|
||||||
|
const showSmsFields = !isManualCampaign;
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
|
if (isLoading && logs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка…</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
|
||||||
|
{/* Balance banner */}
|
||||||
|
{allSettings.first_sms?.last_balance != null && (
|
||||||
|
<Panel className="p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">💰</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
||||||
|
<div className="text-lg font-bold text-[var(--color-text)]">
|
||||||
|
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Campaign cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 max-w-4xl">
|
||||||
|
{CAMPAIGNS.map(c => {
|
||||||
|
const isActive = activeCampaign === c.key;
|
||||||
|
const s = allSettings[c.key];
|
||||||
|
const isEnabled = s?.enabled ?? true;
|
||||||
|
const isTest = s?.test_mode ?? true;
|
||||||
|
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||||||
|
const hasS = HAS_SETTINGS.includes(c.key);
|
||||||
|
const isToggling = togglingCampaign === c.key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.key}
|
||||||
|
className={`rounded-2xl border p-4 transition cursor-pointer ${
|
||||||
|
isActive
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
onClick={() => { setActiveCampaign(c.key); setFilter("all"); setLogPage(1); setManualPage(1); }}
|
||||||
|
>
|
||||||
|
{/* Header: icon + toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl">{c.icon}</span>
|
||||||
|
{hasS && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleCampaignEnabled(c.key); }}
|
||||||
|
disabled={isToggling}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition ${isEnabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition ${isEnabled ? "left-[20px]" : "left-0.5"}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="mt-2 text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
{hasS && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||||
|
isEnabled
|
||||||
|
? (isTest ? "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]" : "bg-[rgba(34,197,94,0.15)] text-[#22c55e]")
|
||||||
|
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
||||||
|
}`}>
|
||||||
|
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
{hasS && cnt.total > 0 && (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-1 text-center">
|
||||||
|
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">Всего</div>
|
||||||
|
<div className="text-xs font-bold text-[var(--color-text)]">{cnt.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">Доставл.</div>
|
||||||
|
<div className="text-xs font-bold text-[#22c55e]">{cnt.delivered}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">Ошибок</div>
|
||||||
|
<div className="text-xs font-bold text-[#ef4444]">{cnt.errors}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasS && (
|
||||||
|
<div className="mt-2 text-[11px] text-[var(--color-text-muted)] leading-snug">В разработке</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── City rules button ──────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCityRules(!showCityRules)}
|
||||||
|
className={[
|
||||||
|
"rounded-2xl border px-4 py-2.5 text-sm font-medium transition flex items-center gap-2",
|
||||||
|
showCityRules
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
🏙️ SMS по городам
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── City profiles panel ────────────────────────────────────────── */}
|
||||||
|
{showCityRules && <SmsCityProfilesPanel />}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Panel className="p-3">
|
||||||
|
<div className="text-xs text-[var(--color-danger)]">{error}</div>
|
||||||
|
<button onClick={() => { setError(null); loadData(); }} className="mt-2 text-xs text-[var(--color-accent)]">Повторить</button>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
||||||
|
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
|
||||||
|
|
||||||
|
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
||||||
|
{!showCityRules && showSettings && settings && (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label}
|
||||||
|
</h3>
|
||||||
|
{settingsSaved && <span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test / Production toggle — только для SMS-кампаний */}
|
||||||
|
{!isManualCampaign && (
|
||||||
|
<div className={`mb-4 rounded-xl border p-3 ${settings.test_mode ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]" : "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"}`}>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
{settings.test_mode ? "🧪 Тестовый режим" : "🚀 Боевой режим"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{settings.test_mode
|
||||||
|
? `SMS только на: ${settings.test_phone || "—"}`
|
||||||
|
: "SMS реальным клиентам из базы"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateSetting("test_mode", !settings.test_mode)}
|
||||||
|
className={`relative h-7 w-12 rounded-full transition ${settings.test_mode ? "bg-[var(--color-warning)]" : "bg-[var(--color-accent)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.test_mode ? "left-0.5" : "left-[22px]"}`} />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{settings.test_mode && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── SMS text template ──────────────────────────────────────────── */}
|
||||||
|
{!isManualCampaign && (
|
||||||
|
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
|
||||||
|
<textarea
|
||||||
|
value={settings.sms_text_template || ""}
|
||||||
|
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
|
||||||
|
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Интервалы ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">
|
||||||
|
{isManualCampaign ? "⚙️ Настройки" : "📊 Интервалы и попытки"}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{!isManualCampaign && (
|
||||||
|
<>
|
||||||
|
<SettingField label="Пауза между проверками (сек)" value={settings.wait_between_checks_seconds} onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} />
|
||||||
|
<SettingField label="Макс. время ожидания (мин)" value={settings.max_check_duration_minutes} onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)} />
|
||||||
|
<SettingField label="Макс. попыток отправки" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 2)} />
|
||||||
|
{!isPaidStorageCampaign && (
|
||||||
|
<SettingField label="Вторая SMS через (часов)" value={settings.second_sms_delay_hours} onChange={(v) => updateSetting("second_sms_delay_hours", parseInt(v) || 3)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
|
||||||
|
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Время работы ────────────────────────────────────────────── */}
|
||||||
|
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">
|
||||||
|
{isManualCampaign ? "⏰ Время проверки" : "⏰ Время отправки SMS"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Часы */}
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={settings.work_hours_start ?? 8}
|
||||||
|
onChange={(e) => updateSetting("work_hours_start", parseInt(e.target.value))}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||||
|
<select
|
||||||
|
value={settings.work_hours_end ?? 21}
|
||||||
|
onChange={(e) => updateSetting("work_hours_end", parseInt(e.target.value))}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Дни недели — таблетки */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS.map(d => {
|
||||||
|
const activeDays = parseWorkDays(settings.work_days);
|
||||||
|
const isOn = activeDays.has(d.num);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.num}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWorkDay(d.num)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||||
|
isOn
|
||||||
|
? "bg-[var(--color-accent)] text-white"
|
||||||
|
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.short}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Проверка статусов работает круглосуточно. Отправка — только в выбранные часы и дни.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Вкл/выкл + сохранить ──────────────────────────────────────── */}
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
|
||||||
|
className={`relative h-7 w-12 rounded-full transition ${settings.enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.enabled ? "left-[22px]" : "left-0.5"}`} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text)]">
|
||||||
|
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
disabled={savingSettings}
|
||||||
|
className="ml-auto rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingSettings ? "Сохранение…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
||||||
|
{!showCityRules && !showSettings && (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Manual campaign: groups table ─────────────────────────────────── */}
|
||||||
|
{isManualCampaign ? (
|
||||||
|
<>
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-[var(--color-text-muted)]">В ручном управлении: {manualGroups.length}</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[800px]">
|
||||||
|
<div className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
|
||||||
|
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">1-я SMS</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">2-я SMS</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||||||
|
</div>
|
||||||
|
{manualGroups.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const start = (manualPage - 1) * PER_PAGE;
|
||||||
|
const pageGroups = manualGroups.slice(start, start + PER_PAGE);
|
||||||
|
return pageGroups.map((group) => (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => handleOpenGroup(group.id)}
|
||||||
|
className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||||
|
<div className="font-medium">{group.customer_name || "—"}</div>
|
||||||
|
{group.last_sms_error && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{group.last_sms_error.slice(0, 60)}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{fmtPhone(group.customer_phone)}</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{group.first_sms_sent_at ? fmtTime(group.first_sms_sent_at) : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{group.second_sms_sent_at ? fmtTime(group.second_sms_sent_at) : <span className="text-[var(--color-danger)]">не отправлена</span>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{fmtTime(group.updated_at)}
|
||||||
|
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* ── Pagination: manual groups ─────────────────────────────────── */}
|
||||||
|
{manualGroups.length > PER_PAGE && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setManualPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={manualPage <= 1}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
||||||
|
>← Назад</button>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Стр. {manualPage} / {Math.ceil(manualGroups.length / PER_PAGE)} · {manualGroups.length} записей
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setManualPage(p => Math.min(Math.ceil(manualGroups.length / PER_PAGE), p + 1))}
|
||||||
|
disabled={manualPage >= Math.ceil(manualGroups.length / PER_PAGE)}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
||||||
|
>Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Stats ─────────────────────────────────────────────────────────── */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||||||
|
const count = stats[status] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <Badge key={status} tone={STATUS_TONES[status] || "neutral"}>{label}: {count}</Badge>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* ── Filter + auto-refresh ─────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<FilterButton active={filter === "all"} onClick={() => { setFilter("all"); setLogPage(1); }}>Все ({logs.length})</FilterButton>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||||||
|
const count = stats[status] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <FilterButton key={status} active={filter === status} onClick={() => { setFilter(status); setLogPage(1); }}>{label} ({count})</FilterButton>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} className="h-3.5 w-3.5 rounded border-[var(--color-border)]" />
|
||||||
|
Авто-обновление (30с)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Log table ──────────────────────────────────────────────────────── */}
|
||||||
|
<Panel className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[900px]">
|
||||||
|
<div className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
|
||||||
|
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">SMS ID</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Код</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Поп.</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Создано</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Действие</div>
|
||||||
|
</div>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const totalPages = Math.ceil(logs.length / PER_PAGE);
|
||||||
|
const start = (logPage - 1) * PER_PAGE;
|
||||||
|
const pageLogs = logs.slice(start, start + PER_PAGE);
|
||||||
|
return pageLogs.map((entry) => {
|
||||||
|
const canRecheck = entry.status === "sent" || entry.status === "checking";
|
||||||
|
const isChecking = checkingIds.has(entry.id);
|
||||||
|
const isTestMode = settings?.test_mode;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => handleOpenGroup(entry.order_group_id)}
|
||||||
|
className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||||
|
{fmtPhone(entry.customer_phone)}
|
||||||
|
{isTestMode && <div className="text-[10px] text-[var(--color-warning)]">🧪 на тест. номер</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.sms_id || "—"}</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>{STATUS_LABELS[entry.status] || entry.status}</Badge>
|
||||||
|
{entry.needs_check && <div className="mt-0.5 text-[10px] text-[var(--color-accent)]">⟳ в очереди</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]" title={SMS_CODE_LABELS[entry.sms_code] || ""}>
|
||||||
|
{entry.sms_code || "—"}
|
||||||
|
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && <div className="text-[10px]">{SMS_CODE_LABELS[entry.sms_code]}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.attempts || 0}</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{fmtTime(entry.created_at)}
|
||||||
|
<div className="text-[10px]">{fmtElapsed(entry.created_at)}</div>
|
||||||
|
{entry.error_message && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{entry.error_message.slice(0, 80)}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{canRecheck ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRecheck(entry.id)}
|
||||||
|
disabled={isChecking || entry.needs_check}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] px-2 py-1 text-[11px] font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isChecking ? "…" : entry.needs_check ? "⟳ В очереди" : "↻ Проверить"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">→ к доставке</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* ── Pagination: logs ─────────────────────────────────────────────── */}
|
||||||
|
{logs.length > PER_PAGE && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLogPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={logPage <= 1}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
||||||
|
>← Назад</button>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Стр. {logPage} / {Math.ceil(logs.length / PER_PAGE)} · {logs.length} записей
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / PER_PAGE), p + 1))}
|
||||||
|
disabled={logPage >= Math.ceil(logs.length / PER_PAGE)}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
||||||
|
>Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
|
||||||
|
↻ Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const SettingField = ({ label, value, onChange }) => (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FilterButton = ({ active, onClick, children }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||||||
|
active
|
||||||
|
? "bg-[var(--color-accent)] text-white"
|
||||||
|
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_order_group_change()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id uuid;
|
||||||
|
v_changes jsonb := '{}'::jsonb;
|
||||||
|
v_action text;
|
||||||
|
v_source text;
|
||||||
|
BEGIN
|
||||||
|
-- Determine who made the change
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
v_source := CASE WHEN v_user_id IS NOT NULL THEN 'user' ELSE 'system' END;
|
||||||
|
|
||||||
|
-- Track only meaningful field changes
|
||||||
|
IF NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'notification_status',
|
||||||
|
jsonb_build_object('old', OLD.notification_status, 'new', NEW.notification_status)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'delivery_status',
|
||||||
|
jsonb_build_object('old', OLD.delivery_status, 'new', NEW.delivery_status)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'status',
|
||||||
|
jsonb_build_object('old', OLD.status, 'new', NEW.status)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'assigned_driver_id',
|
||||||
|
jsonb_build_object('old', OLD.assigned_driver_id, 'new', NEW.assigned_driver_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'delivery_link',
|
||||||
|
jsonb_build_object('old', OLD.delivery_link, 'new', NEW.delivery_link)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'first_sms_sent_at',
|
||||||
|
jsonb_build_object('old', OLD.first_sms_sent_at, 'new', NEW.first_sms_sent_at)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'second_sms_sent_at',
|
||||||
|
jsonb_build_object('old', OLD.second_sms_sent_at, 'new', NEW.second_sms_sent_at)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
'last_sms_error',
|
||||||
|
jsonb_build_object('old', OLD.last_sms_error, 'new', NEW.last_sms_error)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Only insert if something meaningful changed
|
||||||
|
IF v_changes = '{}'::jsonb THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Determine action label
|
||||||
|
v_action := CASE
|
||||||
|
WHEN NEW.delivery_status IS DISTINCT FROM OLD.delivery_status THEN 'delivery_status_change'
|
||||||
|
WHEN NEW.notification_status IS DISTINCT FROM OLD.notification_status THEN 'notification_status_change'
|
||||||
|
WHEN NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id THEN 'driver_assignment_change'
|
||||||
|
WHEN NEW.delivery_link IS DISTINCT FROM OLD.delivery_link THEN 'delivery_link_change'
|
||||||
|
ELSE 'field_change'
|
||||||
|
END;
|
||||||
|
|
||||||
|
INSERT INTO order_history (order_group_id, action, old_status, new_status, user_id, metadata)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
v_action,
|
||||||
|
OLD.delivery_status,
|
||||||
|
NEW.delivery_status,
|
||||||
|
v_user_id,
|
||||||
|
jsonb_build_object('changes', v_changes, 'source', v_source, 'group_key', NEW.group_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Drop old trigger if exists
|
||||||
|
DROP TRIGGER IF EXISTS log_order_group_change_trigger ON public.order_groups;
|
||||||
|
|
||||||
|
-- Create trigger AFTER UPDATE (only when fields actually change)
|
||||||
|
CREATE TRIGGER log_order_group_change_trigger
|
||||||
|
AFTER UPDATE ON public.order_groups
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (
|
||||||
|
NEW.notification_status IS DISTINCT FROM OLD.notification_status
|
||||||
|
OR NEW.delivery_status IS DISTINCT FROM OLD.delivery_status
|
||||||
|
OR NEW.status IS DISTINCT FROM OLD.status
|
||||||
|
OR NEW.assigned_driver_id IS DISTINCT FROM OLD.assigned_driver_id
|
||||||
|
OR NEW.delivery_link IS DISTINCT FROM OLD.delivery_link
|
||||||
|
OR NEW.first_sms_sent_at IS DISTINCT FROM OLD.first_sms_sent_at
|
||||||
|
OR NEW.second_sms_sent_at IS DISTINCT FROM OLD.second_sms_sent_at
|
||||||
|
OR NEW.last_sms_error IS DISTINCT FROM OLD.last_sms_error
|
||||||
|
)
|
||||||
|
EXECUTE FUNCTION public.log_order_group_change();
|
||||||
|
|
||||||
|
-- Grant access
|
||||||
|
GRANT SELECT ON order_history TO authenticated;
|
||||||
|
GRANT INSERT ON order_history TO authenticated;
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- Drop old policies that reference orders table
|
||||||
|
DROP POLICY IF EXISTS "history select by order role" ON order_history;
|
||||||
|
DROP POLICY IF EXISTS "history insert workflow" ON order_history;
|
||||||
|
|
||||||
|
-- New SELECT policy: users can read history for order_groups they have access to
|
||||||
|
CREATE POLICY "order_history_select" ON order_history
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM order_groups og
|
||||||
|
WHERE og.id = order_history.order_group_id
|
||||||
|
AND (
|
||||||
|
current_role_name() = ANY (ARRAY['admin', 'mega_admin', 'production_lead'])
|
||||||
|
OR (current_role_name() = 'manager')
|
||||||
|
OR (current_role_name() = 'logistician')
|
||||||
|
OR (current_role_name() = 'driver' AND og.assigned_driver_id = auth.uid())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT policy: only trigger (SECURITY DEFINER) inserts, users don't insert directly
|
||||||
|
CREATE POLICY "order_history_insert_trigger" ON order_history
|
||||||
|
FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simple webhook listener for Gitea push events to trigger supersam deploy."""
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
SECRET = '1032ef91aacd726907bb72c023813c6827f56354902cc7b30e72626690c6d51d'
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||||
|
logger = logging.getLogger('webhook')
|
||||||
|
|
||||||
|
class WebhookHandler(BaseHTTPRequestHandler):
|
||||||
|
def verify_signature(self, body):
|
||||||
|
signature = self.headers.get('X-Gitea-Signature', '')
|
||||||
|
if not signature:
|
||||||
|
return False
|
||||||
|
computed = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(signature, computed)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path == '/webhook/supersam':
|
||||||
|
content_length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(content_length) if content_length else b''
|
||||||
|
if not self.verify_signature(body):
|
||||||
|
self.send_response(403)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error": "invalid signature"}')
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(body) if body else {}
|
||||||
|
ref = payload.get('ref', '')
|
||||||
|
if ref == 'refs/heads/main':
|
||||||
|
logger.info('Push to main detected, starting deploy...')
|
||||||
|
result = subprocess.run(
|
||||||
|
['/opt/supersam/deploy.sh'],
|
||||||
|
capture_output=True, text=True, timeout=600
|
||||||
|
)
|
||||||
|
logger.info('Deploy exit code: %s', result.returncode)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error('Deploy stderr: %s', result.stderr[-2000:])
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({'status': 'deployed', 'exit_code': result.returncode}).encode())
|
||||||
|
else:
|
||||||
|
logger.info('Ignoring push to %s', ref)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"status": "ignored"}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error: %s', e)
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({'error': str(e)}).encode())
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == '/health':
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"status": "ok"}')
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
server = HTTPServer(('0.0.0.0', 9000), WebhookHandler)
|
||||||
|
logger.info('Webhook listener on http://0.0.0.0:9000')
|
||||||
|
server.serve_forever()
|
||||||
Loading…
Reference in New Issue