Files
Kordant/web/src/server/services/notification.service.ts
Michael Freno 5154990acd feat(notifications): implement notification router with email, push, SMS support
- Add notification router (sendEmail, sendPush, sendSMS, device mgmt, prefs)
- Create provider clients: Resend, Firebase Admin (FCM), Twilio
- Add notification_preferences table to Drizzle schema
- Create branded email templates (welcome, alert, password reset, family invite, billing)
- Implement notification service with error handling and E.164 validation
- Wire router into app root
- Write unit tests with mocked providers (25 tests passing)
- Add resend, firebase-admin, twilio dependencies
2026-05-25 16:13:02 -04:00

257 lines
6.0 KiB
TypeScript

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<string, string>,
) {
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;
}