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:
@@ -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;
|
||||
Reference in New Issue
Block a user