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