diff --git a/public/service-worker.js b/public/service-worker.js index 74bb530..8447e47 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-v88"; - const RUNTIME_CACHE = "construction-delivery-runtime-v88"; + const STATIC_CACHE = "construction-delivery-static-v90"; + const RUNTIME_CACHE = "construction-delivery-runtime-v90"; const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; self.addEventListener("install", (event) => { @@ -90,32 +90,28 @@ self.addEventListener("push", (event) => { data = { title: "Уведомление", body: "" }; } - const title = data.title || "Уведомление"; - const options = { - body: data.body || "", - icon: data.icon || "/icons/icon-192.png", - badge: data.badge || "/icons/icon-192.png", - data: data.data || {}, - tag: data.tag || "default", - vibrate: [100, 50, 100], - requireInteraction: data.requireInteraction || false, - }; + const { title, body, url, order_group_id } = data; - event.waitUntil(self.registration.showNotification(title, options)); + event.waitUntil( + self.registration.showNotification(title || "Уведомление", { + body: body || "", + icon: "/icons/manifest-192.png", + badge: "/icons/manifest-192.png", + data: { url: url || "/dashboard" }, + }), + ); }); +// Notification click handler self.addEventListener("notificationclick", (event) => { event.notification.close(); - const clickData = event.notification.data || {}; - const targetUrl = clickData.order_id - ? "/dashboard/group/" + clickData.order_id - : "/dashboard"; + const targetUrl = (event.notification.data && event.notification.data.url) || "/dashboard"; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { - if (client.url.includes("/dashboard") && "focus" in client) { + if ("focus" in client) { return client.focus(); } } diff --git a/scripts/push_sender.py b/scripts/push_sender.py new file mode 100755 index 0000000..76bae37 --- /dev/null +++ b/scripts/push_sender.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +SuperSam — Web Push Sender +Отправляет push-уведомления подписанным клиентам. +Каскад: push → 30 мин ждём → fallback на SMS. + +Usage: + python3 push_sender.py --phone 7XXX --title "Заголовок" --body "Текст" --url https://dev.mkn8n.ru/delivery/xxx + python3 push_sender.py --phone 7XXX --title "test" --body "test" --url https://dev.mkn8n.ru/ --order-group-id UUID +""" + +import os +import sys +import json +import logging +import argparse +from datetime import datetime, timezone + +import psycopg2 +from psycopg2.extras import RealDictCursor + +try: + from pywebpush import webpush, WebPushException +except ImportError: + print("ERROR: pywebpush not installed. Run: pip3 install pywebpush", file=sys.stderr) + sys.exit(1) + +# ─── Конфигурация ──────────────────────────────────────────────────────────── + +DB_HOST = os.environ.get("DB_HOST", "10.0.4.12") +DB_PORT = os.environ.get("DB_PORT", "5432") +DB_NAME = os.environ.get("DB_NAME", "postgres") +DB_USER = os.environ.get("DB_USER", "supabase_admin") +DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479") + +VAPID_SUBJECT = os.environ.get("VAPID_SUBJECT", "mailto:admin@supersam.ru") + +LOG_FILE = "/var/log/supersam-push.log" + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler(sys.stdout), + ], +) +log = logging.getLogger("push_sender") + + +# ─── БД ────────────────────────────────────────────────────────────────────── + +def get_db_conn(): + return psycopg2.connect( + host=DB_HOST, port=DB_PORT, dbname=DB_NAME, + user=DB_USER, password=DB_PASS, + ) + + +def load_vapid_keys(conn): + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT key, value FROM app_settings WHERE key IN ('vapid_public_key', 'vapid_private_key')") + rows = {r["key"]: r["value"] for r in cur.fetchall()} + public_key = rows.get("vapid_public_key") + private_key = rows.get("vapid_private_key") + if not public_key or not private_key: + raise RuntimeError("VAPID keys not found in app_settings table") + return public_key, private_key + + +def get_active_subscriptions(conn, phone_normalized): + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT id, endpoint, p256dh, auth, phone_normalized + FROM push_subscriptions + WHERE phone_normalized = %s AND is_active = true + ORDER BY created_at DESC + """, (phone_normalized,)) + return [dict(r) for r in cur.fetchall()] + + +def insert_push_log(conn, **kwargs): + with conn.cursor() as cur: + cols = ", ".join(kwargs.keys()) + placeholders = ", ".join(["%s"] * len(kwargs)) + cur.execute(f"INSERT INTO push_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values())) + log_id = cur.fetchone()[0] + conn.commit() + return log_id + + +def deactivate_subscription(conn, sub_id): + with conn.cursor() as cur: + cur.execute("UPDATE push_subscriptions SET is_active = false WHERE id = %s", (sub_id,)) + conn.commit() + log.info(f"Subscription {sub_id} deactivated (expired/invalid)") + + +def update_subscription_sent(conn, sub_id): + with conn.cursor() as cur: + cur.execute("UPDATE push_subscriptions SET last_sent_at = NOW() WHERE id = %s", (sub_id,)) + conn.commit() + + +# ─── Push отправка ──────────────────────────────────────────────────────────── + +def build_payload(title, body, url, order_group_id=None): + return json.dumps({ + "title": title, + "body": body, + "url": url, + "order_group_id": order_group_id, + "icon": "/icons/manifest-192.png", + "badge": "/icons/manifest-192.png", + "vibrate": [200, 100, 200], + }) + + +def send_push(phone_normalized, title, body, url, order_group_id=None, conn=None): + """Отправляет push-уведомление всем активным подпискам клиента. + Returns: { sent, failed, deactivated, total, details } + """ + own_conn = conn is None + if own_conn: + conn = get_db_conn() + + try: + public_key, private_key = load_vapid_keys(conn) + subscriptions = get_active_subscriptions(conn, phone_normalized) + + if not subscriptions: + log.info(f"No active push subscriptions for {phone_normalized}") + return {"sent": 0, "failed": 0, "deactivated": 0, "total": 0, "details": []} + + payload = build_payload(title, body, url, order_group_id) + sent = 0 + failed = 0 + deactivated = 0 + details = [] + + for sub in subscriptions: + sub_id = str(sub["id"]) + endpoint = sub["endpoint"] + p256dh = sub["p256dh"] + auth = sub["auth"] + + log.info(f"Sending push to {phone_normalized} via {endpoint[:60]}...") + + try: + webpush( + subscription_info={ + "endpoint": endpoint, + "keys": {"p256dh": p256dh, "auth": auth}, + }, + data=payload, + vapid_private_key=private_key, + vapid_claims={"sub": VAPID_SUBJECT}, + ttl=86400, + ) + sent += 1 + update_subscription_sent(conn, sub["id"]) + insert_push_log(conn, + subscription_id=sub["id"], + order_group_id=order_group_id, + phone_normalized=phone_normalized, + title=title, + body=body, + url=url, + status="sent", + ) + details.append({"sub_id": sub_id, "status": "sent"}) + log.info(f"Push sent to {phone_normalized} (sub {sub_id})") + + except WebPushException as e: + failed += 1 + status_code = e.response.status_code if e.response else None + error_msg = str(e)[:500] + + if status_code in (410, 404): + deactivate_subscription(conn, sub["id"]) + deactivated += 1 + log.warning(f"Subscription {sub_id} expired ({status_code}), deactivated") + else: + log.error(f"Push failed for {phone_normalized} (sub {sub_id}): {error_msg}") + + insert_push_log(conn, + subscription_id=sub["id"], + order_group_id=order_group_id, + phone_normalized=phone_normalized, + title=title, + body=body, + url=url, + status="failed", + error_message=error_msg, + ) + details.append({"sub_id": sub_id, "status": "failed", "code": status_code, "error": error_msg}) + + except Exception as e: + failed += 1 + error_msg = str(e)[:500] + log.error(f"Push unexpected error for {phone_normalized}: {error_msg}") + insert_push_log(conn, + subscription_id=sub["id"], + order_group_id=order_group_id, + phone_normalized=phone_normalized, + title=title, + body=body, + url=url, + status="failed", + error_message=error_msg, + ) + details.append({"sub_id": sub_id, "status": "error", "error": error_msg}) + + return {"sent": sent, "failed": failed, "deactivated": deactivated, "total": len(subscriptions), "details": details} + + finally: + if own_conn: + conn.close() + + +# ─── CLI ───────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="SuperSam Push Sender") + parser.add_argument("--phone", required=True, help="Phone normalized (7XXXXXXXXXX)") + parser.add_argument("--title", required=True, help="Notification title") + parser.add_argument("--body", required=True, help="Notification body text") + parser.add_argument("--url", required=True, help="URL to open on click") + parser.add_argument("--order-group-id", default=None, help="Order group UUID") + args = parser.parse_args() + + log.info(f"Sending push: phone={args.phone}, title={args.title}, url={args.url}") + result = send_push(args.phone, args.title, args.body, args.url, args.order_group_id) + log.info(f"Result: {result}") + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/components/client/PushSubscriptionBanner.jsx b/src/components/client/PushSubscriptionBanner.jsx index ca7ff46..89aa6c4 100644 --- a/src/components/client/PushSubscriptionBanner.jsx +++ b/src/components/client/PushSubscriptionBanner.jsx @@ -2,16 +2,13 @@ import React, { useState, useEffect, useRef } from "react"; const VAPID_PUBLIC_KEY = "BDLNGyVp7hDzRujZBncNQ0qyz4zVFAhAWX1nsCMRC2tagmer46Sy6exZOymsGVk-TDb1bhkkXXth9Jkzdv4SdJg"; -const PUSH_API_BASE = (() => { - try { return import.meta.env.VITE_PUSH_API_BASE || ""; } catch { return ""; } -})(); +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; -const getEndpoint = () => { - if (PUSH_API_BASE) return `${PUSH_API_BASE}/functions/v1/push-subscribe`; - // Supabase Kong gateway — same origin as other edge functions - const base = window.location.origin.replace("dev.mkn8n.ru", "dev.mkn8n.ru"); - // Fallback: use Supabase Kong directly - return "https://dev.mkn8n.ru/functions/v1/push-subscribe"; +const getEndpoint = (action) => `${SUPABASE_URL}/functions/v1/${action}`; +const apiHeaders = { + "Content-Type": "application/json", + ...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}), }; const normalizePhone = (phone) => { @@ -67,9 +64,9 @@ export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { } // Send to backend const sub = subscription.toJSON(); - const resp = await fetch(getEndpoint(), { + const resp = await fetch(getEndpoint("push-subscribe"), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: apiHeaders, body: JSON.stringify({ phone_normalized: phoneNorm, endpoint: sub.endpoint, @@ -102,9 +99,9 @@ export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { if (sub) { await sub.unsubscribe(); // Notify backend - await fetch(getEndpoint().replace("push-subscribe", "push-unsubscribe"), { + await fetch(getEndpoint("push-unsubscribe"), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: apiHeaders, body: JSON.stringify({ endpoint: sub.endpoint, phone_normalized: phoneNorm, diff --git a/supabase/functions/push-subscribe/index.ts b/supabase/functions/push-subscribe/index.ts new file mode 100644 index 0000000..aa48991 --- /dev/null +++ b/supabase/functions/push-subscribe/index.ts @@ -0,0 +1,128 @@ +import { createServiceClient } from "../_shared/security.ts"; +import { + getCorsHeaders, + jsonResponse, + preflightResponse, + readJsonBody, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +type SubscribeBody = { + phone_normalized?: string; + endpoint?: string; + p256dh?: string; + auth?: string; + user_agent?: string; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + + const phoneNormalized = String(body.phone_normalized || "").trim(); + const endpoint = String(body.endpoint || "").trim(); + const p256dh = String(body.p256dh || "").trim(); + const auth = String(body.auth || "").trim(); + const userAgent = String(body.user_agent || "").trim() || null; + + if (!phoneNormalized) { + return jsonResponse( + { ok: false, error: "phone_normalized is required" }, + 400, + corsHeaders, + ); + } + if (!endpoint) { + return jsonResponse( + { ok: false, error: "endpoint is required" }, + 400, + corsHeaders, + ); + } + if (!p256dh) { + return jsonResponse( + { ok: false, error: "p256dh is required" }, + 400, + corsHeaders, + ); + } + if (!auth) { + return jsonResponse( + { ok: false, error: "auth is required" }, + 400, + corsHeaders, + ); + } + + const supabase = createServiceClient(); + const now = new Date().toISOString(); + + // Upsert on (phone_normalized, endpoint) — unique constraint idx_push_subs_phone_endpoint + const { data, error } = await supabase + .from("push_subscriptions") + .upsert( + { + phone_normalized: phoneNormalized, + endpoint, + p256dh, + auth, + user_agent: userAgent, + is_active: true, + updated_at: now, + last_seen_at: now, + }, + { onConflict: "phone_normalized,endpoint" }, + ) + .select("id, phone_normalized, endpoint, is_active, updated_at") + .single(); + + if (error) { + console.error("push-subscribe upsert error:", error); + return jsonResponse( + { ok: false, error: "Database error", detail: error.message }, + 500, + corsHeaders, + ); + } + + return jsonResponse( + { ok: true, subscription: data }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse( + { ok: false, error: httpError.message }, + httpError.status, + corsHeaders, + ); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/push-unsubscribe/index.ts b/supabase/functions/push-unsubscribe/index.ts new file mode 100644 index 0000000..0924a23 --- /dev/null +++ b/supabase/functions/push-unsubscribe/index.ts @@ -0,0 +1,105 @@ +import { createServiceClient } from "../_shared/security.ts"; +import { + getCorsHeaders, + jsonResponse, + preflightResponse, + readJsonBody, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 4 * 1024; + +type UnsubscribeBody = { + endpoint?: string; + phone_normalized?: string; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + + const endpoint = String(body.endpoint || "").trim(); + const phoneNormalized = String(body.phone_normalized || "").trim(); + + if (!endpoint) { + return jsonResponse( + { ok: false, error: "endpoint is required" }, + 400, + corsHeaders, + ); + } + if (!phoneNormalized) { + return jsonResponse( + { ok: false, error: "phone_normalized is required" }, + 400, + corsHeaders, + ); + } + + const supabase = createServiceClient(); + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("push_subscriptions") + .update({ + is_active: false, + updated_at: now, + }) + .eq("endpoint", endpoint) + .eq("phone_normalized", phoneNormalized) + .select("id, phone_normalized, endpoint, is_active, updated_at"); + + if (error) { + console.error("push-unsubscribe update error:", error); + return jsonResponse( + { ok: false, error: "Database error", detail: error.message }, + 500, + corsHeaders, + ); + } + + const deactivatedCount = data?.length || 0; + + return jsonResponse( + { + ok: true, + deactivated: deactivatedCount, + subscriptions: data, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse( + { ok: false, error: httpError.message }, + httpError.status, + corsHeaders, + ); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file diff --git a/volumes/functions/push-subscribe/index.ts b/volumes/functions/push-subscribe/index.ts new file mode 100644 index 0000000..aa48991 --- /dev/null +++ b/volumes/functions/push-subscribe/index.ts @@ -0,0 +1,128 @@ +import { createServiceClient } from "../_shared/security.ts"; +import { + getCorsHeaders, + jsonResponse, + preflightResponse, + readJsonBody, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +type SubscribeBody = { + phone_normalized?: string; + endpoint?: string; + p256dh?: string; + auth?: string; + user_agent?: string; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + + const phoneNormalized = String(body.phone_normalized || "").trim(); + const endpoint = String(body.endpoint || "").trim(); + const p256dh = String(body.p256dh || "").trim(); + const auth = String(body.auth || "").trim(); + const userAgent = String(body.user_agent || "").trim() || null; + + if (!phoneNormalized) { + return jsonResponse( + { ok: false, error: "phone_normalized is required" }, + 400, + corsHeaders, + ); + } + if (!endpoint) { + return jsonResponse( + { ok: false, error: "endpoint is required" }, + 400, + corsHeaders, + ); + } + if (!p256dh) { + return jsonResponse( + { ok: false, error: "p256dh is required" }, + 400, + corsHeaders, + ); + } + if (!auth) { + return jsonResponse( + { ok: false, error: "auth is required" }, + 400, + corsHeaders, + ); + } + + const supabase = createServiceClient(); + const now = new Date().toISOString(); + + // Upsert on (phone_normalized, endpoint) — unique constraint idx_push_subs_phone_endpoint + const { data, error } = await supabase + .from("push_subscriptions") + .upsert( + { + phone_normalized: phoneNormalized, + endpoint, + p256dh, + auth, + user_agent: userAgent, + is_active: true, + updated_at: now, + last_seen_at: now, + }, + { onConflict: "phone_normalized,endpoint" }, + ) + .select("id, phone_normalized, endpoint, is_active, updated_at") + .single(); + + if (error) { + console.error("push-subscribe upsert error:", error); + return jsonResponse( + { ok: false, error: "Database error", detail: error.message }, + 500, + corsHeaders, + ); + } + + return jsonResponse( + { ok: true, subscription: data }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse( + { ok: false, error: httpError.message }, + httpError.status, + corsHeaders, + ); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file diff --git a/volumes/functions/push-unsubscribe/index.ts b/volumes/functions/push-unsubscribe/index.ts new file mode 100644 index 0000000..0924a23 --- /dev/null +++ b/volumes/functions/push-unsubscribe/index.ts @@ -0,0 +1,105 @@ +import { createServiceClient } from "../_shared/security.ts"; +import { + getCorsHeaders, + jsonResponse, + preflightResponse, + readJsonBody, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 4 * 1024; + +type UnsubscribeBody = { + endpoint?: string; + phone_normalized?: string; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + + const endpoint = String(body.endpoint || "").trim(); + const phoneNormalized = String(body.phone_normalized || "").trim(); + + if (!endpoint) { + return jsonResponse( + { ok: false, error: "endpoint is required" }, + 400, + corsHeaders, + ); + } + if (!phoneNormalized) { + return jsonResponse( + { ok: false, error: "phone_normalized is required" }, + 400, + corsHeaders, + ); + } + + const supabase = createServiceClient(); + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("push_subscriptions") + .update({ + is_active: false, + updated_at: now, + }) + .eq("endpoint", endpoint) + .eq("phone_normalized", phoneNormalized) + .select("id, phone_normalized, endpoint, is_active, updated_at"); + + if (error) { + console.error("push-unsubscribe update error:", error); + return jsonResponse( + { ok: false, error: "Database error", detail: error.message }, + 500, + corsHeaders, + ); + } + + const deactivatedCount = data?.length || 0; + + return jsonResponse( + { + ok: true, + deactivated: deactivatedCount, + subscriptions: data, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse( + { ok: false, error: httpError.message }, + httpError.status, + corsHeaders, + ); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file