бэкап-снимок: прод-правки (перед пушем SMS-лимитера)

This commit is contained in:
Mikhail 2026-10-09 21:11:39 +00:00
parent 5170c4fc93
commit b713bdcf52
14 changed files with 1564 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

95
scripts/backup.py Normal file → Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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