summaryrefslogtreecommitdiff
path: root/src/notifications
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-02-06 12:16:40 +0100
committerJustZvan <justzvan@justzvan.xyz>2026-02-06 12:16:40 +0100
commite904e9634548e47d611bdcbb88d7b180b927fd5f (patch)
tree21aa5be08fc5b22585508c0263ee5ea4effcc593 /src/notifications
feat: initial commit!
Diffstat (limited to 'src/notifications')
-rw-r--r--src/notifications/push.ts248
1 files changed, 248 insertions, 0 deletions
diff --git a/src/notifications/push.ts b/src/notifications/push.ts
new file mode 100644
index 0000000..1e0308d
--- /dev/null
+++ b/src/notifications/push.ts
@@ -0,0 +1,248 @@
+import Expo, { ExpoPushMessage, ExpoPushTicket } from "expo-server-sdk";
+import { logger } from "../lib/pino";
+
+/** Expo SDK client for sending push notifications */
+let expo: Expo;
+try {
+ expo = new Expo();
+ logger.info("Expo push notification client initialized");
+} catch (error) {
+ logger.fatal({ error }, "Failed to initialize Expo push notification client");
+ throw error;
+}
+
+/** Data structure for push notification payloads */
+export type PushNotificationData = {
+ title: string;
+ body: string;
+ data?: Record<string, unknown>;
+ sound?: "default" | null;
+ badge?: number;
+ channelId?: string;
+};
+
+/**
+ * Sends a push notification to one device.
+ * Validates the token format and notification data before sending.
+ */
+export async function sendPushNotification(
+ pushToken: string,
+ notification: PushNotificationData,
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ if (!pushToken || typeof pushToken !== "string") {
+ logger.warn("Invalid push token: empty or not a string");
+ return { success: false, error: "Invalid push token" };
+ }
+
+ if (!notification || typeof notification !== "object") {
+ logger.error({ pushToken }, "Invalid notification data");
+ return { success: false, error: "Invalid notification data" };
+ }
+
+ if (!notification.title || !notification.body) {
+ logger.error({ pushToken }, "Notification missing title or body");
+ return { success: false, error: "Notification must have title and body" };
+ }
+
+ if (!Expo.isExpoPushToken(pushToken)) {
+ logger.warn({ pushToken }, "Invalid Expo push token format");
+ return { success: false, error: "Invalid push token" };
+ }
+
+ const message: ExpoPushMessage = {
+ to: pushToken,
+ sound: notification.sound ?? "default",
+ title: notification.title,
+ body: notification.body,
+ data: notification.data ?? {},
+ channelId: notification.channelId ?? "default",
+ };
+
+ if (notification.badge !== undefined) {
+ message.badge = notification.badge;
+ }
+
+ try {
+ const tickets = await expo.sendPushNotificationsAsync([message]);
+ const ticket = tickets[0];
+
+ if (!ticket) {
+ logger.error({ pushToken }, "No ticket returned from Expo");
+ return { success: false, error: "No ticket returned" };
+ }
+
+ if (ticket.status === "error") {
+ logger.error(
+ { pushToken, error: ticket.message },
+ "Push notification error from Expo",
+ );
+ return { success: false, error: ticket.message };
+ }
+
+ logger.info(
+ { pushToken, ticketId: (ticket as { id: string }).id },
+ "Push notification sent successfully",
+ );
+ return { success: true };
+ } catch (error) {
+ logger.error(
+ { error, pushToken },
+ "Failed to send push notification to Expo",
+ );
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+ } catch (error) {
+ logger.error(
+ { error, pushToken },
+ "Unexpected error in sendPushNotification",
+ );
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+}
+
+/**
+ * Sends push notifications to multiple devices in batches.
+ * Filters out invalid tokens and chunks requests to avoid rate limits.
+ */
+export async function sendPushNotifications(
+ pushTokens: string[],
+ notification: PushNotificationData,
+): Promise<{ success: boolean; results: ExpoPushTicket[] }> {
+ try {
+ if (!Array.isArray(pushTokens)) {
+ logger.error("pushTokens is not an array");
+ return { success: false, results: [] };
+ }
+
+ if (pushTokens.length === 0) {
+ logger.warn("Empty pushTokens array provided");
+ return { success: false, results: [] };
+ }
+
+ if (!notification || typeof notification !== "object") {
+ logger.error(
+ { tokenCount: pushTokens.length },
+ "Invalid notification data for bulk send",
+ );
+ return { success: false, results: [] };
+ }
+
+ if (!notification.title || !notification.body) {
+ logger.error(
+ { tokenCount: pushTokens.length },
+ "Bulk notification missing title or body",
+ );
+ return { success: false, results: [] };
+ }
+
+ const validTokens = pushTokens.filter((token) => {
+ const isValid = Expo.isExpoPushToken(token);
+ if (!isValid) {
+ logger.warn(
+ { token },
+ "Invalid Expo push token in bulk send, filtering out",
+ );
+ }
+ return isValid;
+ });
+
+ if (validTokens.length === 0) {
+ logger.warn(
+ { originalCount: pushTokens.length },
+ "No valid tokens after filtering",
+ );
+ return { success: false, results: [] };
+ }
+
+ logger.info(
+ {
+ validTokenCount: validTokens.length,
+ totalTokenCount: pushTokens.length,
+ },
+ "Sending bulk push notifications",
+ );
+
+ const messages: ExpoPushMessage[] = validTokens.map((token) => ({
+ to: token,
+ sound: notification.sound ?? "default",
+ title: notification.title,
+ body: notification.body,
+ data: notification.data ?? {},
+ channelId: notification.channelId ?? "default",
+ }));
+
+ try {
+ const chunks = expo.chunkPushNotifications(messages);
+ const tickets: ExpoPushTicket[] = [];
+
+ for (let i = 0; i < chunks.length; i++) {
+ const chunk = chunks[i];
+ try {
+ const ticketChunk = await expo.sendPushNotificationsAsync(chunk!);
+ tickets.push(...ticketChunk);
+ logger.debug(
+ {
+ chunkIndex: i,
+ chunkSize: chunk!.length,
+ totalChunks: chunks.length,
+ },
+ "Push notification chunk sent",
+ );
+ } catch (chunkError) {
+ logger.error(
+ { error: chunkError, chunkIndex: i, chunkSize: chunk!.length },
+ "Failed to send push notification chunk",
+ );
+ }
+ }
+
+ const errorTickets = tickets.filter(
+ (ticket) => ticket.status === "error",
+ );
+ const hasErrors = errorTickets.length > 0;
+
+ if (hasErrors) {
+ logger.warn(
+ { errorCount: errorTickets.length, totalCount: tickets.length },
+ "Some push notifications failed",
+ );
+ errorTickets.forEach((ticket) => {
+ if (ticket.status === "error") {
+ logger.error(
+ { error: ticket.message, details: ticket.details },
+ "Push notification ticket error",
+ );
+ }
+ });
+ } else {
+ logger.info(
+ { sentCount: tickets.length },
+ "All push notifications sent successfully",
+ );
+ }
+
+ return { success: !hasErrors, results: tickets };
+ } catch (error) {
+ logger.error(
+ { error, tokenCount: validTokens.length },
+ "Failed to send bulk push notifications",
+ );
+ return { success: false, results: [] };
+ }
+ } catch (error) {
+ logger.error({ error }, "Unexpected error in sendPushNotifications");
+ return { success: false, results: [] };
+ }
+}
+
+/** Checks whether a token string is a valid Expo push token */
+export function isValidPushToken(token: string): boolean {
+ return Expo.isExpoPushToken(token);
+}