Cleaned up Deno implementation

This commit is contained in:
Christopher Sanden
2026-03-18 17:25:17 +01:00
parent 4b7d87ab57
commit 5210d5ae56
4 changed files with 342 additions and 23 deletions

View File

@@ -1,9 +1,6 @@
// deno-lint-ignore no-import-prefix
import { createClient } from "npm:@supabase/supabase-js@2"
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
const EXPO_ACCESS_TOKEN = Deno.env.get("EXPO_ACCESS_TOKEN") ?? ""
type NoteRecord = {
id: number | string
created_by: string
@@ -38,7 +35,62 @@ type ExpoPushMessage = {
}
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
type ExpoPushTicket = {
status?: "ok" | "error"
id?: string
message?: string
details?: {
error?: string
}
}
type ExpoPushResponse = {
data?: ExpoPushTicket[]
}
type ExpoSendResult = {
acceptedCount: number
failedTickets: Array<{
token: string
error: string
}>
invalidTokens: string[]
}
type SupabaseAdminClient = ReturnType<typeof createClient>
let supabase: SupabaseAdminClient | null = null
function getOptionalEnv(name: string) {
return Deno.env.get(name)?.trim() ?? ""
}
function requireEnv(name: string) {
const value = getOptionalEnv(name)
if (!value) {
throw new Error(`Missing required env var: ${name}`)
}
return value
}
function getSupabaseClient() {
if (!supabase) {
supabase = createClient(
requireEnv("SUPABASE_URL"),
requireEnv("SUPABASE_SERVICE_ROLE_KEY"),
{
auth: {
persistSession: false,
autoRefreshToken: false,
},
}
)
}
return supabase
}
function jsonResponse(status: number, body: Record<string, unknown>) {
return new Response(JSON.stringify(body), {
@@ -57,16 +109,20 @@ function chunkMessages<T>(items: T[], size: number) {
return chunks
}
async function loadCreatorEmail(userId: string): Promise<string> {
const { data: profile } = await supabase
async function loadCreatorEmail(supabase: SupabaseAdminClient, userId: string): Promise<string> {
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("email")
.eq("id", userId)
.maybeSingle()
if (profileError) {
console.error("Failed to load note creator profile:", profileError.message)
}
const typedProfile = profile as ProfileEmailRow | null
if (typedProfile?.email) {
if (typedProfile?.email?.trim()) {
return typedProfile.email
}
@@ -80,7 +136,7 @@ async function loadCreatorEmail(userId: string): Promise<string> {
return data.user.email ?? "unknown user"
}
async function loadRecipientTokens(userId: string): Promise<string[]> {
async function loadRecipientTokens(supabase: SupabaseAdminClient, userId: string): Promise<string[]> {
const { data, error } = await supabase
.from("user_push_tokens")
.select("push_token")
@@ -96,14 +152,50 @@ async function loadRecipientTokens(userId: string): Promise<string[]> {
return Array.from(new Set(rows.map((row) => row.push_token).filter((token): token is string => Boolean(token))))
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
async function deactivatePushTokens(supabase: SupabaseAdminClient, tokens: string[]) {
if (tokens.length === 0) {
return
}
// deno-lint-ignore no-explicit-any
const pushTokensTable = supabase.from("user_push_tokens") as any
const { error } = await pushTokensTable
.update({
is_active: false,
updated_at: new Date().toISOString(),
})
.in("push_token", tokens)
if (error) {
console.error("Failed to deactivate invalid push tokens:", error.message)
}
}
function parseExpoPushResponse(responseText: string): ExpoPushResponse {
try {
return JSON.parse(responseText) as ExpoPushResponse
} catch {
throw new Error("Expo push response was not valid JSON.")
}
}
async function sendExpoPushNotifications(
supabase: SupabaseAdminClient,
messages: ExpoPushMessage[],
): Promise<ExpoSendResult> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
}
if (EXPO_ACCESS_TOKEN) {
headers.Authorization = `Bearer ${EXPO_ACCESS_TOKEN}`
const invalidTokens = new Set<string>()
const failedTickets: ExpoSendResult["failedTickets"] = []
let acceptedCount = 0
const expoAccessToken = getOptionalEnv("EXPO_ACCESS_TOKEN")
if (expoAccessToken) {
headers.Authorization = `Bearer ${expoAccessToken}`
}
for (const chunk of chunkMessages(messages, 100)) {
@@ -113,10 +205,48 @@ async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
body: JSON.stringify(chunk),
})
const responseText = await response.text()
if (!response.ok) {
const errorBody = await response.text()
throw new Error(`Expo push request failed with ${response.status}: ${errorBody}`)
throw new Error(`Expo push request failed with ${response.status}: ${responseText}`)
}
const responseBody = parseExpoPushResponse(responseText)
const tickets = Array.isArray(responseBody.data) ? responseBody.data : []
if (tickets.length !== chunk.length) {
throw new Error(`Expo push response size mismatch: expected ${chunk.length} tickets, got ${tickets.length}.`)
}
for (let index = 0; index < tickets.length; index += 1) {
const ticket = tickets[index]
const token = chunk[index].to
if (ticket?.status === "ok") {
acceptedCount += 1
continue
}
const expoError = ticket?.details?.error
const errorMessage = ticket?.message ?? "Unknown Expo push ticket error."
failedTickets.push({
token,
error: expoError ? `${expoError}: ${errorMessage}` : errorMessage,
})
if (expoError === "DeviceNotRegistered") {
invalidTokens.add(token)
}
}
}
const tokensToDeactivate = Array.from(invalidTokens)
await deactivatePushTokens(supabase, tokensToDeactivate)
return {
acceptedCount,
failedTickets,
invalidTokens: tokensToDeactivate,
}
}
@@ -125,15 +255,19 @@ Deno.serve(async (request: Request) => {
return jsonResponse(405, { error: "Method not allowed" })
}
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
return jsonResponse(500, { error: "Missing Supabase environment variables." })
}
let payload: DatabaseWebhookPayload
let supabase: SupabaseAdminClient
try {
supabase = getSupabaseClient()
payload = await request.json()
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : "Unexpected error."
if (message.startsWith("Missing required env var:")) {
return jsonResponse(500, { error: message })
}
return jsonResponse(400, { error: "Invalid JSON payload." })
}
@@ -144,8 +278,8 @@ Deno.serve(async (request: Request) => {
try {
const note = payload.record
const [creatorEmail, recipientTokens] = await Promise.all([
loadCreatorEmail(note.created_by),
loadRecipientTokens(note.created_by),
loadCreatorEmail(supabase, note.created_by),
loadRecipientTokens(supabase, note.created_by),
])
if (recipientTokens.length === 0) {
@@ -165,9 +299,17 @@ Deno.serve(async (request: Request) => {
},
}))
await sendExpoPushNotifications(messages)
const sendResult = await sendExpoPushNotifications(supabase, messages)
return jsonResponse(200, { sent: messages.length })
if (sendResult.failedTickets.length > 0) {
console.error("Expo rejected one or more push messages:", sendResult.failedTickets)
}
return jsonResponse(200, {
sent: sendResult.acceptedCount,
failed: sendResult.failedTickets.length,
deactivated: sendResult.invalidTokens.length,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Unexpected error."
console.error("Push notification webhook failed:", message)