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
This commit is contained in:
2026-05-25 16:13:02 -04:00
parent 40a9ef146c
commit 5154990acd
14 changed files with 2484 additions and 6 deletions

1385
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,12 +25,15 @@
"@typeschema/valibot": "^0.13.4",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"firebase-admin": "^13.10.0",
"jose": "^5",
"pg": "^8.21.0",
"resend": "^6.12.4",
"solid-js": "^1.9.5",
"stripe": "^22.1.1",
"tailwindcss": "^4.0.0",
"three": "^0.184.0",
"twilio": "^6.0.2",
"valibot": "^0.29.0",
"vite": "^7.0.0"
},

View File

@@ -1,12 +1,14 @@
import { exampleRouter } from "./routers/example";
import { userRouter } from "./routers/user";
import { billingRouter } from "./routers/billing";
import { notificationRouter } from "./routers/notification";
import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
example: exampleRouter,
user: userRouter,
billing: billingRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,100 @@
import { wrap } from "@typeschema/valibot";
import {
object,
string,
optional,
record,
boolean,
picklist,
} from "valibot";
import { createTRPCRouter, protectedProcedure, adminProcedure } from "../utils";
import {
sendEmail,
sendPush,
sendSMS,
registerDevice,
unregisterDevice,
listDevices,
getPreferences,
updatePreferences,
} from "~/server/services/notification.service";
const SendEmailSchema = object({
to: string(),
subject: string(),
html: string(),
text: optional(string()),
});
const SendPushSchema = object({
title: string(),
body: string(),
data: optional(record(string(), string())),
});
const SendSMSSchema = object({
phoneNumber: string(),
message: string(),
});
const RegisterDeviceSchema = object({
token: string(),
platform: picklist(["ios", "android", "web"]),
deviceType: picklist(["mobile", "web", "desktop"]),
});
const UnregisterDeviceSchema = object({
token: string(),
});
const UpdatePreferencesSchema = object({
emailEnabled: optional(boolean()),
pushEnabled: optional(boolean()),
smsEnabled: optional(boolean()),
});
export const notificationRouter = createTRPCRouter({
sendEmail: adminProcedure
.input(wrap(SendEmailSchema))
.mutation(async ({ input }) => {
return sendEmail(input.to, input.subject, input.html, input.text);
}),
sendPush: protectedProcedure
.input(wrap(SendPushSchema))
.mutation(async ({ ctx, input }) => {
return sendPush(ctx.user.id, input.title, input.body, input.data);
}),
sendSMS: protectedProcedure
.input(wrap(SendSMSSchema))
.mutation(async ({ input }) => {
return sendSMS(input.phoneNumber, input.message);
}),
registerDevice: protectedProcedure
.input(wrap(RegisterDeviceSchema))
.mutation(async ({ ctx, input }) => {
return registerDevice(ctx.user.id, input.token, input.platform, input.deviceType);
}),
unregisterDevice: protectedProcedure
.input(wrap(UnregisterDeviceSchema))
.mutation(async ({ ctx, input }) => {
return unregisterDevice(ctx.user.id, input.token);
}),
listDevices: protectedProcedure.query(async ({ ctx }) => {
return listDevices(ctx.user.id);
}),
getPreferences: protectedProcedure.query(async ({ ctx }) => {
return getPreferences(ctx.user.id);
}),
updatePreferences: protectedProcedure
.input(wrap(UpdatePreferencesSchema))
.mutation(async ({ ctx, input }) => {
return updatePreferences(ctx.user.id, input);
}),
});

View File

@@ -12,4 +12,5 @@ export * from "./marketing";
export * from "./hometitle";
export * from "./removebrokers";
export * from "./invitation";
export * from "./notifications";
export * from "./relations";

View File

@@ -0,0 +1,15 @@
import { pgTable, uuid, boolean, timestamp } from "drizzle-orm/pg-core";
import { users } from "./auth";
export const notificationPreferences = pgTable("notification_preferences", {
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" })
.unique(),
emailEnabled: boolean("email_enabled").default(true).notNull(),
pushEnabled: boolean("push_enabled").default(true).notNull(),
smsEnabled: boolean("sms_enabled").default(true).notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()),
});

View File

@@ -4,6 +4,7 @@ import { users } from "./auth";
import { accounts } from "./auth";
import { sessions } from "./auth";
import { deviceTokens } from "./auth";
import { notificationPreferences } from "./notifications";
import { familyGroups, familyGroupMembers, subscriptions } from "./subscription";
import { invitations } from "./invitation";
import { watchlistItems, exposures } from "./darkwatch";
@@ -15,7 +16,7 @@ import { securityReports } from "./reports";
import { propertyWatchlistItems, propertySnapshots, propertyChanges } from "./hometitle";
import { infoBrokers, removalRequests, brokerListings } from "./removebrokers";
export const usersRelations = relations(users, ({ many }) => ({
export const usersRelations = relations(users, ({ one, many }) => ({
accounts: many(accounts),
sessions: many(sessions),
deviceTokens: many(deviceTokens),
@@ -32,6 +33,7 @@ export const usersRelations = relations(users, ({ many }) => ({
correlationGroups: many(correlationGroups),
securityReports: many(securityReports),
analysisJobs: many(analysisJobs),
notificationPreferences: one(notificationPreferences),
}));
export const accountsRelations = relations(accounts, ({ one }) => ({

18
web/src/server/lib/fcm.ts Normal file
View File

@@ -0,0 +1,18 @@
import { initializeApp, cert, getApps } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";
const projectId = process.env.FCM_PROJECT_ID;
const clientEmail = process.env.FCM_CLIENT_EMAIL;
const privateKey = process.env.FCM_PRIVATE_KEY;
if (!getApps().length && projectId && clientEmail && privateKey) {
initializeApp({
credential: cert({
projectId,
clientEmail,
privateKey: privateKey.replace(/\\n/g, "\n"),
}),
});
}
export const messaging = getApps().length ? getMessaging() : null;

View File

@@ -0,0 +1,3 @@
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY ?? "");

View File

@@ -0,0 +1,6 @@
import twilio from "twilio";
const accountSid = process.env.TWILIO_ACCOUNT_SID ?? "";
const authToken = process.env.TWILIO_AUTH_TOKEN ?? "";
export const twilioClient = accountSid && authToken ? twilio(accountSid, authToken) : null;

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import {
welcomeEmail,
alertNotificationEmail,
passwordResetEmail,
familyInviteEmail,
billingReceiptEmail,
} from "./email.templates";
describe("welcomeEmail", () => {
it("includes the user name in the output", () => {
const result = welcomeEmail("Alice");
expect(result.subject).toContain("Welcome");
expect(result.html).toContain("Alice");
expect(result.text).toContain("Alice");
expect(result.html).toContain("ShieldAI");
});
});
describe("alertNotificationEmail", () => {
it("includes severity and alert details", () => {
const result = alertNotificationEmail("Data Breach Found", "Your email was exposed", "critical");
expect(result.subject).toContain("CRITICAL");
expect(result.html).toContain("Data Breach Found");
expect(result.html).toContain("Your email was exposed");
expect(result.text).toContain("CRITICAL");
});
it("renders different severity levels", () => {
const infoResult = alertNotificationEmail("Scan Complete", "Scan done", "info");
expect(infoResult.subject).toContain("INFO");
const warnResult = alertNotificationEmail("Suspicious Activity", "Suspicious login", "warning");
expect(warnResult.subject).toContain("WARNING");
});
});
describe("passwordResetEmail", () => {
it("includes the reset link", () => {
const result = passwordResetEmail("https://shieldai.app/reset/token123");
expect(result.html).toContain("https://shieldai.app/reset/token123");
expect(result.text).toContain("https://shieldai.app/reset/token123");
expect(result.subject).toContain("Reset");
});
});
describe("familyInviteEmail", () => {
it("includes inviter name and group name", () => {
const result = familyInviteEmail("Bob", "Smith Family", "https://shieldai.app/invite/abc");
expect(result.html).toContain("Bob");
expect(result.html).toContain("Smith Family");
expect(result.html).toContain("https://shieldai.app/invite/abc");
expect(result.subject).toContain("Bob");
});
});
describe("billingReceiptEmail", () => {
it("includes payment details", () => {
const result = billingReceiptEmail("Premium Plan", "$19.99", "Mar 15, 2025", "https://shieldai.app/receipt/r1");
expect(result.html).toContain("Premium Plan");
expect(result.html).toContain("$19.99");
expect(result.html).toContain("Mar 15, 2025");
expect(result.html).toContain("https://shieldai.app/receipt/r1");
expect(result.subject).toContain("Premium Plan");
});
});

View File

@@ -0,0 +1,167 @@
function brandedWrapper(title: string, body: string) {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background-color:#f4f4f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f4;padding:24px 16px">
<tr>
<td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden">
<tr>
<td style="background-color:#1a1a2e;padding:24px;text-align:center">
<h1 style="color:#ffffff;margin:0;font-size:20px;font-weight:700">🛡️ ShieldAI</h1>
<p style="color:#94a3b8;margin:4px 0 0;font-size:13px">Intelligent Protection</p>
</td>
</tr>
<tr>
<td style="padding:32px 24px">
<h2 style="color:#1a1a2e;margin:0 0 16px;font-size:18px">${title}</h2>
${body}
</td>
</tr>
<tr>
<td style="background-color:#f8fafc;padding:16px 24px;text-align:center;border-top:1px solid #e2e8f0">
<p style="color:#64748b;margin:0;font-size:12px">ShieldAI &mdash; Your intelligent digital protection platform</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function brandedText(text: string) {
return `ShieldAI - Intelligent Protection\n\n${text}\n\n---\nShieldAI - Your intelligent digital protection platform`;
}
export interface EmailTemplate {
subject: string;
html: string;
text: string;
}
export function welcomeEmail(name: string): EmailTemplate {
return {
subject: "Welcome to ShieldAI",
html: brandedWrapper(
"Welcome to ShieldAI!",
`<p style="color:#334155;margin:0 0 12px;line-height:1.6">Hi ${name},</p>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">Thank you for joining ShieldAI. We're here to help you monitor and protect your digital identity.</p>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">Get started by adding your first watchlist item, and we'll alert you to any exposures or threats.</p>
<p style="color:#334155;margin:0;line-height:1.6">Stay safe,<br>The ShieldAI Team</p>`,
),
text: brandedText(
`Hi ${name},\n\nThank you for joining ShieldAI. We're here to help you monitor and protect your digital identity.\n\nGet started by adding your first watchlist item, and we'll alert you to any exposures or threats.\n\nStay safe,\nThe ShieldAI Team`,
),
};
}
export function alertNotificationEmail(
alertTitle: string,
alertMessage: string,
severity: string,
): EmailTemplate {
const severityColor =
severity === "critical" ? "#dc2626" :
severity === "warning" ? "#d97706" : "#2563eb";
return {
subject: `[${severity.toUpperCase()}] ShieldAI Alert: ${alertTitle}`,
html: brandedWrapper(
`Alert: ${alertTitle}`,
`<div style="display:inline-block;padding:4px 10px;border-radius:4px;font-size:12px;font-weight:600;text-transform:uppercase;color:#ffffff;background-color:${severityColor};margin-bottom:16px">${severity}</div>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">${alertMessage}</p>
<p style="color:#64748b;margin:0;line-height:1.6;font-size:13px">Log in to ShieldAI for more details.</p>`,
),
text: brandedText(
`[${severity.toUpperCase()}] Alert: ${alertTitle}\n\n${alertMessage}\n\nLog in to ShieldAI for more details.`,
),
};
}
export function passwordResetEmail(resetLink: string): EmailTemplate {
return {
subject: "Reset your ShieldAI password",
html: brandedWrapper(
"Password Reset",
`<p style="color:#334155;margin:0 0 12px;line-height:1.6">You requested a password reset. Click the button below to set a new password.</p>
<table cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
<td align="center">
<a href="${resetLink}" style="display:inline-block;padding:12px 24px;background-color:#1a1a2e;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600">Reset Password</a>
</td>
</tr>
</table>
<p style="color:#64748b;margin:0;line-height:1.6;font-size:13px">If you didn't request this, you can safely ignore this email. The link expires in 1 hour.</p>`,
),
text: brandedText(
`Password Reset\n\nYou requested a password reset. Use this link to set a new password:\n${resetLink}\n\nIf you didn't request this, you can safely ignore this email. The link expires in 1 hour.`,
),
};
}
export function familyInviteEmail(
inviterName: string,
groupName: string,
acceptLink: string,
): EmailTemplate {
return {
subject: `${inviterName} invited you to ${groupName} on ShieldAI`,
html: brandedWrapper(
"Family Invitation",
`<p style="color:#334155;margin:0 0 12px;line-height:1.6"><strong>${inviterName}</strong> has invited you to join <strong>${groupName}</strong> on ShieldAI.</p>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">As a family member, you'll get shared protection and alerts for your digital identity.</p>
<table cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
<td align="center">
<a href="${acceptLink}" style="display:inline-block;padding:12px 24px;background-color:#1a1a2e;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600">Accept Invitation</a>
</td>
</tr>
</table>`,
),
text: brandedText(
`Family Invitation\n\n${inviterName} has invited you to join ${groupName} on ShieldAI.\n\nAs a family member, you'll get shared protection and alerts for your digital identity.\n\nAccept the invitation: ${acceptLink}`,
),
};
}
export function billingReceiptEmail(
planName: string,
amount: string,
date: string,
receiptUrl: string,
): EmailTemplate {
return {
subject: `ShieldAI receipt — ${planName} (${date})`,
html: brandedWrapper(
"Payment Receipt",
`<p style="color:#334155;margin:0 0 8px;line-height:1.6">Thank you for your payment.</p>
<table width="100%" cellpadding="8" cellspacing="0" style="margin:16px 0;background-color:#f8fafc;border-radius:6px">
<tr>
<td style="color:#64748b;font-size:13px">Plan</td>
<td style="color:#1a1a2e;font-weight:600;text-align:right">${planName}</td>
</tr>
<tr>
<td style="color:#64748b;font-size:13px">Amount</td>
<td style="color:#1a1a2e;font-weight:600;text-align:right">${amount}</td>
</tr>
<tr>
<td style="color:#64748b;font-size:13px">Date</td>
<td style="color:#1a1a2e;font-weight:600;text-align:right">${date}</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
<td align="center">
<a href="${receiptUrl}" style="display:inline-block;padding:10px 20px;background-color:#1a1a2e;color:#ffffff;text-decoration:none;border-radius:6px;font-size:13px;font-weight:600">View Receipt</a>
</td>
</tr>
</table>`,
),
text: brandedText(
`Payment Receipt\n\nThank you for your payment.\n\nPlan: ${planName}\nAmount: ${amount}\nDate: ${date}\n\nView receipt: ${receiptUrl}`,
),
};
}

View File

@@ -0,0 +1,464 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
const mockResendSend = vi.fn();
const mockMessagingSend = vi.fn();
const mockTwilioCreate = vi.fn();
vi.mock("~/server/db", () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
},
}));
vi.mock("~/server/lib/resend", () => ({
resend: { emails: { send: mockResendSend } },
}));
vi.mock("~/server/lib/fcm", () => ({
messaging: { send: mockMessagingSend },
}));
vi.mock("~/server/lib/twilio", () => ({
twilioClient: { messages: { create: mockTwilioCreate } },
}));
import { db } from "~/server/db";
beforeEach(() => {
vi.clearAllMocks();
});
describe("sendEmail", () => {
it("calls Resend with correct parameters", async () => {
process.env.RESEND_API_KEY = "test-key";
mockResendSend.mockResolvedValue({
data: { id: "email-1" },
error: null,
});
const { sendEmail } = await import("./notification.service");
const result = await sendEmail("test@example.com", "Subject", "<p>Body</p>", "Text body");
expect(mockResendSend).toHaveBeenCalledWith({
from: "noreply@shieldai.app",
to: "test@example.com",
subject: "Subject",
html: "<p>Body</p>",
text: "Text body",
});
expect(result).toEqual({ id: "email-1" });
});
it("skips sending when Resend API key is not configured", async () => {
delete process.env.RESEND_API_KEY;
const { sendEmail } = await import("./notification.service");
const result = await sendEmail("test@example.com", "Subject", "<p>Body</p>");
expect(result).toEqual({ id: null });
expect(mockResendSend).not.toHaveBeenCalled();
});
it("throws INTERNAL_SERVER_ERROR when Resend returns an error", async () => {
process.env.RESEND_API_KEY = "test-key";
mockResendSend.mockResolvedValue({
data: null,
error: { message: "API error" },
});
const { sendEmail } = await import("./notification.service");
await expect(sendEmail("test@example.com", "Subject", "<p>Body</p>")).rejects.toThrow(TRPCError);
await expect(sendEmail("test@example.com", "Subject", "<p>Body</p>")).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
});
});
});
describe("sendPush", () => {
it("sends FCM message to all active devices", async () => {
const devices = [
{ id: "d1", userId: "u1", token: "token-1", platform: "android", deviceType: "mobile", isActive: true },
{ id: "d2", userId: "u1", token: "token-2", platform: "ios", deviceType: "mobile", isActive: true },
];
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(devices),
}),
});
mockMessagingSend.mockResolvedValue({});
const { sendPush } = await import("./notification.service");
const result = await sendPush("u1", "Title", "Body", { key: "val" });
expect(result).toEqual({ successCount: 2 });
expect(mockMessagingSend).toHaveBeenCalledTimes(2);
expect(mockMessagingSend).toHaveBeenCalledWith({
token: "token-1",
notification: { title: "Title", body: "Body" },
data: { key: "val" },
android: { priority: "high" },
apns: { payload: { aps: { alert: { title: "Title", body: "Body" }, sound: "default", badge: 1 } } },
});
});
it("returns 0 success when no active devices", async () => {
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
const { sendPush } = await import("./notification.service");
const result = await sendPush("u1", "Title", "Body");
expect(result).toEqual({ successCount: 0 });
expect(mockMessagingSend).not.toHaveBeenCalled();
});
it("continues sending if one push fails", async () => {
const devices = [
{ id: "d1", userId: "u1", token: "token-1", platform: "android", deviceType: "mobile", isActive: true },
{ id: "d2", userId: "u1", token: "token-2", platform: "ios", deviceType: "mobile", isActive: true },
];
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(devices),
}),
});
mockMessagingSend
.mockRejectedValueOnce(new Error("FCM error"))
.mockResolvedValueOnce({});
const { sendPush } = await import("./notification.service");
const result = await sendPush("u1", "Title", "Body");
expect(result).toEqual({ successCount: 1 });
});
});
describe("sendSMS", () => {
it("calls Twilio with correct parameters", async () => {
process.env.TWILIO_MESSAGING_SERVICE_SID = "MGxxx";
mockTwilioCreate.mockResolvedValue({ sid: "SMxxx" });
const { sendSMS } = await import("./notification.service");
const result = await sendSMS("+1234567890", "Hello");
expect(mockTwilioCreate).toHaveBeenCalledWith({
body: "Hello",
to: "+1234567890",
messagingServiceSid: "MGxxx",
});
expect(result).toEqual({ sid: "SMxxx" });
});
it("throws BAD_REQUEST for non-E.164 phone numbers", async () => {
const { sendSMS } = await import("./notification.service");
await expect(sendSMS("1234567890", "Hello")).rejects.toThrow(TRPCError);
await expect(sendSMS("1234567890", "Hello")).rejects.toMatchObject({
code: "BAD_REQUEST",
});
await expect(sendSMS("+12", "Hello")).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("accepts valid E.164 phone numbers", async () => {
mockTwilioCreate.mockResolvedValue({ sid: "SMxxx" });
const { sendSMS } = await import("./notification.service");
await expect(sendSMS("+1234567890", "Hello")).resolves.toEqual({ sid: "SMxxx" });
await expect(sendSMS("+447911123456", "Hello")).resolves.toEqual({ sid: "SMxxx" });
});
});
describe("registerDevice", () => {
it("creates a new device token record", async () => {
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
const newDevice = {
id: "d-new",
userId: "u1",
token: "new-token",
platform: "android",
deviceType: "mobile",
isActive: true,
lastUsedAt: new Date(),
};
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([newDevice]),
}),
});
const { registerDevice } = await import("./notification.service");
const result = await registerDevice("u1", "new-token", "android", "mobile");
expect(result).toEqual(newDevice);
expect(db.insert).toHaveBeenCalled();
});
it("reactivates an existing token for the same user", async () => {
const existing = {
id: "d1",
userId: "u1",
token: "existing-token",
platform: "android",
deviceType: "mobile",
isActive: false,
lastUsedAt: new Date(0),
};
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([existing]),
}),
}),
});
const updated = { ...existing, isActive: true, lastUsedAt: expect.any(Date) };
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updated]),
}),
}),
});
const { registerDevice } = await import("./notification.service");
const result = await registerDevice("u1", "existing-token", "android", "mobile");
expect(result).toEqual(updated);
expect(db.update).toHaveBeenCalled();
});
it("throws CONFLICT when token belongs to another user", async () => {
const existing = {
id: "d1",
userId: "u2",
token: "other-user-token",
platform: "android",
deviceType: "mobile",
isActive: true,
};
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([existing]),
}),
}),
});
const { registerDevice } = await import("./notification.service");
await expect(registerDevice("u1", "other-user-token", "android", "mobile")).rejects.toThrow(TRPCError);
await expect(registerDevice("u1", "other-user-token", "android", "mobile")).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("unregisterDevice", () => {
it("marks a device token as inactive", async () => {
const existing = {
id: "d1",
userId: "u1",
token: "token-1",
platform: "android",
deviceType: "mobile",
isActive: true,
};
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([existing]),
}),
}),
});
const deactivated = { ...existing, isActive: false };
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([deactivated]),
}),
}),
});
const { unregisterDevice } = await import("./notification.service");
const result = await unregisterDevice("u1", "token-1");
expect(result.isActive).toBe(false);
});
it("throws NOT_FOUND when token does not exist", async () => {
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
const { unregisterDevice } = await import("./notification.service");
await expect(unregisterDevice("u1", "nonexistent")).rejects.toThrow(TRPCError);
await expect(unregisterDevice("u1", "nonexistent")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
});
describe("listDevices", () => {
it("returns all devices for a user ordered by creation date", async () => {
const devices = [
{ id: "d1", userId: "u1", token: "token-1", platform: "android", createdAt: new Date("2024-01-01") },
{ id: "d2", userId: "u1", token: "token-2", platform: "ios", createdAt: new Date("2024-01-02") },
];
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue(devices),
}),
}),
});
const { listDevices } = await import("./notification.service");
const result = await listDevices("u1");
expect(result).toEqual(devices);
expect(result).toHaveLength(2);
});
});
describe("getPreferences", () => {
it("returns existing preferences from DB", async () => {
const prefs = {
id: "p1",
userId: "u1",
emailEnabled: false,
pushEnabled: true,
smsEnabled: false,
};
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([prefs]),
}),
}),
});
const { getPreferences } = await import("./notification.service");
const result = await getPreferences("u1");
expect(result).toMatchObject({
emailEnabled: false,
pushEnabled: true,
smsEnabled: false,
});
});
it("returns default preferences when no record exists", async () => {
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
const { getPreferences } = await import("./notification.service");
const result = await getPreferences("u1");
expect(result).toEqual({
emailEnabled: true,
pushEnabled: true,
smsEnabled: true,
});
});
});
describe("updatePreferences", () => {
it("updates existing preferences", async () => {
const existing = {
id: "p1",
userId: "u1",
emailEnabled: true,
pushEnabled: true,
smsEnabled: true,
};
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([existing]),
}),
}),
});
const updated = { ...existing, smsEnabled: false };
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([updated]),
}),
}),
});
const { updatePreferences } = await import("./notification.service");
const result = await updatePreferences("u1", { smsEnabled: false });
expect(result.smsEnabled).toBe(false);
});
it("creates new preferences record when none exists", async () => {
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
const created = {
id: "p-new",
userId: "u1",
emailEnabled: false,
pushEnabled: true,
smsEnabled: true,
};
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([created]),
}),
});
const { updatePreferences } = await import("./notification.service");
const result = await updatePreferences("u1", { emailEnabled: false });
expect(result).toEqual(created);
expect(db.insert).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,256 @@
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;
}