Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ Copy `.env` to `.env.local` for local development.

> Vercel is the recommended deployment target. The project deploys as serverless functions with zero configuration.

### Option 1: Deploy button
### Option 1: Deploy

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fgasleakdetector%2Fgasleakdetector-server)

Expand Down
31 changes: 31 additions & 0 deletions api/fcm/register.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// POST /api/fcm/register — stores or refreshes a device FCM token.
// Body: { device_id, token }
import { saveFcmToken } from '../../lib/supabase.js';
import { validateApiKey } from '../../lib/validator.js';

export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}

if (!validateApiKey(req.headers['x-api-key'])) {
return res.status(401).json({ error: 'Unauthorized' });
}

const { device_id, token } = req.body || {};

if (!device_id || typeof device_id !== 'string' || !device_id.trim()) {
return res.status(400).json({ error: 'Invalid device_id' });
}
if (!token || typeof token !== 'string' || !token.trim()) {
return res.status(400).json({ error: 'Invalid token' });
}

try {
await saveFcmToken(device_id.trim(), token.trim());
return res.status(200).json({ success: true });
} catch (err) {
console.error('[fcm/register] ERROR:', err.message);
return res.status(500).json({ error: 'Server error' });
}
}
27 changes: 25 additions & 2 deletions api/ingest.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// POST /api/ingest — ESP8266 sensor data ingestion.
// Accepts single: { device_id, ppm } or batch: { batch: [{device_id, ppm}, ...] }
import { saveLog, shouldSendAlert } from '../lib/supabase.js';
import { sendAlert } from '../lib/email.js';
import { saveLog, shouldSendAlert, getFcmTokensForDevice, shouldSendFcmAlert, markFcmAlerted } from '../lib/supabase.js';
import { sendAlert } from '../lib/email.js';
import { sendFcmAlert } from '../lib/fcm.js';
import { validateApiKey, determineStatus, validateLogData } from '../lib/validator.js';

export default async function handler(req, res) {
Expand Down Expand Up @@ -31,6 +32,16 @@ export default async function handler(req, res) {
if (await shouldSendAlert(item.device_id, status)) {
await sendAlert({ deviceId: item.device_id, ppm: v.ppm, timestamp: log.created_at });
}

if (await shouldSendFcmAlert(item.device_id, status)) {
const tokens = await getFcmTokensForDevice(item.device_id);
if (tokens.length > 0) {
await sendFcmAlert(tokens, status, item.device_id, v.ppm);
await markFcmAlerted(item.device_id);
} else {
console.warn(`[ingest] FCM skipped — no tokens registered for device=${item.device_id}`);
}
}
}
return res.status(200).json({ success: true, count: results.length, results });
}
Expand All @@ -46,6 +57,18 @@ export default async function handler(req, res) {
await sendAlert({ deviceId: device_id, ppm: v.ppm, timestamp: log.created_at }).catch(() => {});
}

if (await shouldSendFcmAlert(device_id, status)) {
const tokens = await getFcmTokensForDevice(device_id);
if (tokens.length > 0) {
await sendFcmAlert(tokens, status, device_id, v.ppm).catch((e) =>
console.error('[ingest] sendFcmAlert error:', e.message)
);
await markFcmAlerted(device_id).catch(() => {});
} else {
console.warn(`[ingest] FCM skipped — no tokens registered for device=${device_id}`);
}
}

return res.status(200).json({ success: true, log_id: log.id, status });

} catch (error) {
Expand Down
49 changes: 49 additions & 0 deletions lib/fcm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Firebase Cloud Messaging — send high-priority data messages to registered tokens.
import { initializeApp, getApps, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';

function getApp() {
if (getApps().length) return getApps()[0];

const raw = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
if (!raw) throw new Error('FIREBASE_SERVICE_ACCOUNT_JSON env var is not set');

return initializeApp({ credential: cert(JSON.parse(raw)) });
}

/**
* Send a high-priority FCM data message to one or more tokens.
* Data-only payload wakes the device even in Doze mode.
*
* @param {string[]} tokens FCM registration tokens
* @param {string} status 'warning' | 'danger'
* @param {string} deviceId originating device_id
* @param {number} ppm gas concentration
*/
export async function sendFcmAlert(tokens, status, deviceId, ppm) {
if (!tokens || tokens.length === 0) return;

getApp();
const messaging = getMessaging();

const message = {
tokens,
data: {
type: 'gas_alert',
status,
device_id: deviceId,
ppm: String(ppm),
timestamp: new Date().toISOString(),
},
android: {
priority: 'high',
},
};

try {
const response = await messaging.sendEachForMulticast(message);
console.log(`[fcm] sent=${response.successCount} failed=${response.failureCount} device=${deviceId} status=${status}`);
} catch (err) {
console.error('[fcm] sendFcmAlert failed:', err.message);
}
}
61 changes: 61 additions & 0 deletions lib/supabase.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,67 @@ export async function shouldSendAlert(deviceId, status) {
return (new Date(data[0].created_at) - new Date(data[1].created_at)) >= EMAIL_COOLDOWN_MS;
}

const FCM_COOLDOWN_MS = parseInt(process.env.FCM_COOLDOWN_MINUTES || '2') * 60 * 1000;

export async function saveFcmToken(deviceId, token) {
const { error } = await supabaseService
.from('fcm_tokens')
.upsert(
{ device_id: deviceId, token, updated_at: new Date().toISOString() },
{ onConflict: 'device_id,token' }
);

if (error) throw error;
}

export async function getFcmTokensForDevice(deviceId) {
const { data, error } = await supabaseService
.from('fcm_tokens')
.select('token')
.eq('device_id', deviceId);

if (error) {
console.error('[fcm] getFcmTokensForDevice error:', error.message);
return [];
}

const tokens = data.map((r) => r.token);
console.log(`[fcm] tokens for device=${deviceId}: count=${tokens.length}`);
return tokens;
}

export async function shouldSendFcmAlert(deviceId, status) {
if (status !== 'danger' && status !== 'warning') return false;

const cooldownTime = new Date(Date.now() - FCM_COOLDOWN_MS).toISOString();

// Use .maybeSingle() to avoid throwing when no rows exist.
// last_alerted_at is stored as the most recent alert time across all tokens for this device;
// we take the MAX to guard against partial updates.
const { data, error } = await supabaseService
.from('fcm_tokens')
.select('last_alerted_at')
.eq('device_id', deviceId)
.order('last_alerted_at', { ascending: false, nullsFirst: false })
.limit(1)
.maybeSingle();

if (error) {
console.error('[fcm] shouldSendFcmAlert query error:', error.message);
return true; // fail-open: attempt to send rather than silently drop
}

if (!data || !data.last_alerted_at) return true;
return new Date(data.last_alerted_at) < new Date(cooldownTime);
}

export async function markFcmAlerted(deviceId) {
await supabaseService
.from('fcm_tokens')
.update({ last_alerted_at: new Date().toISOString() })
.eq('device_id', deviceId);
}

export function getRealtimeConfig() {
return { url: process.env.SUPABASE_URL, anonKey: process.env.SUPABASE_ANON_KEY };
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"type": "module",
"dependencies": {
"@supabase/supabase-js": "^2.39.0",
"firebase-admin": "^12.0.0",
"resend": "^3.0.0"
}
}
22 changes: 21 additions & 1 deletion supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,24 @@ drop policy if exists "anon read devices" on public.devices;
create policy "anon read raw" on public.gas_logs_raw for select using (true);
create policy "anon read minute" on public.gas_logs_minute for select using (true);
create policy "anon read hour" on public.gas_logs_hour for select using (true);
create policy "anon read devices" on public.devices for select using (true);
create policy "anon read devices" on public.devices for select using (true);

-- FCM push notification tokens registered by the Android app.
-- One row per (device_id, token) pair; token rotations are handled via upsert.
create table if not exists public.fcm_tokens (
id bigserial primary key,
device_id text not null,
token text not null,
last_alerted_at timestamptz,
updated_at timestamptz not null default now(),

constraint fcm_tokens_unique unique (device_id, token)
);

create index if not exists idx_fcm_device on public.fcm_tokens (device_id);

alter table public.fcm_tokens enable row level security;

-- Tokens are write-only from the public side; reads use the service key on the server.
drop policy if exists "deny anon read fcm_tokens" on public.fcm_tokens;
create policy "deny anon read fcm_tokens" on public.fcm_tokens for select using (false);