feat: Implement a new push notification service and enhance appointment reminder functionality with new database columns and logic.
This commit is contained in:
71
supabase/functions/send-push-notification/index.ts
Normal file
71
supabase/functions/send-push-notification/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||
)
|
||||
|
||||
try {
|
||||
const payload = await req.json();
|
||||
// Payload do Webhook do Supabase: { record: { user_id, message, ... } }
|
||||
const { record } = payload;
|
||||
|
||||
if (!record || !record.user_id) {
|
||||
return new Response("No record found", { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Obter o token do utilizador
|
||||
const { data: profile, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('fcm_token')
|
||||
.eq('id', record.user_id)
|
||||
.single();
|
||||
|
||||
if (error || !profile?.fcm_token) {
|
||||
console.log(`No token found for user ${record.user_id}`);
|
||||
return new Response("No token found", { status: 200 });
|
||||
}
|
||||
|
||||
const expoToken = profile.fcm_token;
|
||||
if (!expoToken.startsWith('ExponentPushToken')) {
|
||||
console.log(`Invalid Expo token for user ${record.user_id}`);
|
||||
return new Response("Invalid token", { status: 200 });
|
||||
}
|
||||
|
||||
// 2. Enviar para o Expo
|
||||
const notification = {
|
||||
to: expoToken,
|
||||
sound: 'default',
|
||||
title: 'Smart Agenda',
|
||||
body: record.message,
|
||||
data: { notificationId: record.id },
|
||||
};
|
||||
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(notification),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Expo Result:', result);
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error sending push notification:', err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -1,68 +1,80 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
// URL da API de Push do Expo (não precisa de chaves de servidor para o básico)
|
||||
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Criar o cliente Supabase com as variáveis de ambiente do sistema
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||
)
|
||||
|
||||
try {
|
||||
// 1. Procurar agendamentos pendentes com tokens válidos
|
||||
// Nota: O campo 'fcm_token' na BD irá guardar o 'ExponentPushToken'
|
||||
const now = new Date();
|
||||
|
||||
// 1. Buscar agendamentos que:
|
||||
// - Estão pendentes
|
||||
// - Ainda não enviaram lembrete (field 'reminder_sent' deve existir)
|
||||
// - O tempo para o agendamento é <= reminder_minutes
|
||||
const { data: appts, error } = await supabase
|
||||
.from('appointments')
|
||||
.select('*, profiles(fcm_token)')
|
||||
.select('*, profiles(fcm_token), shops(name)')
|
||||
.eq('status', 'pendente')
|
||||
.eq('reminder_sent', false)
|
||||
.not('profiles.fcm_token', 'is', null);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const notifications = [];
|
||||
const appointmentsToUpdate = [];
|
||||
|
||||
for (const appt of appts) {
|
||||
const expoToken = appt.profiles?.fcm_token;
|
||||
|
||||
// Verifica se é um token válido do Expo
|
||||
if (expoToken && expoToken.startsWith('ExponentPushToken')) {
|
||||
notifications.push({
|
||||
to: expoToken,
|
||||
sound: 'default',
|
||||
title: 'Lembrete Smart Agenda',
|
||||
body: `Olá! Não se esqueça do seu agendamento em breve.`,
|
||||
data: { appointmentId: appt.id },
|
||||
});
|
||||
const apptDate = new Date(appt.date.replace(' ', 'T')); // Converte "YYYY-MM-DD HH:mm" para ISO
|
||||
const diffInMinutes = (apptDate.getTime() - now.getTime()) / (1000 * 60);
|
||||
const reminderMinutes = appt.reminder_minutes || 1440; // Default 24h
|
||||
|
||||
// Se estiver dentro da janela de lembrete (por exemplo, se faltarem 10min e o lembrete for de 10min)
|
||||
// Ajustamos uma margem de segurança de 5 min para o Cron não perder a janela
|
||||
if (diffInMinutes <= reminderMinutes && diffInMinutes > -10) {
|
||||
const expoToken = appt.profiles?.fcm_token;
|
||||
if (expoToken && expoToken.startsWith('ExponentPushToken')) {
|
||||
notifications.push({
|
||||
to: expoToken,
|
||||
sound: 'default',
|
||||
title: `Lembrete: ${appt.shops?.name}`,
|
||||
body: `O seu agendamento está a aproximar-se (${appt.date.split(' ')[1]}).`,
|
||||
data: { appointmentId: appt.id },
|
||||
});
|
||||
appointmentsToUpdate.push(appt.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Enviando ${notifications.length} notificações via Expo...`);
|
||||
console.log(`Sending ${notifications.length} reminders...`);
|
||||
|
||||
if (notifications.length > 0) {
|
||||
// 2. Enviar notificações em lote (Batch) via Expo
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
},
|
||||
body: JSON.stringify(notifications),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Resultado do Expo:', result);
|
||||
// Marcar como enviado para não repetir
|
||||
await supabase
|
||||
.from('appointments')
|
||||
.update({ reminder_sent: true })
|
||||
.in('id', appointmentsToUpdate);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, sent: notifications.length }), {
|
||||
return new Response(JSON.stringify({ success: true, count: notifications.length }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro na função send-reminder:', err);
|
||||
console.error('Error in send-reminder:', err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 500,
|
||||
|
||||
13
supabase/migrations/update_reminders.sql
Normal file
13
supabase/migrations/update_reminders.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 1. Adicionar coluna para controlo de lembretes enviados
|
||||
-- Isto evita que o utilizador receba o mesmo lembrete várias vezes
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN IF NOT EXISTS reminder_sent BOOLEAN DEFAULT false;
|
||||
|
||||
-- 2. Garantir que a coluna reminder_minutes existe (caso não tenha sido criada no outro PC)
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN IF NOT EXISTS reminder_minutes INTEGER DEFAULT 1440;
|
||||
|
||||
-- 3. Criar índice para performance na busca de lembretes pendentes
|
||||
CREATE INDEX IF NOT EXISTS idx_appointments_reminder_status
|
||||
ON appointments (status, reminder_sent)
|
||||
WHERE status = 'pendente' AND reminder_sent = false;
|
||||
Reference in New Issue
Block a user