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