# 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` (from `src/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[]` and `export function designationFilter(ids: string[]): Array<{ designationId: string | null } | { designationId: { in: string[] } }>` — both now importable from `@/lib/sync/auto-generate`. - [ ] **Step 1: Add `export` to both helper function declarations** In `src/lib/sync/auto-generate.ts`, change: ```typescript function designationIdsOf(entity: { ``` to: ```typescript export function designationIdsOf(entity: { ``` And change: ```typescript function designationFilter(ids: string[]) { ``` to: ```typescript 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** ```bash 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`: ```typescript 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`: ```typescript 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** ```bash 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`, `designationIdsOf` from `@/lib/sync/auto-generate` (Task 1); `isRelevantDueDate` from `@/lib/sync/task-due-date`; `prisma` from `@/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` 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: ```typescript 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`): ```typescript 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 { 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 { 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() 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** ```bash 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`, `GenerateAndAssignError` from 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`: ```typescript 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** ```bash 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`, `GenerateAndAssignError` from `@/lib/tasks/generate-and-assign-core`. - Produces: `POST` handler returning `GenerateAndAssignResult` JSON (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: ```typescript 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** ```bash 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: `clients` prop passed to `BulkAssignClient` now includes `claimsAdvocateId: string | null` per client (consumed by Task 7's updated `SimpleClient` interface). - [ ] **Step 1: Add `claimsAdvocateId` to the client select** In `src/app/(dashboard)/tasks/assign/page.tsx`, change: ```typescript prisma.client.findMany({ select: { id: true, name: true }, orderBy: { name: 'asc' }, }), ``` to: ```typescript 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** ```bash 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: `GenerateAndAssignResult` shape from Task 3/5 (`{ dryRun, clientsFound, totalEstimatedTasks, advocateName, clients, tasksCreated, tasksAssigned }`); `claimsAdvocateId` field added to `clients` prop 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: ```typescript 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: ```typescript interface SimpleClient { id: string; name: string } ``` with: ```typescript interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null } ``` Replace the `GenResult` interface: ```typescript interface GenResult { tasksCreated: number tasksAssigned: number clientsFound: number groupsProcessed: number advocateName: string | null message?: string } ``` with: ```typescript 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: ```typescript // Generate & assign state const [genAdvocate, setGenAdvocate] = useState('') const [genDesignation, setGenDesignation] = useState('') const [generating, setGenerating] = useState(false) const [genResult, setGenResult] = useState(null) ``` with: ```typescript // 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(null) const [genResult, setGenResult] = useState(null) ``` - [ ] **Step 4: Replace `handleGenerateAndAssign` with preview + confirm handlers** Replace the entire `handleGenerateAndAssign` function: ```typescript 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: ```typescript 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 `` block inside the "Generate & Assign card" (currently spanning the advocate/designation selects, the button, and the result paragraph — everything between `` and its matching `` right before `{/* Filter bar */}`): ```tsx

Claims Advocate

Designation

{genResult && (

{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.'}

)}
``` with: ```tsx { setGenMode(v as 'advocate-designation' | 'client'); setGenResult(null) }} > By Advocate + Designation By Client {genMode === 'advocate-designation' ? (

Claims Advocate

Designation

) : (

Client

{genClientId && !clients.find((c) => c.id === genClientId)?.claimsAdvocateId && (

This client has no claims advocate assigned. Set one before generating tasks.

)}
)} {genResult && (

{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.'}

)}
``` - [ ] **Step 6: Add the preview confirmation dialog** Immediately after the closing `` of the "Generate & Assign card" (right before the `{/* Filter bar */}` comment), add: ```tsx { if (!open) setPreview(null) }}> Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}? This will create {preview?.totalEstimatedTasks} task(s) across {preview?.clientsFound} client(s) and assign them to {preview?.advocateName}.
Client Tasks {preview?.clients.map((c) => ( {c.name} {c.estimatedTasks} ))}
Cancel {generating ? 'Generating...' : 'Confirm'}
``` - [ ] **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** ```bash 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) - [x] **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. - [x] **Step 2: Rebuild and restart the app container** Run: ```bash 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. - [x] **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. - [x] **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." - [x] **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 frontend `GenResult` (Task 7) use identical field names (`dryRun`, `clientsFound`, `totalEstimatedTasks`, `advocateName`, `clients`, `tasksCreated`, `tasksAssigned`) and `ClientTaskSummary`/`ClientTaskSummary`-shaped objects (`id`, `name`, `estimatedTasks`) match on both sides.