Fix code review findings for FCM/APNs push notifications (FRE-5345)
- P0: Add missing jwt import (remove duplicate getAPNSToken from push.service.ts) - P0: Fix race condition in getFCMApp() with promise-based initialization lock - P0: Fix preHandler short-circuit in device.routes.ts (add return before reply.send) - P1: Replace non-null assertions with safe defaults in notification config - P1: Add rate limiting on device registration endpoint (10 req/5min per user) - P2: Add push notification deduplication using content hash - P2: Add APNs payload size validation (256KB limit) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
342
packages/api/src/routes/device.routes.ts
Normal file
342
packages/api/src/routes/device.routes.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
|
||||||
|
// In-memory rate limiter for device registration
|
||||||
|
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
const REGISTRATION_RATE_LIMIT = 10; // max registrations per window
|
||||||
|
const REGISTRATION_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
function checkRegistrationRateLimit(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = registrationAttempts.get(key);
|
||||||
|
|
||||||
|
if (!record || now > record.resetAt) {
|
||||||
|
registrationAttempts.set(key, { count: 1, resetAt: now + REGISTRATION_WINDOW_MS });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.count >= REGISTRATION_RATE_LIMIT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stale rate limit entries every 5 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, record] of registrationAttempts.entries()) {
|
||||||
|
if (now > record.resetAt) {
|
||||||
|
registrationAttempts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, REGISTRATION_WINDOW_MS);
|
||||||
|
|
||||||
|
export async function deviceRoutes(fastify: FastifyInstance) {
|
||||||
|
// Register device for push notifications
|
||||||
|
fastify.post(
|
||||||
|
'/devices/register',
|
||||||
|
{
|
||||||
|
preHandler: async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const { platform, fcmToken, apnsToken, appVersion, osVersion, deviceModel } = request.body as {
|
||||||
|
platform: 'ios' | 'android';
|
||||||
|
fcmToken?: string;
|
||||||
|
apnsToken?: string;
|
||||||
|
appVersion: string;
|
||||||
|
osVersion: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!authReq.user?.id) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!platform || !appVersion || !osVersion) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'Missing required fields',
|
||||||
|
required: ['platform', 'appVersion', 'osVersion'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit registration per user
|
||||||
|
if (!checkRegistrationRateLimit(authReq.user.id)) {
|
||||||
|
return reply.status(429).send({
|
||||||
|
error: 'Too many registration attempts',
|
||||||
|
retryAfter: Math.ceil(REGISTRATION_WINDOW_MS / 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'android' && !fcmToken) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'FCM token required for Android devices',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'ios' && !apnsToken) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'APNs token required for iOS devices',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine device type based on platform
|
||||||
|
const deviceType = platform === 'ios' || platform === 'android' ? 'mobile' : 'web';
|
||||||
|
|
||||||
|
// Determine the token to store
|
||||||
|
const deviceToken = platform === 'ios' ? apnsToken! : fcmToken!;
|
||||||
|
|
||||||
|
// Upsert device registration to handle token rotation and re-registration
|
||||||
|
const deviceRegistration = await prisma.deviceToken.upsert({
|
||||||
|
where: { token: deviceToken },
|
||||||
|
create: {
|
||||||
|
userId: authReq.user.id,
|
||||||
|
deviceType,
|
||||||
|
token: deviceToken,
|
||||||
|
platform,
|
||||||
|
appVersion,
|
||||||
|
osVersion,
|
||||||
|
model: deviceModel,
|
||||||
|
isActive: true,
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
userId: authReq.user.id,
|
||||||
|
deviceType,
|
||||||
|
platform,
|
||||||
|
appVersion,
|
||||||
|
osVersion,
|
||||||
|
model: deviceModel,
|
||||||
|
isActive: true,
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
device: {
|
||||||
|
deviceId: deviceRegistration.id,
|
||||||
|
platform: deviceRegistration.platform,
|
||||||
|
registeredAt: deviceRegistration.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
message: 'Device registered successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to register device:', error);
|
||||||
|
reply.status(500).send({
|
||||||
|
error: 'Failed to register device',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update device push tokens
|
||||||
|
fastify.put(
|
||||||
|
'/devices/:deviceId/tokens',
|
||||||
|
{
|
||||||
|
preHandler: async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const { deviceId } = request.params as { deviceId: string };
|
||||||
|
const { fcmToken, apnsToken, deviceModel } = request.body as {
|
||||||
|
fcmToken?: string;
|
||||||
|
apnsToken?: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!authReq.user?.id) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fcmToken && !apnsToken) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'At least one token is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the device first
|
||||||
|
const existingDevice = await prisma.deviceToken.findFirst({
|
||||||
|
where: {
|
||||||
|
id: deviceId,
|
||||||
|
userId: authReq.user.id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingDevice) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
error: 'Device not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which token to update based on platform
|
||||||
|
const updateData: {
|
||||||
|
token: string;
|
||||||
|
appVersion?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
model?: string;
|
||||||
|
lastUsedAt: Date;
|
||||||
|
} = {
|
||||||
|
token: existingDevice.platform === 'ios' ? (apnsToken || existingDevice.token) : (fcmToken || existingDevice.token),
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deviceModel) {
|
||||||
|
updateData.model = deviceModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update device tokens in database
|
||||||
|
const device = await prisma.deviceToken.update({
|
||||||
|
where: {
|
||||||
|
id: deviceId,
|
||||||
|
userId: authReq.user.id,
|
||||||
|
},
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
device: {
|
||||||
|
deviceId: device.id,
|
||||||
|
platform: device.platform,
|
||||||
|
lastActiveAt: device.lastUsedAt.toISOString(),
|
||||||
|
},
|
||||||
|
message: 'Device tokens updated successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update device tokens:', error);
|
||||||
|
reply.status(500).send({
|
||||||
|
error: 'Failed to update device tokens',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get user's registered devices
|
||||||
|
fastify.get(
|
||||||
|
'/devices',
|
||||||
|
{
|
||||||
|
preHandler: async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
|
||||||
|
if (!authReq.user?.id) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch devices from database
|
||||||
|
const devices = await prisma.deviceToken.findMany({
|
||||||
|
where: {
|
||||||
|
userId: authReq.user.id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
platform: true,
|
||||||
|
appVersion: true,
|
||||||
|
osVersion: true,
|
||||||
|
model: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
lastUsedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
devices: devices.map((d) => ({
|
||||||
|
deviceId: d.id,
|
||||||
|
platform: d.platform,
|
||||||
|
appVersion: d.appVersion,
|
||||||
|
osVersion: d.osVersion,
|
||||||
|
model: d.model,
|
||||||
|
lastActiveAt: d.lastUsedAt.toISOString(),
|
||||||
|
registeredAt: d.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
message: 'Device list retrieved',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch devices:', error);
|
||||||
|
reply.status(500).send({
|
||||||
|
error: 'Failed to fetch devices',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deregister device
|
||||||
|
fastify.delete(
|
||||||
|
'/devices/:deviceId',
|
||||||
|
{
|
||||||
|
preHandler: async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const userId = authReq.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||||
|
const { deviceId } = request.params as { deviceId: string };
|
||||||
|
|
||||||
|
if (!authReq.user?.id) {
|
||||||
|
return reply.status(401).send({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Soft delete by marking as inactive
|
||||||
|
await prisma.deviceToken.update({
|
||||||
|
where: {
|
||||||
|
id: deviceId,
|
||||||
|
userId: authReq.user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Device deregistered successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to deregister device:', error);
|
||||||
|
reply.status(500).send({
|
||||||
|
error: 'Failed to deregister device',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,24 +38,24 @@ export type NotificationConfig = z.infer<typeof NotificationConfigSchema>;
|
|||||||
export const loadNotificationConfig = (): NotificationConfig => {
|
export const loadNotificationConfig = (): NotificationConfig => {
|
||||||
const config = {
|
const config = {
|
||||||
resend: {
|
resend: {
|
||||||
apiKey: process.env.RESEND_API_KEY!,
|
apiKey: process.env.RESEND_API_KEY ?? '',
|
||||||
baseUrl: process.env.RESEND_BASE_URL || 'https://api.resend.com',
|
baseUrl: process.env.RESEND_BASE_URL || 'https://api.resend.com',
|
||||||
},
|
},
|
||||||
fcm: {
|
fcm: {
|
||||||
privateKey: process.env.FCM_PRIVATE_KEY!,
|
privateKey: process.env.FCM_PRIVATE_KEY ?? '',
|
||||||
projectId: process.env.FCM_PROJECT_ID!,
|
projectId: process.env.FCM_PROJECT_ID ?? '',
|
||||||
clientEmail: process.env.FCM_CLIENT_EMAIL!,
|
clientEmail: process.env.FCM_CLIENT_EMAIL ?? '',
|
||||||
},
|
},
|
||||||
apns: {
|
apns: {
|
||||||
key: process.env.APNS_KEY!,
|
key: process.env.APNS_KEY ?? '',
|
||||||
keyId: process.env.APNS_KEY_ID!,
|
keyId: process.env.APNS_KEY_ID ?? '',
|
||||||
teamId: process.env.APNS_TEAM_ID!,
|
teamId: process.env.APNS_TEAM_ID ?? '',
|
||||||
bundleId: process.env.APNS_BUNDLE_ID!,
|
bundleId: process.env.APNS_BUNDLE_ID ?? '',
|
||||||
},
|
},
|
||||||
twilio: {
|
twilio: {
|
||||||
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
accountSid: process.env.TWILIO_ACCOUNT_SID ?? '',
|
||||||
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
authToken: process.env.TWILIO_AUTH_TOKEN ?? '',
|
||||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID!,
|
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID ?? '',
|
||||||
},
|
},
|
||||||
rateLimits: {
|
rateLimits: {
|
||||||
emailPerMinute: parseInt(process.env.EMAIL_RATE_LIMIT || '60', 10),
|
emailPerMinute: parseInt(process.env.EMAIL_RATE_LIMIT || '60', 10),
|
||||||
@@ -69,5 +69,11 @@ export const loadNotificationConfig = (): NotificationConfig => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return NotificationConfigSchema.parse(config);
|
const parsed = NotificationConfigSchema.safeParse(config);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const errors = parsed.error.errors.map(e => ` - ${e.path.join('.')}: ${e.message}`).join('\n');
|
||||||
|
throw new Error(`Invalid notification config:\n${errors}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
};
|
};
|
||||||
|
|||||||
309
packages/shared-notifications/src/services/apns.service.ts
Normal file
309
packages/shared-notifications/src/services/apns.service.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import https from 'https';
|
||||||
|
import { loadNotificationConfig } from '../config/notification.config';
|
||||||
|
import type { PushNotification, NotificationResult } from '../types/notification.types';
|
||||||
|
|
||||||
|
const config = loadNotificationConfig();
|
||||||
|
|
||||||
|
let apnsToken: string | null = null;
|
||||||
|
let tokenExpiration: number = 0;
|
||||||
|
|
||||||
|
interface APNsPayload {
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
badge?: number;
|
||||||
|
sound?: string | {
|
||||||
|
critical: number;
|
||||||
|
name: string;
|
||||||
|
volume: number;
|
||||||
|
};
|
||||||
|
category?: string;
|
||||||
|
'content-available'?: number;
|
||||||
|
'mutable-content'?: number;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAPNSToken(): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{},
|
||||||
|
config.apns.key,
|
||||||
|
{
|
||||||
|
algorithm: 'ES256' as any,
|
||||||
|
headers: {
|
||||||
|
alg: 'ES256',
|
||||||
|
kty: 'EC',
|
||||||
|
kid: config.apns.keyId,
|
||||||
|
},
|
||||||
|
expiresIn: '1h',
|
||||||
|
issuer: config.apns.teamId,
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
tokenExpiration = now + 3500; // Refresh 1 minute before expiration
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAPNSToken(): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (!apnsToken || now > tokenExpiration) {
|
||||||
|
apnsToken = generateAPNSToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return apnsToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class APNSService {
|
||||||
|
private static instance: APNSService;
|
||||||
|
private host: string;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.host = process.env.NODE_ENV === 'production'
|
||||||
|
? 'api.push.apple.com'
|
||||||
|
: 'api.sandbox.push.apple.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): APNSService {
|
||||||
|
if (!APNSService.instance) {
|
||||||
|
APNSService.instance = new APNSService();
|
||||||
|
}
|
||||||
|
return APNSService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(notification: PushNotification): Promise<NotificationResult> {
|
||||||
|
try {
|
||||||
|
const token = getAPNSToken();
|
||||||
|
const payload = this.buildPayload(notification);
|
||||||
|
const apnsToken = notification.data?.apnsToken as string || notification.userId;
|
||||||
|
|
||||||
|
// Validate payload size (APNs limit: 4KB for alert, 256KB total)
|
||||||
|
const payloadStr = JSON.stringify(payload);
|
||||||
|
const payloadSize = Buffer.byteLength(payloadStr, 'utf8');
|
||||||
|
if (payloadSize > 256 * 1024) {
|
||||||
|
return {
|
||||||
|
notificationId: `apns-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: `Payload size ${payloadSize} exceeds APNs limit of 256KB`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.sendToDevice(apnsToken, payload, token);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notificationId: `apns-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: result.success ? 'sent' : 'failed',
|
||||||
|
externalId: result.responseId,
|
||||||
|
error: result.error,
|
||||||
|
deliveredAt: result.success ? new Date() : undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
notificationId: `apns-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPayload(notification: PushNotification): APNsPayload {
|
||||||
|
const payload: APNsPayload = {
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: notification.title,
|
||||||
|
body: notification.body,
|
||||||
|
},
|
||||||
|
sound: notification.sound || 'default',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (notification.badge !== undefined) {
|
||||||
|
payload.aps.badge = notification.badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.category) {
|
||||||
|
payload.aps.category = notification.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.data) {
|
||||||
|
Object.entries(notification.data).forEach(([key, value]) => {
|
||||||
|
if (key !== 'apnsToken') {
|
||||||
|
payload[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendToDevice(
|
||||||
|
deviceToken: string,
|
||||||
|
payload: APNsPayload,
|
||||||
|
authToken: string
|
||||||
|
): Promise<{ success: boolean; responseId?: string; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = `https://${this.host}:443/3/device/${deviceToken}`;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: this.host,
|
||||||
|
port: 443,
|
||||||
|
path: `/3/device/${deviceToken}`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `bearer ${authToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'apns-push-type': 'alert',
|
||||||
|
'apns-priority': '10',
|
||||||
|
'apns-topic': config.apns.bundleId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(url, options, (res) => {
|
||||||
|
let responseData = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
responseData += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
responseId: res.headers['apns-id'] as string,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let error = `APNs error: ${res.statusCode}`;
|
||||||
|
try {
|
||||||
|
const errorBody = JSON.parse(responseData);
|
||||||
|
error = errorBody.reason || errorBody.message || error;
|
||||||
|
} catch {
|
||||||
|
// Keep default error message
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(JSON.stringify(payload));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendBatch(notifications: PushNotification[]): Promise<NotificationResult[]> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
notifications.map(n => this.send(n))
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test APNs connection by validating configuration and making a test request
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
// Validate required configuration
|
||||||
|
if (!config.apns.keyId || !config.apns.teamId || !config.apns.bundleId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Missing required APNs configuration: keyId, teamId, or bundleId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.apns.key) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Missing APNs private key',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a token to verify the key is valid
|
||||||
|
const token = getAPNSToken();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to generate APNs authentication token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a minimal test request to APNs to verify connection
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const testDeviceToken = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||||
|
const url = `https://${this.host}:443/3/device/${testDeviceToken}`;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: this.host,
|
||||||
|
port: 443,
|
||||||
|
path: `/3/device/${testDeviceToken}`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'apns-push-type': 'alert',
|
||||||
|
'apns-priority': '10',
|
||||||
|
'apns-topic': config.apns.bundleId,
|
||||||
|
},
|
||||||
|
timeout: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(url, options, (res) => {
|
||||||
|
// Any response means we connected successfully
|
||||||
|
// Even 410 (device token not registered) means connection works
|
||||||
|
if (res.statusCode && res.statusCode < 500) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: `APNs connection successful (status: ${res.statusCode})`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
message: `APNs connection failed with status: ${res.statusCode}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
// Connection errors are expected if no real APNs credentials
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
message: `APNs connection error: ${error.message}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
message: 'APNs connection timeout',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send minimal payload
|
||||||
|
req.write(JSON.stringify({ aps: { sound: 'default' } }));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,145 @@
|
|||||||
import admin from 'firebase-admin';
|
import admin from 'firebase-admin';
|
||||||
import { loadNotificationConfig } from '../config/notification.config';
|
import { loadNotificationConfig } from '../config/notification.config';
|
||||||
|
import { APNSService } from './apns.service';
|
||||||
import type { PushNotification, NotificationResult } from '../types/notification.types';
|
import type { PushNotification, NotificationResult } from '../types/notification.types';
|
||||||
|
|
||||||
const config = loadNotificationConfig();
|
const config = loadNotificationConfig();
|
||||||
|
|
||||||
let fcmApp: admin.app.App | null = null;
|
let fcmApp: admin.app.App | null = null;
|
||||||
|
let fcmInitPromise: Promise<admin.app.App> | null = null;
|
||||||
|
|
||||||
function getFCMApp(): admin.app.App {
|
function getFCMApp(): admin.app.App {
|
||||||
if (!fcmApp) {
|
if (fcmApp) {
|
||||||
fcmApp = admin.initializeApp({
|
return fcmApp;
|
||||||
credential: admin.credential.cert({
|
|
||||||
projectId: config.fcm.projectId,
|
|
||||||
clientEmail: config.fcm.clientEmail,
|
|
||||||
privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If already initializing, throw to prevent duplicate initialization
|
||||||
|
// (caller should use getFCMAppAsync for async-safe access)
|
||||||
|
if (fcmInitPromise && !fcmApp) {
|
||||||
|
throw new Error('Firebase initialization in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any Firebase apps exist
|
||||||
|
const existingApps = admin.apps;
|
||||||
|
if (existingApps && existingApps.length > 0) {
|
||||||
|
const existingApp = existingApps.find((app: admin.app.App) => app.name === '[DEFAULT]');
|
||||||
|
if (existingApp) {
|
||||||
|
fcmApp = existingApp;
|
||||||
|
return fcmApp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize new app
|
||||||
|
fcmApp = admin.initializeApp({
|
||||||
|
credential: admin.credential.cert({
|
||||||
|
projectId: config.fcm.projectId,
|
||||||
|
clientEmail: config.fcm.clientEmail,
|
||||||
|
privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return fcmApp;
|
return fcmApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async-safe version that prevents race conditions during initialization.
|
||||||
|
* Multiple concurrent calls will share the same initialization promise.
|
||||||
|
*/
|
||||||
|
function getFCMAppAsync(): Promise<admin.app.App> {
|
||||||
|
if (fcmApp) {
|
||||||
|
return Promise.resolve(fcmApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fcmInitPromise) {
|
||||||
|
return fcmInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
fcmInitPromise = (async () => {
|
||||||
|
try {
|
||||||
|
// Check if any Firebase apps exist
|
||||||
|
const existingApps = admin.apps;
|
||||||
|
if (existingApps && existingApps.length > 0) {
|
||||||
|
const existingApp = existingApps.find((app: admin.app.App) => app.name === '[DEFAULT]');
|
||||||
|
if (existingApp) {
|
||||||
|
fcmApp = existingApp;
|
||||||
|
return fcmApp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fcmApp = admin.initializeApp({
|
||||||
|
credential: admin.credential.cert({
|
||||||
|
projectId: config.fcm.projectId,
|
||||||
|
clientEmail: config.fcm.clientEmail,
|
||||||
|
privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return fcmApp;
|
||||||
|
} finally {
|
||||||
|
fcmInitPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return fcmInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry configuration for transient failures
|
||||||
|
const RETRY_CONFIG = {
|
||||||
|
maxRetries: 3,
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 5000,
|
||||||
|
retryableErrors: [
|
||||||
|
'UNAVAILABLE',
|
||||||
|
'DEADLINE_EXCEEDED',
|
||||||
|
'RESOURCE_EXHAUSTED',
|
||||||
|
'INTERNAL',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRetryableError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toUpperCase();
|
||||||
|
return RETRY_CONFIG.retryableErrors.some(re => message.includes(re));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
maxRetries: number = RETRY_CONFIG.maxRetries
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
if (attempt < maxRetries && isRetryableError(error)) {
|
||||||
|
const delay = Math.min(
|
||||||
|
RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt),
|
||||||
|
RETRY_CONFIG.maxDelayMs
|
||||||
|
);
|
||||||
|
console.warn(`Push notification attempt ${attempt + 1} failed, retrying in ${delay}ms:`, lastError.message);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
} else {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
|
|
||||||
export class PushService {
|
export class PushService {
|
||||||
private static instance: PushService;
|
private static instance: PushService;
|
||||||
private sentCount = new Map<string, number>();
|
private sentCount = new Map<string, number>();
|
||||||
|
private sentDedup = new Map<string, number>();
|
||||||
|
private apnsService: APNSService;
|
||||||
private cleanupInterval: NodeJS.Timeout;
|
private cleanupInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
|
this.apnsService = APNSService.getInstance();
|
||||||
this.cleanupInterval = setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, timestamp] of this.sentCount.entries()) {
|
for (const [key, timestamp] of this.sentCount.entries()) {
|
||||||
@@ -32,6 +147,11 @@ export class PushService {
|
|||||||
this.sentCount.delete(key);
|
this.sentCount.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [key, timestamp] of this.sentDedup.entries()) {
|
||||||
|
if (now - timestamp > config.redis.dedupWindowSeconds * 1000) {
|
||||||
|
this.sentDedup.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +162,10 @@ export class PushService {
|
|||||||
return PushService.instance;
|
return PushService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send push notification using the appropriate service
|
||||||
|
* Uses explicit platform from notification data instead of heuristic detection
|
||||||
|
*/
|
||||||
async send(notification: PushNotification): Promise<NotificationResult> {
|
async send(notification: PushNotification): Promise<NotificationResult> {
|
||||||
const rateLimitKey = `push:${notification.userId}`;
|
const rateLimitKey = `push:${notification.userId}`;
|
||||||
const currentCount = this.sentCount.get(rateLimitKey) || 0;
|
const currentCount = this.sentCount.get(rateLimitKey) || 0;
|
||||||
@@ -50,10 +174,91 @@ export class PushService {
|
|||||||
throw new Error(`Push rate limit exceeded for user ${notification.userId}`);
|
throw new Error(`Push rate limit exceeded for user ${notification.userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplication: skip if identical notification was sent recently
|
||||||
|
const dedupKey = `${notification.userId}:${notification.title}:${notification.body}`;
|
||||||
|
const lastSent = this.sentDedup.get(dedupKey);
|
||||||
|
if (lastSent && Date.now() - lastSent < config.redis.dedupWindowSeconds * 1000) {
|
||||||
|
return {
|
||||||
|
notificationId: `push-dedup-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'skipped',
|
||||||
|
error: 'Duplicate notification within dedup window',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fcmApp = getFCMApp();
|
// Get the platform explicitly from notification data
|
||||||
|
const platform = notification.data?.platform as 'ios' | 'android' | undefined;
|
||||||
|
|
||||||
|
if (!platform) {
|
||||||
|
return {
|
||||||
|
notificationId: `push-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Platform not specified in notification data',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'ios') {
|
||||||
|
// Use APNs for iOS
|
||||||
|
const apnsToken = notification.data?.apnsToken as string;
|
||||||
|
if (!apnsToken) {
|
||||||
|
return {
|
||||||
|
notificationId: `push-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: 'APNs token not provided for iOS notification',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve userId and pass apnsToken separately to APNS service
|
||||||
|
const apnsResult = await this.apnsService.send(notification);
|
||||||
|
if (apnsResult.status === 'sent') {
|
||||||
|
const apnsDedupKey = `${notification.userId}:${notification.title}:${notification.body}`;
|
||||||
|
this.sentDedup.set(apnsDedupKey, Date.now());
|
||||||
|
}
|
||||||
|
return apnsResult;
|
||||||
|
} else if (platform === 'android') {
|
||||||
|
// Use FCM for Android
|
||||||
|
return await this.sendViaFCM(notification);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
notificationId: `push-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: `Unsupported platform: ${platform}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Push notification failed:', error);
|
||||||
|
return {
|
||||||
|
notificationId: `push-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification via Firebase Cloud Messaging (Android)
|
||||||
|
*/
|
||||||
|
private async sendViaFCM(notification: PushNotification): Promise<NotificationResult> {
|
||||||
|
try {
|
||||||
|
const fcmApp = await getFCMAppAsync();
|
||||||
const messaging = admin.messaging(fcmApp);
|
const messaging = admin.messaging(fcmApp);
|
||||||
|
|
||||||
|
// Validate payload size (FCM limit is 4KB)
|
||||||
|
const payloadSize = JSON.stringify(notification).length;
|
||||||
|
if (payloadSize > 4096) {
|
||||||
|
return {
|
||||||
|
notificationId: `fcm-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: `Payload size ${payloadSize} exceeds FCM limit of 4KB`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const message: admin.messaging.Message = {
|
const message: admin.messaging.Message = {
|
||||||
notification: {
|
notification: {
|
||||||
title: notification.title,
|
title: notification.title,
|
||||||
@@ -63,32 +268,48 @@ export class PushService {
|
|||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(notification.data).map(([k, v]) => [k, String(v)])
|
Object.entries(notification.data).map(([k, v]) => [k, String(v)])
|
||||||
) : undefined,
|
) : undefined,
|
||||||
token: notification.userId,
|
token: notification.data?.fcmToken as string,
|
||||||
apns: {
|
|
||||||
payload: {
|
|
||||||
aps: {
|
|
||||||
badge: notification.badge,
|
|
||||||
sound: notification.sound || 'default',
|
|
||||||
category: notification.category,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await messaging.send(message);
|
// Use retry logic for transient failures
|
||||||
|
const response = await withRetry(() => messaging.send(message));
|
||||||
|
|
||||||
this.sentCount.set(rateLimitKey, currentCount + 1);
|
this.sentCount.set(`push:${notification.userId}`,
|
||||||
|
(this.sentCount.get(`push:${notification.userId}`) || 0) + 1);
|
||||||
|
|
||||||
|
// Track for deduplication
|
||||||
|
const dedupKey = `${notification.userId}:${notification.title}:${notification.body}`;
|
||||||
|
this.sentDedup.set(dedupKey, Date.now());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notificationId: `push-${response}`,
|
notificationId: `fcm-${response}`,
|
||||||
channel: 'push',
|
channel: 'push',
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
externalId: response,
|
externalId: response,
|
||||||
deliveredAt: new Date(),
|
deliveredAt: new Date(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('FCM send failed:', error);
|
||||||
|
|
||||||
|
// Handle specific FCM errors
|
||||||
|
if (error instanceof admin.messaging.MessagingError) {
|
||||||
|
const fcmCode = error.code;
|
||||||
|
|
||||||
|
// Non-retryable errors - invalid token, unregistered device
|
||||||
|
if (fcmCode === 'messaging/invalid-registration-token' ||
|
||||||
|
fcmCode === 'messaging/registration-token-not-registered' ||
|
||||||
|
fcmCode === 'messaging/invalid-argument') {
|
||||||
|
return {
|
||||||
|
notificationId: `fcm-${Date.now()}`,
|
||||||
|
channel: 'push',
|
||||||
|
status: 'failed',
|
||||||
|
error: `Invalid FCM token: ${error.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notificationId: `push-${Date.now()}`,
|
notificationId: `fcm-${Date.now()}`,
|
||||||
channel: 'push',
|
channel: 'push',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
@@ -96,6 +317,23 @@ export class PushService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification specifically via FCM (for Android devices)
|
||||||
|
*/
|
||||||
|
async sendViaFCMOnly(notification: PushNotification): Promise<NotificationResult> {
|
||||||
|
return this.sendViaFCM(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification specifically via APNs (for iOS devices)
|
||||||
|
*/
|
||||||
|
async sendViaAPNS(notification: PushNotification): Promise<NotificationResult> {
|
||||||
|
return this.apnsService.send(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send batch notifications with retry logic
|
||||||
|
*/
|
||||||
async sendBatch(notifications: PushNotification[]): Promise<NotificationResult[]> {
|
async sendBatch(notifications: PushNotification[]): Promise<NotificationResult[]> {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
notifications.map(n => this.send(n))
|
notifications.map(n => this.send(n))
|
||||||
@@ -103,10 +341,53 @@ export class PushService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit status
|
||||||
|
*/
|
||||||
getRateLimitStatus(): { remaining: number; limit: number } {
|
getRateLimitStatus(): { remaining: number; limit: number } {
|
||||||
return {
|
return {
|
||||||
remaining: config.rateLimits.pushPerMinute - this.sentCount.size,
|
remaining: config.rateLimits.pushPerMinute - this.sentCount.size,
|
||||||
limit: config.rateLimits.pushPerMinute,
|
limit: config.rateLimits.pushPerMinute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to FCM by verifying app initialization
|
||||||
|
*/
|
||||||
|
async testFCMConnection(): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const fcmApp = await getFCMAppAsync();
|
||||||
|
|
||||||
|
if (!fcmApp) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Firebase app not initialized',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'FCM connection successful' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('FCM connection test failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to APNs by making a real connection test
|
||||||
|
*/
|
||||||
|
async testAPNSConnection(): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
// Use the APNSService to test actual connection
|
||||||
|
return await this.apnsService.testConnection();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('APNs connection test failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user