af9e7ce1af
- 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>
128 lines
3.5 KiB
TypeScript
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' } },
|
|
);
|
|
});
|