dow-prod-tracker/src/lib/services/assignment-service.ts
DJP d4bee0e8d3 Assignment notifications + workload approved-filter + My Work fixes
Four connected UX improvements in one commit:

1. Assignment creates a Notification row. notifyAssignment() was
   already written in notification-service but nothing called it.
   Wired into assignUserToStage right after the upsert — fires
   only on NEW assignments (not role updates), never self-notifies,
   non-blocking. The assignee sees it on /notifications with a
   link straight to the deliverable.

2. Workload drops approved stages from weekly totals. Previously
   terminal stages (APPROVED / DELIVERED / SKIPPED) stayed in the
   week they completed, making people look busier than they were.
   Now they drop the moment the stage goes terminal, matching the
   user's intuition ("if it's approved it shouldn't count as my
   workload any more").

3. My Work rows are clickable — each row is now a <Link> to the
   deliverable detail page. Hover state too.

4. My Work has a completed-window toggle. Pill group in the header:
   "Active only" (default) / "+ completed 1w" / 2w / 4w. Switches in
   APPROVED / DELIVERED / SKIPPED assignments whose completedDate
   falls inside the chosen window. No "all time" option — that list
   grows without bound.
2026-04-21 15:29:10 -04:00

317 lines
9 KiB
TypeScript

import { prisma } from "@/lib/prisma";
import type { AssignmentRole } from "@/generated/prisma/client";
import { emitAssignmentCreated } from "@/lib/automation/event-bus";
import { notifyAssignment } from "@/lib/services/notification-service";
import {
assertProjectVisible,
VisibilityError,
type VisibilityContext,
} from "@/lib/rbac/visibility";
// Register automation handler (side-effect import)
import "@/lib/services/automation-service";
export async function assignUserToStage(
deliverableStageId: string,
userId: string,
ctx: VisibilityContext,
role: AssignmentRole = "LEAD",
options: { dryRun?: boolean } = {}
) {
// Validate stage and user exist
const [stage, user] = await Promise.all([
prisma.deliverableStage.findUnique({
where: { id: deliverableStageId },
include: {
template: true,
deliverable: {
select: {
id: true,
name: true,
project: { select: { id: true, name: true, organizationId: true } },
},
},
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, maxCapacity: true },
}),
]);
if (!stage) throw new Error("Stage not found");
if (!user) throw new Error("User not found");
// Visibility gate — the stage's parent project must be visible to the caller
await assertProjectVisible(stage.deliverable.project.id, ctx);
// Check for existing assignment
const existing = await prisma.stageAssignment.findUnique({
where: { deliverableStageId_userId: { deliverableStageId, userId } },
});
if (options.dryRun) {
return {
dryRun: true,
action: existing ? "update_role" : "create_assignment",
stageName: stage.template.name,
userName: user.name || user.email,
role,
};
}
const result = await prisma.stageAssignment.upsert({
where: {
deliverableStageId_userId: { deliverableStageId, userId },
},
update: { role },
create: { deliverableStageId, userId, role },
include: { user: true, deliverableStage: { include: { template: true } } },
});
// Fire a Notification row for the assignee — shows up on their
// /notifications page. Only for NEW assignments, not role updates
// (where `existing` was truthy), and never self-notify. Non-blocking.
if (!existing) {
notifyAssignment(
userId,
ctx.userId,
stage!.deliverable.name,
stage!.template.name,
stage!.deliverable.project.id,
stage!.deliverable.id
).catch((err) => {
console.error("[Notification] notifyAssignment failed:", err);
});
}
// Emit automation event (non-blocking) — downstream rules may fire
// additional side effects beyond the direct notification above.
emitAssignmentCreated(stage!.deliverable.project.organizationId, {
assignmentId: result.id,
stageId: deliverableStageId,
stageName: stage!.template.name,
userId,
userName: user!.name || user!.email,
deliverableId: stage!.deliverable.id,
projectId: stage!.deliverable.project.id,
}).catch((err) => {
console.error("[Automation] Failed to emit assignment.created:", err);
});
return result;
}
export async function removeAssignment(
deliverableStageId: string,
userId: string
) {
return prisma.stageAssignment.delete({
where: {
deliverableStageId_userId: { deliverableStageId, userId },
},
});
}
export async function getMyWork(userId: string, ctx: VisibilityContext) {
const rows = await prisma.stageAssignment.findMany({
where: { userId },
include: {
deliverableStage: {
include: {
template: true,
stageDefinition: true,
deliverable: {
include: {
project: {
select: {
id: true,
name: true,
projectCode: true,
clientTeamId: true,
},
},
},
},
},
},
},
orderBy: { createdAt: "desc" },
});
// Filter by visibility — non-admins only see assignments whose project is on their team
if (ctx.role === "ADMIN") return rows;
const allowed = new Set(ctx.clientTeamIds ?? []);
return rows.filter((a) => {
const teamId = a.deliverableStage.deliverable.project.clientTeamId;
return teamId && allowed.has(teamId);
});
}
// ─── Bulk Operations ────────────────────────────────────
export interface BulkAssignItem {
deliverableStageId: string;
userId: string;
role?: AssignmentRole;
}
export interface BulkAssignResult {
total: number;
succeeded: {
deliverableStageId: string;
stageName: string;
userId: string;
userName: string;
}[];
failed: {
deliverableStageId: string;
userId: string;
reason: string;
}[];
}
/**
* Assign multiple artists to stages in a single transaction.
* When dryRun is true, validates and returns preview without executing.
*/
export async function bulkAssignArtists(
items: BulkAssignItem[],
ctx: VisibilityContext,
options: { dryRun?: boolean } = {}
): Promise<BulkAssignResult> {
if (items.length === 0) {
return { total: 0, succeeded: [], failed: [] };
}
// Fetch all referenced stages and users in bulk
const stageIds = [...new Set(items.map((i) => i.deliverableStageId))];
const userIds = [...new Set(items.map((i) => i.userId))];
const [stages, users] = await Promise.all([
prisma.deliverableStage.findMany({
where: { id: { in: stageIds } },
include: {
template: true,
deliverable: {
select: {
id: true,
name: true,
project: {
select: {
id: true,
name: true,
organizationId: true,
clientTeamId: true,
},
},
},
},
},
}),
prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
}),
]);
const stageMap = new Map(stages.map((s) => [s.id, s]));
const userMap = new Map(users.map((u) => [u.id, u]));
const succeeded: BulkAssignResult["succeeded"] = [];
const failed: BulkAssignResult["failed"] = [];
const isAdmin = ctx.role === "ADMIN";
const allowedTeams = new Set(ctx.clientTeamIds ?? []);
for (const item of items) {
const stage = stageMap.get(item.deliverableStageId);
if (!stage) {
failed.push({
deliverableStageId: item.deliverableStageId,
userId: item.userId,
reason: "Stage not found",
});
continue;
}
// Visibility: non-admins can only assign on projects they can see
if (!isAdmin) {
const teamId = stage.deliverable.project.clientTeamId;
if (!teamId || !allowedTeams.has(teamId)) {
failed.push({
deliverableStageId: item.deliverableStageId,
userId: item.userId,
reason: "Stage not visible to current user",
});
continue;
}
}
const user = userMap.get(item.userId);
if (!user) {
failed.push({
deliverableStageId: item.deliverableStageId,
userId: item.userId,
reason: "User not found",
});
continue;
}
succeeded.push({
deliverableStageId: item.deliverableStageId,
stageName: stage.template.name,
userId: item.userId,
userName: user.name || user.email,
});
}
if (options.dryRun) {
return { total: items.length, succeeded, failed };
}
// Execute all valid assignments in a transaction
if (succeeded.length > 0) {
await prisma.$transaction(async (tx) => {
for (const item of items) {
if (!stageMap.has(item.deliverableStageId) || !userMap.has(item.userId))
continue;
await tx.stageAssignment.upsert({
where: {
deliverableStageId_userId: {
deliverableStageId: item.deliverableStageId,
userId: item.userId,
},
},
update: { role: item.role || "LEAD" },
create: {
deliverableStageId: item.deliverableStageId,
userId: item.userId,
role: item.role || "LEAD",
},
});
}
});
}
// Emit automation events AFTER transaction commits
if (succeeded.length > 0 && !options.dryRun) {
for (const item of succeeded) {
const stage = stageMap.get(item.deliverableStageId);
if (!stage) continue;
emitAssignmentCreated(stage.deliverable.project.organizationId, {
assignmentId: `${item.deliverableStageId}:${item.userId}`,
stageId: item.deliverableStageId,
stageName: item.stageName,
userId: item.userId,
userName: item.userName,
deliverableId: stage.deliverable.id,
projectId: stage.deliverable.project.id,
}).catch((err) => {
console.error("[Automation] Failed to emit assignment.created (bulk):", err);
});
}
}
return { total: items.length, succeeded, failed };
}