fix(push): test notification targets the device's own subscription (by endpoint), not localStorage phone

This commit is contained in:
Hermes 2026-08-13 15:02:26 +02:00
parent 4fed962f23
commit 7654413d8b
2 changed files with 226 additions and 3 deletions

View File

@ -28,11 +28,23 @@ const TestPushButton = () => {
setSending(true);
setSendResult(null);
try {
const phone = window.localStorage.getItem("supersam_phone") || "";
if (!phone) {
// Send to THIS device's own subscription (endpoint), not a phone from localStorage
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: "Сначала подпишитесь на уведомления (кнопка «Разрешить» выше)" });
return;
}
const resp = await fetch(`${SUPABASE_URL}/functions/v1/send-test-push`, {
method: "POST",
headers: {
@ -40,7 +52,8 @@ const TestPushButton = () => {
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
},
body: JSON.stringify({
phone_normalized: phone,
phone_normalized: phone || undefined,
endpoint: endpoint || undefined,
title: "СуперСам — согласуйте доставку",
body: "Ваш заказ готов к доставке. Выберите удобное время.",
url: `${window.location.origin}/client/test-consent`,

View File

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