From 5210d5ae563e99986cae7a48edb391b4395ff519 Mon Sep 17 00:00:00 2001 From: Christopher Sanden Date: Wed, 18 Mar 2026 17:25:17 +0100 Subject: [PATCH] Cleaned up Deno implementation --- FastNotes/package-lock.json | 97 +++++++++++ FastNotes/package.json | 1 + FastNotes/supabase/functions/deno.lock | 79 +++++++++ FastNotes/supabase/functions/push/index.ts | 188 ++++++++++++++++++--- 4 files changed, 342 insertions(+), 23 deletions(-) create mode 100644 FastNotes/supabase/functions/deno.lock diff --git a/FastNotes/package-lock.json b/FastNotes/package-lock.json index d068ffe..e3bdc17 100644 --- a/FastNotes/package-lock.json +++ b/FastNotes/package-lock.json @@ -15,6 +15,7 @@ "@react-navigation/native": "^7.1.28", "@supabase/supabase-js": "^2.98.0", "async-storage": "^0.1.0", + "deno": "^2.7.6", "expo": "~54.0.33", "expo-blur": "~15.0.8", "expo-constants": "~18.0.13", @@ -1556,6 +1557,84 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@deno/darwin-arm64": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@deno/darwin-arm64/-/darwin-arm64-2.7.6.tgz", + "integrity": "sha512-PRRzLK3AfCYthVUEy8/QcCwbWOLt8yt9KLQcnPqi+BoxnRsWHdbDwoJ9xwSigHtD2wOvay98/wU0C+NOiZ8W0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@deno/darwin-x64": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@deno/darwin-x64/-/darwin-x64-2.7.6.tgz", + "integrity": "sha512-xy+jkR5HdGBjdjvRr1EQT62YSrdnYYn6i4k2Jw0hqNIwp2+IyiEbmD3wQ/uQZTt+mMkl+cq2adYuDg4lmGA9vw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@deno/linux-arm64-glibc": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@deno/linux-arm64-glibc/-/linux-arm64-glibc-2.7.6.tgz", + "integrity": "sha512-bMe/5MVeQqzEFleC7oCCxyftUuK/5IM8F66txs33dL+a101QUn3cCSZnor5Ckcf0tY42crFMJ7DiCWuJCP/rtQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@deno/linux-x64-glibc": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@deno/linux-x64-glibc/-/linux-x64-glibc-2.7.6.tgz", + "integrity": "sha512-pyKN9G7qFJWLE2pE3G5Hb05b1ZcjakK3af5U7bk7QQRlKTZGygUTzVlOyzkFh827NpJa3roPaYXsMVZgcml8dw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@deno/win32-arm64": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@deno/win32-arm64/-/win32-arm64-2.7.6.tgz", + "integrity": "sha512-dG29tSiV4gKC9V1+55RovzMfdgKKeVrQYOPUL1xREMWG2+ZacQ++LjwP/RCGyhZXOwvVBLDJw3AK9GXTLBp6Jw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@deno/win32-x64": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@deno/win32-x64/-/win32-x64-2.7.6.tgz", + "integrity": "sha512-m6N4rrJUjLyVuZ9YjsdiSHNldbljq9z5+LlTajIHs+JzDaWbs+mXIfhLB4y9cGB73R1msCrE93Q6acnoFlfEFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -6308,6 +6387,24 @@ "node": ">=0.4.0" } }, + "node_modules/deno": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/deno/-/deno-2.7.6.tgz", + "integrity": "sha512-c1f/1YbZjlIoH+xqRQfz4ir/HcB0h4VBG8OJrM8EANIQxnW+izAylNKzK7jShgNeZtFXkZujZETxa+Rh954xkA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "deno": "bin.cjs" + }, + "optionalDependencies": { + "@deno/darwin-arm64": "2.7.6", + "@deno/darwin-x64": "2.7.6", + "@deno/linux-arm64-glibc": "2.7.6", + "@deno/linux-x64-glibc": "2.7.6", + "@deno/win32-arm64": "2.7.6", + "@deno/win32-x64": "2.7.6" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", diff --git a/FastNotes/package.json b/FastNotes/package.json index e6ccbe4..f4d423c 100644 --- a/FastNotes/package.json +++ b/FastNotes/package.json @@ -22,6 +22,7 @@ "@react-navigation/native": "^7.1.28", "@supabase/supabase-js": "^2.98.0", "async-storage": "^0.1.0", + "deno": "^2.7.6", "expo": "~54.0.33", "expo-blur": "~15.0.8", "expo-constants": "~18.0.13", diff --git a/FastNotes/supabase/functions/deno.lock b/FastNotes/supabase/functions/deno.lock new file mode 100644 index 0000000..6b7eb4b --- /dev/null +++ b/FastNotes/supabase/functions/deno.lock @@ -0,0 +1,79 @@ +{ + "version": "5", + "specifiers": { + "npm:@supabase/supabase-js@2": "2.99.2" + }, + "npm": { + "@supabase/auth-js@2.99.2": { + "integrity": "sha512-uRGNXMKEw4VhwouNW7N0XDAGqJP9redHNDmWi17dTrcO1lvFfyRiXsqqfgnVC8aqtRn8kLkLPEzHjiRWsni+oQ==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/functions-js@2.99.2": { + "integrity": "sha512-xuXQARvjdfB1UPK1yUceZ5EGjOLkVz4rBAaloS9foXiAuseWEdgWBCxkIAFRxGBLGX8Uzo8kseq90jhPb+07Vg==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/postgrest-js@2.99.2": { + "integrity": "sha512-ueiOVkbkTQ7RskwVmjR8zxWYw3VKOMxo1+qep+Dx/SgApqyhWBGd92waQb45tbLc7ydB5x8El8utXOLQTuTojQ==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/realtime-js@2.99.2": { + "integrity": "sha512-J6Jm9601dkpZf3+EJ48ki2pM4sFtCNm/BI0l8iEnrczgg+JSEQkMoOW5VSpM54t0pNs69bsz5PTmYJahDZKiIQ==", + "dependencies": [ + "@types/phoenix", + "@types/ws", + "tslib", + "ws" + ] + }, + "@supabase/storage-js@2.99.2": { + "integrity": "sha512-V/FF8kX8JGSefsVCG1spCLSrHdNR/JFeUMn1jS9KG/Eizjx+evtdKQKLJXFgIylY/bKTXKhc2SYDPIGrIhzsug==", + "dependencies": [ + "iceberg-js", + "tslib" + ] + }, + "@supabase/supabase-js@2.99.2": { + "integrity": "sha512-179rn5wq0wBAqqGwAwR7TUGg2NOaP+fkd5FCVbYJXby85fsRNPFoNJN8YRBepqX2tN7JJcnTjqaAMXuNjiyisA==", + "dependencies": [ + "@supabase/auth-js", + "@supabase/functions-js", + "@supabase/postgrest-js", + "@supabase/realtime-js", + "@supabase/storage-js" + ] + }, + "@types/node@25.5.0": { + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dependencies": [ + "undici-types" + ] + }, + "@types/phoenix@1.6.7": { + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==" + }, + "@types/ws@8.18.1": { + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": [ + "@types/node" + ] + }, + "iceberg-js@0.8.1": { + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "undici-types@7.18.2": { + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + }, + "ws@8.19.0": { + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==" + } + } +} diff --git a/FastNotes/supabase/functions/push/index.ts b/FastNotes/supabase/functions/push/index.ts index 344a964..ff4bfb5 100644 --- a/FastNotes/supabase/functions/push/index.ts +++ b/FastNotes/supabase/functions/push/index.ts @@ -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 + +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) { return new Response(JSON.stringify(body), { @@ -57,16 +109,20 @@ function chunkMessages(items: T[], size: number) { return chunks } -async function loadCreatorEmail(userId: string): Promise { - const { data: profile } = await supabase +async function loadCreatorEmail(supabase: SupabaseAdminClient, userId: string): Promise { + 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 { return data.user.email ?? "unknown user" } -async function loadRecipientTokens(userId: string): Promise { +async function loadRecipientTokens(supabase: SupabaseAdminClient, userId: string): Promise { const { data, error } = await supabase .from("user_push_tokens") .select("push_token") @@ -96,14 +152,50 @@ async function loadRecipientTokens(userId: string): Promise { 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 { const headers: Record = { "Content-Type": "application/json", Accept: "application/json", } - if (EXPO_ACCESS_TOKEN) { - headers.Authorization = `Bearer ${EXPO_ACCESS_TOKEN}` + const invalidTokens = new Set() + 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)