diff --git a/supabase/functions/send-reminders/index.ts b/supabase/functions/send-reminders/index.ts new file mode 100644 index 0000000..efb6c28 --- /dev/null +++ b/supabase/functions/send-reminders/index.ts @@ -0,0 +1,126 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +// Minimal Web Push implementation using Deno crypto +// Copied verbatim from push-notify/index.ts +async function sendWebPush( + subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, + payload: string, + vapidPrivateKeyB64: string, + vapidPublicKeyB64: string, +): Promise { + 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; +} + +const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, +); + +Deno.serve(async () => { + const now = new Date(); + const from = new Date(now.getTime() + 50 * 60 * 1000).toISOString(); + const to = new Date(now.getTime() + 70 * 60 * 1000).toISOString(); + + const { data: orders } = await supabaseAdmin + .from('orders') + .select('id, name, scheduled_at, address, film_thickness, area_sqm') + .gte('scheduled_at', from) + .lte('scheduled_at', to) + .neq('status', 'закрыт') + .is('reminder_sent_at', null); + + if (!orders?.length) return new Response('no reminders', { status: 200 }); + + const { data: subs } = await supabaseAdmin.from('push_subscriptions').select('subscription'); + if (!subs?.length) return new Response('no subscribers', { status: 200 }); + + const vapidPriv = Deno.env.get('VAPID_PRIVATE_KEY')!; + const vapidPub = Deno.env.get('VITE_VAPID_PUBLIC_KEY')!; + + for (const order of orders) { + const time = new Date(order.scheduled_at).toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + }); + const body = `${order.name} — ${time}${order.address ? `, ${order.address}` : ''}`; + const payload = JSON.stringify({ title: 'Визит через 1 час', body, url: '/admin/kanban' }); + + for (const { subscription: sub } of subs) { + try { + const status = await sendWebPush(sub, payload, vapidPriv, vapidPub); + // Clean up expired/invalid subscriptions + if (status === 404 || status === 410) { + await supabaseAdmin + .from('push_subscriptions') + .delete() + .eq('subscription->>endpoint', sub.endpoint); + } + } catch (_) { + // Ignore individual push failures — don't abort the whole batch + } + } + + // Mark reminder as sent to prevent duplicates + await supabaseAdmin + .from('orders') + .update({ reminder_sent_at: new Date().toISOString() }) + .eq('id', order.id); + } + + return new Response(`reminders sent: ${orders.length}`, { status: 200 }); +}); diff --git a/supabase/migrations/006_reminder_already_sent.sql b/supabase/migrations/006_reminder_already_sent.sql new file mode 100644 index 0000000..91b71ea --- /dev/null +++ b/supabase/migrations/006_reminder_already_sent.sql @@ -0,0 +1 @@ +ALTER TABLE orders ADD COLUMN IF NOT EXISTS reminder_sent_at TIMESTAMPTZ;