Allow background sound notification
This commit is contained in:
parent
6ccf820438
commit
9cc99e612a
|
|
@ -8,7 +8,7 @@
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:label="tasq"
|
android:label="tasq"
|
||||||
android:name="${applicationName}"
|
android:name=".App"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
android:value="tasq_high_importance_channel" />
|
android:value="tasq_custom_sound_channel_2" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
|
||||||
32
android/app/src/main/kotlin/com/example/tasq/App.kt
Normal file
32
android/app/src/main/kotlin/com/example/tasq/App.kt
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.example.tasq
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
class App : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createTasqChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTasqChannel() {
|
||||||
|
val channelId = "tasq_custom_sound_channel"
|
||||||
|
val name = "TasQ notifications"
|
||||||
|
val importance = NotificationManager.IMPORTANCE_HIGH
|
||||||
|
val channel = NotificationChannel(channelId, name, importance)
|
||||||
|
|
||||||
|
val soundUri = Uri.parse("android.resource://$packageName/raw/tasq_notification")
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
channel.setSound(soundUri, audioAttributes)
|
||||||
|
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm?.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
android/app/src/main/res/raw/keep.xml
Normal file
3
android/app/src/main/res/raw/keep.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:keep="@raw/tasq_notification" />
|
||||||
BIN
android/app/src/main/res/raw/tasq_notification.wav
Normal file
BIN
android/app/src/main/res/raw/tasq_notification.wav
Normal file
Binary file not shown.
|
|
@ -11,23 +11,50 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'providers/profile_provider.dart';
|
import 'providers/profile_provider.dart';
|
||||||
import 'models/profile.dart';
|
import 'models/profile.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
|
||||||
import 'providers/notifications_provider.dart';
|
import 'providers/notifications_provider.dart';
|
||||||
import 'utils/app_time.dart';
|
import 'utils/app_time.dart';
|
||||||
import 'utils/notification_permission.dart';
|
import 'utils/notification_permission.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/notification_bridge.dart';
|
import 'services/notification_bridge.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
// Initialize the plugin
|
||||||
|
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
/// Handle messages received while the app is terminated or in background.
|
/// Handle messages received while the app is terminated or in background.
|
||||||
|
@pragma('vm:entry-point') // Required for background execution
|
||||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
// Do minimal work in background isolate. Avoid initializing Flutter
|
// 1. Initialize the plugin inside the background isolate
|
||||||
// plugins here (e.g. flutter_local_notifications) as that can hang
|
final FlutterLocalNotificationsPlugin localNotifPlugin =
|
||||||
// the background isolate and interfere with subsequent launches.
|
FlutterLocalNotificationsPlugin();
|
||||||
// If you need to persist data from a background message, perform a
|
|
||||||
// lightweight HTTP call or write to a background-capable DB.
|
// 2. Extract title and body from the DATA payload (not message.notification)
|
||||||
return;
|
final String title = message.data['title'] ?? 'New Notification';
|
||||||
|
final String body = message.data['body'] ?? 'You have a new update in TasQ.';
|
||||||
|
|
||||||
|
// Create a unique ID
|
||||||
|
final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
// 3. Define the exact same channel specifics
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
'tasq_custom_sound_channel_2',
|
||||||
|
'High Importance Notifications',
|
||||||
|
importance: Importance.max,
|
||||||
|
priority: Priority.high,
|
||||||
|
playSound: true,
|
||||||
|
sound: RawResourceAndroidNotificationSound('tasq_notification'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Show the notification manually
|
||||||
|
await localNotifPlugin.show(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: const NotificationDetails(android: androidDetails),
|
||||||
|
payload: message.data['type'], // Or whatever payload you need for routing
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
|
|
@ -97,6 +124,7 @@ Future<void> main() async {
|
||||||
// request FCM permission (iOS/Android13+) and handle foreground messages
|
// request FCM permission (iOS/Android13+) and handle foreground messages
|
||||||
await FirebaseMessaging.instance.requestPermission();
|
await FirebaseMessaging.instance.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||||
final notification = message.notification;
|
final notification = message.notification;
|
||||||
if (notification != null) {
|
if (notification != null) {
|
||||||
|
|
@ -122,6 +150,25 @@ Future<void> main() async {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 1. Define the High Importance Channel (This MUST match your manifest exactly)
|
||||||
|
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||||
|
'tasq_custom_sound_channel', // id
|
||||||
|
'High Importance Notifications', // title visible to user in phone settings
|
||||||
|
description: 'This channel is used for important TasQ notifications.',
|
||||||
|
importance:
|
||||||
|
Importance.max, // THIS is what forces the sound and heads-up banner
|
||||||
|
playSound: true,
|
||||||
|
// 👇 Tell Android to use your specific file in the raw folder
|
||||||
|
sound: RawResourceAndroidNotificationSound('tasq_notification'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Create the channel on the device
|
||||||
|
await flutterLocalNotificationsPlugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>()
|
||||||
|
?.createNotificationChannel(channel);
|
||||||
|
|
||||||
// global navigator key used for snackbars/navigation from notification
|
// global navigator key used for snackbars/navigation from notification
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ class NotificationService {
|
||||||
|
|
||||||
static final FlutterLocalNotificationsPlugin _plugin =
|
static final FlutterLocalNotificationsPlugin _plugin =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
static const String _channelId = 'tasq_default_channel';
|
||||||
|
static const String _channelName = 'General';
|
||||||
|
static const String _highChannelId = 'tasq_custom_sound_channel_2';
|
||||||
|
static const String _highChannelName = 'High Priority';
|
||||||
|
|
||||||
/// Call during app startup, after any necessary permissions have been
|
/// Call during app startup, after any necessary permissions have been
|
||||||
/// granted. The callback receives a [NotificationResponse] when the user
|
/// granted. The callback receives a [NotificationResponse] when the user
|
||||||
|
|
@ -19,6 +23,38 @@ class NotificationService {
|
||||||
);
|
);
|
||||||
const iosSettings = DarwinInitializationSettings();
|
const iosSettings = DarwinInitializationSettings();
|
||||||
|
|
||||||
|
// Ensure the Android notification channel exists with sound and vibration
|
||||||
|
final androidImpl = _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
if (androidImpl != null) {
|
||||||
|
const channel = AndroidNotificationChannel(
|
||||||
|
_channelId,
|
||||||
|
_channelName,
|
||||||
|
description: 'General notifications for TasQ',
|
||||||
|
importance: Importance.defaultImportance,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
);
|
||||||
|
const highChannel = AndroidNotificationChannel(
|
||||||
|
_highChannelId,
|
||||||
|
_highChannelName,
|
||||||
|
description: 'High priority notifications (sound + vibration)',
|
||||||
|
importance: Importance.max,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
// 👇 Tell Android to use your specific file in the raw folder
|
||||||
|
sound: RawResourceAndroidNotificationSound('tasq_notification'),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await androidImpl.createNotificationChannel(channel);
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await androidImpl.createNotificationChannel(highChannel);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
await _plugin.initialize(
|
await _plugin.initialize(
|
||||||
settings: const InitializationSettings(
|
settings: const InitializationSettings(
|
||||||
android: androidSettings,
|
android: androidSettings,
|
||||||
|
|
@ -36,12 +72,17 @@ class NotificationService {
|
||||||
String? payload,
|
String? payload,
|
||||||
}) async {
|
}) async {
|
||||||
const androidDetails = AndroidNotificationDetails(
|
const androidDetails = AndroidNotificationDetails(
|
||||||
'default_channel',
|
_highChannelId,
|
||||||
'General',
|
_highChannelName,
|
||||||
importance: Importance.high,
|
importance: Importance.max,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
sound: RawResourceAndroidNotificationSound('tasq_notification'),
|
||||||
|
);
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
sound: 'tasq_notification.wav',
|
||||||
);
|
);
|
||||||
const iosDetails = DarwinNotificationDetails();
|
|
||||||
await _plugin.show(
|
await _plugin.show(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
|
|
|
||||||
|
|
@ -1,141 +1,141 @@
|
||||||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||||
import { JWT } from 'npm:google-auth-library@9';
|
import { JWT } from 'npm:google-auth-library@9'
|
||||||
|
|
||||||
// we no longer use google-auth-library or a service account; instead
|
// CRITICAL: We MUST use the static file import to prevent the 546 CPU crash
|
||||||
// send FCM via a legacy server key provided as an environment variable
|
import serviceAccount from '../service-account.json' with { type: 'json' }
|
||||||
// (FCM_SERVER_KEY). This avoids compatibility problems on the edge.
|
|
||||||
|
|
||||||
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
|
interface Notification {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
type: string
|
||||||
|
ticket_id: string | null
|
||||||
|
task_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookPayload {
|
||||||
|
type: 'INSERT'
|
||||||
|
table: string
|
||||||
|
record: Notification
|
||||||
|
schema: 'public'
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
Deno.env.get('SUPABASE_URL')!,
|
||||||
|
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||||
|
)
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
try {
|
try {
|
||||||
await supabase.from('send_fcm_errors').insert({ payload: null, error: 'step1', stack: null });
|
const payload: WebhookPayload = await req.json()
|
||||||
const body = await req.json();
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: body, error: 'step2', stack: null }); } catch (_) {}
|
|
||||||
|
|
||||||
// gather tokens (same as before)
|
// 1. Get all tokens for this user
|
||||||
let tokens: any[] = [];
|
const { data: tokenData, error } = await supabase
|
||||||
if (Array.isArray(body.tokens)) {
|
|
||||||
tokens = body.tokens;
|
|
||||||
} else if (Array.isArray(body.user_ids)) {
|
|
||||||
try {
|
|
||||||
const { data: rows } = await supabase
|
|
||||||
.from('fcm_tokens')
|
.from('fcm_tokens')
|
||||||
.select('token')
|
.select('token')
|
||||||
.in('user_id', body.user_ids);
|
.eq('user_id', payload.record.user_id)
|
||||||
if (rows) tokens = rows.map((r: any) => r.token);
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4a', stack: null }); } catch (_) {}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
if (body.record && body.record.user_id) {
|
|
||||||
try {
|
|
||||||
const { data: rows } = await supabase
|
|
||||||
.from('fcm_tokens')
|
|
||||||
.select('token')
|
|
||||||
.eq('user_id', body.record.user_id);
|
|
||||||
if (rows) tokens = rows.map((r: any) => r.token);
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4b', stack: null }); } catch (_) {}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: tokens, error: 'step3', stack: null }); } catch (_) {}
|
|
||||||
|
|
||||||
// build notification text
|
if (error || !tokenData || tokenData.length === 0) {
|
||||||
let notificationTitle = '';
|
console.log('No active FCM tokens found for user:', payload.record.user_id)
|
||||||
let notificationBody = '';
|
return new Response('No tokens', { status: 200 })
|
||||||
if (body.record) {
|
|
||||||
notificationTitle = `New ${body.record.type}`;
|
|
||||||
notificationBody = 'You have a new update in TasQ.';
|
|
||||||
if (body.record.ticket_id) {
|
|
||||||
notificationBody = 'You have a new update on your ticket.';
|
|
||||||
} else if (body.record.task_id) {
|
|
||||||
notificationBody = 'You have a new update on your task.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: {title: notificationTitle, body: notificationBody}, error: 'step5', stack: null }); } catch (_) {}
|
|
||||||
|
|
||||||
// Use Firebase HTTP v1 API with service account JSON (recommended).
|
|
||||||
const serviceAccountStr = Deno.env.get('FIREBASE_SERVICE_ACCOUNT_JSON');
|
|
||||||
if (!serviceAccountStr) {
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: null, error: 'missing_service_account', stack: null }); } catch (_) {}
|
|
||||||
} else {
|
|
||||||
let serviceAccount: any = null;
|
|
||||||
try {
|
|
||||||
serviceAccount = JSON.parse(serviceAccountStr);
|
|
||||||
} catch (e) {
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'service_account_parse_error', stack: null }); } catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serviceAccount && tokens.length) {
|
// 2. Auth with Google using the statically loaded JSON
|
||||||
// helper to get short-lived OAuth2 access token
|
const accessToken = await getAccessToken({
|
||||||
const getAccessToken = async () => {
|
clientEmail: serviceAccount.client_email,
|
||||||
const jwtClient = new JWT({
|
privateKey: serviceAccount.private_key,
|
||||||
email: serviceAccount.client_email,
|
})
|
||||||
key: serviceAccount.private_key,
|
|
||||||
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
|
|
||||||
});
|
|
||||||
const tokens = await jwtClient.authorize();
|
|
||||||
return tokens.access_token as string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const accessToken = await getAccessToken().catch(async (e) => {
|
// 3. Build the notification text
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'access_token_error', stack: null }); } catch (_) {}
|
const notificationTitle = `New ${payload.record.type}`
|
||||||
return undefined;
|
let notificationBody = 'You have a new update in TasQ.'
|
||||||
});
|
if (payload.record.ticket_id) {
|
||||||
|
notificationBody = 'You have a new update on your ticket.'
|
||||||
|
} else if (payload.record.task_id) {
|
||||||
|
notificationBody = 'You have a new update on your task.'
|
||||||
|
}
|
||||||
|
|
||||||
if (!accessToken) {
|
// 4. Send to all devices concurrently
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: null, error: 'no_access_token', stack: null }); } catch (_) {}
|
const sendPromises = tokenData.map(async (row) => {
|
||||||
} else {
|
const res = await fetch(
|
||||||
const projectId = serviceAccount.project_id;
|
`https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,
|
||||||
const fcmEndpoint = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;
|
{
|
||||||
|
|
||||||
// send concurrently per token (HTTP v1 doesn't support registration_ids)
|
|
||||||
const sendPromises = tokens.map(async (token) => {
|
|
||||||
const payload = {
|
|
||||||
message: {
|
|
||||||
token: token,
|
|
||||||
notification: { title: notificationTitle, body: notificationBody },
|
|
||||||
data: body.data || {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(fcmEndpoint, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({
|
||||||
});
|
message: {
|
||||||
const json = await res.json().catch(() => null);
|
token: row.token,
|
||||||
|
// ❌ REMOVED the top-level 'notification' block entirely!
|
||||||
// Log result for debugging
|
data: {
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: json ?? { status: res.status }, error: 'send_fcm_result', stack: null }); } catch (_) {}
|
title: notificationTitle, // ✅ Moved title here
|
||||||
|
body: notificationBody, // ✅ Moved body here
|
||||||
// Token invalidation handling
|
notification_id: payload.record.id,
|
||||||
if (!res.ok && json && json.error) {
|
type: payload.record.type,
|
||||||
// Example detail: json.error.details?.[0]?.errorCode === 'UNREGISTERED'
|
ticket_id: payload.record.ticket_id || '',
|
||||||
const detailCode = json.error.details && Array.isArray(json.error.details) && json.error.details[0] ? json.error.details[0].errorCode : undefined;
|
task_id: payload.record.task_id || '',
|
||||||
const status = json.error.status || json.error.code || res.status;
|
},
|
||||||
if (detailCode === 'UNREGISTERED' || status === 'NOT_FOUND' || status === 404) {
|
// Android priority (keep this to wake the device)
|
||||||
try { await supabase.from('fcm_tokens').delete().eq('token', token); } catch (_) {}
|
android: {
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: { token, error: detailCode || status }, error: 'removed_dead_token', stack: null }); } catch (_) {}
|
priority: 'high',
|
||||||
|
},
|
||||||
|
// iOS must STILL use the apns block for background processing
|
||||||
|
apns: {
|
||||||
|
payload: {
|
||||||
|
aps: {
|
||||||
|
sound: 'tasq_notification.wav',
|
||||||
|
contentAvailable: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const resData = await res.json()
|
||||||
|
|
||||||
|
// 5. Automatic Cleanup: If Firebase says the token is dead, delete it from the DB
|
||||||
|
if (!res.ok && resData.error?.details?.[0]?.errorCode === 'UNREGISTERED') {
|
||||||
|
console.log(`Dead token detected. Removing from DB: ${row.token}`)
|
||||||
|
await supabase.from('fcm_tokens').delete().eq('token', row.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, status: res.status, response: json };
|
return { token: row.token, status: res.status, response: resData }
|
||||||
} catch (e) {
|
})
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'send_exception', stack: null }); } catch (_) {}
|
|
||||||
return { token, status: 0, response: String(e) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for send results but don't fail the function if some fail
|
const results = await Promise.all(sendPromises)
|
||||||
const results = await Promise.all(sendPromises);
|
|
||||||
try { await supabase.from('send_fcm_errors').insert({ payload: results, error: 'send_batch_complete', stack: null }); } catch (_) {}
|
return new Response(JSON.stringify(results), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('FCM Error:', err)
|
||||||
|
return new Response(JSON.stringify({ error: String(err) }), { status: 500 })
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// JWT helper
|
||||||
|
const getAccessToken = ({
|
||||||
|
clientEmail,
|
||||||
|
privateKey,
|
||||||
|
}: {
|
||||||
|
clientEmail: string
|
||||||
|
privateKey: string
|
||||||
|
}): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const jwtClient = new JWT({
|
||||||
|
email: clientEmail,
|
||||||
|
key: privateKey,
|
||||||
|
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
|
||||||
|
})
|
||||||
|
jwtClient.authorize((err, tokens) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
resolve(tokens!.access_token!)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
|
||||||
return new Response('ok', { headers: { 'Access-Control-Allow-Origin': '*' } });
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user