import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createNotification } from "../lib/create-notification.js"; import { sendEmail } from "../lib/email.js"; import { emitTaskAssigned } from "../sse/event-bus.js"; import type { TRPCContext } from "../trpc.js"; export type NotificationProcedureContext = Pick< TRPCContext, "db" | "dbUser" | "roleDefaults" | "session" >; export function requireNotificationDbUser(ctx: NotificationProcedureContext) { if (!ctx.dbUser) { throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" }); } return ctx.dbUser; } export async function resolveUserId(ctx: { db: { user: { findUnique: (args: { where: { email: string }; select: { id: true }; }) => Promise<{ id: string } | null>; }; }; session: { user?: { email?: string | null } | null } | null; }): Promise { const email = ctx.session?.user?.email; if (!email) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const user = await ctx.db.user.findUnique({ where: { email }, select: { id: true }, }); if (!user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return user.id; } export function getNotificationErrorCandidates(error: unknown): Array<{ code?: unknown; message?: unknown; meta?: { field_name?: unknown; modelName?: unknown }; cause?: unknown; }> { const queue: unknown[] = [error]; const seen = new Set(); const candidates: Array<{ code?: unknown; message?: unknown; meta?: { field_name?: unknown; modelName?: unknown }; cause?: unknown; }> = []; while (queue.length > 0) { const current = queue.shift(); if (!current || seen.has(current) || typeof current !== "object") { continue; } seen.add(current); const candidate = current as { code?: unknown; message?: unknown; meta?: { field_name?: unknown; modelName?: unknown }; cause?: unknown; shape?: { message?: unknown; data?: { cause?: unknown } }; }; candidates.push(candidate); if ("cause" in candidate) { queue.push(candidate.cause); } if (candidate.shape?.data?.cause) { queue.push(candidate.shape.data.cause); } } return candidates; } export function rethrowNotificationReferenceError( error: unknown, recipientContext: "notification" | "task" | "broadcast" = "notification", ): never { for (const candidate of getNotificationErrorCandidates(error)) { const fieldName = typeof candidate.meta?.field_name === "string" ? candidate.meta.field_name.toLowerCase() : ""; const modelName = typeof candidate.meta?.modelName === "string" ? candidate.meta.modelName.toLowerCase() : ""; if ( typeof candidate.code === "string" && (candidate.code === "P2003" || candidate.code === "P2025") && fieldName.includes("assignee") ) { throw new TRPCError({ code: "NOT_FOUND", message: "Assignee user not found", cause: error, }); } if ( typeof candidate.code === "string" && (candidate.code === "P2003" || candidate.code === "P2025") && fieldName.includes("sender") ) { throw new TRPCError({ code: "NOT_FOUND", message: "Sender user not found", cause: error, }); } if ( typeof candidate.code === "string" && (candidate.code === "P2003" || candidate.code === "P2025") && fieldName.includes("userid") ) { const message = recipientContext === "broadcast" ? "Broadcast recipient user not found" : recipientContext === "task" ? "Task recipient user not found" : "Notification recipient user not found"; throw new TRPCError({ code: "NOT_FOUND", message, cause: error, }); } if ( typeof candidate.code === "string" && (candidate.code === "P2003" || candidate.code === "P2025") && (modelName.includes("notificationbroadcast") || fieldName.includes("broadcast") || fieldName.includes("sourceid")) ) { throw new TRPCError({ code: "NOT_FOUND", message: "Notification broadcast not found", cause: error, }); } } throw error; } export const categoryEnum = z.enum(["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"]); export const priorityEnum = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]); export const taskStatusEnum = z.enum(["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"]); export const channelEnum = z.enum(["in_app", "email", "both"]); export const recurrenceEnum = z.enum(["daily", "weekly", "monthly"]); export const targetTypeEnum = z.enum(["user", "role", "project", "orgUnit", "all"]); export const SCHEDULED_TASK_BROADCAST_UNSUPPORTED_MESSAGE = "Scheduled broadcasts with task metadata are not supported yet."; export const NotificationListInputSchema = z.object({ unreadOnly: z.boolean().optional(), category: categoryEnum.optional(), taskStatus: taskStatusEnum.optional(), priority: priorityEnum.optional(), limit: z.number().min(1).max(100).default(50), }); export const MarkNotificationReadInputSchema = z.object({ id: z.string().optional(), }); export const CreateManagedNotificationInputSchema = z.object({ userId: z.string(), type: z.string().max(100), title: z.string().max(500), body: z.string().max(2000).optional(), entityId: z.string().max(200).optional(), entityType: z.string().max(200).optional(), category: categoryEnum.optional(), priority: priorityEnum.optional(), link: z.string().max(1000).optional(), taskStatus: taskStatusEnum.optional(), taskAction: z.string().max(200).optional(), assigneeId: z.string().optional(), dueDate: z.date().optional(), channel: channelEnum.optional(), senderId: z.string().optional(), }); export const ListNotificationTasksInputSchema = z.object({ status: taskStatusEnum.optional(), includeAssigned: z.boolean().default(true), limit: z.number().min(1).max(100).default(20), }); export const NotificationIdInputSchema = z.object({ id: z.string().max(64), }); export const UpdateNotificationTaskStatusInputSchema = z.object({ id: z.string().max(64), status: taskStatusEnum, }); export const CreateReminderInputSchema = z.object({ title: z.string().min(1).max(200), body: z.string().max(2000).optional(), remindAt: z.date(), recurrence: recurrenceEnum.optional(), entityId: z.string().max(64).optional(), entityType: z.string().max(64).optional(), link: z.string().max(2048).optional(), }); export const UpdateReminderInputSchema = z.object({ id: z.string().max(64), title: z.string().min(1).max(200).optional(), body: z.string().max(2000).optional(), remindAt: z.date().optional(), recurrence: recurrenceEnum.nullish(), }); export const ListRemindersInputSchema = z.object({ limit: z.number().min(1).max(100).default(20), }); export const CreateBroadcastInputSchema = z.object({ title: z.string().min(1).max(200), body: z.string().max(2000).optional(), link: z.string().max(2048).optional(), category: categoryEnum.default("NOTIFICATION"), priority: priorityEnum.default("NORMAL"), channel: channelEnum.default("in_app"), targetType: targetTypeEnum, targetValue: z.string().max(200).optional(), scheduledAt: z.date().optional(), taskAction: z.string().max(64).optional(), dueDate: z.date().optional(), }); export const ListBroadcastsInputSchema = z.object({ limit: z.number().min(1).max(50).default(20), }); export const CreateTaskInputSchema = z.object({ userId: z.string().max(64), title: z.string().min(1).max(200), body: z.string().max(2000).optional(), priority: priorityEnum.default("NORMAL"), dueDate: z.date().optional(), taskAction: z.string().max(64).optional(), entityId: z.string().max(64).optional(), entityType: z.string().max(64).optional(), link: z.string().max(2048).optional(), channel: channelEnum.default("in_app"), }); export const AssignTaskInputSchema = z.object({ id: z.string().max(64), assigneeId: z.string().max(64), }); export type BroadcastRecipientNotification = { id: string; userId: string }; export type BroadcastPersistenceDb = NotificationProcedureContext["db"]; export async function sendNotificationEmail( db: { user: { findUnique: (args: { where: { id: string }; select: { email: true; name: true }; }) => Promise<{ email: string; name: string | null } | null>; }; }, userId: string, title: string, body?: string | null, ): Promise { try { const user = await db.user.findUnique({ where: { id: userId }, select: { email: true, name: true }, }); if (!user) { return; } void sendEmail({ to: user.email, subject: title, text: body ?? title, ...(body !== undefined && body !== null ? { html: `

${body}

` } : {}), }); } catch { // non-blocking } } export async function listNotifications( ctx: NotificationProcedureContext, input: z.infer, ) { const userId = await resolveUserId(ctx); return ctx.db.notification.findMany({ where: { userId, ...(input.unreadOnly ? { readAt: null } : {}), ...(input.category !== undefined ? { category: input.category } : {}), ...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}), ...(input.priority !== undefined ? { priority: input.priority } : {}), }, orderBy: { createdAt: "desc" }, take: input.limit, }); } export async function countUnreadNotifications(ctx: NotificationProcedureContext) { const userId = await resolveUserId(ctx); return ctx.db.notification.count({ where: { userId, readAt: null }, }); } export async function markNotificationsRead( ctx: NotificationProcedureContext, input: z.infer, ) { const userId = await resolveUserId(ctx); const now = new Date(); if (input.id) { await ctx.db.notification.update({ where: { id: input.id, userId }, data: { readAt: now }, }); return; } await ctx.db.notification.updateMany({ where: { userId, readAt: null }, data: { readAt: now }, }); } export async function createManagedNotification( ctx: NotificationProcedureContext, input: z.infer, ) { const currentUserId = requireNotificationDbUser(ctx).id; const isTaskLikeCategory = input.category === "TASK" || input.category === "APPROVAL"; const taskStatus = input.taskStatus ?? (isTaskLikeCategory ? "OPEN" : undefined); let notificationId: string; try { notificationId = await createNotification({ db: ctx.db, userId: input.userId, type: input.type, title: input.title, body: input.body, entityId: input.entityId, entityType: input.entityType, category: input.category, priority: input.priority, link: input.link, taskStatus, taskAction: input.taskAction, assigneeId: input.assigneeId, dueDate: input.dueDate, channel: input.channel, senderId: input.senderId ?? currentUserId, }); } catch (error) { rethrowNotificationReferenceError(error, "notification"); } if (input.category === "TASK" || input.category === "APPROVAL") { emitTaskAssigned(input.userId, notificationId); } const channel = input.channel ?? "in_app"; if (channel === "email" || channel === "both") { void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); } return ctx.db.notification.findUnique({ where: { id: notificationId } }); } export async function deleteNotification( ctx: NotificationProcedureContext, input: z.infer, ) { const userId = await resolveUserId(ctx); const existing = await ctx.db.notification.findFirst({ where: { id: input.id, userId }, }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Notification not found", }); } if ( (existing.category === "TASK" || existing.category === "APPROVAL") && existing.senderId && existing.senderId !== userId ) { throw new TRPCError({ code: "FORBIDDEN", message: "Cannot delete tasks created by others", }); } await ctx.db.notification.delete({ where: { id: input.id } }); }