feat(push): prepare client subscription test contour

This commit is contained in:
root 2026-08-06 08:56:12 +00:00
parent 82eefb2225
commit 1922778e0f
7 changed files with 729 additions and 31 deletions

View File

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

239
scripts/push_sender.py Executable file
View File

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

View File

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

View File

@ -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<SubscribeBody>(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,
);
}
});

View File

@ -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<UnsubscribeBody>(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,
);
}
});

View File

@ -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<SubscribeBody>(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,
);
}
});

View File

@ -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<UnsubscribeBody>(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,
);
}
});