211 lines
6.0 KiB
TypeScript
211 lines
6.0 KiB
TypeScript
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,
|
||
);
|
||
}
|
||
});
|