feat(push): prepare client subscription test contour
This commit is contained in:
parent
82eefb2225
commit
1922778e0f
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue