/opt/canhelp/apps/api/src/lib
NameSizeModeActions
activity.ts13240644editdlrm
apple-client-secret.ts40730644editdlrm
balance.ts35060644editdlrm
daily-limits.ts42820644editdlrm
email.ts527080644editdlrm
fomo.ts113660644editdlrm
google-oauth.ts105260644editdlrm
helper-capability.ts42070644editdlrm
messaging-rules.ts31230644editdlrm
nameUtils.ts7090644editdlrm
notif.ts189290644editdlrm
plan-history.ts10200644editdlrm
push.ts63860644editdlrm
referral.ts61100644editdlrm
telegram.ts62070644editdlrm
turnstile.ts7410644editdlrm
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, } }