Files
glass/supabase/functions/push-notify/index.ts
T
houseassassin af9e7ce1af feat: push notifications, calendar view, photo attachments
- Push notifications: custom SW (injectManifest), usePushNotifications hook,
  push-notify Edge Function, push_subscriptions migration, bell toggle in header
- Calendar view: month-grid CalendarPage + useCalendar hook, scheduled_at column
  migration, datetime field in OrderModal, CalendarDays nav item + route
- Photo attachments: useOrderPhotos hook, photo gallery/upload in OrderModal (edit
  mode), order-photos Storage bucket migration with RLS policies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:25:04 +03:00

128 lines
3.5 KiB
TypeScript

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
// Minimal Web Push implementation using Deno crypto
async function sendWebPush(
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string,
vapidPrivateKeyB64: string,
vapidPublicKeyB64: string,
): Promise<number> {
const endpoint = subscription.endpoint;
const audience = new URL(endpoint).origin;
const header = { alg: 'ES256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const claims = {
aud: audience,
exp: now + 12 * 3600,
sub: 'mailto:admin@oskolkam.net',
};
const encode = (obj: object) =>
btoa(JSON.stringify(obj))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const headerB64 = encode(header);
const claimsB64 = encode(claims);
const signingInput = `${headerB64}.${claimsB64}`;
// Import private key from raw 32-byte scalar
const privKeyBytes = Uint8Array.from(
atob(vapidPrivateKeyB64.replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
);
const cryptoKey = await crypto.subtle.importKey(
'raw',
privKeyBytes,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign'],
);
const encoder = new TextEncoder();
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
cryptoKey,
encoder.encode(signingInput),
);
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const jwt = `${signingInput}.${sigB64}`;
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `vapid t=${jwt},k=${vapidPublicKeyB64}`,
'Content-Type': 'application/octet-stream',
'TTL': '86400',
},
body: encoder.encode(payload),
});
return res.status;
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
const body = await req.json().catch(() => ({}));
const order = body.record;
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
const { data: subs } = await supabaseAdmin
.from('push_subscriptions')
.select('subscription');
if (!subs?.length) {
return new Response(
JSON.stringify({ sent: 0 }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
);
}
const payload = JSON.stringify({
title: 'Новая заявка',
body: `${order?.name || 'Клиент'}${order?.phone || ''}`,
url: '/admin/kanban',
});
const vapidPriv = Deno.env.get('VAPID_PRIVATE_KEY')!;
const vapidPub = Deno.env.get('VITE_VAPID_PUBLIC_KEY')!;
let sent = 0;
for (const row of subs) {
try {
const status = await sendWebPush(row.subscription, payload, vapidPriv, vapidPub);
if (status === 200 || status === 201) sent++;
// Clean up expired/invalid subscriptions
if (status === 404 || status === 410) {
await supabaseAdmin
.from('push_subscriptions')
.delete()
.eq('subscription->>endpoint', row.subscription.endpoint);
}
} catch (_) {
// Ignore individual push failures — don't abort the whole batch
}
}
return new Response(
JSON.stringify({ sent }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
);
});