Nicholai 861cf51d8d
feat(native): Capacitor mobile app for iOS + Android (#50)
* feat(native): Capacitor mobile app shell with native features

Adds iOS + Android native app via Capacitor WebView wrapper
pointing at the live deployment. Includes push notifications,
biometric auth, camera with offline photo queue, offline
detection, status bar theming, keyboard handling, and deep
linking. Zero server-side refactoring required -- web deploys
update the app instantly.

* docs(native): add developer documentation for iOS and Android

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-07 02:06:59 -07:00

103 lines
2.4 KiB
TypeScript
Executable File

// Push notification sender via FCM HTTP v1 API.
// Works from Cloudflare Workers (no Firebase SDK needed).
import { getDb } from "@/db"
import { pushTokens } from "@/db/schema"
import { eq } from "drizzle-orm"
type PushPayload = Readonly<{
userId: string
title: string
body: string
data?: Readonly<Record<string, string>>
}>
type FcmMessage = {
message: {
token: string
notification: { title: string; body: string }
data?: Record<string, string>
android?: { priority: string }
apns?: {
payload: { aps: { sound: string; badge: number } }
}
}
}
export async function sendPushNotification(
d1: D1Database,
fcmServerKey: string,
payload: PushPayload,
): Promise<{ sent: number; failed: number }> {
const db = getDb(d1)
const tokens = await db
.select()
.from(pushTokens)
.where(eq(pushTokens.userId, payload.userId))
if (tokens.length === 0) {
return { sent: 0, failed: 0 }
}
let sent = 0
let failed = 0
const results = await Promise.allSettled(
tokens.map(async (t) => {
const message: FcmMessage = {
message: {
token: t.token,
notification: {
title: payload.title,
body: payload.body,
},
data: payload.data
? { ...payload.data }
: undefined,
android: { priority: "high" },
apns: {
payload: {
aps: { sound: "default", badge: 1 },
},
},
},
}
const response = await fetch(
"https://fcm.googleapis.com/v1/projects/-/messages:send",
{
method: "POST",
headers: {
Authorization: `Bearer ${fcmServerKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(message),
},
)
if (!response.ok) {
const text = await response.text()
console.error(
`FCM push failed for token ${t.id}:`,
text,
)
// remove invalid tokens (404 = unregistered device)
if (response.status === 404) {
await db
.delete(pushTokens)
.where(eq(pushTokens.id, t.id))
}
throw new Error(`FCM error: ${response.status}`)
}
}),
)
for (const result of results) {
if (result.status === "fulfilled") sent++
else failed++
}
return { sent, failed }
}