import { eq, and } from "drizzle-orm"; import { TRPCError } from "@trpc/server"; import { db } from "~/server/db"; import { deviceTokens } from "~/server/db/schema/auth"; import { notificationPreferences } from "~/server/db/schema/notifications"; import { resend } from "~/server/lib/resend"; import { messaging } from "~/server/lib/fcm"; import { twilioClient } from "~/server/lib/twilio"; export async function sendEmail( to: string, subject: string, html: string, text?: string, ) { if (!process.env.RESEND_API_KEY) { console.warn("[notifications] Resend not configured, skipping email"); return { id: null }; } try { const { data, error } = await resend.emails.send({ from: process.env.RESEND_FROM_EMAIL ?? "noreply@shieldai.app", to, subject, html, text: text ?? "", }); if (error) { console.error("[notifications] Resend error:", error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to send email", }); } console.log("[notifications] Email sent:", data?.id); return { id: data?.id ?? null }; } catch (err) { if (err instanceof TRPCError) throw err; console.error("[notifications] Email send error:", err); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to send email", }); } } export async function sendPush( userId: string, title: string, body: string, data?: Record, ) { const tokens = await db .select() .from(deviceTokens) .where( and( eq(deviceTokens.userId, userId), eq(deviceTokens.isActive, true), ), ); if (!tokens.length) { console.warn("[notifications] No active devices for user", userId); return { successCount: 0 }; } if (!messaging) { console.warn("[notifications] FCM not configured, skipping push"); return { successCount: 0 }; } const tokenStrings = tokens.map((t) => t.token); let successCount = 0; for (const token of tokenStrings) { try { await messaging.send({ token, notification: { title, body }, data, android: { priority: "high" }, apns: { payload: { aps: { alert: { title, body }, sound: "default", badge: 1, }, }, }, }); successCount++; } catch (err) { console.error("[notifications] Push send error for token:", err); } } console.log("[notifications] Push sent to", successCount, "/", tokens.length, "devices"); return { successCount }; } export async function sendSMS(phoneNumber: string, message: string) { const e164Regex = /^\+[1-9]\d{6,14}$/; if (!e164Regex.test(phoneNumber)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Phone number must be in E.164 format (e.g. +1234567890)", }); } if (!twilioClient) { console.warn("[notifications] Twilio not configured, skipping SMS"); return { sid: null }; } try { const result = await twilioClient.messages.create({ body: message, to: phoneNumber, messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID, }); console.log("[notifications] SMS sent:", result.sid); return { sid: result.sid }; } catch (err) { console.error("[notifications] SMS send error:", err); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to send SMS", }); } } export async function registerDevice( userId: string, token: string, platform: "ios" | "android" | "web", deviceType: "mobile" | "web" | "desktop", ) { const [existing] = await db .select() .from(deviceTokens) .where(eq(deviceTokens.token, token)) .limit(1); if (existing) { if (existing.userId !== userId) { throw new TRPCError({ code: "CONFLICT", message: "Device token already registered to another user", }); } const [updated] = await db .update(deviceTokens) .set({ isActive: true, lastUsedAt: new Date() }) .where(eq(deviceTokens.id, existing.id)) .returning(); return updated; } const [created] = await db .insert(deviceTokens) .values({ userId, token, platform, deviceType }) .returning(); return created; } export async function unregisterDevice(userId: string, token: string) { const [existing] = await db .select() .from(deviceTokens) .where( and( eq(deviceTokens.token, token), eq(deviceTokens.userId, userId), ), ) .limit(1); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Device token not found", }); } const [updated] = await db .update(deviceTokens) .set({ isActive: false }) .where(eq(deviceTokens.id, existing.id)) .returning(); return updated; } export async function listDevices(userId: string) { const devices = await db .select() .from(deviceTokens) .where(eq(deviceTokens.userId, userId)) .orderBy(deviceTokens.createdAt); return devices; } export async function getPreferences(userId: string) { const [prefs] = await db .select() .from(notificationPreferences) .where(eq(notificationPreferences.userId, userId)) .limit(1); if (!prefs) { return { emailEnabled: true, pushEnabled: true, smsEnabled: true, }; } return prefs; } export async function updatePreferences( userId: string, prefs: { emailEnabled?: boolean; pushEnabled?: boolean; smsEnabled?: boolean }, ) { const [existing] = await db .select() .from(notificationPreferences) .where(eq(notificationPreferences.userId, userId)) .limit(1); if (existing) { const [updated] = await db .update(notificationPreferences) .set(prefs) .where(eq(notificationPreferences.userId, userId)) .returning(); return updated; } const [created] = await db .insert(notificationPreferences) .values({ userId, ...prefs }) .returning(); return created; }