From d8aaea33e6236a6e45e4df4a80e5d1482ad9ee1a Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 10:10:10 +0000 Subject: [PATCH 01/11] refactor(sync): export designation-matching helpers for reuse --- ondeck/src/lib/sync/auto-generate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ondeck/src/lib/sync/auto-generate.ts b/ondeck/src/lib/sync/auto-generate.ts index c6f5bc8..1c07a8a 100644 --- a/ondeck/src/lib/sync/auto-generate.ts +++ b/ondeck/src/lib/sync/auto-generate.ts @@ -5,7 +5,7 @@ import { isRelevantDueDate } from './task-due-date' const DAY_MS = 24 * 60 * 60 * 1000 /** Designation IDs (primary + secondary) of a client, with nulls dropped. */ -function designationIdsOf(entity: { +export function designationIdsOf(entity: { designationId: string | null designation2Id: string | null }): string[] { @@ -16,7 +16,7 @@ function designationIdsOf(entity: { * Prisma `OR` clause matching templates that apply to a client: templates with * no designation, plus templates scoped to one of the client's designations. */ -function designationFilter(ids: string[]) { +export function designationFilter(ids: string[]) { return [ { designationId: null }, ...(ids.length > 0 ? [{ designationId: { in: ids } }] : []), From 64f8b716035955a280e9772b9f92f84d91a2929e Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 10:12:47 +0000 Subject: [PATCH 02/11] feat(tasks): add request parsing for generate-and-assign core module --- .../generate-and-assign-core.test.ts | 47 +++++++++++++++++++ .../src/lib/tasks/generate-and-assign-core.ts | 38 +++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts create mode 100644 ondeck/src/lib/tasks/generate-and-assign-core.ts diff --git a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts new file mode 100644 index 0000000..ed5dccb --- /dev/null +++ b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts @@ -0,0 +1,47 @@ +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) + } + }) +}) diff --git a/ondeck/src/lib/tasks/generate-and-assign-core.ts b/ondeck/src/lib/tasks/generate-and-assign-core.ts new file mode 100644 index 0000000..da5d545 --- /dev/null +++ b/ondeck/src/lib/tasks/generate-and-assign-core.ts @@ -0,0 +1,38 @@ +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) +} From fd38e0c1480fb359f4bf9c5fc82a026d06b31002 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 10:17:17 +0000 Subject: [PATCH 03/11] feat(tasks): implement runGenerateAndAssign for advocate-designation mode --- .../generate-and-assign-core.test.ts | 142 +++++++- .../src/lib/tasks/generate-and-assign-core.ts | 311 ++++++++++++++++++ 2 files changed, 452 insertions(+), 1 deletion(-) diff --git a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts index ed5dccb..aafcb2b 100644 --- a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts +++ b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts @@ -1,4 +1,144 @@ -import { parseGenerateAndAssignTarget, GenerateAndAssignError } from '../generate-and-assign-core' +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, parseGenerateAndAssignTarget, GenerateAndAssignError } 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, + }) + }) +}) describe('parseGenerateAndAssignTarget', () => { it('parses advocate+designation mode', () => { diff --git a/ondeck/src/lib/tasks/generate-and-assign-core.ts b/ondeck/src/lib/tasks/generate-and-assign-core.ts index da5d545..f8a1142 100644 --- a/ondeck/src/lib/tasks/generate-and-assign-core.ts +++ b/ondeck/src/lib/tasks/generate-and-assign-core.ts @@ -36,3 +36,314 @@ export function parseGenerateAndAssignTarget(body: any): { target: GenerateTarge } throw new GenerateAndAssignError('advocateId and designationId, or clientId, are required', 400) } + +import { prisma } from '@/lib/db' +import { designationFilter, designationIdsOf } from '@/lib/sync/auto-generate' +import { isRelevantDueDate } from '@/lib/sync/task-due-date' + +export interface ClientTaskSummary { + id: string + name: string + estimatedTasks: number +} + +export interface GenerateAndAssignResult { + dryRun: boolean + clientsFound: number + totalEstimatedTasks: number + advocateName: string | null + clients: ClientTaskSummary[] + tasksCreated: number + tasksAssigned: number +} + +interface ResolvedTarget { + advocateId: string + advocateName: string | null + clientIds: string[] + templateDesignationIds: string[] +} + +async function resolveTarget(target: GenerateTarget): Promise { + if (target.mode === 'advocate-designation') { + const advocate = await prisma.user.findUnique({ + where: { id: target.advocateId }, + select: { id: true, displayName: true, isActive: true }, + }) + if (!advocate || !advocate.isActive) { + throw new GenerateAndAssignError('Advocate not found or inactive', 404) + } + const clients = await prisma.client.findMany({ + where: { + claimsAdvocateId: target.advocateId, + OR: [{ designationId: target.designationId }, { designation2Id: target.designationId }], + }, + select: { id: true }, + }) + return { + advocateId: advocate.id, + advocateName: advocate.displayName, + clientIds: clients.map((c) => c.id), + templateDesignationIds: [target.designationId], + } + } + + const client = await prisma.client.findUnique({ + where: { id: target.clientId }, + select: { id: true, claimsAdvocateId: true, designationId: true, designation2Id: true }, + }) + if (!client) { + throw new GenerateAndAssignError('Client not found', 404) + } + if (!client.claimsAdvocateId) { + throw new GenerateAndAssignError('Client has no claims advocate assigned', 400) + } + const advocate = await prisma.user.findUnique({ + where: { id: client.claimsAdvocateId }, + select: { id: true, displayName: true, isActive: true }, + }) + if (!advocate || !advocate.isActive) { + throw new GenerateAndAssignError('Advocate not found or inactive', 404) + } + return { + advocateId: advocate.id, + advocateName: advocate.displayName, + clientIds: [client.id], + templateDesignationIds: designationIdsOf(client), + } +} + +export async function runGenerateAndAssign( + target: GenerateTarget, + options: { dryRun: boolean; actorUserId: string } +): Promise { + const resolved = await resolveTarget(target) + + if (resolved.clientIds.length === 0) { + return { + dryRun: options.dryRun, + clientsFound: 0, + totalEstimatedTasks: 0, + advocateName: resolved.advocateName, + clients: [], + tasksCreated: 0, + tasksAssigned: 0, + } + } + + const estimatedByClient = new Map() + let tasksCreated = 0 + let tasksAssigned = 0 + + const allTemplates = await prisma.taskTemplate.findMany({ + where: { isActive: true, OR: designationFilter(resolved.templateDesignationIds) }, + orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }], + }) + + if (allTemplates.length > 0) { + const groupTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP') + const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY') + const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT') + + const groups = await prisma.policyGroup.findMany({ + where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } }, + }) + + for (const group of groups) { + const renewalDate = new Date(group.renewalDate) + const tasksToCreate = groupTemplates + .map((template) => { + const dueDate = new Date(renewalDate) + dueDate.setDate(dueDate.getDate() + template.daysOffset) + return { + title: template.name, + description: template.description, + department: template.department, + timing: template.timing, + daysOffset: template.daysOffset, + dueDate, + status: 'NOT_STARTED' as const, + priority: template.defaultPriority, + clientId: group.clientId, + policyGroupId: group.id, + templateId: template.id, + createdBy: options.actorUserId, + } + }) + .filter((task) => isRelevantDueDate(task.dueDate)) + + if (tasksToCreate.length === 0) continue + estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length) + if (options.dryRun) continue + + const created = await prisma.task.createMany({ data: tasksToCreate }) + tasksCreated += created.count + + if (created.count > 0) { + const newTasks = await prisma.task.findMany({ + where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } }, + select: { id: true }, + }) + if (newTasks.length > 0) { + const assigned = await prisma.taskAssignment.createMany({ + data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })), + skipDuplicates: true, + }) + tasksAssigned += assigned.count + } + } + } + + const policies = await prisma.policy.findMany({ + where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } }, + }) + + for (const policy of policies) { + const anchorDate = new Date(policy.expirationDate) + anchorDate.setDate(anchorDate.getDate() + 1) + const tasksToCreate = policyTemplates + .map((template) => { + const dueDate = new Date(anchorDate) + dueDate.setDate(dueDate.getDate() + template.daysOffset) + return { + title: template.name, + description: template.description, + department: template.department, + timing: template.timing, + daysOffset: template.daysOffset, + dueDate, + status: 'NOT_STARTED' as const, + priority: template.defaultPriority, + clientId: policy.clientId, + policyId: policy.id, + templateId: template.id, + createdBy: options.actorUserId, + } + }) + .filter((task) => isRelevantDueDate(task.dueDate)) + + if (tasksToCreate.length === 0) continue + estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length) + if (options.dryRun) continue + + const created = await prisma.task.createMany({ data: tasksToCreate }) + tasksCreated += created.count + + if (created.count > 0) { + const newTasks = await prisma.task.findMany({ + where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } }, + select: { id: true }, + }) + if (newTasks.length > 0) { + const assigned = await prisma.taskAssignment.createMany({ + data: newTasks.map((t) => ({ taskId: t.id, userId: resolved.advocateId })), + skipDuplicates: true, + }) + tasksAssigned += assigned.count + } + } + } + + if (clientTemplates.length > 0) { + for (const clientId of resolved.clientIds) { + const clientRecord = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + policyGroups: { select: { renewalDate: true } }, + policies: { where: { policyGroupId: null }, select: { expirationDate: true } }, + }, + }) + if (!clientRecord) continue + + const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime()) + const policyDates = clientRecord.policies.map((p) => { + const d = new Date(p.expirationDate) + d.setDate(d.getDate() + 1) + return d.getTime() + }) + const allDates = [...groupDates, ...policyDates] + if (allDates.length === 0) continue + const anchorDate = new Date(Math.min(...allDates)) + + for (const template of clientTemplates) { + const existing = await prisma.task.findFirst({ + where: { clientId, templateId: template.id, policyId: null, policyGroupId: null }, + select: { id: true }, + }) + if (existing) continue + + const dueDate = new Date(anchorDate) + dueDate.setDate(dueDate.getDate() + template.daysOffset) + if (!isRelevantDueDate(dueDate)) continue + + estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1) + if (options.dryRun) continue + + const createdTask = await prisma.task.create({ + data: { + title: template.name, + description: template.description, + department: template.department, + timing: template.timing, + daysOffset: template.daysOffset, + dueDate, + status: 'NOT_STARTED', + priority: template.defaultPriority, + clientId, + templateId: template.id, + createdBy: options.actorUserId, + }, + select: { id: true }, + }) + tasksCreated++ + + await prisma.taskAssignment.create({ data: { taskId: createdTask.id, userId: resolved.advocateId } }) + tasksAssigned++ + } + } + } + } + + if (!options.dryRun) { + await prisma.auditLog.create({ + data: { + userId: options.actorUserId, + action: 'GENERATE_AND_ASSIGN_TASKS', + entityType: 'Task', + newValues: { + mode: target.mode, + ...(target.mode === 'advocate-designation' + ? { advocateId: target.advocateId, designationId: target.designationId } + : { clientId: target.clientId }), + advocateName: resolved.advocateName, + clientsFound: resolved.clientIds.length, + tasksCreated, + tasksAssigned, + }, + }, + }) + } + + const clientNames = await prisma.client.findMany({ + where: { id: { in: resolved.clientIds } }, + select: { id: true, name: true }, + }) + const nameById = new Map(clientNames.map((c) => [c.id, c.name])) + + const clients: ClientTaskSummary[] = resolved.clientIds + .map((id) => ({ id, name: nameById.get(id) ?? id, estimatedTasks: estimatedByClient.get(id) ?? 0 })) + .filter((c) => c.estimatedTasks > 0) + .sort((a, b) => b.estimatedTasks - a.estimatedTasks) + + const totalEstimatedTasks = clients.reduce((sum, c) => sum + c.estimatedTasks, 0) + + return { + dryRun: options.dryRun, + clientsFound: resolved.clientIds.length, + totalEstimatedTasks, + advocateName: resolved.advocateName, + clients, + tasksCreated, + tasksAssigned, + } +} From 06842f9031f5637e37bcd069f1e7909ac6a78934 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 10:24:07 +0000 Subject: [PATCH 04/11] fix(tasks): skip audit log on zero-template runs, add POLICY/CLIENT/multi-section test coverage - runGenerateAndAssign now returns early (no audit log write, no client-name lookup) when no active templates match the resolved designation, mirroring the existing early return for zero matched clients. - Add tests for: zero-template real run writes no audit log; POLICY-level template against an ungrouped policy; CLIENT-level template created via prisma.task.create; and a single client accumulating estimatedTasks from both a group-level and a policy-level template in the same run. --- .../generate-and-assign-core.test.ts | 115 +++++++ .../src/lib/tasks/generate-and-assign-core.ts | 302 +++++++++--------- 2 files changed, 271 insertions(+), 146 deletions(-) diff --git a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts index aafcb2b..639577a 100644 --- a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts +++ b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts @@ -138,6 +138,121 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { status: 404, }) }) + + it('does not write an audit log and returns zero counts when no templates match', async () => { + mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true }) + mockClientsForAdvocateDesignation() + mockTaskTemplateFindMany.mockResolvedValue([]) + + const result = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' }) + + expect(mockAuditLogCreate).not.toHaveBeenCalled() + expect(result.totalEstimatedTasks).toBe(0) + expect(result.tasksCreated).toBe(0) + }) + + it('creates and counts a POLICY-level task for an ungrouped policy', async () => { + mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true }) + mockClientsForAdvocateDesignation() + mockTaskTemplateFindMany.mockResolvedValue([ + { + id: 'template-policy', + name: 'Review policy renewal', + description: null, + department: 'Personal Lines', + timing: 'PRE_RENEWAL', + daysOffset: -1, + defaultPriority: 'NORMAL', + level: 'POLICY', + }, + ]) + mockPolicyFindMany.mockResolvedValue([ + { id: 'policy-1', clientId: 'client-1', policyType: 'AUTO', expirationDate: new Date('2026-08-01') }, + ]) + mockTaskCreateMany.mockResolvedValue({ count: 1 }) + mockTaskFindMany.mockResolvedValue([{ id: 'task-policy-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(dryRunResult.totalEstimatedTasks).toBe(1) + expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }]) + expect(mockTaskCreateMany).toHaveBeenCalledTimes(1) + expect(realResult.tasksCreated).toBe(1) + }) + + it('creates and counts a CLIENT-level task via prisma.task.create', async () => { + mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true }) + mockClientsForAdvocateDesignation() + mockTaskTemplateFindMany.mockResolvedValue([ + { + id: 'template-client', + name: 'Send client renewal letter', + description: null, + department: 'Commercial Lines', + timing: 'PRE_RENEWAL', + daysOffset: -1, + defaultPriority: 'NORMAL', + level: 'CLIENT', + }, + ]) + mockClientFindUnique.mockResolvedValue({ + policyGroups: [{ renewalDate: new Date('2026-08-01') }], + policies: [], + }) + mockTaskFindFirst.mockResolvedValue(null) + mockTaskCreate.mockResolvedValue({ id: 'task-client-1' }) + mockTaskAssignmentCreate.mockResolvedValue({}) + + const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' }) + const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' }) + + expect(dryRunResult.totalEstimatedTasks).toBe(1) + expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }]) + expect(mockTaskCreate).toHaveBeenCalledTimes(1) + expect(mockTaskCreateMany).not.toHaveBeenCalled() + expect(realResult.tasksCreated).toBe(1) + expect(realResult.tasksAssigned).toBe(1) + }) + + it('sums estimatedTasks for a client that gets tasks from both group- and policy-level templates', async () => { + mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true }) + mockClientsForAdvocateDesignation() + mockTaskTemplateFindMany.mockResolvedValue([ + { + id: 'template-group', + name: 'Prepare loss summary', + description: null, + department: 'Commercial Lines', + timing: 'PRE_RENEWAL', + daysOffset: -1, + defaultPriority: 'NORMAL', + level: 'RENEWAL_GROUP', + }, + { + id: 'template-policy', + name: 'Review policy renewal', + description: null, + department: 'Personal Lines', + timing: 'PRE_RENEWAL', + daysOffset: -1, + defaultPriority: 'NORMAL', + level: 'POLICY', + }, + ]) + mockPolicyGroupFindMany.mockResolvedValue([ + { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') }, + ]) + mockPolicyFindMany.mockResolvedValue([ + { id: 'policy-1', clientId: 'client-1', policyType: 'AUTO', expirationDate: new Date('2026-08-01') }, + ]) + + const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' }) + + expect(result.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 2 }]) + expect(result.totalEstimatedTasks).toBe(2) + }) }) describe('parseGenerateAndAssignTarget', () => { diff --git a/ondeck/src/lib/tasks/generate-and-assign-core.ts b/ondeck/src/lib/tasks/generate-and-assign-core.ts index f8a1142..8c58b32 100644 --- a/ondeck/src/lib/tasks/generate-and-assign-core.ts +++ b/ondeck/src/lib/tasks/generate-and-assign-core.ts @@ -140,166 +140,176 @@ export async function runGenerateAndAssign( 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') + if (allTemplates.length === 0) { + return { + dryRun: options.dryRun, + clientsFound: resolved.clientIds.length, + totalEstimatedTasks: 0, + advocateName: resolved.advocateName, + clients: [], + tasksCreated: 0, + tasksAssigned: 0, + } + } - const groups = await prisma.policyGroup.findMany({ - where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } }, - }) + 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') - 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 { + 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' as const, + status: 'NOT_STARTED', priority: template.defaultPriority, - clientId: group.clientId, - policyGroupId: group.id, + clientId, 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 } }, }, + select: { id: true }, }) - if (!clientRecord) continue + tasksCreated++ - 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++ - } + await prisma.taskAssignment.create({ data: { taskId: createdTask.id, userId: resolved.advocateId } }) + tasksAssigned++ } } } From a1206f9515361777b9c78e473ec23125a08a1365 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 12:52:17 +0000 Subject: [PATCH 05/11] test(tasks): cover client-mode targeting and missing-advocate guard Co-Authored-By: Claude Sonnet 5 --- .../generate-and-assign-core.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts index 639577a..83c064d 100644 --- a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts +++ b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts @@ -255,6 +255,67 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { }) }) +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', + }) + }) +}) + describe('parseGenerateAndAssignTarget', () => { it('parses advocate+designation mode', () => { const { target, dryRun } = parseGenerateAndAssignTarget({ From 5daab12d98482f87bfd818610d26d6e5348aa017 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 12:54:44 +0000 Subject: [PATCH 06/11] refactor(tasks): route generate-and-assign through the shared core module --- .../api/tasks/generate-and-assign/route.ts | 273 ++---------------- 1 file changed, 18 insertions(+), 255 deletions(-) diff --git a/ondeck/src/app/api/tasks/generate-and-assign/route.ts b/ondeck/src/app/api/tasks/generate-and-assign/route.ts index 2e8594c..aa840e3 100644 --- a/ondeck/src/app/api/tasks/generate-and-assign/route.ts +++ b/ondeck/src/app/api/tasks/generate-and-assign/route.ts @@ -1,14 +1,19 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/db' -import { isRelevantDueDate } from '@/lib/sync/task-due-date' +import { + parseGenerateAndAssignTarget, + runGenerateAndAssign, + GenerateAndAssignError, +} from '@/lib/tasks/generate-and-assign-core' /** * POST /api/tasks/generate-and-assign - * Find clients matching the given advocate + designation, generate tasks from - * active templates, and assign them to the advocate. - * Body: { advocateId: string, designationId: string } + * 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) { @@ -24,260 +29,18 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { advocateId, designationId } = body + const { target, dryRun } = parseGenerateAndAssignTarget(body) - if (!advocateId || !designationId) { - return NextResponse.json( - { error: 'advocateId and designationId are required' }, - { status: 400 } - ) - } - - const advocate = await prisma.user.findUnique({ - where: { id: advocateId }, - select: { id: true, displayName: true, isActive: true }, - }) - if (!advocate || !advocate.isActive) { - return NextResponse.json({ error: 'Advocate not found or inactive' }, { status: 404 }) - } - - // Clients assigned to this advocate with this designation - const clients = await prisma.client.findMany({ - where: { - claimsAdvocateId: advocateId, - OR: [{ designationId }, { designation2Id: designationId }], - }, - select: { id: true }, + const result = await runGenerateAndAssign(target, { + dryRun, + actorUserId: (session.user as any).id, }) - if (clients.length === 0) { - return NextResponse.json({ - tasksCreated: 0, - tasksAssigned: 0, - clientsFound: 0, - groupsProcessed: 0, - advocateName: advocate.displayName, - }) + return NextResponse.json(result) + } catch (error: any) { + if (error instanceof GenerateAndAssignError) { + return NextResponse.json({ error: error.message }, { status: error.status }) } - - const clientIds = clients.map((c) => c.id) - - const allTemplates = await prisma.taskTemplate.findMany({ - where: { - isActive: true, - OR: [{ designationId: null }, { designationId }], - }, - orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }], - }) - - if (allTemplates.length === 0) { - return NextResponse.json({ - tasksCreated: 0, - tasksAssigned: 0, - clientsFound: clients.length, - groupsProcessed: 0, - advocateName: advocate.displayName, - message: 'No active templates found for this designation', - }) - } - - const groupTemplates = allTemplates.filter((t: any) => t.level === 'BOTH' || t.level === 'RENEWAL_GROUP') - const policyTemplates = allTemplates.filter((t: any) => t.level === 'BOTH' || t.level === 'POLICY') - let tasksCreated = 0 - let tasksAssigned = 0 - let groupsProcessed = 0 - - // ── Policy groups with no template-generated tasks yet ────────────────── - const groups = await prisma.policyGroup.findMany({ - where: { - clientId: { in: 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: (session.user as any).id, - } - }) - .filter((task) => isRelevantDueDate(task.dueDate)) - - if (tasksToCreate.length === 0) 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: advocateId })), - skipDuplicates: true, - }) - tasksAssigned += assigned.count - } - groupsProcessed++ - } - } - - // ── Ungrouped policies with no template-generated tasks yet ───────────── - const policies = await prisma.policy.findMany({ - where: { - clientId: { in: clientIds }, - policyGroupId: null, - tasks: { none: { templateId: { not: null } } }, - }, - }) - - for (const policy of policies) { - const anchorDate = new Date(policy.expirationDate) - anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 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: (session.user as any).id, - } - }) - .filter((task) => isRelevantDueDate(task.dueDate)) - - if (tasksToCreate.length === 0) 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: advocateId })), - skipDuplicates: true, - }) - tasksAssigned += assigned.count - } - } - } - - // ── CLIENT-level tasks (once per client) ──────────────────────────────── - const clientTemplates = allTemplates.filter((t: any) => t.level === 'CLIENT') - - if (clientTemplates.length > 0) { - for (const clientId of 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 - - const created = 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: (session.user as any).id, - }, - select: { id: true }, - }) - tasksCreated++ - - const assigned = await prisma.taskAssignment.create({ - data: { taskId: created.id, userId: advocateId }, - }) - tasksAssigned++ - } - } - } - - await prisma.auditLog.create({ - data: { - userId: (session.user as any).id, - action: 'GENERATE_AND_ASSIGN_TASKS', - entityType: 'Task', - newValues: { - advocateId, - advocateName: advocate.displayName, - designationId, - clientsFound: clients.length, - tasksCreated, - tasksAssigned, - }, - }, - }) - - return NextResponse.json({ - tasksCreated, - tasksAssigned, - clientsFound: clients.length, - groupsProcessed, - advocateName: advocate.displayName, - }) - } catch (error) { console.error('Generate and assign error:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } From adc3d9bfa787e0388750317f67c4a8e23479d493 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 13:00:46 +0000 Subject: [PATCH 07/11] feat(tasks): include claimsAdvocateId in the client list for the assign page --- ondeck/src/app/(dashboard)/tasks/assign/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page.tsx index 7421fc5..1e4a8d3 100644 --- a/ondeck/src/app/(dashboard)/tasks/assign/page.tsx +++ b/ondeck/src/app/(dashboard)/tasks/assign/page.tsx @@ -22,7 +22,7 @@ export default async function BulkAssignPage() { orderBy: { displayName: 'asc' }, }), prisma.client.findMany({ - select: { id: true, name: true }, + select: { id: true, name: true, claimsAdvocateId: true }, orderBy: { name: 'asc' }, }), prisma.designation.findMany({ From b8ea94e37b36acc0965953b70a0d2a58430a91aa Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 13:05:12 +0000 Subject: [PATCH 08/11] feat(tasks): add per-client mode and preview-before-generate to Generate & Assign --- .../(dashboard)/tasks/assign/page-client.tsx | 216 ++++++++++++++---- 1 file changed, 172 insertions(+), 44 deletions(-) diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx index 04218f2..78eea06 100644 --- a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx @@ -22,12 +22,23 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Users, Filter, CheckSquare, Wand2, MessageSquare, Pencil } from 'lucide-react' import { formatDate, formatRenewalDate } from '@/lib/utils' import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal' interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null } -interface SimpleClient { id: string; name: string } +interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null } interface SimpleDesignation { id: string; name: string } interface BulkAssignClientProps { @@ -52,13 +63,20 @@ const DEPT_OPTIONS = [ { value: 'OTHER', label: 'Other' }, ] +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 - clientsFound: number - groupsProcessed: number - advocateName: string | null - message?: string } export function BulkAssignClient({ users, clients, designations }: BulkAssignClientProps) { @@ -70,9 +88,13 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli const [editingTask, setEditingTask] = useState(null) // Generate & assign state + const [genMode, setGenMode] = useState<'advocate-designation' | 'client'>('advocate-designation') const [genAdvocate, setGenAdvocate] = useState('') const [genDesignation, setGenDesignation] = useState('') + const [genClientId, setGenClientId] = useState('') + const [previewing, setPreviewing] = useState(false) const [generating, setGenerating] = useState(false) + const [preview, setPreview] = useState(null) const [genResult, setGenResult] = useState(null) // Filters @@ -140,26 +162,55 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli } } - const handleGenerateAndAssign = async () => { - if (!genAdvocate || !genDesignation) return - setGenerating(true) + 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({ advocateId: genAdvocate, designationId: genDesignation }), + 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 if (data.clientsFound === 0) { - toast.info('No clients found with this advocate and designation') } else { - toast.info(data.message || 'No new tasks to generate') + toast.info('No new tasks to generate') } } catch (err: any) { toast.error(err.message || 'Failed to generate tasks') @@ -194,49 +245,126 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli -
-
-

Claims Advocate

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

Claims Advocate

+ +
+
+

Designation

+ +
+
-
-

Designation

- + ) : ( +
+
+

Client

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

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

+ )} +
+
- -
+ )} + {genResult && (

{genResult.tasksCreated > 0 ? `Created ${genResult.tasksCreated} task(s) across ${genResult.clientsFound} client(s) and assigned to ${genResult.advocateName}.` - : 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.'} + : 'No new tasks to generate — all tasks may already exist.'}

)} + { if (!open) setPreview(null) }}> + + + + Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}? + + + This will create {preview?.totalEstimatedTasks} task(s) across {preview?.clientsFound} client(s) + and assign them to {preview?.advocateName}. + + +
+ + + + Client + Tasks + + + + {preview?.clients.map((c) => ( + + {c.name} + {c.estimatedTasks} + + ))} + +
+
+ + Cancel + + {generating ? 'Generating...' : 'Confirm'} + + +
+
+ {/* Filter bar */} From 2906f31502c7eab41104169b960ba1e78e0a487a Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 13:10:42 +0000 Subject: [PATCH 09/11] fix(tasks): keep generate confirmation dialog open during in-flight request AlertDialogAction closes the dialog synchronously on click before any awaited handler logic runs, so the disabled/'Generating...' state on the Confirm button was unreachable. Call event.preventDefault() in handleConfirmGenerate to suppress Radix's auto-close; the dialog now closes explicitly via setPreview(null) only on success, and stays open (with the toast) on error so the user can retry or cancel. --- ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx index 78eea06..2c065a5 100644 --- a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx @@ -194,7 +194,8 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli } } - const handleConfirmGenerate = async () => { + const handleConfirmGenerate = async (event: React.MouseEvent) => { + event.preventDefault() setGenerating(true) try { const res = await fetch('/api/tasks/generate-and-assign', { From db7ea9fc86f97c3cb5b04e1a05429c31b0c4ed01 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 13:20:48 +0000 Subject: [PATCH 10/11] fix(tasks): block Cancel and Escape from closing generate dialog mid-request AlertDialogCancel and Escape both closed the confirmation dialog via Radix's default onOpenChange(false) while the generate-and-assign fetch was still in flight, leaving an orphaned request that could resolve after the user believed they'd cancelled and could leave `generating` stuck true. Disable Cancel and suppress onEscapeKeyDown while `generating` is true so the dialog can only close intentionally once the request settles. --- ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx index 2c065a5..4091e70 100644 --- a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx @@ -329,7 +329,11 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli { if (!open) setPreview(null) }}> - + { + if (generating) event.preventDefault() + }} + > Generate {preview?.totalEstimatedTasks} task{preview && preview.totalEstimatedTasks !== 1 ? 's' : ''}? @@ -358,7 +362,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
- Cancel + Cancel {generating ? 'Generating...' : 'Confirm'} From 347e4b342b81fe266ff44c4a39661204eb45f14e Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 30 Jul 2026 10:56:46 +0000 Subject: [PATCH 11/11] 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 --- ...-task-generation-preview-and-per-client.md | 18 ++- .../(dashboard)/tasks/assign/page-client.tsx | 125 +++++++++++---- .../src/app/(dashboard)/tasks/assign/page.tsx | 8 +- .../[id]/generate-tasks/route.ts | 21 +++ .../src/components/clients/client-select.tsx | 144 ++++++++++++++++++ .../clients/policy-group-manager.tsx | 115 +++++++++++++- .../generate-and-assign-core.test.ts | 87 ++++++++++- .../src/lib/tasks/generate-and-assign-core.ts | 129 +++++++++++++++- 8 files changed, 583 insertions(+), 64 deletions(-) create mode 100644 ondeck/src/components/clients/client-select.tsx diff --git a/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md index 06d3b5a..1e5796f 100644 --- a/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md +++ b/ondeck/docs/superpowers/plans/2026-07-18-task-generation-preview-and-per-client.md @@ -1274,12 +1274,14 @@ git commit -m "feat(tasks): add per-client mode and preview-before-generate to G **Files:** none (verification only) -- [ ] **Step 1: Run the full test suite** +- [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. -- [ ] **Step 2: Rebuild and restart the app container** +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 @@ -1288,15 +1290,21 @@ 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** +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. -- [ ] **Step 4: Manually verify the "By Client" mode** +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. -- [ ] **Step 5: No commit for this task** — verification only, nothing to stage. +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. --- diff --git a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx index 4091e70..9e51d3b 100644 --- a/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx +++ b/ondeck/src/app/(dashboard)/tasks/assign/page-client.tsx @@ -14,6 +14,7 @@ import { SelectValue, } from '@/components/ui/select' import { UserSelectContent } from '@/components/ui/user-select-content' +import { ClientSelect } from '@/components/clients/client-select' import { Table, TableBody, @@ -38,12 +39,10 @@ import { formatDate, formatRenewalDate } from '@/lib/utils' import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal' interface SimpleUser { id: string; displayName: string | null; email: string; department: string | null } -interface SimpleClient { id: string; name: string; claimsAdvocateId: string | null } interface SimpleDesignation { id: string; name: string } interface BulkAssignClientProps { users: SimpleUser[] - clients: SimpleClient[] designations: SimpleDesignation[] } @@ -63,10 +62,46 @@ const DEPT_OPTIONS = [ { value: 'OTHER', label: 'Other' }, ] +interface TaskPreviewItem { + title: string + department: string + dueDate: string + priority: string + context: string +} + +interface TaskBreakdown { + groupsWithTasks: number + groupLevelTasks: number + policiesWithTasks: number + policyLevelTasks: number + clientLevelTasks: number +} + interface ClientTaskSummary { id: string name: string estimatedTasks: number + tasks: TaskPreviewItem[] + breakdown: TaskBreakdown +} + +function breakdownLines(b: TaskBreakdown): string[] { + const lines: string[] = [] + if (b.clientLevelTasks > 0) { + lines.push(`${b.clientLevelTasks} client-level task${b.clientLevelTasks !== 1 ? 's' : ''}`) + } + if (b.groupsWithTasks > 0) { + lines.push( + `${b.groupsWithTasks} renewal group${b.groupsWithTasks !== 1 ? 's' : ''}, ${b.groupLevelTasks} group-level task${b.groupLevelTasks !== 1 ? 's' : ''}` + ) + } + if (b.policiesWithTasks > 0) { + lines.push( + `${b.policiesWithTasks} ungrouped polic${b.policiesWithTasks !== 1 ? 'ies' : 'y'}, ${b.policyLevelTasks} policy-level task${b.policyLevelTasks !== 1 ? 's' : ''}` + ) + } + return lines } interface GenResult { @@ -79,7 +114,7 @@ interface GenResult { tasksAssigned: number } -export function BulkAssignClient({ users, clients, designations }: BulkAssignClientProps) { +export function BulkAssignClient({ users, designations }: BulkAssignClientProps) { const [tasks, setTasks] = useState([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState>(new Set()) @@ -92,6 +127,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli const [genAdvocate, setGenAdvocate] = useState('') const [genDesignation, setGenDesignation] = useState('') const [genClientId, setGenClientId] = useState('') + const [genClientAdvocateId, setGenClientAdvocateId] = useState(null) const [previewing, setPreviewing] = useState(false) const [generating, setGenerating] = useState(false) const [preview, setPreview] = useState(null) @@ -290,20 +326,18 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
) : (
-
+

Client

- - {genClientId && !clients.find((c) => c.id === genClientId)?.claimsAdvocateId && ( + { + setGenClientId(client?.id || '') + setGenClientAdvocateId(client?.claimsAdvocateId ?? null) + setGenResult(null) + }} + placeholder="Search clients..." + /> + {genClientId && !genClientAdvocateId && (

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

@@ -311,7 +345,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
@@ -330,6 +364,7 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli { if (!open) setPreview(null) }}> { if (generating) event.preventDefault() }} @@ -343,24 +378,52 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli and assign them to {preview?.advocateName}. -
+
Client - Tasks + Task + Context + Department + Due Date + Priority - {preview?.clients.map((c) => ( - - {c.name} - {c.estimatedTasks} - - ))} + {preview?.clients.flatMap((c) => + c.tasks.map((t, i) => ( + + {c.name} + {t.title} + {t.context} + {t.department?.replace('_', ' ')} + {formatDate(t.dueDate)} + {t.priority} + + )) + )}
+ {preview && preview.clients.some((c) => breakdownLines(c.breakdown).length > 0) && ( +
+ {preview.clients.map((c) => { + const lines = breakdownLines(c.breakdown) + if (lines.length === 0) return null + return ( +
+ {preview.clients.length > 1 &&

{c.name}

} +
    + {lines.map((line, i) => ( +
  • {line}
  • + ))} +
+
+ ) + })} +
+ )} Cancel @@ -380,13 +443,11 @@ export function BulkAssignClient({ users, clients, designations }: BulkAssignCli
- + setClientFilter(client?.id || '_all')} + placeholder="All clients" + /> setQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {displayLabel || placeholder} + + )} +
+ {value && !open && ( + + )} + +
+
+ + {open && ( +
+ {loading && results.length === 0 ? ( +
Searching...
+ ) : results.length === 0 ? ( +
No clients found
+ ) : ( + results.map((c) => ( +
e.preventDefault()} + onClick={() => handleSelect(c)} + > + {c.name} +
+ )) + )} +
+ )} +
+ ) +} diff --git a/ondeck/src/components/clients/policy-group-manager.tsx b/ondeck/src/components/clients/policy-group-manager.tsx index 207059c..634560d 100644 --- a/ondeck/src/components/clients/policy-group-manager.tsx +++ b/ondeck/src/components/clients/policy-group-manager.tsx @@ -40,6 +40,14 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Checkbox } from '@/components/ui/checkbox' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' import { formatDate, formatRenewalDate } from '@/lib/utils' interface Policy { @@ -63,6 +71,13 @@ interface PolicyGroup { _count: { tasks: number } } +interface GenerateTaskPreviewItem { + title: string + department: string + dueDate: string + priority: string +} + interface PolicyGroupManagerProps { clientId: string initialGroups: PolicyGroup[] @@ -97,6 +112,13 @@ export function PolicyGroupManager({ const [deleteTarget, setDeleteTarget] = useState(null) const [cancelTasks, setCancelTasks] = useState(false) const [generatingFor, setGeneratingFor] = useState(null) + const [generatePreview, setGeneratePreview] = useState<{ + groupId: string + groupName: string + tasks: GenerateTaskPreviewItem[] + skipped: number + } | null>(null) + const [generating, setGenerating] = useState(false) const [adhocGroupId, setAdhocGroupId] = useState(null) const [sessionUserId, setSessionUserId] = useState('') // Policy movement confirmation @@ -313,10 +335,39 @@ export function PolicyGroupManager({ } } - const handleGenerateTasks = async (groupId: string) => { + const handlePreviewGenerate = async (groupId: string) => { setGeneratingFor(groupId) try { - const res = await fetch(`/api/policy-groups/${groupId}/generate-tasks`, { + const res = await fetch(`/api/policy-groups/${groupId}/generate-tasks?dryRun=true`, { + method: 'POST', + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to preview tasks') + + if (!data.estimatedTasks) { + toast.info(data.message || 'No new tasks to generate') + return + } + + setGeneratePreview({ + groupId, + groupName: groups.find((g) => g.id === groupId)?.name ?? 'this group', + tasks: data.tasks ?? [], + skipped: data.skipped ?? 0, + }) + } catch (err: any) { + toast.error(err.message || 'Failed to preview tasks') + } finally { + setGeneratingFor(null) + } + } + + const handleConfirmGenerate = async (event: React.MouseEvent) => { + event.preventDefault() + if (!generatePreview) return + setGenerating(true) + try { + const res = await fetch(`/api/policy-groups/${generatePreview.groupId}/generate-tasks`, { method: 'POST', }) const data = await res.json() @@ -324,7 +375,7 @@ export function PolicyGroupManager({ setGroups((prev) => prev.map((g) => - g.id === groupId + g.id === generatePreview.groupId ? { ...g, _count: { tasks: g._count.tasks + data.created } } : g ) @@ -336,10 +387,11 @@ export function PolicyGroupManager({ toast.success(`Generated ${data.created} task${data.created !== 1 ? 's' : ''}`) onTasksGenerated?.(data.created) } + setGeneratePreview(null) } catch (err: any) { toast.error(err.message || 'Failed to generate tasks') } finally { - setGeneratingFor(null) + setGenerating(false) } } @@ -418,11 +470,11 @@ export function PolicyGroupManager({
) } diff --git a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts index 83c064d..10b7341 100644 --- a/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts +++ b/ondeck/src/lib/tasks/__tests__/generate-and-assign-core.test.ts @@ -88,7 +88,7 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { }, ]) mockPolicyGroupFindMany.mockResolvedValue([ - { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') }, + { id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') }, ]) const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' }) @@ -97,7 +97,10 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { 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.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }]) + expect(result.clients[0].tasks).toEqual([ + expect.objectContaining({ title: 'Prepare loss summary', department: 'Commercial Lines' }), + ]) expect(result.advocateName).toBe('Jane Smith') }) @@ -117,7 +120,7 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { }, ]) mockPolicyGroupFindMany.mockResolvedValue([ - { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') }, + { id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') }, ]) mockTaskCreateMany.mockResolvedValue({ count: 1 }) mockTaskFindMany.mockResolvedValue([{ id: 'task-1' }]) @@ -177,7 +180,10 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' }) expect(dryRunResult.totalEstimatedTasks).toBe(1) - expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }]) + expect(dryRunResult.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }]) + expect(dryRunResult.clients[0].tasks).toEqual([ + expect.objectContaining({ title: 'Review policy renewal', department: 'Personal Lines' }), + ]) expect(mockTaskCreateMany).toHaveBeenCalledTimes(1) expect(realResult.tasksCreated).toBe(1) }) @@ -209,7 +215,10 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' }) expect(dryRunResult.totalEstimatedTasks).toBe(1) - expect(dryRunResult.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }]) + expect(dryRunResult.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 1 }]) + expect(dryRunResult.clients[0].tasks).toEqual([ + expect.objectContaining({ title: 'Send client renewal letter', context: 'Client-level' }), + ]) expect(mockTaskCreate).toHaveBeenCalledTimes(1) expect(mockTaskCreateMany).not.toHaveBeenCalled() expect(realResult.tasksCreated).toBe(1) @@ -242,7 +251,7 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { }, ]) mockPolicyGroupFindMany.mockResolvedValue([ - { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') }, + { id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') }, ]) mockPolicyFindMany.mockResolvedValue([ { id: 'policy-1', clientId: 'client-1', policyType: 'AUTO', expirationDate: new Date('2026-08-01') }, @@ -250,9 +259,71 @@ describe('runGenerateAndAssign — advocate-designation mode', () => { const result = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' }) - expect(result.clients).toEqual([{ id: 'client-1', name: 'Client One', estimatedTasks: 2 }]) + expect(result.clients).toMatchObject([{ id: 'client-1', name: 'Client One', estimatedTasks: 2 }]) + expect(result.clients[0].tasks).toHaveLength(2) + expect(result.clients[0].breakdown).toEqual({ + groupsWithTasks: 1, + groupLevelTasks: 1, + policiesWithTasks: 1, + policyLevelTasks: 1, + clientLevelTasks: 0, + }) expect(result.totalEstimatedTasks).toBe(2) }) + + it('backfills a missing group-level template when the group already has a task from a different template', async () => { + mockUserFindUnique.mockResolvedValue({ id: 'adv-1', displayName: 'Jane Smith', isActive: true }) + mockClientsForAdvocateDesignation() + mockTaskTemplateFindMany.mockResolvedValue([ + { + id: 'template-existing', + name: 'SHAPE Onboarding Checklist', + description: null, + department: 'Claims', + timing: 'PRE_RENEWAL', + daysOffset: -335, + defaultPriority: 'NORMAL', + level: 'BOTH', + }, + { + id: 'template-missing', + name: 'Prepare loss summary/analysis for internal pre-renewal meeting', + description: null, + department: 'Claims', + timing: 'PRE_RENEWAL', + daysOffset: -115, + defaultPriority: 'NORMAL', + level: 'BOTH', + }, + ]) + mockPolicyGroupFindMany.mockResolvedValue([ + { id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-12-28') }, + ]) + // Group already has a task tied to template-existing; template-missing has never run. + mockTaskFindMany.mockImplementation((args: any) => + args?.select?.policyGroupId + ? Promise.resolve([{ policyGroupId: 'group-1', templateId: 'template-existing' }]) + : Promise.resolve([{ id: 'new-task-1' }]) + ) + mockTaskCreateMany.mockResolvedValue({ count: 1 }) + mockTaskAssignmentCreateMany.mockResolvedValue({ count: 1 }) + + const dryRunResult = await runGenerateAndAssign(target, { dryRun: true, actorUserId: 'user-1' }) + + expect(dryRunResult.totalEstimatedTasks).toBe(1) + expect(dryRunResult.clients[0].tasks).toEqual([ + expect.objectContaining({ title: 'Prepare loss summary/analysis for internal pre-renewal meeting' }), + ]) + + const groupQueryArgs = mockPolicyGroupFindMany.mock.calls[0][0] + expect(groupQueryArgs.where.tasks).toBeUndefined() + + const realResult = await runGenerateAndAssign(target, { dryRun: false, actorUserId: 'user-1' }) + expect(mockTaskCreateMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ templateId: 'template-missing' })], + }) + expect(realResult.tasksCreated).toBe(1) + }) }) describe('runGenerateAndAssign — client mode', () => { @@ -279,7 +350,7 @@ describe('runGenerateAndAssign — client mode', () => { }, ]) mockPolicyGroupFindMany.mockResolvedValue([ - { id: 'group-1', clientId: 'client-1', renewalDate: new Date('2026-08-01') }, + { id: 'group-1', clientId: 'client-1', name: 'Renewal Group', renewalDate: new Date('2026-08-01') }, ]) mockClientFindMany.mockResolvedValue([{ id: 'client-1', name: 'Client One' }]) diff --git a/ondeck/src/lib/tasks/generate-and-assign-core.ts b/ondeck/src/lib/tasks/generate-and-assign-core.ts index 8c58b32..b50f8b4 100644 --- a/ondeck/src/lib/tasks/generate-and-assign-core.ts +++ b/ondeck/src/lib/tasks/generate-and-assign-core.ts @@ -41,10 +41,28 @@ import { prisma } from '@/lib/db' import { designationFilter, designationIdsOf } from '@/lib/sync/auto-generate' import { isRelevantDueDate } from '@/lib/sync/task-due-date' +export interface TaskPreview { + title: string + department: string + dueDate: Date + priority: string + context: string +} + +export interface SourceBreakdown { + groupsWithTasks: number + groupLevelTasks: number + policiesWithTasks: number + policyLevelTasks: number + clientLevelTasks: number +} + export interface ClientTaskSummary { id: string name: string estimatedTasks: number + tasks: TaskPreview[] + breakdown: SourceBreakdown } export interface GenerateAndAssignResult { @@ -132,6 +150,21 @@ export async function runGenerateAndAssign( } const estimatedByClient = new Map() + const previewsByClient = new Map() + const addPreview = (clientId: string, preview: TaskPreview) => { + const list = previewsByClient.get(clientId) + if (list) list.push(preview) + else previewsByClient.set(clientId, [preview]) + } + const breakdownByClient = new Map() + const getBreakdown = (clientId: string) => { + let breakdown = breakdownByClient.get(clientId) + if (!breakdown) { + breakdown = { groupsWithTasks: 0, groupLevelTasks: 0, policiesWithTasks: 0, policyLevelTasks: 0, clientLevelTasks: 0 } + breakdownByClient.set(clientId, breakdown) + } + return breakdown + } let tasksCreated = 0 let tasksAssigned = 0 @@ -156,13 +189,34 @@ export async function runGenerateAndAssign( const policyTemplates = allTemplates.filter((t) => t.level === 'BOTH' || t.level === 'POLICY') const clientTemplates = allTemplates.filter((t) => t.level === 'CLIENT') + // Fetched without a "no templated tasks yet" filter — unlike the fully + // idempotent nightly auto-generate job, this manual tool must also backfill + // groups/policies that already have *some* template tasks but are missing + // others (e.g. a new template was added after the group was first + // processed). Per-entity template applicability is resolved below instead. const groups = await prisma.policyGroup.findMany({ - where: { clientId: { in: resolved.clientIds }, tasks: { none: { templateId: { not: null } } } }, + where: { clientId: { in: resolved.clientIds } }, }) + const groupTemplateTaskRows = await prisma.task.findMany({ + where: { policyGroupId: { in: groups.map((g) => g.id) }, templateId: { not: null } }, + select: { policyGroupId: true, templateId: true }, + }) + const existingTemplateIdsByGroup = new Map>() + for (const row of groupTemplateTaskRows) { + if (!row.policyGroupId || !row.templateId) continue + const set = existingTemplateIdsByGroup.get(row.policyGroupId) + if (set) set.add(row.templateId) + else existingTemplateIdsByGroup.set(row.policyGroupId, new Set([row.templateId])) + } + for (const group of groups) { + const existingTemplateIds = existingTemplateIdsByGroup.get(group.id) ?? new Set() + const applicableTemplates = groupTemplates.filter((t) => !existingTemplateIds.has(t.id)) + if (applicableTemplates.length === 0) continue + const renewalDate = new Date(group.renewalDate) - const tasksToCreate = groupTemplates + const tasksToCreate = applicableTemplates .map((template) => { const dueDate = new Date(renewalDate) dueDate.setDate(dueDate.getDate() + template.daysOffset) @@ -185,6 +239,18 @@ export async function runGenerateAndAssign( if (tasksToCreate.length === 0) continue estimatedByClient.set(group.clientId, (estimatedByClient.get(group.clientId) ?? 0) + tasksToCreate.length) + for (const task of tasksToCreate) { + addPreview(group.clientId, { + title: task.title, + department: task.department, + dueDate: task.dueDate, + priority: task.priority, + context: group.name, + }) + } + const groupBreakdown = getBreakdown(group.clientId) + groupBreakdown.groupsWithTasks += 1 + groupBreakdown.groupLevelTasks += tasksToCreate.length if (options.dryRun) continue const created = await prisma.task.createMany({ data: tasksToCreate }) @@ -192,7 +258,7 @@ export async function runGenerateAndAssign( if (created.count > 0) { const newTasks = await prisma.task.findMany({ - where: { policyGroupId: group.id, templateId: { in: groupTemplates.map((t) => t.id) } }, + where: { policyGroupId: group.id, templateId: { in: applicableTemplates.map((t) => t.id) } }, select: { id: true }, }) if (newTasks.length > 0) { @@ -206,13 +272,29 @@ export async function runGenerateAndAssign( } const policies = await prisma.policy.findMany({ - where: { clientId: { in: resolved.clientIds }, policyGroupId: null, tasks: { none: { templateId: { not: null } } } }, + where: { clientId: { in: resolved.clientIds }, policyGroupId: null }, }) + const policyTemplateTaskRows = await prisma.task.findMany({ + where: { policyId: { in: policies.map((p) => p.id) }, templateId: { not: null } }, + select: { policyId: true, templateId: true }, + }) + const existingTemplateIdsByPolicy = new Map>() + for (const row of policyTemplateTaskRows) { + if (!row.policyId || !row.templateId) continue + const set = existingTemplateIdsByPolicy.get(row.policyId) + if (set) set.add(row.templateId) + else existingTemplateIdsByPolicy.set(row.policyId, new Set([row.templateId])) + } + for (const policy of policies) { + const existingTemplateIds = existingTemplateIdsByPolicy.get(policy.id) ?? new Set() + const applicableTemplates = policyTemplates.filter((t) => !existingTemplateIds.has(t.id)) + if (applicableTemplates.length === 0) continue + const anchorDate = new Date(policy.expirationDate) anchorDate.setDate(anchorDate.getDate() + 1) - const tasksToCreate = policyTemplates + const tasksToCreate = applicableTemplates .map((template) => { const dueDate = new Date(anchorDate) dueDate.setDate(dueDate.getDate() + template.daysOffset) @@ -235,6 +317,19 @@ export async function runGenerateAndAssign( if (tasksToCreate.length === 0) continue estimatedByClient.set(policy.clientId, (estimatedByClient.get(policy.clientId) ?? 0) + tasksToCreate.length) + const policyContext = [policy.policyNumber, policy.policyType].filter(Boolean).join(' — ') || 'Policy' + for (const task of tasksToCreate) { + addPreview(policy.clientId, { + title: task.title, + department: task.department, + dueDate: task.dueDate, + priority: task.priority, + context: policyContext, + }) + } + const policyBreakdown = getBreakdown(policy.clientId) + policyBreakdown.policiesWithTasks += 1 + policyBreakdown.policyLevelTasks += tasksToCreate.length if (options.dryRun) continue const created = await prisma.task.createMany({ data: tasksToCreate }) @@ -242,7 +337,7 @@ export async function runGenerateAndAssign( if (created.count > 0) { const newTasks = await prisma.task.findMany({ - where: { policyId: policy.id, templateId: { in: policyTemplates.map((t) => t.id) } }, + where: { policyId: policy.id, templateId: { in: applicableTemplates.map((t) => t.id) } }, select: { id: true }, }) if (newTasks.length > 0) { @@ -288,6 +383,14 @@ export async function runGenerateAndAssign( if (!isRelevantDueDate(dueDate)) continue estimatedByClient.set(clientId, (estimatedByClient.get(clientId) ?? 0) + 1) + addPreview(clientId, { + title: template.name, + department: template.department, + dueDate, + priority: template.defaultPriority, + context: 'Client-level', + }) + getBreakdown(clientId).clientLevelTasks += 1 if (options.dryRun) continue const createdTask = await prisma.task.create({ @@ -341,7 +444,19 @@ export async function runGenerateAndAssign( 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 })) + .map((id) => ({ + id, + name: nameById.get(id) ?? id, + estimatedTasks: estimatedByClient.get(id) ?? 0, + tasks: (previewsByClient.get(id) ?? []).sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime()), + breakdown: breakdownByClient.get(id) ?? { + groupsWithTasks: 0, + groupLevelTasks: 0, + policiesWithTasks: 0, + policyLevelTasks: 0, + clientLevelTasks: 0, + }, + })) .filter((c) => c.estimatedTasks > 0) .sort((a, b) => b.estimatedTasks - a.estimatedTasks)