seubert-claims/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md
lorentz 6ab58dec68 docs: implementation plan for per-client generation + preview dialog
Bite-sized, TDD-ordered plan for docs/superpowers/specs/2026-07-18-task-generation-preview-and-per-client-design.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 09:21:20 +00:00

50 KiB
Raw Blame History

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:

function designationIdsOf(entity: {

to:

export function designationIdsOf(entity: {

And change:

function designationFilter(ids: string[]) {

to:

export function designationFilter(ids: string[]) {
  • Step 2: Run the existing test suite for this file to confirm no regression

Run: npx jest src/lib/sync/__tests__/auto-generate.test.ts Expected: 4 passed, 4 total (unchanged from before this edit — adding export doesn't change behavior).

  • Step 3: Commit
git add src/lib/sync/auto-generate.ts
git commit -m "refactor(sync): export designation-matching helpers for reuse"

Task 2: Core module — target parsing/validation (TDD)

Files:

  • Create: src/lib/tasks/generate-and-assign-core.ts
  • Test: src/lib/tasks/__tests__/generate-and-assign-core.test.ts

Interfaces:

  • Consumes: nothing yet (pure parsing logic, no DB calls in this task).

  • Produces:

    • export class GenerateAndAssignError extends Error { status: number }
    • export type GenerateTarget = { mode: 'advocate-designation'; advocateId: string; designationId: string } | { mode: 'client'; clientId: string }
    • export function parseGenerateAndAssignTarget(body: any): { target: GenerateTarget; dryRun: boolean }
  • Step 1: Write the failing tests

Create src/lib/tasks/__tests__/generate-and-assign-core.test.ts:

import { parseGenerateAndAssignTarget, GenerateAndAssignError } from '../generate-and-assign-core'

describe('parseGenerateAndAssignTarget', () => {
  it('parses advocate+designation mode', () => {
    const { target, dryRun } = parseGenerateAndAssignTarget({
      advocateId: 'adv-1',
      designationId: 'des-1',
    })
    expect(target).toEqual({ mode: 'advocate-designation', advocateId: 'adv-1', designationId: 'des-1' })
    expect(dryRun).toBe(false)
  })

  it('parses client mode', () => {
    const { target, dryRun } = parseGenerateAndAssignTarget({ clientId: 'client-1', dryRun: true })
    expect(target).toEqual({ mode: 'client', clientId: 'client-1' })
    expect(dryRun).toBe(true)
  })

  it('defaults dryRun to false when omitted', () => {
    const { dryRun } = parseGenerateAndAssignTarget({ clientId: 'client-1' })
    expect(dryRun).toBe(false)
  })

  it('throws when both modes are provided', () => {
    expect(() =>
      parseGenerateAndAssignTarget({ advocateId: 'a', designationId: 'd', clientId: 'c' })
    ).toThrow(GenerateAndAssignError)
  })

  it('throws when neither mode is provided', () => {
    expect(() => parseGenerateAndAssignTarget({})).toThrow(GenerateAndAssignError)
  })

  it('throws when advocateId is given without designationId', () => {
    expect(() => parseGenerateAndAssignTarget({ advocateId: 'a' })).toThrow(GenerateAndAssignError)
  })

  it('sets a 400 status on validation errors', () => {
    try {
      parseGenerateAndAssignTarget({})
      fail('expected throw')
    } catch (err) {
      expect(err).toBeInstanceOf(GenerateAndAssignError)
      expect((err as GenerateAndAssignError).status).toBe(400)
    }
  })
})
  • Step 2: Run test to verify it fails

Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts Expected: FAIL — Cannot find module '../generate-and-assign-core'

  • Step 3: Write minimal implementation

Create src/lib/tasks/generate-and-assign-core.ts:

export class GenerateAndAssignError extends Error {
  status: number
  constructor(message: string, status: number) {
    super(message)
    this.status = status
  }
}

export type GenerateTarget =
  | { mode: 'advocate-designation'; advocateId: string; designationId: string }
  | { mode: 'client'; clientId: string }

/**
 * Parses and validates a generate-and-assign request body. Exactly one of
 * (advocateId + designationId) or clientId must be present.
 */
export function parseGenerateAndAssignTarget(body: any): { target: GenerateTarget; dryRun: boolean } {
  const dryRun = body?.dryRun === true
  const hasAdvocateDesignation = !!body?.advocateId && !!body?.designationId
  const hasClient = !!body?.clientId

  if (hasAdvocateDesignation && hasClient) {
    throw new GenerateAndAssignError(
      'Provide either advocateId+designationId or clientId, not both',
      400
    )
  }
  if (hasClient) {
    return { target: { mode: 'client', clientId: body.clientId }, dryRun }
  }
  if (hasAdvocateDesignation) {
    return {
      target: { mode: 'advocate-designation', advocateId: body.advocateId, designationId: body.designationId },
      dryRun,
    }
  }
  throw new GenerateAndAssignError('advocateId and designationId, or clientId, are required', 400)
}
  • Step 4: Run test to verify it passes

Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts Expected: 7 passed, 7 total

  • Step 5: Commit
git add src/lib/tasks/generate-and-assign-core.ts src/lib/tasks/__tests__/generate-and-assign-core.test.ts
git commit -m "feat(tasks): add request parsing for generate-and-assign core module"

Task 3: Core module — target resolution + runGenerateAndAssign for advocate-designation mode (TDD)

Files:

  • Modify: src/lib/tasks/generate-and-assign-core.ts
  • Modify: src/lib/tasks/__tests__/generate-and-assign-core.test.ts

Interfaces:

  • Consumes: designationFilter, 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:

const mockUserFindUnique = jest.fn()
const mockClientFindMany = jest.fn()
const mockClientFindUnique = jest.fn()
const mockTaskTemplateFindMany = jest.fn()
const mockPolicyGroupFindMany = jest.fn()
const mockPolicyFindMany = jest.fn()
const mockTaskCreateMany = jest.fn().mockResolvedValue({ count: 0 })
const mockTaskCreate = jest.fn()
const mockTaskFindMany = jest.fn().mockResolvedValue([])
const mockTaskFindFirst = jest.fn().mockResolvedValue(null)
const mockTaskAssignmentCreateMany = jest.fn().mockResolvedValue({ count: 0 })
const mockTaskAssignmentCreate = jest.fn()
const mockAuditLogCreate = jest.fn().mockResolvedValue({})

jest.mock('@/lib/db', () => ({
  prisma: {
    user: { findUnique: (...args: any[]) => mockUserFindUnique(...args) },
    client: {
      findMany: (...args: any[]) => mockClientFindMany(...args),
      findUnique: (...args: any[]) => mockClientFindUnique(...args),
    },
    taskTemplate: { findMany: (...args: any[]) => mockTaskTemplateFindMany(...args) },
    policyGroup: { findMany: (...args: any[]) => mockPolicyGroupFindMany(...args) },
    policy: { findMany: (...args: any[]) => mockPolicyFindMany(...args) },
    task: {
      createMany: (...args: any[]) => mockTaskCreateMany(...args),
      create: (...args: any[]) => mockTaskCreate(...args),
      findMany: (...args: any[]) => mockTaskFindMany(...args),
      findFirst: (...args: any[]) => mockTaskFindFirst(...args),
    },
    taskAssignment: {
      createMany: (...args: any[]) => mockTaskAssignmentCreateMany(...args),
      create: (...args: any[]) => mockTaskAssignmentCreate(...args),
    },
    auditLog: { create: (...args: any[]) => mockAuditLogCreate(...args) },
  },
}))

import { runGenerateAndAssign } from '../generate-and-assign-core'

const now = new Date('2026-07-18T00:00:00Z')

beforeEach(() => {
  jest.clearAllMocks()
  jest.useFakeTimers().setSystemTime(now)
  mockClientFindMany.mockResolvedValue([])
  mockTaskTemplateFindMany.mockResolvedValue([])
  mockPolicyGroupFindMany.mockResolvedValue([])
  mockPolicyFindMany.mockResolvedValue([])
  mockTaskCreateMany.mockResolvedValue({ count: 0 })
  mockTaskFindMany.mockResolvedValue([])
  mockTaskFindFirst.mockResolvedValue(null)
  mockTaskAssignmentCreateMany.mockResolvedValue({ count: 0 })
})

afterEach(() => {
  jest.useRealTimers()
})

describe('runGenerateAndAssign — advocate-designation mode', () => {
  const target = { mode: 'advocate-designation' as const, advocateId: 'adv-1', designationId: 'des-1' }

  // The implementation calls client.findMany twice: once to find clients
  // matching the advocate+designation (select: { id }), and once at the end
  // to look up display names for the summary (select: { id, name }). This
  // mock serves both from one place, branching on which fields were selected.
  const mockClientsForAdvocateDesignation = () => {
    mockClientFindMany.mockImplementation((args: any) =>
      args?.select?.name
        ? Promise.resolve([{ id: 'client-1', name: 'Client One' }])
        : Promise.resolve([{ id: 'client-1' }])
    )
  }

  it('computes counts without writing when dryRun is true', async () => {
    mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
    mockClientsForAdvocateDesignation()
    mockTaskTemplateFindMany.mockResolvedValue([
      {
        id: 'template-1',
        name: 'Prepare loss summary',
        description: null,
        department: 'Commercial Lines',
        timing: 'PRE_RENEWAL',
        daysOffset: -1,
        defaultPriority: 'NORMAL',
        level: 'RENEWAL_GROUP',
      },
    ])
    mockPolicyGroupFindMany.mockResolvedValue([
      { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
    ])

    const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })

    expect(mockTaskCreateMany).not.toHaveBeenCalled()
    expect(mockAuditLogCreate).not.toHaveBeenCalled()
    expect(result.dryRun).toBe(true)
    expect(result.totalEstimatedTasks).toBe(1)
    expect(result.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }])
    expect(result.advocateName).toBe('Jane Smith')
  })

  it('creates and assigns tasks, matching the dry-run count, when dryRun is false', async () => {
    mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true })
    mockClientsForAdvocateDesignation()
    mockTaskTemplateFindMany.mockResolvedValue([
      {
        id: 'template-1',
        name: 'Prepare loss summary',
        description: null,
        department: 'Commercial Lines',
        timing: 'PRE_RENEWAL',
        daysOffset: -1,
        defaultPriority: 'NORMAL',
        level: 'RENEWAL_GROUP',
      },
    ])
    mockPolicyGroupFindMany.mockResolvedValue([
      { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
    ])
    mockTaskCreateMany.mockResolvedValue({ count: 1 })
    mockTaskFindMany.mockResolvedValue([{ id: 'task-1' }])
    mockTaskAssignmentCreateMany.mockResolvedValue({ count: 1 })

    const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })
    const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' })

    expect(mockTaskCreateMany).toHaveBeenCalledTimes(1)
    expect(mockAuditLogCreate).toHaveBeenCalledTimes(1)
    expect(realResult.tasksCreated).toBe(dryRunResult.totalEstimatedTasks)
    expect(realResult.tasksAssigned).toBe(1)
  })

  it('throws 404 when the advocate is not found or inactive', async () => {
    mockUserFindUnique.mockResolvedValue(null)
    await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
      status: 404,
    })
  })
})
  • Step 2: Run test to verify it fails

Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts Expected: FAIL — runGenerateAndAssign is not a function (or similar export-not-found error)

  • Step 3: Write the implementation

Add to src/lib/tasks/generate-and-assign-core.ts (below the existing parseGenerateAndAssignTarget):

import { prisma } from '@/lib/db'
import { designationFilter, designationIdsOf } from '@/lib/sync/auto-generate'
import { isRelevantDueDate } from '@/lib/sync/task-due-date'

export interface ClientTaskSummary {
  id: string
  name: string
  estimatedTasks: number
}

export interface GenerateAndAssignResult {
  dryRun: boolean
  clientsFound: number
  totalEstimatedTasks: number
  advocateName: string | null
  clients: ClientTaskSummary[]
  tasksCreated: number
  tasksAssigned: number
}

interface ResolvedTarget {
  advocateId: string
  advocateName: string | null
  clientIds: string[]
  templateDesignationIds: string[]
}

async function resolveTarget(target: GenerateTarget): Promise<ResolvedTarget> {
  if (target.mode === 'advocate-designation') {
    const advocate = await prisma.user.findUnique({
      where: { id: target.advocateId },
      select: { id: true, displayName: true, isActive: true },
    })
    if (!advocate || !advocate.isActive) {
      throw new GenerateAndAssignError('Advocate not found or inactive', 404)
    }
    const clients = await prisma.client.findMany({
      where: {
        claimsAdvocateId: target.advocateId,
        OR: [{ designationId: target.designationId }, { designation2Id: target.designationId }],
      },
      select: { id: true },
    })
    return {
      advocateId: advocate.id,
      advocateName: advocate.displayName,
      clientIds: clients.map((c) => c.id),
      templateDesignationIds: [target.designationId],
    }
  }

  const client = await prisma.client.findUnique({
    where: { id: target.clientId },
    select: { id: true, claimsAdvocateId: true, designationId: true, designation2Id: true },
  })
  if (!client) {
    throw new GenerateAndAssignError('Client not found', 404)
  }
  if (!client.claimsAdvocateId) {
    throw new GenerateAndAssignError('Client has no claims advocate assigned', 400)
  }
  const advocate = await prisma.user.findUnique({
    where: { id: client.claimsAdvocateId },
    select: { id: true, displayName: true, isActive: true },
  })
  if (!advocate || !advocate.isActive) {
    throw new GenerateAndAssignError('Advocate not found or inactive', 404)
  }
  return {
    advocateId: advocate.id,
    advocateName: advocate.displayName,
    clientIds: [client.id],
    templateDesignationIds: designationIdsOf(client),
  }
}

export async function runGenerateAndAssign(
  target: GenerateTarget,
  options: { dryRun: boolean; actorUserId: string }
): Promise<GenerateAndAssignResult> {
  const resolved = await resolveTarget(target)

  if (resolved.clientIds.length === 0) {
    return {
      dryRun: options.dryRun,
      clientsFound: 0,
      totalEstimatedTasks: 0,
      advocateName: resolved.advocateName,
      clients: [],
      tasksCreated: 0,
      tasksAssigned: 0,
    }
  }

  const estimatedByClient = new Map<string, number>()
  let tasksCreated = 0
  let tasksAssigned = 0

  const allTemplates = await prisma.taskTemplate.findMany({
    where: { isActive: true, OR: designationFilter(resolved.templateDesignationIds) },
    orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
  })

  if (allTemplates.length > 0) {
    const groupTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP')
    const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY')
    const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT')

    const groups = await prisma.policyGroup.findMany({
      where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } },
    })

    for (const group of groups) {
      const renewalDate = new Date(group.renewalDate)
      const tasksToCreate = groupTemplates
        .map((template) => {
          const dueDate = new Date(renewalDate)
          dueDate.setDate(dueDate.getDate() + template.daysOffset)
          return {
            title: template.name,
            description: template.description,
            department: template.department,
            timing: template.timing,
            daysOffset: template.daysOffset,
            dueDate,
            status: 'NOT_STARTED' as const,
            priority: template.defaultPriority,
            clientId: group.clientId,
            policyGroupId: group.id,
            templateId: template.id,
            createdBy: options.actorUserId,
          }
        })
        .filter((task) => isRelevantDueDate(task.dueDate))

      if (tasksToCreate.length === 0) continue
      estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length)
      if (options.dryRun) continue

      const created = await prisma.task.createMany({ data: tasksToCreate })
      tasksCreated += created.count

      if (created.count > 0) {
        const newTasks = await prisma.task.findMany({
          where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } },
          select: { id: true },
        })
        if (newTasks.length > 0) {
          const assigned = await prisma.taskAssignment.createMany({
            data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
            skipDuplicates: true,
          })
          tasksAssigned += assigned.count
        }
      }
    }

    const policies = await prisma.policy.findMany({
      where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } },
    })

    for (const policy of policies) {
      const anchorDate = new Date(policy.expirationDate)
      anchorDate.setDate(anchorDate.getDate() + 1)
      const tasksToCreate = policyTemplates
        .map((template) => {
          const dueDate = new Date(anchorDate)
          dueDate.setDate(dueDate.getDate() + template.daysOffset)
          return {
            title: template.name,
            description: template.description,
            department: template.department,
            timing: template.timing,
            daysOffset: template.daysOffset,
            dueDate,
            status: 'NOT_STARTED' as const,
            priority: template.defaultPriority,
            clientId: policy.clientId,
            policyId: policy.id,
            templateId: template.id,
            createdBy: options.actorUserId,
          }
        })
        .filter((task) => isRelevantDueDate(task.dueDate))

      if (tasksToCreate.length === 0) continue
      estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length)
      if (options.dryRun) continue

      const created = await prisma.task.createMany({ data: tasksToCreate })
      tasksCreated += created.count

      if (created.count > 0) {
        const newTasks = await prisma.task.findMany({
          where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } },
          select: { id: true },
        })
        if (newTasks.length > 0) {
          const assigned = await prisma.taskAssignment.createMany({
            data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })),
            skipDuplicates: true,
          })
          tasksAssigned += assigned.count
        }
      }
    }

    if (clientTemplates.length > 0) {
      for (const clientId of resolved.clientIds) {
        const clientRecord = await prisma.client.findUnique({
          where: { id: clientId },
          select: {
            policyGroups: { select: { renewalDate: true } },
            policies: { where: { policyGroupId: null }, select: { expirationDate: true } },
          },
        })
        if (!clientRecord) continue

        const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime())
        const policyDates = clientRecord.policies.map((p) => {
          const d = new Date(p.expirationDate)
          d.setDate(d.getDate() + 1)
          return d.getTime()
        })
        const allDates = [...groupDates, ...policyDates]
        if (allDates.length === 0) continue
        const anchorDate = new Date(Math.min(...allDates))

        for (const template of clientTemplates) {
          const existing = await prisma.task.findFirst({
            where: { clientId, templateId: template.id, policyId: null, policyGroupId: null },
            select: { id: true },
          })
          if (existing) continue

          const dueDate = new Date(anchorDate)
          dueDate.setDate(dueDate.getDate() + template.daysOffset)
          if (!isRelevantDueDate(dueDate)) continue

          estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1)
          if (options.dryRun) continue

          const createdTask = await prisma.task.create({
            data: {
              title: template.name,
              description: template.description,
              department: template.department,
              timing: template.timing,
              daysOffset: template.daysOffset,
              dueDate,
              status: 'NOT_STARTED',
              priority: template.defaultPriority,
              clientId,
              templateId: template.id,
              createdBy: options.actorUserId,
            },
            select: { id: true },
          })
          tasksCreated++

          await prisma.taskAssignment.create({ data: { taskId: createdTask.id, userId: resolved.advocateId } })
          tasksAssigned++
        }
      }
    }
  }

  if (!options.dryRun) {
    await prisma.auditLog.create({
      data: {
        userId: options.actorUserId,
        action: 'GENERATE_AND_ASSIGN_TASKS',
        entityType: 'Task',
        newValues: {
          mode: target.mode,
          ...(target.mode === 'advocate-designation'
            ? { advocateId: target.advocateId, designationId: target.designationId }
            : { clientId: target.clientId }),
          advocateName: resolved.advocateName,
          clientsFound: resolved.clientIds.length,
          tasksCreated,
          tasksAssigned,
        },
      },
    })
  }

  const clientNames = await prisma.client.findMany({
    where: { id: { in: resolved.clientIds } },
    select: { id: true, name: true },
  })
  const nameById = new Map(clientNames.map((c) => [c.id, c.name]))

  const clients: ClientTaskSummary[] = resolved.clientIds
    .map((id) => ({ id, name: nameById.get(id) ?? id, estimatedTasks: estimatedByClient.get(id) ?? 0 }))
    .filter((c) => c.estimatedTasks > 0)
    .sort((a, b) => b.estimatedTasks - a.estimatedTasks)

  const totalEstimatedTasks = clients.reduce((sum, c) => sum + c.estimatedTasks, 0)

  return {
    dryRun: options.dryRun,
    clientsFound: resolved.clientIds.length,
    totalEstimatedTasks,
    advocateName: resolved.advocateName,
    clients,
    tasksCreated,
    tasksAssigned,
  }
}
  • Step 4: Run test to verify it passes

Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts Expected: 10 passed, 10 total

  • Step 5: Commit
git add src/lib/tasks/generate-and-assign-core.ts src/lib/tasks/__tests__/generate-and-assign-core.test.ts
git commit -m "feat(tasks): implement runGenerateAndAssign for advocate-designation mode"

Task 4: Core module — client mode + missing-advocate guard (TDD)

Files:

  • Modify: src/lib/tasks/__tests__/generate-and-assign-core.test.ts (implementation already handles client mode from Task 3 — this task only adds coverage)

Interfaces:

  • Consumes: runGenerateAndAssign, 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:

describe('runGenerateAndAssign — client mode', () => {
  const target = { mode: 'client' as const, clientId: 'client-1' }

  it('targets only the specified client and assigns to its own claims advocate', async () => {
    mockClientFindUnique.mockResolvedValueOnce({
      id: 'client-1',
      claimsAdvocateId: 'adv-2',
      designationId: 'des-1',
      designation2Id: null,
    })
    mockUserFindUnique.mockResolvedValue({ id: 'adv-2', displayName: 'Bob Advocate', isActive: true })
    mockTaskTemplateFindMany.mockResolvedValue([
      {
        id: 'template-1',
        name: 'Prepare loss summary',
        description: null,
        department: 'Commercial Lines',
        timing: 'PRE_RENEWAL',
        daysOffset: -1,
        defaultPriority: 'NORMAL',
        level: 'RENEWAL_GROUP',
      },
    ])
    mockPolicyGroupFindMany.mockResolvedValue([
      { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') },
    ])
    mockClientFindMany.mockResolvedValue([{ id: 'client-1', name: 'Client One' }])

    const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })

    expect(mockPolicyGroupFindMany).toHaveBeenCalledWith(
      expect.objectContaining({ where: expect.objectContaining({ clientId: { in: ['client-1'] } }) })
    )
    expect(result.advocateName).toBe('Bob Advocate')
    expect(result.clientsFound).toBe(1)
  })

  it('throws a 400 error when the client has no claims advocate assigned', async () => {
    mockClientFindUnique.mockResolvedValueOnce({
      id: 'client-1',
      claimsAdvocateId: null,
      designationId: null,
      designation2Id: null,
    })

    await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
      status: 400,
      message: 'Client has no claims advocate assigned',
    })
  })

  it('throws a 404 error when the client does not exist', async () => {
    mockClientFindUnique.mockResolvedValueOnce(null)

    await expect(runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' })).rejects.toMatchObject({
      status: 404,
      message: 'Client not found',
    })
  })
})
  • Step 2: Run test to verify it fails

Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts Expected: FAIL on the first new test — mockClientFindUnique was declared in Task 3's mock setup but resolveTarget's client-mode branch must be reachable; if it fails on an assertion mismatch (not a missing-function error), check that mockClientFindMany in this test file's beforeEach doesn't overwrite the per-test mockResolvedValueOnce on mockClientFindUnique — these are separate mock functions so no clash is expected. It should fail here simply because mockClientFindUnique was never called yet in earlier tests, i.e. this exercises new code paths for the first time — expect real assertion failures if the implementation from Task 3 has a bug, otherwise these should already pass since Task 3 implemented client mode.

  • Step 3: No implementation changes expected

Client mode was already implemented in Task 3's resolveTarget. If Step 2 failed, re-read resolveTarget's client branch in src/lib/tasks/generate-and-assign-core.ts and fix the specific mismatch reported — do not add new logic beyond what's already there.

  • Step 4: Run test to verify it passes

Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts Expected: 13 passed, 13 total

  • Step 5: Commit
git add src/lib/tasks/__tests__/generate-and-assign-core.test.ts
git commit -m "test(tasks): cover client-mode targeting and missing-advocate guard"

Task 5: Refactor the route to a thin wrapper around the core module

Files:

  • Modify: src/app/api/tasks/generate-and-assign/route.ts (full rewrite of the file body)

Interfaces:

  • Consumes: parseGenerateAndAssignTarget, runGenerateAndAssign, 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:

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import {
  parseGenerateAndAssignTarget,
  runGenerateAndAssign,
  GenerateAndAssignError,
} from '@/lib/tasks/generate-and-assign-core'

/**
 * POST /api/tasks/generate-and-assign
 * Generate tasks from active templates for either (a) every client matching
 * an advocate + designation, or (b) one specific client, and assign them to
 * the relevant claims advocate. Pass `dryRun: true` to compute the summary
 * without creating anything.
 * Body: { advocateId, designationId, dryRun? } | { clientId, dryRun? }
 * Requires Admin or Manager role.
 */
export async function POST(request: NextRequest) {
  try {
    const session = await getServerSession(authOptions)
    if (!session?.user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }

    const userRoles = (session.user as any).roles || []
    if (!userRoles.includes('Admin') && !userRoles.includes('Manager')) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
    }

    const body = await request.json()
    const { target, dryRun } = parseGenerateAndAssignTarget(body)

    const result = await runGenerateAndAssign(target, {
      dryRun,
      actorUserId: (session.user as any).id,
    })

    return NextResponse.json(result)
  } catch (error: any) {
    if (error instanceof GenerateAndAssignError) {
      return NextResponse.json({ error: error.message }, { status: error.status })
    }
    console.error('Generate and assign error:', error)
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}
  • Step 2: Run the full core-module test suite plus a typecheck

Run: npx jest src/lib/tasks/__tests__/generate-and-assign-core.test.ts && npx tsc --noEmit Expected: 13 passed, 13 total, and no new TypeScript errors introduced by this file (pre-existing unrelated errors elsewhere in the repo, if any, are out of scope).

  • Step 3: Commit
git add src/app/api/tasks/generate-and-assign/route.ts
git commit -m "refactor(tasks): route generate-and-assign through the shared core module"

Task 6: Server component — expose claimsAdvocateId on each client

Files:

  • Modify: src/app/(dashboard)/tasks/assign/page.tsx:24-27

Interfaces:

  • Produces: 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:

    prisma.client.findMany({
      select: { id: true, name: true },
      orderBy: { name: 'asc' },
    }),

to:

    prisma.client.findMany({
      select: { id: true, name: true, claimsAdvocateId: true },
      orderBy: { name: 'asc' },
    }),
  • Step 2: Typecheck

Run: npx tsc --noEmit Expected: a new error will appear at this point because BulkAssignClientProps.clients (in page-client.tsx) doesn't yet declare claimsAdvocateId — this is expected and resolved by Task 7. Confirm the error is exactly this shape mismatch and nothing else.

  • Step 3: Commit
git add "src/app/(dashboard)/tasks/assign/page.tsx"
git commit -m "feat(tasks): include claimsAdvocateId in the client list for the assign page"

Task 7: Frontend — mode toggle, per-client picker, preview dialog

Files:

  • Modify: src/app/(dashboard)/tasks/assign/page-client.tsx (imports, types, state, handlers, JSX)

Interfaces:

  • Consumes: 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:

import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog'
  • Step 2: Update types

Replace:

interface SimpleClient { id: string; name: string }

with:

interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null }

Replace the GenResult interface:

interface GenResult {
  tasksCreated: number
  tasksAssigned: number
  clientsFound: number
  groupsProcessed: number
  advocateName: string | null
  message?: string
}

with:

interface ClientTaskSummary {
  id: string
  name: string
  estimatedTasks: number
}

interface GenResult {
  dryRun: boolean
  clientsFound: number
  totalEstimatedTasks: number
  advocateName: string | null
  clients: ClientTaskSummary[]
  tasksCreated: number
  tasksAssigned: number
}
  • Step 3: Add mode/preview state

In the BulkAssignClient function body, replace:

  // Generate & assign state
  const [genAdvocate, setGenAdvocate] = useState('')
  const [genDesignation, setGenDesignation] = useState('')
  const [generating, setGenerating] = useState(false)
  const [genResult, setGenResult] = useState<GenResult | null>(null)

with:

  // Generate & assign state
  const [genMode, setGenMode] = useState<'advocate-designation' | 'client'>('advocate-designation')
  const [genAdvocate, setGenAdvocate] = useState('')
  const [genDesignation, setGenDesignation] = useState('')
  const [genClientId, setGenClientId] = useState('')
  const [previewing, setPreviewing] = useState(false)
  const [generating, setGenerating] = useState(false)
  const [preview, setPreview] = useState<GenResult | null>(null)
  const [genResult, setGenResult] = useState<GenResult | null>(null)
  • Step 4: Replace handleGenerateAndAssign with preview + confirm handlers

Replace the entire handleGenerateAndAssign function:

  const handleGenerateAndAssign = async () => {
    if (!genAdvocate || !genDesignation) return
    setGenerating(true)
    setGenResult(null)
    try {
      const res = await fetch('/api/tasks/generate-and-assign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ advocateId: genAdvocate, designationId: genDesignation }),
      })
      const data = await res.json()
      if (!res.ok) throw new Error(data.error)
      setGenResult(data)
      if (data.tasksCreated > 0) {
        toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
        fetchTasks()
      } else if (data.clientsFound === 0) {
        toast.info('No clients found with this advocate and designation')
      } else {
        toast.info(data.message || 'No new tasks to generate')
      }
    } catch (err: any) {
      toast.error(err.message || 'Failed to generate tasks')
    } finally {
      setGenerating(false)
    }
  }

with:

  const buildGenBody = (dryRun: boolean) =>
    genMode === 'client'
      ? { clientId: genClientId, dryRun }
      : { advocateId: genAdvocate, designationId: genDesignation, dryRun }

  const handlePreview = async () => {
    setPreviewing(true)
    setGenResult(null)
    try {
      const res = await fetch('/api/tasks/generate-and-assign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(buildGenBody(true)),
      })
      const data = await res.json()
      if (!res.ok) throw new Error(data.error)
      if (data.totalEstimatedTasks === 0) {
        toast.info(
          data.clientsFound === 0
            ? 'No clients found matching this selection'
            : 'No new tasks to generate — all tasks may already exist'
        )
        return
      }
      setPreview(data)
    } catch (err: any) {
      toast.error(err.message || 'Failed to preview task generation')
    } finally {
      setPreviewing(false)
    }
  }

  const handleConfirmGenerate = async () => {
    setGenerating(true)
    try {
      const res = await fetch('/api/tasks/generate-and-assign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(buildGenBody(false)),
      })
      const data = await res.json()
      if (!res.ok) throw new Error(data.error)
      setGenResult(data)
      setPreview(null)
      if (data.tasksCreated > 0) {
        toast.success(`Generated ${data.tasksCreated} task(s) and assigned to ${data.advocateName}`)
        fetchTasks()
      } else {
        toast.info('No new tasks to generate')
      }
    } catch (err: any) {
      toast.error(err.message || 'Failed to generate tasks')
    } finally {
      setGenerating(false)
    }
  }
  • Step 5: Replace the "Generate & Assign card" JSX

Replace the <CardContent> block inside the "Generate & Assign card" (currently spanning the advocate/designation selects, the button, and the result paragraph — everything between <CardContent> and its matching </CardContent> right before {/* Filter bar */}):

        <CardContent>
          <div className="flex items-end gap-3 flex-wrap">
            <div className="flex-1 min-w-[200px]">
              <p className="text-sm font-medium mb-1.5">Claims Advocate</p>
              <Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
                <SelectTrigger>
                  <SelectValue placeholder="Select advocate..." />
                </SelectTrigger>
                <UserSelectContent
                  users={users}
                  prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
                />
              </Select>
            </div>
            <div className="flex-1 min-w-[200px]">
              <p className="text-sm font-medium mb-1.5">Designation</p>
              <Select value={genDesignation || '_none'} onValueChange={(v) => { setGenDesignation(v === '_none' ? '' : v); setGenResult(null) }}>
                <SelectTrigger>
                  <SelectValue placeholder="Select designation..." />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="_none">Select designation</SelectItem>
                  {designations.map((d) => (
                    <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <Button onClick={handleGenerateAndAssign} disabled={!genAdvocate || !genDesignation || generating}>
              {generating ? 'Generating...' : 'Generate & Assign'}
            </Button>
          </div>
          {genResult && (
            <p className="mt-3 text-sm text-muted-foreground">
              {genResult.tasksCreated > 0
                ? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
                : genResult.clientsFound === 0
                  ? `No clients found assigned to this advocate with that designation.`
                  : genResult.message || 'No new tasks to generate — all tasks may already exist.'}
            </p>
          )}
        </CardContent>

with:

        <CardContent>
          <Tabs
            value={genMode}
            onValueChange={(v) => { setGenMode(v as 'advocate-designation' | 'client'); setGenResult(null) }}
          >
            <TabsList>
              <TabsTrigger value="advocate-designation">By Advocate + Designation</TabsTrigger>
              <TabsTrigger value="client">By Client</TabsTrigger>
            </TabsList>
          </Tabs>

          {genMode === 'advocate-designation' ? (
            <div className="flex items-end gap-3 flex-wrap mt-3">
              <div className="flex-1 min-w-[200px]">
                <p className="text-sm font-medium mb-1.5">Claims Advocate</p>
                <Select value={genAdvocate || '_none'} onValueChange={(v) => { setGenAdvocate(v === '_none' ? '' : v); setGenResult(null) }}>
                  <SelectTrigger>
                    <SelectValue placeholder="Select advocate..." />
                  </SelectTrigger>
                  <UserSelectContent
                    users={users}
                    prefixItem={<SelectItem value="_none">Select advocate</SelectItem>}
                  />
                </Select>
              </div>
              <div className="flex-1 min-w-[200px]">
                <p className="text-sm font-medium mb-1.5">Designation</p>
                <Select value={genDesignation || '_none'} onValueChange={(v) => { setGenDesignation(v === '_none' ? '' : v); setGenResult(null) }}>
                  <SelectTrigger>
                    <SelectValue placeholder="Select designation..." />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="_none">Select designation</SelectItem>
                    {designations.map((d) => (
                      <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </div>
              <Button onClick={handlePreview} disabled={!genAdvocate || !genDesignation || previewing}>
                {previewing ? 'Checking...' : 'Preview'}
              </Button>
            </div>
          ) : (
            <div className="flex items-end gap-3 flex-wrap mt-3">
              <div className="flex-1 min-w-[240px]">
                <p className="text-sm font-medium mb-1.5">Client</p>
                <Select value={genClientId || '_none'} onValueChange={(v) => { setGenClientId(v === '_none' ? '' : v); setGenResult(null) }}>
                  <SelectTrigger>
                    <SelectValue placeholder="Select client..." />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="_none">Select client</SelectItem>
                    {clients.map((c) => (
                      <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                {genClientId && !clients.find((c) => c.id === genClientId)?.claimsAdvocateId && (
                  <p className="text-sm text-destructive mt-1.5">
                    This client has no claims advocate assigned. Set one before generating tasks.
                  </p>
                )}
              </div>
              <Button
                onClick={handlePreview}
                disabled={!genClientId || !clients.find((c) => c.id === genClientId)?.claimsAdvocateId || previewing}
              >
                {previewing ? 'Checking...' : 'Preview'}
              </Button>
            </div>
          )}

          {genResult && (
            <p className="mt-3 text-sm text-muted-foreground">
              {genResult.tasksCreated > 0
                ? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.`
                : 'No new tasks to generate — all tasks may already exist.'}
            </p>
          )}
        </CardContent>
  • Step 6: Add the preview confirmation dialog

Immediately after the closing </Card> of the "Generate & Assign card" (right before the {/* Filter bar */} comment), add:

      <AlertDialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null) }}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>
              Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}?
            </AlertDialogTitle>
            <AlertDialogDescription>
              This will create {preview?.totalEstimatedTasks} task(s) across {preview?.clientsFound} client(s)
              and assign them to {preview?.advocateName}.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <div className="max-h-64 overflow-y-auto rounded-md border">
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>Client</TableHead>
                  <TableHead className="text-right">Tasks</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {preview?.clients.map((c) => (
                  <TableRow key={c.id}>
                    <TableCell>{c.name}</TableCell>
                    <TableCell className="text-right">{c.estimatedTasks}</TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </div>
          <AlertDialogFooter>
            <AlertDialogCancel>Cancel</AlertDialogCancel>
            <AlertDialogAction onClick={handleConfirmGenerate} disabled={generating}>
              {generating ? 'Generating...' : 'Confirm'}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
  • Step 7: Typecheck

Run: npx tsc --noEmit Expected: no errors referencing page-client.tsx or page.tsx (the mismatch introduced in Task 6 is now resolved by the updated SimpleClient interface in Step 2 of this task).

  • Step 8: Commit
git add "src/app/(dashboard)/tasks/assign/page-client.tsx"
git commit -m "feat(tasks): add per-client mode and preview-before-generate to Generate & Assign"

Task 8: Build, deploy, and manually verify end-to-end

Files: none (verification only)

  • Step 1: Run the full test suite

Run: npx jest Expected: the new generate-and-assign-core.test.ts (13 tests) and auto-generate.test.ts (4 tests) pass. The three pre-existing unrelated failures (auth.test.ts, mappers.test.ts, renewal-group-recommendations.test.ts) are expected and out of scope — confirmed pre-existing in an earlier session.

  • Step 2: Rebuild and restart the app container

Run:

cd /opt/stacks/horizon
docker compose build horizon-app && docker compose up -d horizon-app

Expected: build succeeds; docker compose logs --tail 20 horizon-app shows ✓ Ready with no errors.

  • 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 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.