From fd38e0c1480fb359f4bf9c5fc82a026d06b31002 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 10:17:17 +0000 Subject: [PATCH] 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, + } +}