diff --git a/packages/api/src/router/notification-broadcast-procedure-support.ts b/packages/api/src/router/notification-broadcast-procedure-support.ts new file mode 100644 index 0000000..a5490e5 --- /dev/null +++ b/packages/api/src/router/notification-broadcast-procedure-support.ts @@ -0,0 +1,222 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { createNotification } from "../lib/create-notification.js"; +import { resolveRecipients } from "../lib/notification-targeting.js"; +import { + emitNotificationCreated, + emitTaskAssigned, +} from "../sse/event-bus.js"; +import { + type BroadcastPersistenceDb, + type BroadcastRecipientNotification, + CreateBroadcastInputSchema, + ListBroadcastsInputSchema, + type NotificationProcedureContext, + NotificationIdInputSchema, + requireNotificationDbUser, + rethrowNotificationReferenceError, + SCHEDULED_TASK_BROADCAST_UNSUPPORTED_MESSAGE, + sendNotificationEmail, +} from "./notification-procedure-base.js"; + +function isFutureScheduledBroadcast(input: z.infer): boolean { + return Boolean(input.scheduledAt && input.scheduledAt > new Date()); +} + +function hasTaskLikeBroadcastMetadata(input: z.infer): boolean { + return input.category === "TASK" + || input.category === "APPROVAL" + || input.taskAction !== undefined + || input.dueDate !== undefined; +} + +function buildBroadcastCreateData( + senderId: string, + input: z.infer, + recipientCount?: number, +) { + return { + senderId, + title: input.title, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.link !== undefined ? { link: input.link } : {}), + category: input.category, + priority: input.priority, + channel: input.channel, + targetType: input.targetType, + ...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}), + ...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}), + ...(recipientCount !== undefined ? { recipientCount } : {}), + }; +} + +async function resolveBroadcastRecipientIds( + ctx: NotificationProcedureContext, + input: z.infer, + senderId: string, +): Promise { + const recipientIds = await resolveRecipients( + input.targetType, + input.targetValue, + ctx.db, + senderId, + ); + + if (recipientIds.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No recipients matched the broadcast target.", + }); + } + + return recipientIds; +} + +async function createScheduledBroadcastRecord( + db: BroadcastPersistenceDb, + senderId: string, + input: z.infer, + recipientIds: string[], +) { + return db.notificationBroadcast.create({ + data: buildBroadcastCreateData(senderId, input, recipientIds.length), + }); +} + +async function persistImmediateBroadcast( + db: BroadcastPersistenceDb, + senderId: string, + input: z.infer, + recipientIds: string[], +) { + const isTask = input.category === "TASK" || input.category === "APPROVAL"; + const sentAt = new Date(); + const broadcast = await db.notificationBroadcast.create({ + data: buildBroadcastCreateData(senderId, input), + }); + + const notificationIds: BroadcastRecipientNotification[] = []; + for (const recipientUserId of recipientIds) { + const notificationId = await createNotification({ + db, + userId: recipientUserId, + type: `BROADCAST_${input.category}`, + title: input.title, + body: input.body, + link: input.link, + category: input.category, + priority: input.priority, + channel: input.channel, + sourceId: broadcast.id, + senderId, + taskStatus: isTask ? "OPEN" : undefined, + taskAction: input.taskAction, + dueDate: input.dueDate, + emit: false, + }); + notificationIds.push({ id: notificationId, userId: recipientUserId }); + } + + const updatedBroadcast = await db.notificationBroadcast.update({ + where: { id: broadcast.id }, + data: { + sentAt, + recipientCount: notificationIds.length, + }, + }); + + return { broadcast: updatedBroadcast, notificationIds }; +} + +function emitImmediateBroadcastSideEffects( + db: BroadcastPersistenceDb, + input: z.infer, + notificationIds: BroadcastRecipientNotification[], +) { + const isTask = input.category === "TASK" || input.category === "APPROVAL"; + + for (const notification of notificationIds) { + emitNotificationCreated(notification.userId, notification.id); + if (isTask) { + emitTaskAssigned(notification.userId, notification.id); + } + } + + if (input.channel === "email" || input.channel === "both") { + for (const notification of notificationIds) { + void sendNotificationEmail(db, notification.userId, input.title, input.body); + } + } +} + +export async function createBroadcast( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const senderId = requireNotificationDbUser(ctx).id; + const isScheduledFutureBroadcast = isFutureScheduledBroadcast(input); + + if (isScheduledFutureBroadcast && hasTaskLikeBroadcastMetadata(input)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: SCHEDULED_TASK_BROADCAST_UNSUPPORTED_MESSAGE, + }); + } + + const recipientIds = await resolveBroadcastRecipientIds(ctx, input, senderId); + + if (isScheduledFutureBroadcast) { + try { + return await createScheduledBroadcastRecord(ctx.db, senderId, input, recipientIds); + } catch (error) { + rethrowNotificationReferenceError(error); + } + } + + let persistedBroadcast: Awaited>["broadcast"]; + let notificationIds: BroadcastRecipientNotification[] = []; + + try { + const transactionResult = typeof ctx.db.$transaction === "function" + ? await ctx.db.$transaction((tx) => + persistImmediateBroadcast(tx as typeof ctx.db, senderId, input, recipientIds)) + : await persistImmediateBroadcast(ctx.db, senderId, input, recipientIds); + + persistedBroadcast = transactionResult.broadcast; + notificationIds = transactionResult.notificationIds; + } catch (error) { + rethrowNotificationReferenceError(error); + } + + emitImmediateBroadcastSideEffects(ctx.db, input, notificationIds); + return persistedBroadcast; +} + +export async function listBroadcasts( + ctx: NotificationProcedureContext, + input: z.infer, +) { + return ctx.db.notificationBroadcast.findMany({ + orderBy: { createdAt: "desc" }, + take: input.limit, + include: { + sender: { select: { id: true, name: true, email: true } }, + }, + }); +} + +export async function getBroadcastById( + ctx: NotificationProcedureContext, + input: z.infer, +) { + return findUniqueOrThrow( + ctx.db.notificationBroadcast.findUnique({ + where: { id: input.id }, + include: { + sender: { select: { id: true, name: true, email: true } }, + }, + }), + "Broadcast", + ); +} diff --git a/packages/api/src/router/notification-procedure-base.ts b/packages/api/src/router/notification-procedure-base.ts new file mode 100644 index 0000000..6a584e2 --- /dev/null +++ b/packages/api/src/router/notification-procedure-base.ts @@ -0,0 +1,398 @@ +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; + +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): 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("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") + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Broadcast recipient user not found", + 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(), + title: z.string(), + body: z.string().optional(), + entityId: z.string().optional(), + entityType: z.string().optional(), + category: categoryEnum.optional(), + priority: priorityEnum.optional(), + link: z.string().optional(), + taskStatus: taskStatusEnum.optional(), + taskAction: z.string().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(), +}); + +export const UpdateNotificationTaskStatusInputSchema = z.object({ + id: z.string(), + 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().optional(), + entityType: z.string().optional(), + link: z.string().optional(), +}); + +export const UpdateReminderInputSchema = z.object({ + id: z.string(), + 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().optional(), + category: categoryEnum.default("NOTIFICATION"), + priority: priorityEnum.default("NORMAL"), + channel: channelEnum.default("in_app"), + targetType: targetTypeEnum, + targetValue: z.string().optional(), + scheduledAt: z.date().optional(), + taskAction: z.string().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(), + title: z.string().min(1).max(200), + body: z.string().max(2000).optional(), + priority: priorityEnum.default("NORMAL"), + dueDate: z.date().optional(), + taskAction: z.string().optional(), + entityId: z.string().optional(), + entityType: z.string().optional(), + link: z.string().optional(), + channel: channelEnum.default("in_app"), +}); + +export const AssignTaskInputSchema = z.object({ + id: z.string(), + assigneeId: z.string(), +}); + +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 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: input.taskStatus, + taskAction: input.taskAction, + assigneeId: input.assigneeId, + dueDate: input.dueDate, + channel: input.channel, + senderId: input.senderId ?? currentUserId, + }); + + 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 } }); +} diff --git a/packages/api/src/router/notification-procedure-support.ts b/packages/api/src/router/notification-procedure-support.ts index 6b74ee2..2c9030a 100644 --- a/packages/api/src/router/notification-procedure-support.ts +++ b/packages/api/src/router/notification-procedure-support.ts @@ -1,945 +1,4 @@ -import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { sendEmail } from "../lib/email.js"; -import { createNotification } from "../lib/create-notification.js"; -import { resolveRecipients } from "../lib/notification-targeting.js"; -import { getTaskAction } from "../lib/task-actions.js"; -import { - emitNotificationCreated, - emitTaskAssigned, - emitTaskCompleted, - emitTaskStatusChanged, -} from "../sse/event-bus.js"; -import type { TRPCContext } from "../trpc.js"; - -type NotificationProcedureContext = Pick; - -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 }; -}): 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; -} - -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 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): 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("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") - ) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Broadcast recipient user not found", - cause: error, - }); - } - - if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && (modelName.includes("notificationbroadcast") || fieldName.includes("broadcast")) - ) { - 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(), - title: z.string(), - body: z.string().optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), - category: categoryEnum.optional(), - priority: priorityEnum.optional(), - link: z.string().optional(), - taskStatus: taskStatusEnum.optional(), - taskAction: z.string().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(), -}); - -export const UpdateNotificationTaskStatusInputSchema = z.object({ - id: z.string(), - 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().optional(), - entityType: z.string().optional(), - link: z.string().optional(), -}); - -export const UpdateReminderInputSchema = z.object({ - id: z.string(), - 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().optional(), - category: categoryEnum.default("NOTIFICATION"), - priority: priorityEnum.default("NORMAL"), - channel: channelEnum.default("in_app"), - targetType: targetTypeEnum, - targetValue: z.string().optional(), - scheduledAt: z.date().optional(), - taskAction: z.string().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(), - title: z.string().min(1).max(200), - body: z.string().max(2000).optional(), - priority: priorityEnum.default("NORMAL"), - dueDate: z.date().optional(), - taskAction: z.string().optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), - link: z.string().optional(), - channel: channelEnum.default("in_app"), -}); - -export const AssignTaskInputSchema = z.object({ - id: z.string(), - assigneeId: z.string(), -}); - -type NotificationListInput = z.infer; -type MarkNotificationReadInput = z.infer; -type CreateManagedNotificationInput = z.infer; -type ListNotificationTasksInput = z.infer; -type NotificationIdInput = z.infer; -type UpdateNotificationTaskStatusInput = z.infer; -type CreateReminderInput = z.infer; -type UpdateReminderInput = z.infer; -type ListRemindersInput = z.infer; -type CreateBroadcastInput = z.infer; -type ListBroadcastsInput = z.infer; -type CreateTaskInput = z.infer; -type AssignTaskInput = z.infer; - -export async function listNotifications( - ctx: NotificationProcedureContext, - input: NotificationListInput, -) { - 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: MarkNotificationReadInput, -) { - 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: CreateManagedNotificationInput, -) { - const currentUserId = ctx.dbUser.id; - - const 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: input.taskStatus, - taskAction: input.taskAction, - assigneeId: input.assigneeId, - dueDate: input.dueDate, - channel: input.channel, - senderId: input.senderId ?? currentUserId, - }); - - 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 listNotificationTasks( - ctx: NotificationProcedureContext, - input: ListNotificationTasksInput, -) { - const userId = await resolveUserId(ctx); - const userFilter = input.includeAssigned - ? { OR: [{ userId }, { assigneeId: userId }] } - : { userId }; - - return ctx.db.notification.findMany({ - where: { - ...userFilter, - category: { in: ["TASK", "APPROVAL"] }, - ...(input.status !== undefined ? { taskStatus: input.status } : {}), - }, - orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], - take: input.limit, - }); -} - -export async function getNotificationTaskCounts(ctx: NotificationProcedureContext) { - const userId = await resolveUserId(ctx); - const now = new Date(); - const where = { - OR: [{ userId }, { assigneeId: userId }], - category: { in: ["TASK" as const, "APPROVAL" as const] }, - }; - - const [grouped, overdue] = await Promise.all([ - ctx.db.notification.groupBy({ - by: ["taskStatus"], - where, - _count: true, - }), - ctx.db.notification.count({ - where: { - ...where, - taskStatus: { in: ["OPEN", "IN_PROGRESS"] }, - dueDate: { lt: now }, - }, - }), - ]); - - const counts: Record = {}; - for (const group of grouped) { - if (group.taskStatus) { - counts[group.taskStatus] = group._count; - } - } - - return { - open: counts.OPEN ?? 0, - inProgress: counts.IN_PROGRESS ?? 0, - done: counts.DONE ?? 0, - dismissed: counts.DISMISSED ?? 0, - overdue, - }; -} - -export async function getNotificationTaskDetail( - ctx: NotificationProcedureContext, - input: NotificationIdInput, -) { - const userId = await resolveUserId(ctx); - const task = await ctx.db.notification.findFirst({ - where: { - id: input.id, - OR: [{ userId }, { assigneeId: userId }], - category: { in: ["TASK", "APPROVAL"] }, - }, - select: { - id: true, - title: true, - body: true, - type: true, - priority: true, - category: true, - taskStatus: true, - taskAction: true, - dueDate: true, - entityId: true, - entityType: true, - completedAt: true, - completedBy: true, - createdAt: true, - userId: true, - assigneeId: true, - sender: { select: { id: true, name: true, email: true } }, - }, - }); - - if (!task) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Task not found or you do not have permission", - }); - } - - return task; -} - -export async function updateNotificationTaskStatus( - ctx: NotificationProcedureContext, - input: UpdateNotificationTaskStatusInput, -) { - const userId = await resolveUserId(ctx); - const existing = await ctx.db.notification.findFirst({ - where: { - id: input.id, - OR: [{ userId }, { assigneeId: userId }], - }, - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Task not found or you do not have permission", - }); - } - - const isCompleting = input.status === "DONE"; - const updated = await ctx.db.notification.update({ - where: { id: input.id }, - data: { - taskStatus: input.status, - ...(isCompleting ? { completedAt: new Date(), completedBy: userId } : {}), - }, - }); - - if (isCompleting) { - emitTaskCompleted(existing.userId, updated.id); - if (existing.assigneeId && existing.assigneeId !== existing.userId) { - emitTaskCompleted(existing.assigneeId, updated.id); - } - } else { - emitTaskStatusChanged(existing.userId, updated.id); - if (existing.assigneeId && existing.assigneeId !== existing.userId) { - emitTaskStatusChanged(existing.assigneeId, updated.id); - } - } - - return updated; -} - -export async function executeNotificationTaskAction( - ctx: NotificationProcedureContext, - input: NotificationIdInput, -) { - const userId = await resolveUserId(ctx); - const task = await ctx.db.notification.findFirst({ - where: { - id: input.id, - OR: [{ userId }, { assigneeId: userId }], - category: { in: ["TASK", "APPROVAL"] }, - }, - select: { - id: true, - userId: true, - assigneeId: true, - taskAction: true, - taskStatus: true, - }, - }); - - if (!task) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Task not found or you do not have permission", - }); - } - if (!task.taskAction) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "This task has no executable action", - }); - } - if (task.taskStatus === "DONE") { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "This task is already completed", - }); - } - - const parsed = parseTaskAction(task.taskAction); - if (!parsed) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid taskAction format: ${task.taskAction}`, - }); - } - - const handler = getTaskAction(parsed.action); - if (!handler) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown action: ${parsed.action}`, - }); - } - - const permissions = resolvePermissions( - ctx.dbUser.systemRole as import("@capakraken/shared").SystemRole, - ctx.dbUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null, - ctx.roleDefaults ?? undefined, - ); - if (handler.permission && !permissions.has(handler.permission as PermissionKey)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `Permission denied: you need "${handler.permission}" to perform this action`, - }); - } - - const actionResult = await handler.execute(parsed.entityId, ctx.db, userId); - if (!actionResult.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: actionResult.message, - }); - } - - const completedTask = await ctx.db.notification.update({ - where: { id: input.id }, - data: { - taskStatus: "DONE", - completedAt: new Date(), - completedBy: userId, - }, - }); - - emitTaskCompleted(task.userId, task.id); - if (task.assigneeId && task.assigneeId !== task.userId) { - emitTaskCompleted(task.assigneeId, task.id); - } - - return { - task: completedTask, - actionResult, - }; -} - -export async function createReminder( - ctx: NotificationProcedureContext, - input: CreateReminderInput, -) { - const userId = await resolveUserId(ctx); - return ctx.db.notification.create({ - data: { - userId, - type: "REMINDER", - category: "REMINDER", - title: input.title, - ...(input.body !== undefined ? { body: input.body } : {}), - remindAt: input.remindAt, - nextRemindAt: input.remindAt, - ...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}), - ...(input.entityId !== undefined ? { entityId: input.entityId } : {}), - ...(input.entityType !== undefined ? { entityType: input.entityType } : {}), - ...(input.link !== undefined ? { link: input.link } : {}), - channel: "in_app", - }, - }); -} - -export async function updateReminder( - ctx: NotificationProcedureContext, - input: UpdateReminderInput, -) { - const userId = await resolveUserId(ctx); - const existing = await ctx.db.notification.findFirst({ - where: { id: input.id, userId, category: "REMINDER" }, - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Reminder not found or you do not have permission", - }); - } - - return ctx.db.notification.update({ - where: { id: input.id }, - data: { - ...(input.title !== undefined ? { title: input.title } : {}), - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.remindAt !== undefined - ? { remindAt: input.remindAt, nextRemindAt: input.remindAt } - : {}), - ...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}), - }, - }); -} - -export async function deleteReminder( - ctx: NotificationProcedureContext, - input: NotificationIdInput, -) { - const userId = await resolveUserId(ctx); - const existing = await ctx.db.notification.findFirst({ - where: { id: input.id, userId, category: "REMINDER" }, - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Reminder not found or you do not have permission", - }); - } - - await ctx.db.notification.delete({ where: { id: input.id } }); -} - -export async function listReminders( - ctx: NotificationProcedureContext, - input: ListRemindersInput, -) { - const userId = await resolveUserId(ctx); - return ctx.db.notification.findMany({ - where: { userId, category: "REMINDER" }, - orderBy: { nextRemindAt: "asc" }, - take: input.limit, - }); -} - -export async function createBroadcast( - ctx: NotificationProcedureContext, - input: CreateBroadcastInput, -) { - const senderId = ctx.dbUser.id; - const isScheduledFutureBroadcast = Boolean(input.scheduledAt && input.scheduledAt > new Date()); - const hasTaskLikeMetadata = - input.category === "TASK" - || input.category === "APPROVAL" - || input.taskAction !== undefined - || input.dueDate !== undefined; - - if (isScheduledFutureBroadcast && hasTaskLikeMetadata) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: SCHEDULED_TASK_BROADCAST_UNSUPPORTED_MESSAGE, - }); - } - - const recipientIds = await resolveRecipients( - input.targetType, - input.targetValue, - ctx.db, - senderId, - ); - - if (recipientIds.length === 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No recipients matched the broadcast target.", - }); - } - - if (isScheduledFutureBroadcast) { - return ctx.db.notificationBroadcast.create({ - data: { - senderId, - title: input.title, - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.link !== undefined ? { link: input.link } : {}), - category: input.category, - priority: input.priority, - channel: input.channel, - targetType: input.targetType, - ...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}), - ...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}), - recipientCount: recipientIds.length, - }, - }); - } - - const isTask = input.category === "TASK" || input.category === "APPROVAL"; - const sentAt = new Date(); - const runImmediateBroadcast = async (db: typeof ctx.db) => { - const broadcast = await db.notificationBroadcast.create({ - data: { - senderId, - title: input.title, - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.link !== undefined ? { link: input.link } : {}), - category: input.category, - priority: input.priority, - channel: input.channel, - targetType: input.targetType, - ...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}), - ...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}), - }, - }); - - const createdNotificationIds: Array<{ id: string; userId: string }> = []; - for (const recipientUserId of recipientIds) { - const notificationId = await createNotification({ - db, - userId: recipientUserId, - type: `BROADCAST_${input.category}`, - title: input.title, - body: input.body, - link: input.link, - category: input.category, - priority: input.priority, - channel: input.channel, - sourceId: broadcast.id, - senderId, - taskStatus: isTask ? "OPEN" : undefined, - taskAction: input.taskAction, - dueDate: input.dueDate, - emit: false, - }); - createdNotificationIds.push({ id: notificationId, userId: recipientUserId }); - } - - const updatedBroadcast = await db.notificationBroadcast.update({ - where: { id: broadcast.id }, - data: { - sentAt, - recipientCount: createdNotificationIds.length, - }, - }); - - return { broadcast: updatedBroadcast, notificationIds: createdNotificationIds }; - }; - - let persistedBroadcast: Awaited>["broadcast"]; - let notificationIds: Array<{ id: string; userId: string }> = []; - - try { - const transactionResult = typeof ctx.db.$transaction === "function" - ? await ctx.db.$transaction((tx) => runImmediateBroadcast(tx as typeof ctx.db)) - : await runImmediateBroadcast(ctx.db); - - persistedBroadcast = transactionResult.broadcast; - notificationIds = transactionResult.notificationIds; - } catch (error) { - rethrowNotificationReferenceError(error); - } - - for (const notification of notificationIds) { - emitNotificationCreated(notification.userId, notification.id); - if (isTask) { - emitTaskAssigned(notification.userId, notification.id); - } - } - - if (input.channel === "email" || input.channel === "both") { - for (const notification of notificationIds) { - void sendNotificationEmail(ctx.db, notification.userId, input.title, input.body); - } - } - - return persistedBroadcast; -} - -export async function listBroadcasts( - ctx: NotificationProcedureContext, - input: ListBroadcastsInput, -) { - return ctx.db.notificationBroadcast.findMany({ - orderBy: { createdAt: "desc" }, - take: input.limit, - include: { - sender: { select: { id: true, name: true, email: true } }, - }, - }); -} - -export async function getBroadcastById( - ctx: NotificationProcedureContext, - input: NotificationIdInput, -) { - return findUniqueOrThrow( - ctx.db.notificationBroadcast.findUnique({ - where: { id: input.id }, - include: { - sender: { select: { id: true, name: true, email: true } }, - }, - }), - "Broadcast", - ); -} - -export async function createTask( - ctx: NotificationProcedureContext, - input: CreateTaskInput, -) { - const senderId = ctx.dbUser.id; - const notificationId = await createNotification({ - db: ctx.db, - userId: input.userId, - type: "TASK_CREATED", - category: "TASK", - taskStatus: "OPEN", - title: input.title, - priority: input.priority, - senderId, - channel: input.channel, - body: input.body, - dueDate: input.dueDate, - taskAction: input.taskAction, - entityId: input.entityId, - entityType: input.entityType, - link: input.link, - }); - - emitTaskAssigned(input.userId, notificationId); - - if (input.channel === "email" || input.channel === "both") { - void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); - } - - return ctx.db.notification.findUnique({ where: { id: notificationId } }); -} - -export async function assignTask( - ctx: NotificationProcedureContext, - input: AssignTaskInput, -) { - const existing = await findUniqueOrThrow( - ctx.db.notification.findUnique({ where: { id: input.id } }), - "Task", - ); - - if (existing.category !== "TASK" && existing.category !== "APPROVAL") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Only tasks and approvals can be assigned", - }); - } - - const updated = await ctx.db.notification.update({ - where: { id: input.id }, - data: { assigneeId: input.assigneeId }, - }); - - emitTaskAssigned(input.assigneeId, updated.id); - return updated; -} - -export async function deleteNotification( - ctx: NotificationProcedureContext, - input: NotificationIdInput, -) { - 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 } }); -} +export * from "./notification-procedure-base.js"; +export * from "./notification-task-procedure-support.js"; +export * from "./notification-reminder-procedure-support.js"; +export * from "./notification-broadcast-procedure-support.js"; diff --git a/packages/api/src/router/notification-reminder-procedure-support.ts b/packages/api/src/router/notification-reminder-procedure-support.ts new file mode 100644 index 0000000..c1f3b21 --- /dev/null +++ b/packages/api/src/router/notification-reminder-procedure-support.ts @@ -0,0 +1,93 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { + CreateReminderInputSchema, + ListRemindersInputSchema, + type NotificationProcedureContext, + NotificationIdInputSchema, + resolveUserId, + UpdateReminderInputSchema, +} from "./notification-procedure-base.js"; + +export async function createReminder( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + return ctx.db.notification.create({ + data: { + userId, + type: "REMINDER", + category: "REMINDER", + title: input.title, + ...(input.body !== undefined ? { body: input.body } : {}), + remindAt: input.remindAt, + nextRemindAt: input.remindAt, + ...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}), + ...(input.entityId !== undefined ? { entityId: input.entityId } : {}), + ...(input.entityType !== undefined ? { entityType: input.entityType } : {}), + ...(input.link !== undefined ? { link: input.link } : {}), + channel: "in_app", + }, + }); +} + +export async function updateReminder( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + const existing = await ctx.db.notification.findFirst({ + where: { id: input.id, userId, category: "REMINDER" }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Reminder not found or you do not have permission", + }); + } + + return ctx.db.notification.update({ + where: { id: input.id }, + data: { + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.remindAt !== undefined + ? { remindAt: input.remindAt, nextRemindAt: input.remindAt } + : {}), + ...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}), + }, + }); +} + +export async function deleteReminder( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + const existing = await ctx.db.notification.findFirst({ + where: { id: input.id, userId, category: "REMINDER" }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Reminder not found or you do not have permission", + }); + } + + await ctx.db.notification.delete({ where: { id: input.id } }); +} + +export async function listReminders( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + return ctx.db.notification.findMany({ + where: { userId, category: "REMINDER" }, + orderBy: { nextRemindAt: "asc" }, + take: input.limit, + }); +} diff --git a/packages/api/src/router/notification-task-procedure-support.ts b/packages/api/src/router/notification-task-procedure-support.ts new file mode 100644 index 0000000..ab664f4 --- /dev/null +++ b/packages/api/src/router/notification-task-procedure-support.ts @@ -0,0 +1,319 @@ +import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { createNotification } from "../lib/create-notification.js"; +import { getTaskAction } from "../lib/task-actions.js"; +import { + emitTaskAssigned, + emitTaskCompleted, + emitTaskStatusChanged, +} from "../sse/event-bus.js"; +import { + AssignTaskInputSchema, + CreateTaskInputSchema, + ListNotificationTasksInputSchema, + NotificationIdInputSchema, + type NotificationProcedureContext, + requireNotificationDbUser, + resolveUserId, + sendNotificationEmail, + UpdateNotificationTaskStatusInputSchema, +} from "./notification-procedure-base.js"; + +export async function listNotificationTasks( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + const userFilter = input.includeAssigned + ? { OR: [{ userId }, { assigneeId: userId }] } + : { userId }; + + return ctx.db.notification.findMany({ + where: { + ...userFilter, + category: { in: ["TASK", "APPROVAL"] }, + ...(input.status !== undefined ? { taskStatus: input.status } : {}), + }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], + take: input.limit, + }); +} + +export async function getNotificationTaskCounts(ctx: NotificationProcedureContext) { + const userId = await resolveUserId(ctx); + const now = new Date(); + const where = { + OR: [{ userId }, { assigneeId: userId }], + category: { in: ["TASK" as const, "APPROVAL" as const] }, + }; + + const [grouped, overdue] = await Promise.all([ + ctx.db.notification.groupBy({ + by: ["taskStatus"], + where, + _count: true, + }), + ctx.db.notification.count({ + where: { + ...where, + taskStatus: { in: ["OPEN", "IN_PROGRESS"] }, + dueDate: { lt: now }, + }, + }), + ]); + + const counts: Record = {}; + for (const group of grouped) { + if (group.taskStatus) { + counts[group.taskStatus] = group._count; + } + } + + return { + open: counts.OPEN ?? 0, + inProgress: counts.IN_PROGRESS ?? 0, + done: counts.DONE ?? 0, + dismissed: counts.DISMISSED ?? 0, + overdue, + }; +} + +export async function getNotificationTaskDetail( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + const task = await ctx.db.notification.findFirst({ + where: { + id: input.id, + OR: [{ userId }, { assigneeId: userId }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: { + id: true, + title: true, + body: true, + type: true, + priority: true, + category: true, + taskStatus: true, + taskAction: true, + dueDate: true, + entityId: true, + entityType: true, + completedAt: true, + completedBy: true, + createdAt: true, + userId: true, + assigneeId: true, + sender: { select: { id: true, name: true, email: true } }, + }, + }); + + if (!task) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }); + } + + return task; +} + +export async function updateNotificationTaskStatus( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + const existing = await ctx.db.notification.findFirst({ + where: { + id: input.id, + OR: [{ userId }, { assigneeId: userId }], + }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }); + } + + const isCompleting = input.status === "DONE"; + const updated = await ctx.db.notification.update({ + where: { id: input.id }, + data: { + taskStatus: input.status, + ...(isCompleting ? { completedAt: new Date(), completedBy: userId } : {}), + }, + }); + + if (isCompleting) { + emitTaskCompleted(existing.userId, updated.id); + if (existing.assigneeId && existing.assigneeId !== existing.userId) { + emitTaskCompleted(existing.assigneeId, updated.id); + } + } else { + emitTaskStatusChanged(existing.userId, updated.id); + if (existing.assigneeId && existing.assigneeId !== existing.userId) { + emitTaskStatusChanged(existing.assigneeId, updated.id); + } + } + + return updated; +} + +export async function executeNotificationTaskAction( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const userId = await resolveUserId(ctx); + const task = await ctx.db.notification.findFirst({ + where: { + id: input.id, + OR: [{ userId }, { assigneeId: userId }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: { + id: true, + userId: true, + assigneeId: true, + taskAction: true, + taskStatus: true, + }, + }); + + if (!task) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }); + } + if (!task.taskAction) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "This task has no executable action", + }); + } + if (task.taskStatus === "DONE") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "This task is already completed", + }); + } + + const parsed = parseTaskAction(task.taskAction); + if (!parsed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid taskAction format: ${task.taskAction}`, + }); + } + + const handler = getTaskAction(parsed.action); + if (!handler) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown action: ${parsed.action}`, + }); + } + + const currentUser = requireNotificationDbUser(ctx); + const permissions = resolvePermissions( + currentUser.systemRole as import("@capakraken/shared").SystemRole, + currentUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null, + ctx.roleDefaults ?? undefined, + ); + if (handler.permission && !permissions.has(handler.permission as PermissionKey)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Permission denied: you need "${handler.permission}" to perform this action`, + }); + } + + const actionResult = await handler.execute(parsed.entityId, ctx.db, userId); + if (!actionResult.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: actionResult.message, + }); + } + + const completedTask = await ctx.db.notification.update({ + where: { id: input.id }, + data: { + taskStatus: "DONE", + completedAt: new Date(), + completedBy: userId, + }, + }); + + emitTaskCompleted(task.userId, task.id); + if (task.assigneeId && task.assigneeId !== task.userId) { + emitTaskCompleted(task.assigneeId, task.id); + } + + return { + task: completedTask, + actionResult, + }; +} + +export async function createTask( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const senderId = requireNotificationDbUser(ctx).id; + const notificationId = await createNotification({ + db: ctx.db, + userId: input.userId, + type: "TASK_CREATED", + category: "TASK", + taskStatus: "OPEN", + title: input.title, + priority: input.priority, + senderId, + channel: input.channel, + body: input.body, + dueDate: input.dueDate, + taskAction: input.taskAction, + entityId: input.entityId, + entityType: input.entityType, + link: input.link, + }); + + emitTaskAssigned(input.userId, notificationId); + + if (input.channel === "email" || input.channel === "both") { + void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); + } + + return ctx.db.notification.findUnique({ where: { id: notificationId } }); +} + +export async function assignTask( + ctx: NotificationProcedureContext, + input: z.infer, +) { + const existing = await findUniqueOrThrow( + ctx.db.notification.findUnique({ where: { id: input.id } }), + "Task", + ); + + if (existing.category !== "TASK" && existing.category !== "APPROVAL") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only tasks and approvals can be assigned", + }); + } + + const updated = await ctx.db.notification.update({ + where: { id: input.id }, + data: { assigneeId: input.assigneeId }, + }); + + emitTaskAssigned(input.assigneeId, updated.id); + return updated; +}