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