diff --git a/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md new file mode 100644 index 0000000..06d3b5a --- /dev/null +++ b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md @@ -0,0 +1,1307 @@ +# 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) + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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.