Extend the per-client preview flow to the per-group "Generate Tasks" button on the client page, backfill missing templates instead of skipping groups/policies that already have any template task, and add a reusable searchable ClientSelect component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
52 KiB
Task Generation: Per-Client Mode & Preview-Before-Generate Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a per-client targeting mode and a preview-and-confirm step to the admin "Generate & Assign Tasks" tool, so admins can generate tasks for one specific client and always see what will happen before anything is created.
Architecture: Extract the existing route's client/group/policy/template-matching logic into a testable src/lib/tasks/generate-and-assign-core.ts module that computes the same tasksToCreate arrays whether or not it actually writes them (dryRun flag branches only at the write step). The route becomes a thin HTTP wrapper. The frontend calls the endpoint once with dryRun: true to populate a confirmation dialog, then again for real on confirm.
Tech Stack: Next.js 15 App Router route handlers, Prisma, React client component, shadcn/ui (Tabs, AlertDialog, Table), Jest.
Global Constraints
- Preview/dry-run applies only to
/api/tasks/generate-and-assign(this admin page). No other generation entry point in the app changes. - Client mode always assigns to the target client's own
claimsAdvocateId— never an arbitrary pick. - Preview shows summary counts (total + per-client breakdown), not a full task-by-task list.
- Every task-creation call site must keep using
isRelevantDueDate(fromsrc/lib/sync/task-due-date.ts) to skip past-due tasks — this is existing behavior from the prior fix and must not regress.
Task 1: Export shared designation-matching helpers from auto-generate.ts
Files:
- Modify:
src/lib/sync/auto-generate.ts:8,19
Interfaces:
-
Produces:
export function designationIdsOf(entity: { designationId: string | null; designation2Id: string | null }): string[]andexport function designationFilter(ids: string[]): Array<{ designationId: string | null } | { designationId: { in: string[] } }>— both now importable from@/lib/sync/auto-generate. -
Step 1: Add
exportto both helper function declarations
In src/lib/sync/auto-generate.ts, change:
function designationIdsOf(entity: {
to:
export function designationIdsOf(entity: {
And change:
function designationFilter(ids: string[]) {
to:
export function designationFilter(ids: string[]) {
- Step 2: Run the existing test suite for this file to confirm no regression
Run: npx jest src/lib/sync/__tests__/auto-generate.test.ts
Expected: 4 passed, 4 total (unchanged from before this edit — adding export doesn't change behavior).
- Step 3: Commit
git add src/lib/sync/auto-generate.ts
git commit -m "refactor(sync): export designation-matching helpers for reuse"
Task 2: Core module — target parsing/validation (TDD)
Files:
- Create:
src/lib/tasks/generate-and-assign-core.ts - Test:
src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Interfaces:
-
Consumes: nothing yet (pure parsing logic, no DB calls in this task).
-
Produces:
export class GenerateAndAssignError extends Error { status: number }export type GenerateTarget = { mode: 'advocate-designation'; advocateId: string; designationId: string } | { mode: 'client'; clientId: string }export function parseGenerateAndAssignTarget(body: any): { target: GenerateTarget; dryRun: boolean }
-
Step 1: Write the failing tests
Create src/lib/tasks/__tests__/generate-and-assign-core.test.ts:
import { parseGenerateAndAssignTarget, GenerateAndAssignError } from '../generate-and-assign-core'
describe('parseGenerateAndAssignTarget', () => {
it('parses advocate+designation mode', () => {
const { target, dryRun } = parseGenerateAndAssignTarget({
advocateId: 'adv-1',
designationId: 'des-1',
})
expect(target).toEqual({ mode: 'advocate-designation', advocateId: 'adv-1', designationId: 'des-1' })
expect(dryRun).toBe(false)
})
it('parses client mode', () => {
const { target, dryRun } = parseGenerateAndAssignTarget({ clientId: 'client-1', dryRun: true })
expect(target).toEqual({ mode: 'client', clientId: 'client-1' })
expect(dryRun).toBe(true)
})
it('defaults dryRun to false when omitted', () => {
const { dryRun } = parseGenerateAndAssignTarget({ clientId: 'client-1' })
expect(dryRun).toBe(false)
})
it('throws when both modes are provided', () => {
expect(() =>
parseGenerateAndAssignTarget({ advocateId: 'a', designationId: 'd', clientId: 'c' })
).toThrow(GenerateAndAssignError)
})
it('throws when neither mode is provided', () => {
expect(() => parseGenerateAndAssignTarget({})).toThrow(GenerateAndAssignError)
})
it('throws when advocateId is given without designationId', () => {
expect(() => parseGenerateAndAssignTarget({ advocateId: 'a' })).toThrow(GenerateAndAssignError)
})
it('sets a 400 status on validation errors', () => {
try {
parseGenerateAndAssignTarget({})
fail('expected throw')
} catch (err) {
expect(err).toBeInstanceOf(GenerateAndAssignError)
expect((err as GenerateAndAssignError).status).toBe(400)
}
})
})
- Step 2: Run test to verify it fails
Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Expected: FAIL — Cannot find module '../generate-and-assign-core'
- Step 3: Write minimal implementation
Create src/lib/tasks/generate-and-assign-core.ts:
export class GenerateAndAssignError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
export type GenerateTarget =
| { mode: 'advocate-designation'; advocateId: string; designationId: string }
| { mode: 'client'; clientId: string }
/**
* Parses and validates a generate-and-assign request body. Exactly one of
* (advocateId + designationId) or clientId must be present.
*/
export function parseGenerateAndAssignTarget(body: any): { target: GenerateTarget; dryRun: boolean } {
const dryRun = body?.dryRun === true
const hasAdvocateDesignation = !!body?.advocateId && !!body?.designationId
const hasClient = !!body?.clientId
if (hasAdvocateDesignation && hasClient) {
throw new GenerateAndAssignError(
'Provide either advocateId+designationId or clientId, not both',
400
)
}
if (hasClient) {
return { target: { mode: 'client', clientId: body.clientId }, dryRun }
}
if (hasAdvocateDesignation) {
return {
target: { mode: 'advocate-designation', advocateId: body.advocateId, designationId: body.designationId },
dryRun,
}
}
throw new GenerateAndAssignError('advocateId and designationId, or clientId, are required', 400)
}
- Step 4: Run test to verify it passes
Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Expected: 7 passed, 7 total
- Step 5: Commit
git add src/lib/tasks/generate-and-assign-core.ts src/lib/tasks/__tests__/generate-and-assign-core.test.ts
git commit -m "feat(tasks): add request parsing for generate-and-assign core module"
Task 3: Core module — target resolution + runGenerateAndAssign for advocate-designation mode (TDD)
Files:
- Modify:
src/lib/tasks/generate-and-assign-core.ts - Modify:
src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Interfaces:
- Consumes:
designationFilter,designationIdsOffrom@/lib/sync/auto-generate(Task 1);isRelevantDueDatefrom@/lib/sync/task-due-date;prismafrom@/lib/db. - Produces:
export interface ClientTaskSummary { id: string; name: string; estimatedTasks: number }export interface GenerateAndAssignResult { dryRun: boolean; clientsFound: number; totalEstimatedTasks: number; advocateName: string | null; clients: ClientTaskSummary[]; tasksCreated: number; tasksAssigned: number }export async function runGenerateAndAssign(target: GenerateTarget, options: { dryRun: boolean; actorUserId: string }): Promise<GenerateAndAssignResult>
This task covers the advocate-designation mode only (client mode is Task 4). The mock setup below only exercises that path; client-mode tests in Task 4 extend the same mock file.
- Step 1: Write the failing tests
Add to the top of src/lib/tasks/__tests__/generate-and-assign-core.test.ts (above the existing describe block), replacing the plain import with a mocked one:
const mockUserFindUnique = jest.fn()
const mockClientFindMany = jest.fn()
const mockClientFindUnique = jest.fn()
const mockTaskTemplateFindMany = jest.fn()
const mockPolicyGroupFindMany = jest.fn()
const mockPolicyFindMany = jest.fn()
const mockTaskCreateMany = jest.fn().mockResolvedValue({ count: 0 })
const mockTaskCreate = jest.fn()
const mockTaskFindMany = jest.fn().mockResolvedValue([])
const mockTaskFindFirst = jest.fn().mockResolvedValue(null)
const mockTaskAssignmentCreateMany = jest.fn().mockResolvedValue({ count: 0 })
const mockTaskAssignmentCreate = jest.fn()
const mockAuditLogCreate = jest.fn().mockResolvedValue({})
jest.mock('@/lib/db', () => ({
prisma: {
user: { findUnique: (...args: any[]) => mockUserFindUnique(...args) },
client: {
findMany: (...args: any[]) => mockClientFindMany(...args),
findUnique: (...args: any[]) => mockClientFindUnique(...args),
},
taskTemplate: { findMany: (...args: any[]) => mockTaskTemplateFindMany(...args) },
policyGroup: { findMany: (...args: any[]) => mockPolicyGroupFindMany(...args) },
policy: { findMany: (...args: any[]) => mockPolicyFindMany(...args) },
task: {
createMany: (...args: any[]) => mockTaskCreateMany(...args),
create: (...args: any[]) => mockTaskCreate(...args),
findMany: (...args: any[]) => mockTaskFindMany(...args),
findFirst: (...args: any[]) => mockTaskFindFirst(...args),
},
taskAssignment: {
createMany: (...args: any[]) => mockTaskAssignmentCreateMany(...args),
create: (...args: any[]) => mockTaskAssignmentCreate(...args),
},
auditLog: { create: (...args: any[]) => mockAuditLogCreate(...args) },
},
}))
import { runGenerateAndAssign } from '../generate-and-assign-core'
const now = new Date('2026-07-18T00:00:00Z')
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers().setSystemTime(now)
mockClientFindMany.mockResolvedValue([])
mockTaskTemplateFindMany.mockResolvedValue([])
mockPolicyGroupFindMany.mockResolvedValue([])
mockPolicyFindMany.mockResolvedValue([])
mockTaskCreateMany.mockResolvedValue({ count: 0 })
mockTaskFindMany.mockResolvedValue([])
mockTaskFindFirst.mockResolvedValue(null)
mockTaskAssignmentCreateMany.mockResolvedValue({ count: 0 })
})
afterEach(() => {
jest.useRealTimers()
})
describe('runGenerateAndAssign — advocate-designation mode', () => {
const target = { mode: 'advocate-designation' as const, advocateId: 'adv-1', designationId: 'des-1' }
// The implementation calls client.findMany twice: once to find clients
// matching the advocate+designation (select: { id }), and once at the end
// to look up display names for the summary (select: { id, name }). This
// mock serves both from one place, branching on which fields were selected.
const mockClientsForAdvocateDesignation = () => {
mockClientFindMany.mockImplementation((args: any) =>
args?.select?.name
? Promise.resolve([{ id: 'client-1', name: 'Client One' }])
: Promise.resolve([{ id: 'client-1' }])
)
}
it('computes counts without writing when dryRun is true', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
mockClientsForAdvocateDesignation()
mockTaskTemplateFindMany.mockResolvedValue([
{
id: 'template-1',
name: 'Prepare loss summary',
description: null,
department: 'Commercial Lines',
timing: 'PRE_RENEWAL',
daysOffset: -1,
defaultPriority: 'NORMAL',
level: 'RENEWAL_GROUP',
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
])
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
expect(mockTaskCreateMany).not.toHaveBeenCalled()
expect(mockAuditLogCreate).not.toHaveBeenCalled()
expect(result.dryRun).toBe(true)
expect(result.totalEstimatedTasks).toBe(1)
expect(result.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
expect(result.advocateName).toBe('Jane Smith')
})
it('creates and assigns tasks, matching the dry-run count, when dryRun is false', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
mockClientsForAdvocateDesignation()
mockTaskTemplateFindMany.mockResolvedValue([
{
id: 'template-1',
name: 'Prepare loss summary',
description: null,
department: 'Commercial Lines',
timing: 'PRE_RENEWAL',
daysOffset: -1,
defaultPriority: 'NORMAL',
level: 'RENEWAL_GROUP',
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
])
mockTaskCreateMany.mockResolvedValue({ count: 1 })
mockTaskFindMany.mockResolvedValue([{ id: 'task-1' }])
mockTaskAssignmentCreateMany.mockResolvedValue({ count: 1 })
const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })
expect(mockTaskCreateMany).toHaveBeenCalledTimes(1)
expect(mockAuditLogCreate).toHaveBeenCalledTimes(1)
expect(realResult.tasksCreated).toBe(dryRunResult.totalEstimatedTasks)
expect(realResult.tasksAssigned).toBe(1)
})
it('throws 404 when the advocate is not found or inactive', async () => {
mockUserFindUnique.mockResolvedValue(null)
await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
status: 404,
})
})
})
- Step 2: Run test to verify it fails
Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Expected: FAIL — runGenerateAndAssign is not a function (or similar export-not-found error)
- Step 3: Write the implementation
Add to src/lib/tasks/generate-and-assign-core.ts (below the existing parseGenerateAndAssignTarget):
import { prisma } from '@/lib/db'
import { designationFilter, designationIdsOf } from '@/lib/sync/auto-generate'
import { isRelevantDueDate } from '@/lib/sync/task-due-date'
export interface ClientTaskSummary {
id: string
name: string
estimatedTasks: number
}
export interface GenerateAndAssignResult {
dryRun: boolean
clientsFound: number
totalEstimatedTasks: number
advocateName: string | null
clients: ClientTaskSummary[]
tasksCreated: number
tasksAssigned: number
}
interface ResolvedTarget {
advocateId: string
advocateName: string | null
clientIds: string[]
templateDesignationIds: string[]
}
async function resolveTarget(target: GenerateTarget): Promise<ResolvedTarget> {
if (target.mode === 'advocate-designation') {
const advocate = await prisma.user.findUnique({
where: { id: target.advocateId },
select: { id: true, displayName: true, isActive: true },
})
if (!advocate || !advocate.isActive) {
throw new GenerateAndAssignError('Advocate not found or inactive', 404)
}
const clients = await prisma.client.findMany({
where: {
claimsAdvocateId: target.advocateId,
OR: [{ designationId: target.designationId }, { designation2Id: target.designationId }],
},
select: { id: true },
})
return {
advocateId: advocate.id,
advocateName: advocate.displayName,
clientIds: clients.map((c) => c.id),
templateDesignationIds: [target.designationId],
}
}
const client = await prisma.client.findUnique({
where: { id: target.clientId },
select: { id: true, claimsAdvocateId: true, designationId: true, designation2Id: true },
})
if (!client) {
throw new GenerateAndAssignError('Client not found', 404)
}
if (!client.claimsAdvocateId) {
throw new GenerateAndAssignError('Client has no claims advocate assigned', 400)
}
const advocate = await prisma.user.findUnique({
where: { id: client.claimsAdvocateId },
select: { id: true, displayName: true, isActive: true },
})
if (!advocate || !advocate.isActive) {
throw new GenerateAndAssignError('Advocate not found or inactive', 404)
}
return {
advocateId: advocate.id,
advocateName: advocate.displayName,
clientIds: [client.id],
templateDesignationIds: designationIdsOf(client),
}
}
export async function runGenerateAndAssign(
target: GenerateTarget,
options: { dryRun: boolean; actorUserId: string }
): Promise<GenerateAndAssignResult> {
const resolved = await resolveTarget(target)
if (resolved.clientIds.length === 0) {
return {
dryRun: options.dryRun,
clientsFound: 0,
totalEstimatedTasks: 0,
advocateName: resolved.advocateName,
clients: [],
tasksCreated: 0,
tasksAssigned: 0,
}
}
const estimatedByClient = new Map<string, number>()
let tasksCreated = 0
let tasksAssigned = 0
const allTemplates = await prisma.taskTemplate.findMany({
where: { isActive: true, OR: designationFilter(resolved.templateDesignationIds) },
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
})
if (allTemplates.length > 0) {
const groupTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP')
const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY')
const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT')
const groups = await prisma.policyGroup.findMany({
where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } },
})
for (const group of groups) {
const renewalDate = new Date(group.renewalDate)
const tasksToCreate = groupTemplates
.map((template) => {
const dueDate = new Date(renewalDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: group.clientId,
policyGroupId: group.id,
templateId: template.id,
createdBy: options.actorUserId,
}
})
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) continue
estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length)
if (options.dryRun) continue
const created = await prisma.task.createMany({ data: tasksToCreate })
tasksCreated += created.count
if (created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } },
select: { id: true },
})
if (newTasks.length > 0) {
const assigned = await prisma.taskAssignment.createMany({
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
skipDuplicates: true,
})
tasksAssigned += assigned.count
}
}
}
const policies = await prisma.policy.findMany({
where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } },
})
for (const policy of policies) {
const anchorDate = new Date(policy.expirationDate)
anchorDate.setDate(anchorDate.getDate() + 1)
const tasksToCreate = policyTemplates
.map((template) => {
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
return {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED' as const,
priority: template.defaultPriority,
clientId: policy.clientId,
policyId: policy.id,
templateId: template.id,
createdBy: options.actorUserId,
}
})
.filter((task) => isRelevantDueDate(task.dueDate))
if (tasksToCreate.length === 0) continue
estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length)
if (options.dryRun) continue
const created = await prisma.task.createMany({ data: tasksToCreate })
tasksCreated += created.count
if (created.count > 0) {
const newTasks = await prisma.task.findMany({
where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } },
select: { id: true },
})
if (newTasks.length > 0) {
const assigned = await prisma.taskAssignment.createMany({
data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
skipDuplicates: true,
})
tasksAssigned += assigned.count
}
}
}
if (clientTemplates.length > 0) {
for (const clientId of resolved.clientIds) {
const clientRecord = await prisma.client.findUnique({
where: { id: clientId },
select: {
policyGroups: { select: { renewalDate: true } },
policies: { where: { policyGroupId: null }, select: { expirationDate: true } },
},
})
if (!clientRecord) continue
const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime())
const policyDates = clientRecord.policies.map((p) => {
const d = new Date(p.expirationDate)
d.setDate(d.getDate() + 1)
return d.getTime()
})
const allDates = [...groupDates, ...policyDates]
if (allDates.length === 0) continue
const anchorDate = new Date(Math.min(...allDates))
for (const template of clientTemplates) {
const existing = await prisma.task.findFirst({
where: { clientId, templateId: template.id, policyId: null, policyGroupId: null },
select: { id: true },
})
if (existing) continue
const dueDate = new Date(anchorDate)
dueDate.setDate(dueDate.getDate() + template.daysOffset)
if (!isRelevantDueDate(dueDate)) continue
estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1)
if (options.dryRun) continue
const createdTask = await prisma.task.create({
data: {
title: template.name,
description: template.description,
department: template.department,
timing: template.timing,
daysOffset: template.daysOffset,
dueDate,
status: 'NOT_STARTED',
priority: template.defaultPriority,
clientId,
templateId: template.id,
createdBy: options.actorUserId,
},
select: { id: true },
})
tasksCreated++
await prisma.taskAssignment.create({ data: { taskId: createdTask.id, userId: resolved.advocateId } })
tasksAssigned++
}
}
}
}
if (!options.dryRun) {
await prisma.auditLog.create({
data: {
userId: options.actorUserId,
action: 'GENERATE_AND_ASSIGN_TASKS',
entityType: 'Task',
newValues: {
mode: target.mode,
...(target.mode === 'advocate-designation'
? { advocateId: target.advocateId, designationId: target.designationId }
: { clientId: target.clientId }),
advocateName: resolved.advocateName,
clientsFound: resolved.clientIds.length,
tasksCreated,
tasksAssigned,
},
},
})
}
const clientNames = await prisma.client.findMany({
where: { id: { in: resolved.clientIds } },
select: { id: true, name: true },
})
const nameById = new Map(clientNames.map((c) => [c.id, c.name]))
const clients: ClientTaskSummary[] = resolved.clientIds
.map((id) => ({ id, name: nameById.get(id) ?? id, estimatedTasks: estimatedByClient.get(id) ?? 0 }))
.filter((c) => c.estimatedTasks > 0)
.sort((a, b) => b.estimatedTasks - a.estimatedTasks)
const totalEstimatedTasks = clients.reduce((sum, c) => sum + c.estimatedTasks, 0)
return {
dryRun: options.dryRun,
clientsFound: resolved.clientIds.length,
totalEstimatedTasks,
advocateName: resolved.advocateName,
clients,
tasksCreated,
tasksAssigned,
}
}
- Step 4: Run test to verify it passes
Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Expected: 10 passed, 10 total
- Step 5: Commit
git add src/lib/tasks/generate-and-assign-core.ts src/lib/tasks/__tests__/generate-and-assign-core.test.ts
git commit -m "feat(tasks): implement runGenerateAndAssign for advocate-designation mode"
Task 4: Core module — client mode + missing-advocate guard (TDD)
Files:
- Modify:
src/lib/tasks/__tests__/generate-and-assign-core.test.ts(implementation already handles client mode from Task 3 — this task only adds coverage)
Interfaces:
-
Consumes:
runGenerateAndAssign,GenerateAndAssignErrorfrom Task 3. -
Produces: nothing new — verifies existing behavior.
-
Step 1: Write the failing tests
Add to src/lib/tasks/__tests__/generate-and-assign-core.test.ts:
describe('runGenerateAndAssign — client mode', () => {
const target = { mode: 'client' as const, clientId: 'client-1' }
it('targets only the specified client and assigns to its own claims advocate', async () => {
mockClientFindUnique.mockResolvedValueOnce({
id: 'client-1',
claimsAdvocateId: 'adv-2',
designationId: 'des-1',
designation2Id: null,
})
mockUserFindUnique.mockResolvedValue({ id: 'adv-2', displayName: 'Bob Advocate', isActive: true })
mockTaskTemplateFindMany.mockResolvedValue([
{
id: 'template-1',
name: 'Prepare loss summary',
description: null,
department: 'Commercial Lines',
timing: 'PRE_RENEWAL',
daysOffset: -1,
defaultPriority: 'NORMAL',
level: 'RENEWAL_GROUP',
},
])
mockPolicyGroupFindMany.mockResolvedValue([
{ id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
])
mockClientFindMany.mockResolvedValue([{ id: 'client-1', name: 'Client One' }])
const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
expect(mockPolicyGroupFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ clientId: { in: ['client-1'] } }) })
)
expect(result.advocateName).toBe('Bob Advocate')
expect(result.clientsFound).toBe(1)
})
it('throws a 400 error when the client has no claims advocate assigned', async () => {
mockClientFindUnique.mockResolvedValueOnce({
id: 'client-1',
claimsAdvocateId: null,
designationId: null,
designation2Id: null,
})
await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
status: 400,
message: 'Client has no claims advocate assigned',
})
})
it('throws a 404 error when the client does not exist', async () => {
mockClientFindUnique.mockResolvedValueOnce(null)
await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
status: 404,
message: 'Client not found',
})
})
})
- Step 2: Run test to verify it fails
Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Expected: FAIL on the first new test — mockClientFindUnique was declared in Task 3's mock setup but resolveTarget's client-mode branch must be reachable; if it fails on an assertion mismatch (not a missing-function error), check that mockClientFindMany in this test file's beforeEach doesn't overwrite the per-test mockResolvedValueOnce on mockClientFindUnique — these are separate mock functions so no clash is expected. It should fail here simply because mockClientFindUnique was never called yet in earlier tests, i.e. this exercises new code paths for the first time — expect real assertion failures if the implementation from Task 3 has a bug, otherwise these should already pass since Task 3 implemented client mode.
- Step 3: No implementation changes expected
Client mode was already implemented in Task 3's resolveTarget. If Step 2 failed, re-read resolveTarget's client branch in src/lib/tasks/generate-and-assign-core.ts and fix the specific mismatch reported — do not add new logic beyond what's already there.
- Step 4: Run test to verify it passes
Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts
Expected: 13 passed, 13 total
- Step 5: Commit
git add src/lib/tasks/__tests__/generate-and-assign-core.test.ts
git commit -m "test(tasks): cover client-mode targeting and missing-advocate guard"
Task 5: Refactor the route to a thin wrapper around the core module
Files:
- Modify:
src/app/api/tasks/generate-and-assign/route.ts(full rewrite of the file body)
Interfaces:
-
Consumes:
parseGenerateAndAssignTarget,runGenerateAndAssign,GenerateAndAssignErrorfrom@/lib/tasks/generate-and-assign-core. -
Produces:
POSThandler returningGenerateAndAssignResultJSON (shape from Task 3) instead of the old{ tasksCreated, tasksAssigned, clientsFound, groupsProcessed, advocateName, message? }shape. This is a breaking response-shape change — Task 7 updates the only consumer. -
Step 1: Replace the file contents
Replace the entire contents of src/app/api/tasks/generate-and-assign/route.ts with:
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import {
parseGenerateAndAssignTarget,
runGenerateAndAssign,
GenerateAndAssignError,
} from '@/lib/tasks/generate-and-assign-core'
/**
* POST /api/tasks/generate-and-assign
* Generate tasks from active templates for either (a) every client matching
* an advocate + designation, or (b) one specific client, and assign them to
* the relevant claims advocate. Pass `dryRun: true` to compute the summary
* without creating anything.
* Body: { advocateId, designationId, dryRun? } | { clientId, dryRun? }
* Requires Admin or Manager role.
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userRoles = (session.user as any).roles || []
if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { target, dryRun } = parseGenerateAndAssignTarget(body)
const result = await runGenerateAndAssign(target, {
dryRun,
actorUserId: (session.user as any).id,
})
return NextResponse.json(result)
} catch (error: any) {
if (error instanceof GenerateAndAssignError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
console.error('Generate and assign error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
- Step 2: Run the full core-module test suite plus a typecheck
Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts && npx tsc --noEmit
Expected: 13 passed, 13 total, and no new TypeScript errors introduced by this file (pre-existing unrelated errors elsewhere in the repo, if any, are out of scope).
- Step 3: Commit
git add src/app/api/tasks/generate-and-assign/route.ts
git commit -m "refactor(tasks): route generate-and-assign through the shared core module"
Task 6: Server component — expose claimsAdvocateId on each client
Files:
- Modify:
src/app/(dashboard)/tasks/assign/page.tsx:24-27
Interfaces:
-
Produces:
clientsprop passed toBulkAssignClientnow includesclaimsAdvocateId: string | nullper client (consumed by Task 7's updatedSimpleClientinterface). -
Step 1: Add
claimsAdvocateIdto the client select
In src/app/(dashboard)/tasks/assign/page.tsx, change:
prisma.client.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
to:
prisma.client.findMany({
select: { id: true, name: true, claimsAdvocateId: true },
orderBy: { name: 'asc' },
}),
- Step 2: Typecheck
Run: npx tsc --noEmit
Expected: a new error will appear at this point because BulkAssignClientProps.clients (in page-client.tsx) doesn't yet declare claimsAdvocateId — this is expected and resolved by Task 7. Confirm the error is exactly this shape mismatch and nothing else.
- Step 3: Commit
git add "src/app/(dashboard)/tasks/assign/page.tsx"
git commit -m "feat(tasks): include claimsAdvocateId in the client list for the assign page"
Task 7: Frontend — mode toggle, per-client picker, preview dialog
Files:
- Modify:
src/app/(dashboard)/tasks/assign/page-client.tsx(imports, types, state, handlers, JSX)
Interfaces:
-
Consumes:
GenerateAndAssignResultshape from Task 3/5 ({ dryRun, clientsFound, totalEstimatedTasks, advocateName, clients, tasksCreated, tasksAssigned });claimsAdvocateIdfield added toclientsprop in Task 6. -
Produces: no new exports (this is the leaf UI component).
-
Step 1: Update imports
At the top of src/app/(dashboard)/tasks/assign/page-client.tsx, add:
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
- Step 2: Update types
Replace:
interface SimpleClient { id: string; name: string }
with:
interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null }
Replace the GenResult interface:
interface GenResult {
tasksCreated: number
tasksAssigned: number
clientsFound: number
groupsProcessed: number
advocateName: string | null
message?: string
}
with:
interface ClientTaskSummary {
id: string
name: string
estimatedTasks: number
}
interface GenResult {
dryRun: boolean
clientsFound: number
totalEstimatedTasks: number
advocateName: string | null
clients: ClientTaskSummary[]
tasksCreated: number
tasksAssigned: number
}
- Step 3: Add mode/preview state
In the BulkAssignClient function body, replace:
// Generate & assign state
const [genAdvocate, setGenAdvocate] = useState('')
const [genDesignation, setGenDesignation] = useState('')
const [generating, setGenerating] = useState(false)
const [genResult, setGenResult] = useState<GenResult | null>(null)
with:
// Generate & assign state
const [genMode, setGenMode] = useState<'advocate-designation' | 'client'>('advocate-designation')
const [genAdvocate, setGenAdvocate] = useState('')
const [genDesignation, setGenDesignation] = useState('')
const [genClientId, setGenClientId] = useState('')
const [previewing, setPreviewing] = useState(false)
const [generating, setGenerating] = useState(false)
const [preview, setPreview] = useState<GenResult | null>(null)
const [genResult, setGenResult] = useState<GenResult | null>(null)
- Step 4: Replace
handleGenerateAndAssignwith preview + confirm handlers
Replace the entire handleGenerateAndAssign function:
const handleGenerateAndAssign = async () => {
if (!genAdvocate || !genDesignation) return
setGenerating(true)
setGenResult(null)
try {
const res = await fetch('/api/tasks/generate-and-assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ advocateId: genAdvocate, designationId: genDesignation }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setGenResult(data)
if (data.tasksCreated > 0) {
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
fetchTasks()
} else if (data.clientsFound === 0) {
toast.info('No clients found with this advocate and designation')
} else {
toast.info(data.message || 'No new tasks to generate')
}
} catch (err: any) {
toast.error(err.message || 'Failed to generate tasks')
} finally {
setGenerating(false)
}
}
with:
const buildGenBody = (dryRun: boolean) =>
genMode === 'client'
? { clientId: genClientId, dryRun }
: { advocateId: genAdvocate, designationId: genDesignation, dryRun }
const handlePreview = async () => {
setPreviewing(true)
setGenResult(null)
try {
const res = await fetch('/api/tasks/generate-and-assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildGenBody(true)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
if (data.totalEstimatedTasks === 0) {
toast.info(
data.clientsFound === 0
? 'No clients found matching this selection'
: 'No new tasks to generate — all tasks may already exist'
)
return
}
setPreview(data)
} catch (err: any) {
toast.error(err.message || 'Failed to preview task generation')
} finally {
setPreviewing(false)
}
}
const handleConfirmGenerate = async () => {
setGenerating(true)
try {
const res = await fetch('/api/tasks/generate-and-assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildGenBody(false)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
setGenResult(data)
setPreview(null)
if (data.tasksCreated > 0) {
toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
fetchTasks()
} else {
toast.info('No new tasks to generate')
}
} catch (err: any) {
toast.error(err.message || 'Failed to generate tasks')
} finally {
setGenerating(false)
}
}
- Step 5: Replace the "Generate & Assign card" JSX
Replace the <CardContent> block inside the "Generate & Assign card" (currently spanning the advocate/designation selects, the button, and the result paragraph — everything between <CardContent> and its matching </CardContent> right before {/* Filter bar */}):
<CardContent>
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<p className="text-sm font-medium mb-1.5">Claims Advocate</p>
<Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select advocate..." />
</SelectTrigger>
<UserSelectContent
users={users}
prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
/>
</Select>
</div>
<div className="flex-1 min-w-[200px]">
<p className="text-sm font-medium mb-1.5">Designation</p>
<Select value={genDesignation || '_none'} onValueChange={(v) => { setGenDesignation(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select designation..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select designation</SelectItem>
{designations.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerateAndAssign} disabled={!genAdvocate || !genDesignation || generating}>
{generating ? 'Generating...' : 'Generate & Assign'}
</Button>
</div>
{genResult && (
<p className="mt-3 text-sm text-muted-foreground">
{genResult.tasksCreated > 0
? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
: genResult.clientsFound === 0
? `No clients found assigned to this advocate with that designation.`
: genResult.message || 'No new tasks to generate — all tasks may already exist.'}
</p>
)}
</CardContent>
with:
<CardContent>
<Tabs
value={genMode}
onValueChange={(v) => { setGenMode(v as 'advocate-designation' | 'client'); setGenResult(null) }}
>
<TabsList>
<TabsTrigger value="advocate-designation">By Advocate + Designation</TabsTrigger>
<TabsTrigger value="client">By Client</TabsTrigger>
</TabsList>
</Tabs>
{genMode === 'advocate-designation' ? (
<div className="flex items-end gap-3 flex-wrap mt-3">
<div className="flex-1 min-w-[200px]">
<p className="text-sm font-medium mb-1.5">Claims Advocate</p>
<Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select advocate..." />
</SelectTrigger>
<UserSelectContent
users={users}
prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
/>
</Select>
</div>
<div className="flex-1 min-w-[200px]">
<p className="text-sm font-medium mb-1.5">Designation</p>
<Select value={genDesignation || '_none'} onValueChange={(v) => { setGenDesignation(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select designation..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select designation</SelectItem>
{designations.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handlePreview} disabled={!genAdvocate || !genDesignation || previewing}>
{previewing ? 'Checking...' : 'Preview'}
</Button>
</div>
) : (
<div className="flex items-end gap-3 flex-wrap mt-3">
<div className="flex-1 min-w-[240px]">
<p className="text-sm font-medium mb-1.5">Client</p>
<Select value={genClientId || '_none'} onValueChange={(v) => { setGenClientId(v === '_none' ? '' : v); setGenResult(null) }}>
<SelectTrigger>
<SelectValue placeholder="Select client..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Select client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{genClientId && !clients.find((c) => c.id === genClientId)?.claimsAdvocateId && (
<p className="text-sm text-destructive mt-1.5">
This client has no claims advocate assigned. Set one before generating tasks.
</p>
)}
</div>
<Button
onClick={handlePreview}
disabled={!genClientId || !clients.find((c) => c.id === genClientId)?.claimsAdvocateId || previewing}
>
{previewing ? 'Checking...' : 'Preview'}
</Button>
</div>
)}
{genResult && (
<p className="mt-3 text-sm text-muted-foreground">
{genResult.tasksCreated > 0
? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
: 'No new tasks to generate — all tasks may already exist.'}
</p>
)}
</CardContent>
- Step 6: Add the preview confirmation dialog
Immediately after the closing </Card> of the "Generate & Assign card" (right before the {/* Filter bar */} comment), add:
<AlertDialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}?
</AlertDialogTitle>
<AlertDialogDescription>
This will create {preview?.totalEstimatedTasks} task(s) across {preview?.clientsFound} client(s)
and assign them to {preview?.advocateName}.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-64 overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead className="text-right">Tasks</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview?.clients.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.name}</TableCell>
<TableCell className="text-right">{c.estimatedTasks}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmGenerate} disabled={generating}>
{generating ? 'Generating...' : 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
- Step 7: Typecheck
Run: npx tsc --noEmit
Expected: no errors referencing page-client.tsx or page.tsx (the mismatch introduced in Task 6 is now resolved by the updated SimpleClient interface in Step 2 of this task).
- Step 8: Commit
git add "src/app/(dashboard)/tasks/assign/page-client.tsx"
git commit -m "feat(tasks): add per-client mode and preview-before-generate to Generate & Assign"
Task 8: Build, deploy, and manually verify end-to-end
Files: none (verification only)
- Step 1: Run the full test suite
Run: npx jest
Expected: the new generate-and-assign-core.test.ts (13 tests) and auto-generate.test.ts (4 tests) pass. The three pre-existing unrelated failures (auth.test.ts, mappers.test.ts, renewal-group-recommendations.test.ts) are expected and out of scope — confirmed pre-existing in an earlier session.
Confirmed 2026-07-18: 113 passed, 13 failed across exactly the 3 expected pre-existing suites (auth.test.ts, mappers.test.ts, renewal-group-recommendations.test.ts); generate-and-assign-core.test.ts and auto-generate.test.ts both fully green.
- Step 2: Rebuild and restart the app container
Run:
cd /opt/stacks/horizon
docker compose build horizon-app && docker compose up -d horizon-app
Expected: build succeeds; docker compose logs --tail 20 horizon-app shows ✓ Ready with no errors.
Confirmed 2026-07-18: branch task-gen-preview-and-per-client fast-forward merged into main, image rebuilt, container recreated, logs show ✓ Ready in 321ms with no errors.
- Step 3: Manually verify the "By Advocate + Designation" preview flow
In a browser, sign in as an Admin, go to Tasks → Generate & Assign. Select an advocate + designation known to have at least one client with missing template tasks, click Preview. Confirm a dialog opens showing a total, client count, and a per-client breakdown table — and that no new tasks appear in the task list yet (nothing was written). Click Cancel and confirm no tasks were created (check via the "Total tasks" count elsewhere or the client's task list). Repeat, this time clicking Confirm, and verify tasks now appear and the dialog closes.
Confirmed 2026-07-18 via Playwright against localhost:3000, signed in as the local dev Admin account: Dawn Boland + Shape showed "Generate 24 tasks? ... across 6 client(s)" with a 3-row breakdown table (rows only list clients with estimatedTasks > 0, by design — see generate-and-assign-core.ts). Cancel left the DB task count unchanged (6950). Confirm created exactly 24 tasks (6950→6974) and closed the dialog.
- Step 4: Manually verify the "By Client" mode
Switch to the By Client tab, pick a client whose claimsAdvocateId is set. Click Preview, confirm the dialog shows just that one client's row and a total matching it. Confirm. Then pick a client with no claims advocate set and confirm the Preview button is disabled with the "no claims advocate assigned" message showing.
Confirmed 2026-07-18: "BEK TRANS GROUP INC" (advocate set, 0 existing tasks) previewed as "Generate 5 tasks?" with a single matching row; Confirm created exactly 5 tasks for that client. "1000 Howard Boulevard Partners, LP" (no advocate) showed the Preview button disabled with "This client has no claims advocate assigned. Set one before generating tasks."
- Step 5: No commit for this task — verification only, nothing to stage.
Self-Review Notes
- Spec coverage: every decision row in the spec (preview scope = this page only, per-client assignee = client's own advocate, separate mode toggle, summary-count preview detail,
dryRun-on-existing-endpoint mechanism) is implemented by Tasks 2–7. Retention/testing plan items from the spec are covered by Tasks 2–4 (core module tests) and Task 8 (manual E2E + full suite run). - Placeholder scan: no TBD/TODO; every step has complete, runnable code.
- Type consistency:
GenerateAndAssignResult(Task 3) and the frontendGenResult(Task 7) use identical field names (dryRun,clientsFound,totalEstimatedTasks,advocateName,clients,tasksCreated,tasksAssigned) andClientTaskSummary/ClientTaskSummary-shaped objects (id,name,estimatedTasks) match on both sides.