drop notification, reget deps
This commit is contained in:
2
web/.pi-lens/cache/review-graph.json
vendored
2
web/.pi-lens/cache/review-graph.json
vendored
File diff suppressed because one or more lines are too long
@@ -23,10 +23,23 @@
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/server": "^11.17.0",
|
||||
"@typeschema/valibot": "^0.14.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clerk-solidjs": "^2.0.10",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"imapflow": "^1.3.5",
|
||||
"ioredis": "^5.11.0",
|
||||
"isomorphic-dompurify": "^3.15.0",
|
||||
"jose": "^6.2.3",
|
||||
"marked": "^18.0.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"onnxruntime-node": "^1.26.0",
|
||||
"puppeteer": "^25.1.0",
|
||||
"resend": "^6.12.4",
|
||||
"solid-js": "^1.9.5",
|
||||
"stripe": "^22.2.0",
|
||||
"three": "^0.184.0",
|
||||
"valibot": "^1.4.1",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import {
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
authenticateWithApple,
|
||||
createUserWithPassword,
|
||||
forgotPassword,
|
||||
@@ -74,27 +73,6 @@ export async function POST(event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
case "google": {
|
||||
const { idToken } = body;
|
||||
if (!idToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "idToken is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateWithGoogle(idToken);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
image: result.user.image,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: result.isNewUser ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
case "apple": {
|
||||
const { identityToken, authorizationCode, userIdentifier } = body;
|
||||
if (!identityToken || !authorizationCode) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { exampleRouter } from "./routers/example";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { billingRouter } from "./routers/billing";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { darkwatchRouter } from "./routers/darkwatch";
|
||||
import { voiceprintRouter } from "./routers/voiceprint";
|
||||
import { spamshieldRouter } from "./routers/spamshield";
|
||||
@@ -20,7 +19,6 @@ export const appRouter = createTRPCRouter({
|
||||
example: exampleRouter,
|
||||
user: userRouter,
|
||||
billing: billingRouter,
|
||||
notification: notificationRouter,
|
||||
darkwatch: darkwatchRouter,
|
||||
voiceprint: voiceprintRouter,
|
||||
spamshield: spamshieldRouter,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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);
|
||||
}),
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
deleteUser,
|
||||
createUserWithPassword,
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
authenticateWithApple,
|
||||
refreshAccessToken,
|
||||
forgotPassword,
|
||||
@@ -39,10 +38,6 @@ const SignupSchema = object({
|
||||
password: string([minLength(8)]),
|
||||
});
|
||||
|
||||
const GoogleAuthSchema = object({
|
||||
idToken: string([minLength(1)]),
|
||||
});
|
||||
|
||||
const AppleAuthSchema = object({
|
||||
identityToken: string([minLength(1)]),
|
||||
authorizationCode: string([minLength(1)]),
|
||||
@@ -75,12 +70,6 @@ export const userRouter = createTRPCRouter({
|
||||
return createUserWithPassword(input.name, input.email, input.password);
|
||||
}),
|
||||
|
||||
googleAuth: publicProcedure
|
||||
.input(wrap(GoogleAuthSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateWithGoogle(input.idToken);
|
||||
}),
|
||||
|
||||
appleAuth: publicProcedure
|
||||
.input(wrap(AppleAuthSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
|
||||
@@ -17,7 +17,6 @@ export function getHandlers(): HandlerMap {
|
||||
"hometitle.scan": require("./hometitle.scan").handler,
|
||||
"removebrokers.process": require("./removebrokers.process").handler,
|
||||
"reports.generate": require("./reports.generate").handler,
|
||||
"notifications.send": require("./notifications.send").handler,
|
||||
};
|
||||
}
|
||||
return handlers;
|
||||
@@ -31,6 +30,5 @@ export function setHandlers(mock: Partial<HandlerMap>): void {
|
||||
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
|
||||
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
|
||||
"reports.generate": mock["reports.generate"] ?? (async () => {}),
|
||||
"notifications.send": mock["notifications.send"] ?? (async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { alerts, subscriptions, users } from "~/server/db/schema";
|
||||
import { sendEmail, sendPush, sendSMS } from "~/server/services/notification.service";
|
||||
|
||||
interface NotificationsSendPayload {
|
||||
userId: string;
|
||||
alertId?: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export async function handler(payload: NotificationsSendPayload): Promise<void> {
|
||||
const { userId, alertId, channel } = payload;
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
console.warn(`[notifications.send] User ${userId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (alertId) {
|
||||
const [alert] = await db
|
||||
.select()
|
||||
.from(alerts)
|
||||
.where(and(eq(alerts.id, alertId), eq(alerts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!alert) {
|
||||
console.warn(`[notifications.send] Alert ${alertId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendViaChannel(channel, user, alert.title, alert.message);
|
||||
} else {
|
||||
const unsentAlerts = await db
|
||||
.select()
|
||||
.from(alerts)
|
||||
.where(and(eq(alerts.userId, userId), eq(alerts.isRead, false)))
|
||||
.limit(20);
|
||||
|
||||
for (const alert of unsentAlerts) {
|
||||
for (const ch of alert.channel as string[]) {
|
||||
await sendViaChannel(ch, user, alert.title, alert.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaChannel(channel: string, user: { email: string; id: string }, title: string, message: string): Promise<void> {
|
||||
try {
|
||||
switch (channel) {
|
||||
case "email":
|
||||
await sendEmail(user.email, title, `<p>${message}</p>`);
|
||||
break;
|
||||
case "push":
|
||||
await sendPush(user.id, title, message);
|
||||
break;
|
||||
case "sms":
|
||||
await sendSMS(user.email, message);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[notifications.send] Failed to send via ${channel}:`, err);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export const JOB_TYPES = [
|
||||
"hometitle.scan",
|
||||
"removebrokers.process",
|
||||
"reports.generate",
|
||||
"notifications.send",
|
||||
] as const;
|
||||
|
||||
export type JobType = (typeof JOB_TYPES)[number];
|
||||
@@ -19,7 +18,6 @@ export type JobPayload = {
|
||||
"hometitle.scan": { userId: string; subscriptionId: string };
|
||||
"removebrokers.process": { subscriptionId?: string; requestId?: string };
|
||||
"reports.generate": { userId: string; reportScheduleId?: string; reportType: string };
|
||||
"notifications.send": { userId: string; alertId?: string; channel: string };
|
||||
};
|
||||
|
||||
export type JobStatus = "pending" | "running" | "completed" | "failed";
|
||||
@@ -137,7 +135,7 @@ function createRedisAdapter(): QueueAdapter {
|
||||
});
|
||||
|
||||
const queue = new BullMQ.Queue("kordant-jobs", { connection });
|
||||
let bullJobs = new Map<string, any>();
|
||||
const bullJobs = new Map<string, any>();
|
||||
|
||||
async function toJob(bullJob: any): Promise<Job> {
|
||||
return {
|
||||
|
||||
42
web/src/server/lib/email.ts
Normal file
42
web/src/server/lib/email.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { resend } from "~/server/lib/resend";
|
||||
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
text?: string,
|
||||
) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn("[email] Resend not configured, skipping email");
|
||||
return { id: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL ?? "noreply@kordant.ai",
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: text ?? "",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[email] Resend error:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[email] Email sent:", data?.id);
|
||||
return { id: data?.id ?? null };
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCError) throw err;
|
||||
console.error("[email] Email send error:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,7 @@ const envSchema = object({
|
||||
// Email
|
||||
RESEND_API_KEY: optional(string()),
|
||||
|
||||
// Push
|
||||
FCM_PROJECT_ID: optional(string()),
|
||||
FCM_CLIENT_EMAIL: optional(string()),
|
||||
FCM_PRIVATE_KEY: optional(string()),
|
||||
|
||||
|
||||
// SMS
|
||||
TWILIO_ACCOUNT_SID: optional(string()),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -7,11 +7,9 @@ vi.mock("~/server/websocket", () => ({
|
||||
broadcastToUser: mockBroadcastToUser,
|
||||
}));
|
||||
|
||||
const mockSendPush = vi.fn();
|
||||
const mockSendEmail = vi.fn();
|
||||
|
||||
vi.mock("~/server/services/notification.service", () => ({
|
||||
sendPush: mockSendPush,
|
||||
vi.mock("~/server/lib/email", () => ({
|
||||
sendEmail: mockSendEmail,
|
||||
}));
|
||||
|
||||
@@ -54,13 +52,20 @@ describe("alert.publisher", () => {
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
expect(mockSendPush).not.toHaveBeenCalled();
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fall back to push notification when user is not connected", async () => {
|
||||
it("should fall back to email when user is not connected and has email", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(false);
|
||||
mockSendPush.mockResolvedValue({ successCount: 1 });
|
||||
|
||||
const db = await import("~/server/db");
|
||||
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: "user-1", email: "user@example.com" }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { publishAlert } = await import("./alert.publisher");
|
||||
await publishAlert("user-1", {
|
||||
@@ -74,17 +79,16 @@ describe("alert.publisher", () => {
|
||||
});
|
||||
|
||||
expect(mockBroadcastToUser).toHaveBeenCalled();
|
||||
expect(mockSendPush).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"Offline Alert",
|
||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||
"user@example.com",
|
||||
"[Kordant] Offline Alert",
|
||||
"<p>Offline message</p>",
|
||||
"Offline message",
|
||||
{ alertId: "alert-2", source: "VOICEPRINT", severity: "WARNING" },
|
||||
);
|
||||
});
|
||||
|
||||
it("should publish alert to multiple users", async () => {
|
||||
it("should not send email when user has no email", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(false);
|
||||
mockSendPush.mockResolvedValue({ successCount: 0 });
|
||||
|
||||
const db = await import("~/server/db");
|
||||
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
@@ -95,9 +99,26 @@ describe("alert.publisher", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const { publishAlert } = await import("./alert.publisher");
|
||||
await publishAlert("user-1", {
|
||||
id: "alert-3",
|
||||
title: "No Email",
|
||||
message: "No email",
|
||||
severity: "INFO",
|
||||
source: "HOME_TITLE",
|
||||
category: "HOME_TITLE",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should publish alert to multiple users", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(true);
|
||||
|
||||
const { publishToGroup } = await import("./alert.publisher");
|
||||
await publishToGroup(["user-1", "user-2"], {
|
||||
id: "alert-3",
|
||||
id: "alert-4",
|
||||
title: "Group Alert",
|
||||
message: "Group message",
|
||||
severity: "INFO",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { broadcastToUser } from "~/server/websocket";
|
||||
import { sendPush, sendEmail } from "~/server/services/notification.service";
|
||||
import { sendEmail } from "~/server/lib/email";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -31,31 +31,23 @@ export async function publishAlert(userId: string, alert: PublishableAlert): Pro
|
||||
const sent = broadcastToUser(userId, message);
|
||||
|
||||
if (!sent) {
|
||||
try {
|
||||
const pushResult = await sendPush(userId, alert.title, alert.message, {
|
||||
alertId: alert.id,
|
||||
source: alert.source,
|
||||
severity: alert.severity,
|
||||
});
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (pushResult.successCount === 0) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (user?.email) {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
`[Kordant] ${alert.title}`,
|
||||
`<p>${alert.message}</p>`,
|
||||
alert.message,
|
||||
);
|
||||
}
|
||||
if (user?.email) {
|
||||
try {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
`[Kordant] ${alert.title}`,
|
||||
`<p>${alert.message}</p>`,
|
||||
alert.message,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[alert.publisher] Email notification failed:", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[alert.publisher] Fallback notification failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { digestAlerts, notificationPreferences } from "~/server/db/schema";
|
||||
import { sendEmail } from "~/server/services/notification.service";
|
||||
import { sendEmail } from "~/server/lib/email";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digest configuration
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
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@kordant.ai",
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
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@kordant.ai",
|
||||
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;
|
||||
}
|
||||
@@ -67,148 +67,10 @@ export async function authenticateUser(
|
||||
return { user, sessionToken: session.sessionToken, accessToken };
|
||||
}
|
||||
|
||||
const GOOGLE_ISSUER = "https://accounts.google.com";
|
||||
const APPLE_ISSUER = "https://appleid.apple.com";
|
||||
const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys");
|
||||
|
||||
/**
|
||||
* Verifies a Google ID token using firebase-admin and returns the user.
|
||||
* If the user does not exist, creates a new account.
|
||||
* If the user exists but has not linked Google, links the provider.
|
||||
*/
|
||||
export async function authenticateWithGoogle(idToken: string) {
|
||||
const { initializeApp, cert, getApps } = await import("firebase-admin/app");
|
||||
|
||||
// Initialize Firebase Admin if not already done
|
||||
if (getApps().length === 0) {
|
||||
// Try to load from environment or use application default credentials
|
||||
const projectId = process.env.FIREBASE_PROJECT_ID;
|
||||
const clientEmail = process.env.FIREBASE_CLIENT_EMAIL;
|
||||
const privateKey = process.env.FIREBASE_PRIVATE_KEY;
|
||||
|
||||
if (projectId && clientEmail && privateKey) {
|
||||
initializeApp({
|
||||
credential: cert({
|
||||
projectId,
|
||||
clientEmail,
|
||||
privateKey: privateKey.replace(/\\n/g, "\n"),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// Fall back to application default credentials
|
||||
initializeApp({ projectId: projectId ?? "kordant" });
|
||||
}
|
||||
}
|
||||
|
||||
let decodedToken: { uid: string; email?: string; name?: string; picture?: string };
|
||||
try {
|
||||
const authModule = await import("firebase-admin/auth");
|
||||
decodedToken = await authModule.getAuth().verifyIdToken(idToken);
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token",
|
||||
});
|
||||
}
|
||||
|
||||
const googleUserId = decodedToken.uid;
|
||||
const email = decodedToken.email;
|
||||
const name = decodedToken.name ?? email?.split("@")[0] ?? "User";
|
||||
const avatarUrl = decodedToken.picture ?? null;
|
||||
|
||||
if (!email) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Google account has no email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this Google account is already linked
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, "google"),
|
||||
eq(accounts.providerAccountId, googleUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let userId: string;
|
||||
let isNewUser = false;
|
||||
|
||||
if (existingAccount) {
|
||||
// Already linked — use the existing user
|
||||
userId = existingAccount.userId;
|
||||
isNewUser = false;
|
||||
|
||||
// Update the access token if provided
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
accessToken: idToken,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(accounts.id, existingAccount.id));
|
||||
} else {
|
||||
// Not linked — check if a user with this email exists
|
||||
const [existingUserByEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (existingUserByEmail) {
|
||||
// Link Google to existing user
|
||||
userId = existingUserByEmail.id;
|
||||
isNewUser = false;
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "google",
|
||||
providerAccountId: googleUserId,
|
||||
accessToken: idToken,
|
||||
});
|
||||
|
||||
// Update avatar if not set
|
||||
if (!existingUserByEmail.image && avatarUrl) {
|
||||
await db.update(users).set({ image: avatarUrl }).where(eq(users.id, userId));
|
||||
}
|
||||
} else {
|
||||
// Create new user with Google
|
||||
isNewUser = true;
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name,
|
||||
email,
|
||||
image: avatarUrl,
|
||||
emailVerified: new Date(),
|
||||
})
|
||||
.returning();
|
||||
userId = newUser.id;
|
||||
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "google",
|
||||
providerAccountId: googleUserId,
|
||||
accessToken: idToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create session and JWT
|
||||
const session = await createSession(userId);
|
||||
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" });
|
||||
}
|
||||
|
||||
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an Apple identity token and authenticates the user.
|
||||
|
||||
Reference in New Issue
Block a user