refactor: deduplicate modals, notifications, confirms, comboboxes, proficiency
Modal Overlay (Finding 1 — 6 admin files): - Migrated CountriesClient, ManagementLevelsClient, OrgUnitsClient, CalculationRulesClient, UtilizationCategoriesClient, RoleModal from inline fixed-overlay to AnimatedModal component - Gains: animated transitions, backdrop blur, escape key for free Notification Helper (Finding 9 — 9 API files, 14 call sites): - New createNotification() + createNotificationsForUsers() in packages/api/src/lib/create-notification.ts - Handles exactOptionalPropertyTypes spread + SSE emit internally - Simplified: budget-alerts, estimate-reminders, auto-staffing, vacation-conflicts, chargeability-alerts, comment, vacation, notification ConfirmDialog (Finding 3 — 11 files): - Replaced all window.confirm() calls with ConfirmDialog component - Files: CommentThread, EffortRules, ExperienceMultipliers, ManagementLevels, CalculationRules, Countries, RateCards, ApplyEffortRules, ApplyExperienceMultipliers, NotificationCenter, ReminderModal EntityCombobox (Finding 4 — 3 files): - New generic EntityCombobox<T> with customization hooks - ResourceCombobox + ProjectCombobox rewritten as thin wrappers - All consumers unchanged (backwards-compatible props) Proficiency Constants (Finding 2 — 2 files): - SkillsAnalytics + SkillMarketplace now import from skills/shared.tsx - Deleted ~70 LOC of local duplicate definitions Regression: 283 engine + 37 staffing tests pass. TypeScript clean. AI Assistant: all 87 tools verified accessible. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -3,6 +3,7 @@ export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedu
|
||||
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||
export { logger } from "./lib/logger.js";
|
||||
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
|
||||
export { createNotification, createNotificationsForUsers } from "./lib/create-notification.js";
|
||||
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
|
||||
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
|
||||
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { listAssignmentBookings } from "@planarchy/application";
|
||||
import { rankResources } from "@planarchy/staffing";
|
||||
import type { SkillEntry } from "@planarchy/shared";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
|
||||
/**
|
||||
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
|
||||
@@ -227,24 +227,19 @@ export async function generateAutoSuggestions(
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const manager of managers) {
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: manager.id,
|
||||
type: "AUTO_STAFFING_SUGGESTION",
|
||||
category: "NOTIFICATION",
|
||||
priority: "NORMAL",
|
||||
title,
|
||||
body,
|
||||
entityId: demandRequirementId,
|
||||
entityType: "demand",
|
||||
link: `/staffing?demandId=${demandRequirementId}`,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(manager.id, notification.id);
|
||||
}
|
||||
await createNotificationsForUsers({
|
||||
db,
|
||||
userIds: managers.map((m) => m.id),
|
||||
type: "AUTO_STAFFING_SUGGESTION",
|
||||
category: "NOTIFICATION",
|
||||
priority: "NORMAL",
|
||||
title,
|
||||
body,
|
||||
entityId: demandRequirementId,
|
||||
entityType: "demand",
|
||||
link: `/staffing?demandId=${demandRequirementId}`,
|
||||
channel: "in_app",
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: swallow all errors to avoid disrupting the caller.
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { listAssignmentBookings } from "@planarchy/application";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
|
||||
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
|
||||
project: {
|
||||
@@ -119,23 +119,18 @@ export async function checkBudgetThresholds(
|
||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
||||
);
|
||||
|
||||
for (const manager of managers) {
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: manager.id,
|
||||
type: threshold.type,
|
||||
category: "NOTIFICATION",
|
||||
priority: threshold.priority,
|
||||
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
|
||||
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
|
||||
entityId: projectId,
|
||||
entityType: "project_budget",
|
||||
link: `/projects/${projectId}`,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(manager.id, notification.id);
|
||||
}
|
||||
await createNotificationsForUsers({
|
||||
db,
|
||||
userIds: managers.map((m) => m.id),
|
||||
type: threshold.type,
|
||||
category: "NOTIFICATION",
|
||||
priority: threshold.priority,
|
||||
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
|
||||
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
|
||||
entityId: projectId,
|
||||
entityType: "project_budget",
|
||||
link: `/projects/${projectId}`,
|
||||
channel: "in_app",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { SpainScheduleRule } from "@planarchy/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
|
||||
import { VacationStatus } from "@planarchy/db";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
|
||||
/**
|
||||
* Minimal DB client type for chargeability alerts.
|
||||
@@ -237,24 +237,19 @@ export async function checkChargeabilityAlerts(
|
||||
|
||||
if (existing) continue;
|
||||
|
||||
for (const manager of managers) {
|
||||
const notification = await (db as DbClient).notification.create({
|
||||
data: {
|
||||
userId: manager.id,
|
||||
type: "CHARGEABILITY_ALERT",
|
||||
category: "NOTIFICATION",
|
||||
priority: "HIGH",
|
||||
title: `Low chargeability: ${resource.displayName}`,
|
||||
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
|
||||
entityId,
|
||||
entityType: "chargeability_alert",
|
||||
link: "/chargeability",
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(manager.id, notification.id);
|
||||
}
|
||||
await createNotificationsForUsers({
|
||||
db: db as DbClient,
|
||||
userIds: managers.map((m) => m.id),
|
||||
type: "CHARGEABILITY_ALERT",
|
||||
category: "NOTIFICATION",
|
||||
priority: "HIGH",
|
||||
title: `Low chargeability: ${resource.displayName}`,
|
||||
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
|
||||
entityId,
|
||||
entityType: "chargeability_alert",
|
||||
link: "/chargeability",
|
||||
channel: "in_app",
|
||||
});
|
||||
|
||||
alertCount++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
|
||||
export interface CreateNotificationParams {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: { notification: { create: (args: any) => Promise<{ id: string; userId: string }> } };
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body?: string | undefined;
|
||||
link?: string | undefined;
|
||||
entityId?: string | undefined;
|
||||
entityType?: string | undefined;
|
||||
category?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
senderId?: string | undefined;
|
||||
channel?: string | undefined;
|
||||
taskStatus?: string | undefined;
|
||||
taskAction?: string | undefined;
|
||||
assigneeId?: string | undefined;
|
||||
dueDate?: Date | undefined;
|
||||
sourceId?: string | undefined;
|
||||
/** Set to false to suppress the SSE emitNotificationCreated call. Default: true. */
|
||||
emit?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single in-app notification and optionally emit an SSE event.
|
||||
*
|
||||
* Handles the `exactOptionalPropertyTypes` spread pattern internally so
|
||||
* callers do not need to repeat the `...(val !== undefined ? { key: val } : {})` boilerplate.
|
||||
*
|
||||
* Returns the created notification's ID.
|
||||
*/
|
||||
export async function createNotification(
|
||||
params: CreateNotificationParams,
|
||||
): Promise<string> {
|
||||
const {
|
||||
db,
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
body,
|
||||
link,
|
||||
entityId,
|
||||
entityType,
|
||||
category,
|
||||
priority,
|
||||
senderId,
|
||||
channel,
|
||||
taskStatus,
|
||||
taskAction,
|
||||
assigneeId,
|
||||
dueDate,
|
||||
sourceId,
|
||||
emit = true,
|
||||
} = params;
|
||||
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
...(body !== undefined ? { body } : {}),
|
||||
...(link !== undefined ? { link } : {}),
|
||||
...(entityId !== undefined ? { entityId } : {}),
|
||||
...(entityType !== undefined ? { entityType } : {}),
|
||||
...(category !== undefined ? { category } : {}),
|
||||
...(priority !== undefined ? { priority } : {}),
|
||||
...(senderId !== undefined ? { senderId } : {}),
|
||||
...(channel !== undefined ? { channel } : {}),
|
||||
...(taskStatus !== undefined ? { taskStatus } : {}),
|
||||
...(taskAction !== undefined ? { taskAction } : {}),
|
||||
...(assigneeId !== undefined ? { assigneeId } : {}),
|
||||
...(dueDate !== undefined ? { dueDate } : {}),
|
||||
...(sourceId !== undefined ? { sourceId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (emit) {
|
||||
emitNotificationCreated(userId, notification.id);
|
||||
}
|
||||
|
||||
return notification.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one notification per user ID.
|
||||
*
|
||||
* Useful for fan-out scenarios (e.g. notifying all managers).
|
||||
* Returns the count of notifications created.
|
||||
*/
|
||||
export async function createNotificationsForUsers(
|
||||
params: Omit<CreateNotificationParams, "userId"> & { userIds: string[] },
|
||||
): Promise<number> {
|
||||
const { userIds, ...rest } = params;
|
||||
let count = 0;
|
||||
for (const userId of userIds) {
|
||||
await createNotification({ ...rest, userId });
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
|
||||
type DbClient = {
|
||||
estimate: {
|
||||
@@ -138,24 +138,19 @@ export async function checkPendingEstimateReminders(
|
||||
)
|
||||
: REMINDER_DAYS;
|
||||
|
||||
for (const manager of managers) {
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: manager.id,
|
||||
type: "ESTIMATE_APPROVAL_REMINDER",
|
||||
category: "REMINDER",
|
||||
priority: "HIGH",
|
||||
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
|
||||
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
|
||||
entityId: version.id,
|
||||
entityType: "estimate_approval_reminder",
|
||||
link: `/estimates/${estimate.id}`,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(manager.id, notification.id);
|
||||
}
|
||||
await createNotificationsForUsers({
|
||||
db,
|
||||
userIds: managers.map((m) => m.id),
|
||||
type: "ESTIMATE_APPROVAL_REMINDER",
|
||||
category: "REMINDER",
|
||||
priority: "HIGH",
|
||||
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
|
||||
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
|
||||
entityId: version.id,
|
||||
entityType: "estimate_approval_reminder",
|
||||
link: `/estimates/${estimate.id}`,
|
||||
channel: "in_app",
|
||||
});
|
||||
|
||||
reminderCount++;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VacationStatus } from "@planarchy/db";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createNotification } from "./create-notification.js";
|
||||
|
||||
type DbClient = {
|
||||
vacation: {
|
||||
@@ -189,21 +189,19 @@ export async function checkVacationConflicts(
|
||||
|
||||
// Create a notification for the approver if provided
|
||||
if (approverUserId) {
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: approverUserId,
|
||||
type: "VACATION_CONFLICT_WARNING",
|
||||
category: "NOTIFICATION",
|
||||
priority: "HIGH",
|
||||
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
|
||||
body: warning,
|
||||
entityId: vacationId,
|
||||
entityType: "vacation",
|
||||
link: "/vacations",
|
||||
channel: "in_app",
|
||||
},
|
||||
await createNotification({
|
||||
db,
|
||||
userId: approverUserId,
|
||||
type: "VACATION_CONFLICT_WARNING",
|
||||
category: "NOTIFICATION",
|
||||
priority: "HIGH",
|
||||
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
|
||||
body: warning,
|
||||
entityId: vacationId,
|
||||
entityType: "vacation",
|
||||
link: "/vacations",
|
||||
channel: "in_app",
|
||||
});
|
||||
emitNotificationCreated(approverUserId, notification.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { SystemRole } from "@planarchy/shared";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createNotification } from "../lib/create-notification.js";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -138,22 +138,20 @@ export const commentRouter = createTRPCRouter({
|
||||
input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body;
|
||||
|
||||
await Promise.all(
|
||||
mentionedUserIds.map(async (userId) => {
|
||||
const notification = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "COMMENT_MENTION",
|
||||
title: `${authorName} mentioned you in a comment`,
|
||||
body: truncatedBody,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
senderId: authorId,
|
||||
link: `/estimates/${input.entityId}?tab=comments`,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
emitNotificationCreated(userId, notification.id);
|
||||
}),
|
||||
mentionedUserIds.map((userId) =>
|
||||
createNotification({
|
||||
db: ctx.db,
|
||||
userId,
|
||||
type: "COMMENT_MENTION",
|
||||
title: `${authorName} mentioned you in a comment`,
|
||||
body: truncatedBody,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
senderId: authorId,
|
||||
link: `/estimates/${input.entityId}?tab=comments`,
|
||||
channel: "in_app",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
emitTaskStatusChanged,
|
||||
emitBroadcastSent,
|
||||
} from "../sse/event-bus.js";
|
||||
import { createNotification } from "../lib/create-notification.js";
|
||||
import { resolveRecipients } from "../lib/notification-targeting.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
|
||||
@@ -154,31 +155,28 @@ export const notificationRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const currentUserId = ctx.dbUser.id;
|
||||
|
||||
const n = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
||||
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
...(input.priority !== undefined ? { priority: input.priority } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}),
|
||||
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
|
||||
...(input.assigneeId !== undefined ? { assigneeId: input.assigneeId } : {}),
|
||||
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
||||
...(input.channel !== undefined ? { channel: input.channel } : {}),
|
||||
senderId: input.senderId ?? currentUserId,
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
emitNotificationCreated(input.userId, n.id);
|
||||
|
||||
// Emit task-specific events
|
||||
if (input.category === "TASK" || input.category === "APPROVAL") {
|
||||
emitTaskAssigned(input.userId, n.id);
|
||||
emitTaskAssigned(input.userId, notificationId);
|
||||
}
|
||||
|
||||
// Email if channel includes email
|
||||
@@ -187,6 +185,8 @@ export const notificationRouter = createTRPCRouter({
|
||||
void sendNotificationEmail(ctx.db, input.userId, input.title, input.body);
|
||||
}
|
||||
|
||||
// Re-fetch for return value (to maintain API contract)
|
||||
const n = await ctx.db.notification.findUnique({ where: { id: notificationId } });
|
||||
return n;
|
||||
}),
|
||||
|
||||
@@ -332,6 +332,9 @@ export const notificationRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
// Reminders have extra fields (remindAt, nextRemindAt, recurrence) not covered
|
||||
// by the generic helper, so we keep the direct create here but still use
|
||||
// the exactOptionalPropertyTypes spread pattern.
|
||||
return ctx.db.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -479,54 +482,51 @@ export const notificationRouter = createTRPCRouter({
|
||||
// 4. Create individual notifications for each recipient
|
||||
const isTask = input.category === "TASK" || input.category === "APPROVAL";
|
||||
|
||||
const notifications = await Promise.all(
|
||||
recipientIds.map((recipientUserId) =>
|
||||
ctx.db.notification.create({
|
||||
data: {
|
||||
userId: recipientUserId,
|
||||
type: `BROADCAST_${input.category}`,
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
category: input.category,
|
||||
priority: input.priority,
|
||||
channel: input.channel,
|
||||
sourceId: broadcast.id,
|
||||
senderId,
|
||||
...(isTask ? { taskStatus: "OPEN" as const } : {}),
|
||||
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
|
||||
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
// SSE emit handled by createNotification; task events need separate emit
|
||||
const notificationIds: Array<{ id: string; userId: string }> = [];
|
||||
for (const recipientUserId of recipientIds) {
|
||||
const nId = await createNotification({
|
||||
db: ctx.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,
|
||||
});
|
||||
notificationIds.push({ id: nId, userId: recipientUserId });
|
||||
if (isTask) {
|
||||
emitTaskAssigned(recipientUserId, nId);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update broadcast with sent info
|
||||
await ctx.db.notificationBroadcast.update({
|
||||
where: { id: broadcast.id },
|
||||
data: {
|
||||
sentAt: new Date(),
|
||||
recipientCount: notifications.length,
|
||||
recipientCount: notificationIds.length,
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Emit SSE events
|
||||
for (const n of notifications) {
|
||||
emitNotificationCreated(n.userId, n.id);
|
||||
if (isTask) {
|
||||
emitTaskAssigned(n.userId, n.id);
|
||||
}
|
||||
}
|
||||
emitBroadcastSent(broadcast.id, notifications.length);
|
||||
// 6. Broadcast-level SSE event
|
||||
emitBroadcastSent(broadcast.id, notificationIds.length);
|
||||
|
||||
// 7. Send emails if channel includes email (non-blocking)
|
||||
if (input.channel === "email" || input.channel === "both") {
|
||||
for (const n of notifications) {
|
||||
for (const n of notificationIds) {
|
||||
void sendNotificationEmail(ctx.db, n.userId, input.title, input.body);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...broadcast, recipientCount: notifications.length, sentAt: new Date() };
|
||||
return { ...broadcast, recipientCount: notificationIds.length, sentAt: new Date() };
|
||||
}),
|
||||
|
||||
/** List broadcasts */
|
||||
@@ -565,33 +565,33 @@ export const notificationRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const senderId = ctx.dbUser.id;
|
||||
|
||||
const n = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: "TASK_CREATED",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
title: input.title,
|
||||
priority: input.priority,
|
||||
senderId,
|
||||
channel: input.channel,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
||||
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
|
||||
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
||||
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
emitNotificationCreated(input.userId, n.id);
|
||||
emitTaskAssigned(input.userId, n.id);
|
||||
emitTaskAssigned(input.userId, notificationId);
|
||||
|
||||
// Send email if channel includes email
|
||||
if (input.channel === "email" || input.channel === "both") {
|
||||
void sendNotificationEmail(ctx.db, input.userId, input.title, input.body);
|
||||
}
|
||||
|
||||
// Re-fetch for return value
|
||||
const n = await ctx.db.notification.findUnique({ where: { id: notificationId } });
|
||||
return n;
|
||||
}),
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js";
|
||||
import { emitVacationCreated, emitVacationUpdated, emitTaskAssigned } from "../sse/event-bus.js";
|
||||
import { createNotification } from "../lib/create-notification.js";
|
||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
@@ -55,17 +56,15 @@ async function notifyVacationStatus(
|
||||
: `Your vacation request has been ${statusLabel}.`;
|
||||
|
||||
// In-app notification
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: resource.user.id,
|
||||
type: `VACATION_${newStatus}`,
|
||||
title,
|
||||
body,
|
||||
entityId: vacationId,
|
||||
entityType: "vacation",
|
||||
},
|
||||
await createNotification({
|
||||
db,
|
||||
userId: resource.user.id,
|
||||
type: `VACATION_${newStatus}`,
|
||||
title,
|
||||
body,
|
||||
entityId: vacationId,
|
||||
entityType: "vacation",
|
||||
});
|
||||
emitNotificationCreated(resource.user.id, notification.id);
|
||||
|
||||
// Email (non-blocking)
|
||||
if (resource.user.email) {
|
||||
@@ -233,25 +232,23 @@ export const vacationRouter = createTRPCRouter({
|
||||
|
||||
for (const manager of managers) {
|
||||
if (manager.id === userRecord.id) continue;
|
||||
const task = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: manager.id,
|
||||
category: "APPROVAL",
|
||||
type: "VACATION_APPROVAL",
|
||||
priority: "NORMAL",
|
||||
title: `Vacation approval: ${resourceName}`,
|
||||
body: `${resourceName} requests ${input.type} from ${startStr} to ${endStr}`,
|
||||
taskStatus: "OPEN",
|
||||
taskAction: buildTaskAction("approve_vacation", vacation.id),
|
||||
entityId: vacation.id,
|
||||
entityType: "vacation",
|
||||
link: "/vacations",
|
||||
senderId: userRecord.id,
|
||||
channel: "in_app",
|
||||
},
|
||||
const taskId = await createNotification({
|
||||
db: ctx.db,
|
||||
userId: manager.id,
|
||||
category: "APPROVAL",
|
||||
type: "VACATION_APPROVAL",
|
||||
priority: "NORMAL",
|
||||
title: `Vacation approval: ${resourceName}`,
|
||||
body: `${resourceName} requests ${input.type} from ${startStr} to ${endStr}`,
|
||||
taskStatus: "OPEN",
|
||||
taskAction: buildTaskAction("approve_vacation", vacation.id),
|
||||
entityId: vacation.id,
|
||||
entityType: "vacation",
|
||||
link: "/vacations",
|
||||
senderId: userRecord.id,
|
||||
channel: "in_app",
|
||||
});
|
||||
emitNotificationCreated(manager.id, task.id);
|
||||
emitTaskAssigned(manager.id, task.id);
|
||||
emitTaskAssigned(manager.id, taskId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user