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.
317 lines
9 KiB
TypeScript
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 };
|
|
}
|