diff --git a/deploy.sh b/deploy.sh index 4a4d953..d5a862f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,12 +1,32 @@ #!/bin/bash +# SuperSam — прод-деплой фронта. +# Реальная схема: /opt/deployer (порт 9900) на push в main пересобирает образ +# из compose-контекста (/opt/supersam-dev, ветка dev) и пересоздаёт контейнер. +# Поэтому docker cp в контейнер ЖИВЁТ ДО ПЕРВОГО ПЕРЕСОЗДАНИЯ — правки должны +# попадать в git (ветка dev) и собираться в образ. set -e -cd /opt/supersam -git pull origin main -docker compose -f docker-compose.app.yml up -d --build -echo 'Deploy completed at' $(date) +SRC=/opt/supersam-dev +echo "── 1. подтянуть dev ──" +cd "$SRC" +git fetch gitea-public dev +git merge --ff-only gitea-public/dev +git log --oneline -1 -# ── Backup code to S3 after deploy ───────────────────────────────────────── -if [ -x ./scripts/backup-code-s3.sh ]; then - echo "Running code backup to S3..." - ./scripts/backup-code-s3.sh || echo "WARNING: code backup failed, deploy OK" -fi +echo "── 2. пересобрать образ и пересоздать контейнер ──" +cd /opt/supersam +docker compose -f docker-compose.app.yml up -d --build + +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)" diff --git a/docker-compose.app.yml b/docker-compose.app.yml index cefa482..de05966 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -1,7 +1,7 @@ services: supersam-app: build: - context: /opt/supersam + context: /opt/supersam-dev dockerfile: Dockerfile args: VITE_SUPABASE_URL: ${VITE_SUPABASE_URL} diff --git a/public/service-worker.js b/public/service-worker.js index e3a9117..7a371d5 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,8 +1,8 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v116"; - const RUNTIME_CACHE = "construction-delivery-runtime-v116"; + const STATIC_CACHE = "construction-delivery-static-v117"; + const RUNTIME_CACHE = "construction-delivery-runtime-v117"; 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 diff --git a/scripts/backup-code-s3.sh b/scripts/backup-code-s3.sh index d458d8f..c4daefe 100755 --- a/scripts/backup-code-s3.sh +++ b/scripts/backup-code-s3.sh @@ -5,6 +5,7 @@ # # 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 +# Каждая операция (успех или провал) логируется в public.backup_logs. set -euo pipefail @@ -18,9 +19,41 @@ SOURCE_DIR="/opt/supersam" SOURCE_DIR_DEV="/opt/supersam-dev" 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 ─────────────────────────────────────────────────────────────── TIMESTAMP=$(date +%Y-%m-%d_%H%M) 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 @@ -34,18 +67,34 @@ tar -czf "${ARCHIVE_FILE}" \ --exclude="node_modules" \ --exclude=".git" \ --exclude="dist" \ - --exclude="volumes" \ + --exclude="volumes/db" \ + --exclude="volumes/storage" \ + --exclude="volumes/logs" \ + --exclude="volumes/api" \ + --exclude="volumes/snippets" \ --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})" +ARCHIVE_BYTES=$(stat -c%s "${ARCHIVE_FILE}") +echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE}, ${ARCHIVE_BYTES} bytes)" # ── 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 +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 ───────────────────────────────────────────────────────── rm -f "${ARCHIVE_FILE}" @@ -54,7 +103,7 @@ 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 \ +{ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null || true; } \ | awk '{print $NF}' \ | while read -r fname; do 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 done -echo "[$(date)] Code backup complete." \ No newline at end of file +log_backup "success" "${ARCHIVE_SIZE}" "${ARCHIVE_BYTES}" "${S3_PATH}" "source code" +echo "[$(date)] Code backup complete." diff --git a/scripts/backup-code-s3.sh.bak-20261007 b/scripts/backup-code-s3.sh.bak-20261007 new file mode 100755 index 0000000..d458d8f --- /dev/null +++ b/scripts/backup-code-s3.sh.bak-20261007 @@ -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." \ No newline at end of file diff --git a/scripts/backup-pgdump-s3.sh b/scripts/backup-pgdump-s3.sh index 44ccaff..04574d5 100755 --- a/scripts/backup-pgdump-s3.sh +++ b/scripts/backup-pgdump-s3.sh @@ -3,7 +3,8 @@ # 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 +# 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 @@ -17,14 +18,40 @@ S3_PREFIX="backups" DB_CONTAINER="supabase-db" DB_USER="supabase_admin" 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 +# ── 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 ─────────────────────────────────────────────────────────────── 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" +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 @@ -37,14 +64,26 @@ docker exec -e PGPASSWORD="${DB_PASS}" "${DB_CONTAINER}" \ | gzip -9 > "${DUMP_FILE}" 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 ───────────────────────────────────────────────────────── 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}" +# ── 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 ───────────────────────────────────────────────────────── rm -f "${DUMP_FILE}" echo "[$(date)] Local cleanup done" @@ -52,7 +91,7 @@ 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 \ +{ mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/pgdump/" 2>/dev/null || true; } \ | awk '{print $NF}' \ | while read -r fname; do 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 done -echo "[$(date)] Backup complete." \ No newline at end of file +log_backup "success" "${DUMP_SIZE}" "${DUMP_BYTES}" "${S3_PATH}" "all (full pg_dump)" +echo "[$(date)] Backup complete." diff --git a/scripts/backup-pgdump-s3.sh.bak-20261007 b/scripts/backup-pgdump-s3.sh.bak-20261007 new file mode 100755 index 0000000..44ccaff --- /dev/null +++ b/scripts/backup-pgdump-s3.sh.bak-20261007 @@ -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." \ No newline at end of file diff --git a/scripts/backup.py b/scripts/backup.py old mode 100644 new mode 100755 index debe254..2d14e17 --- a/scripts/backup.py +++ b/scripts/backup.py @@ -24,6 +24,40 @@ DB_HOST = "supabase-db" DB_USER = "supabase_admin" 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(): """Get list of public tables with data.""" result = subprocess.run( @@ -38,6 +72,7 @@ def get_tables(): 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") @@ -73,6 +108,7 @@ def export_table_csv(table_name, out_dir): print(f" {table_name}: {len(lines)-1} rows") return True + def upload_to_s3(file_path, key): """Upload file to S3.""" s3 = boto3.client( @@ -86,41 +122,58 @@ def upload_to_s3(file_path, key): 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}" + s3_path = f"s3://{S3_BUCKET}/{s3_key}" print(f"=== SuperSam Backup {date_str} ===") - with tempfile.TemporaryDirectory() as tmpdir: - tables = get_tables() - print(f"Found {len(tables)} tables") + try: + 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) + 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) + 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") + # 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)") + # Get size + size_bytes = os.path.getsize(archive_path) + size_mb = size_bytes / (1024 * 1024) + print(f"Archive: {archive_name} ({size_mb:.2f} MB)") - # Upload - upload_to_s3(archive_path, s3_key) + # Upload + 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 ===") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/backup.py.bak-20261007 b/scripts/backup.py.bak-20261007 new file mode 100644 index 0000000..debe254 --- /dev/null +++ b/scripts/backup.py.bak-20261007 @@ -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() \ No newline at end of file diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index adc03b4..471139c 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -21,6 +21,15 @@ const CAMPAIGNS = [ 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 ───────────────────────────────────────────────────────────── const DAYS = [ { num: 1, short: "Пн" }, @@ -479,19 +488,43 @@ export const SmsCampaignPanel = () => { )} - {/* ── SMS text template ──────────────────────────────────────────── */} + {/* ── SMS text templates: доставка / самовывоз ───────────────────── */} {!isManualCampaign && (
📝 Текст SMS
-