feat: send-reminders Edge Function — push 1h before scheduled visit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 11:17:18 +03:00
parent 1fa97ca010
commit 5db7f0c428
2 changed files with 127 additions and 0 deletions
+126
View File
@@ -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<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;
}
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 });
});
@@ -0,0 +1 @@
ALTER TABLE orders ADD COLUMN IF NOT EXISTS reminder_sent_at TIMESTAMPTZ;