supersam/supabase/functions/send-test-push/index.ts

211 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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