seubert-claims/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md
lorentz 347e4b342b feat(tasks): add preview-before-generate to policy group task generation
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>
2026-07-30 10:56:46 +00:00

1315 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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:
```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<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**
```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<GenResult | null>(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<GenResult | null>(null)
const [genResult, setGenResult] = useState<GenResult | null>(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 `<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 */}`):
```tsx
<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:
```tsx
<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:
```tsx
<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**
```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 27. Retention/testing plan items from the spec are covered by Tasks 24 (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.