/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/push.ts (6386B)
import { and, eq, inArray } from 'drizzle-orm'
import { google } from 'googleapis'
import { db } from '../db.js'
import { pushTokens } from '@canhelp/db'
type PushPayload = {
title: string
body?: string | null
data?: Record
}
type PushResult = {
sent: number
invalidTokens: string[]
tokensFound: number
reason?: 'fcm_not_configured' | 'fcm_auth_failed' | 'no_tokens' | 'fcm_send_failed'
failureKey?: string
failureMessage?: string
}
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
type ServiceAccountConfig = {
projectId: string
clientEmail: string
privateKey: string
}
let cachedAccessToken: { token: string; expiresAt: number } | null = null
function getServiceAccountConfig(): ServiceAccountConfig | null {
const projectId =
process.env.FCM_PROJECT_ID?.trim() ||
process.env.FIREBASE_PROJECT_ID?.trim() ||
''
const clientEmail = process.env.FCM_CLIENT_EMAIL?.trim() || ''
const privateKeyRaw = process.env.FCM_PRIVATE_KEY || ''
const privateKey = privateKeyRaw.replace(/\\n/g, '\n').trim()
if (!projectId || !clientEmail || !privateKey) return null
return { projectId, clientEmail, privateKey }
}
async function getAccessToken(cfg: ServiceAccountConfig): Promise {
const now = Date.now()
if (cachedAccessToken && cachedAccessToken.expiresAt - now > 60_000) {
return cachedAccessToken.token
}
const jwt = new google.auth.JWT({
email: cfg.clientEmail,
key: cfg.privateKey,
scopes: [FCM_SCOPE],
})
const token = await jwt.authorize()
if (!token.access_token) return null
cachedAccessToken = {
token: token.access_token,
expiresAt: token.expiry_date ?? now + 50 * 60 * 1000,
}
return token.access_token
}
function isInvalidTokenError(errorBody: unknown): boolean {
if (!errorBody || typeof errorBody !== 'object') return false
const err = (errorBody as { error?: unknown }).error
if (!err || typeof err !== 'object') return false
const status = (err as { status?: string }).status
const details = (err as { details?: unknown }).details
if (status === 'UNREGISTERED') return true
if (!Array.isArray(details)) return status === 'INVALID_ARGUMENT'
for (const d of details) {
if (!d || typeof d !== 'object') continue
const code = (d as { errorCode?: string }).errorCode
if (code === 'UNREGISTERED' || code === 'INVALID_ARGUMENT') return true
}
return status === 'INVALID_ARGUMENT'
}
function extractFcmFailure(errorBody: unknown, httpStatus: number, raw: string): { key: string; message: string } {
if (!errorBody || typeof errorBody !== 'object') {
return { key: `HTTP_${httpStatus}`, message: raw || `FCM request failed with HTTP ${httpStatus}` }
}
const err = (errorBody as { error?: unknown }).error
if (!err || typeof err !== 'object') {
return { key: `HTTP_${httpStatus}`, message: raw || `FCM request failed with HTTP ${httpStatus}` }
}
const status = (err as { status?: string }).status?.trim() || ''
const message = (err as { message?: string }).message?.trim() || raw || `FCM request failed with HTTP ${httpStatus}`
const details = (err as { details?: unknown }).details
if (Array.isArray(details)) {
for (const d of details) {
if (!d || typeof d !== 'object') continue
const code = (d as { errorCode?: string }).errorCode?.trim()
if (code) return { key: code, message }
}
}
if (status) return { key: status, message }
return { key: `HTTP_${httpStatus}`, message }
}
export async function sendPushToUser(
userId: string,
payload: PushPayload,
): Promise {
const serviceCfg = getServiceAccountConfig()
if (!serviceCfg) {
return {
sent: 0,
invalidTokens: [],
tokensFound: 0,
reason: 'fcm_not_configured',
}
}
const accessToken = await getAccessToken(serviceCfg)
if (!accessToken) {
console.error('[push] failed to get FCM access token')
return {
sent: 0,
invalidTokens: [],
tokensFound: 0,
reason: 'fcm_auth_failed',
}
}
const fcmEndpoint = `https://fcm.googleapis.com/v1/projects/${serviceCfg.projectId}/messages:send`
const tokensRows = await db
.select({ token: pushTokens.token })
.from(pushTokens)
.where(eq(pushTokens.userId, userId))
if (tokensRows.length === 0) {
return {
sent: 0,
invalidTokens: [],
tokensFound: 0,
reason: 'no_tokens',
}
}
const tokens = tokensRows.map((r) => r.token).filter((t) => t.length > 0)
const invalidTokens: string[] = []
let sent = 0
let failureKey: string | undefined
let failureMessage: string | undefined
for (const token of tokens) {
try {
const res = await fetch(fcmEndpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token,
notification: {
title: payload.title,
body: payload.body ?? undefined,
},
data: payload.data ?? {},
},
}),
})
const raw = await res.text()
let parsed: unknown = null
try {
parsed = raw ? (JSON.parse(raw) as unknown) : null
} catch (_) {}
if (!res.ok) {
console.error('[push] fcm error', res.status, raw)
if (isInvalidTokenError(parsed)) {
invalidTokens.push(token)
} else if (!failureKey) {
const failure = extractFcmFailure(parsed, res.status, raw)
failureKey = failure.key
failureMessage = failure.message
}
continue
}
sent += 1
} catch (err) {
console.error('[push] request failed', err)
if (!failureKey) {
failureKey = 'REQUEST_FAILED'
failureMessage = err instanceof Error ? err.message : 'FCM request failed before response'
}
}
}
if (invalidTokens.length > 0) {
await db
.delete(pushTokens)
.where(
and(
eq(pushTokens.userId, userId),
inArray(pushTokens.token, invalidTokens),
),
)
}
const reason = sent === 0 ? 'fcm_send_failed' : undefined
return {
sent,
invalidTokens,
tokensFound: tokens.length,
reason,
failureKey,
failureMessage,
}
}