fix(push): test notification targets the device's own subscription (by endpoint), not localStorage phone
This commit is contained in:
parent
4fed962f23
commit
7654413d8b
|
|
@ -28,11 +28,23 @@ const TestPushButton = () => {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setSendResult(null);
|
setSendResult(null);
|
||||||
try {
|
try {
|
||||||
const phone = window.localStorage.getItem("supersam_phone") || "";
|
// Send to THIS device's own subscription (endpoint), not a phone from localStorage
|
||||||
if (!phone) {
|
let endpoint = "";
|
||||||
|
let phone = "";
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
|
endpoint = sub?.endpoint || "";
|
||||||
|
} catch (e) {
|
||||||
|
void e;
|
||||||
|
}
|
||||||
|
phone = window.localStorage.getItem("supersam_phone") || "";
|
||||||
|
|
||||||
|
if (!endpoint && !phone) {
|
||||||
setSendResult({ ok: false, error: "Сначала подпишитесь на уведомления (кнопка «Разрешить» выше)" });
|
setSendResult({ ok: false, error: "Сначала подпишитесь на уведомления (кнопка «Разрешить» выше)" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch(`${SUPABASE_URL}/functions/v1/send-test-push`, {
|
const resp = await fetch(`${SUPABASE_URL}/functions/v1/send-test-push`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -40,7 +52,8 @@ const TestPushButton = () => {
|
||||||
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
|
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
phone_normalized: phone,
|
phone_normalized: phone || undefined,
|
||||||
|
endpoint: endpoint || undefined,
|
||||||
title: "СуперСам — согласуйте доставку",
|
title: "СуперСам — согласуйте доставку",
|
||||||
body: "Ваш заказ готов к доставке. Выберите удобное время.",
|
body: "Ваш заказ готов к доставке. Выберите удобное время.",
|
||||||
url: `${window.location.origin}/client/test-consent`,
|
url: `${window.location.origin}/client/test-consent`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { createServiceClient } from "../_shared/security.ts";
|
||||||
|
import {
|
||||||
|
getCorsHeaders,
|
||||||
|
jsonResponse,
|
||||||
|
preflightResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
|
// @ts-ignore — web-push is a Node package; Deno edge functions resolve it via npm:
|
||||||
|
import webpush from "npm:web-push@3.6.7";
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
|
||||||
|
type TestPushBody = {
|
||||||
|
phone_normalized?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a test push notification to all active subscriptions of a phone.
|
||||||
|
* Used by the /client/test page to demo how notifications look.
|
||||||
|
* URL defaults to the test delivery-consent page.
|
||||||
|
*/
|
||||||
|
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<TestPushBody>(request, {
|
||||||
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const phoneNormalized = String(body.phone_normalized || "").trim();
|
||||||
|
const endpoint = String(body.endpoint || "").trim();
|
||||||
|
|
||||||
|
if (!phoneNormalized && !endpoint) {
|
||||||
|
return jsonResponse({ ok: false, error: "phone_normalized or endpoint is required" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
|
||||||
|
await requireRateLimit(supabase, {
|
||||||
|
scope: "test-push-send",
|
||||||
|
key: endpoint || phoneNormalized,
|
||||||
|
maxCount: 5,
|
||||||
|
windowSeconds: 300,
|
||||||
|
blockSeconds: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load VAPID keys from app_settings
|
||||||
|
const { data: settingsRows, error: settingsError } = await supabase
|
||||||
|
.from("app_settings")
|
||||||
|
.select("key, value")
|
||||||
|
.in("key", ["vapid_public_key", "vapid_private_key"]);
|
||||||
|
|
||||||
|
if (settingsError) {
|
||||||
|
console.error("send-test-push: app_settings error:", settingsError);
|
||||||
|
return jsonResponse({ ok: false, error: "Database error" }, 500, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = (settingsRows || []).reduce((acc, row) => {
|
||||||
|
acc[row.key] = row.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
if (!settings.vapid_public_key || !settings.vapid_private_key) {
|
||||||
|
return jsonResponse({ ok: false, error: "VAPID keys not configured" }, 500, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target subscriptions: by endpoint (this device) when provided, else by phone
|
||||||
|
let subsQuery = supabase
|
||||||
|
.from("push_subscriptions")
|
||||||
|
.select("id, endpoint, p256dh, auth, phone_normalized")
|
||||||
|
.eq("is_active", true)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
subsQuery = subsQuery.eq("endpoint", endpoint);
|
||||||
|
} else {
|
||||||
|
subsQuery = subsQuery.eq("phone_normalized", phoneNormalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: subs, error: subsError } = await subsQuery;
|
||||||
|
|
||||||
|
if (subsError) {
|
||||||
|
console.error("send-test-push: subscriptions error:", subsError);
|
||||||
|
return jsonResponse({ ok: false, error: "Database error" }, 500, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subs || subs.length === 0) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ ok: false, error: "Нет активных подписок для этого устройства" },
|
||||||
|
404,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = String(body.title || "СуперСам — тест уведомления").slice(0, 120);
|
||||||
|
const bodyText = String(body.body || "Проверка, как выглядят уведомления о доставке").slice(0, 300);
|
||||||
|
const url = String(body.url || `${new URL(request.url).origin}/client/test-consent`).slice(0, 500);
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
"mailto:admin@supersam.ru",
|
||||||
|
settings.vapid_public_key,
|
||||||
|
settings.vapid_private_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title,
|
||||||
|
body: bodyText,
|
||||||
|
url,
|
||||||
|
order_group_id: null,
|
||||||
|
icon: "/icons/icon-512.png",
|
||||||
|
badge: "/icons/icon-512.png",
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent: string[] = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
const deactivated: string[] = [];
|
||||||
|
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
{ TTL: 86400 },
|
||||||
|
);
|
||||||
|
sent.push(sub.id);
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("push_subscriptions")
|
||||||
|
.update({ last_sent_at: new Date().toISOString() })
|
||||||
|
.eq("id", sub.id);
|
||||||
|
} catch (err) {
|
||||||
|
const statusCode = err?.statusCode || err?.response?.statusCode || 0;
|
||||||
|
|
||||||
|
if (statusCode === 410 || statusCode === 404) {
|
||||||
|
deactivated.push(sub.id);
|
||||||
|
await supabase
|
||||||
|
.from("push_subscriptions")
|
||||||
|
.update({ is_active: false })
|
||||||
|
.eq("id", sub.id);
|
||||||
|
} else {
|
||||||
|
failed.push(sub.id);
|
||||||
|
console.error("send-test-push: send error:", err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to push_log for the audit trail
|
||||||
|
if (sent.length > 0) {
|
||||||
|
for (const subId of sent) {
|
||||||
|
await supabase.from("push_log").insert({
|
||||||
|
subscription_id: subId,
|
||||||
|
order_group_id: null,
|
||||||
|
phone_normalized: phoneNormalized,
|
||||||
|
title,
|
||||||
|
body: bodyText,
|
||||||
|
url,
|
||||||
|
status: "sent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
sent: sent.length,
|
||||||
|
failed: failed.length,
|
||||||
|
deactivated: deactivated.length,
|
||||||
|
total: subs.length,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
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