diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma index 2e7d52f..4a74ce4 100644 --- a/ondeck/prisma/schema.prisma +++ b/ondeck/prisma/schema.prisma @@ -41,6 +41,7 @@ model User { advocateClients Client[] @relation("ClientAdvocate") clientMemberships ClientMember[] shapeImportRuns ShapeImportRun[] + taskAudits TaskAudit[] @@map("users") } @@ -109,6 +110,9 @@ model Designation { model Client { id String @id @default(cuid()) amsCustomerId String @unique @map("ams_customer_id") + /// AFW's short numeric customer/account number (AFW_Customer.CustNo) — distinct from + /// amsCustomerId (AFW's internal GUID). This is what ImageRight's FileNumberPart1 expects. + amsCustomerNumber Int? @map("ams_customer_number") name String addressLine1 String? @map("address_line1") addressLine2 String? @map("address_line2") @@ -142,6 +146,7 @@ model Client { policyGroups PolicyGroup[] members ClientMember[] contacts ClientContact[] + taskAudits TaskAudit[] @@index([name]) @@index([designationId]) @@ -286,6 +291,12 @@ enum DepartmentType { OTHER } +enum TaskAuditStatus { + COMPLETE + INCOMPLETE + NOT_APPLICABLE +} + model TaskTemplate { id String @id @default(cuid()) name String @@ -347,6 +358,7 @@ model Task { completer User? @relation("TaskCompletedBy", fields: [completedBy], references: [id]) assignments TaskAssignment[] taskNotes TaskNote[] + taskAudits TaskAudit[] @@index([clientId]) @@index([policyId]) @@ -370,6 +382,36 @@ model TaskAssignment { @@map("task_assignments") } +/// Result of checking one SHAPE_IR_Filing_Audit_Spec.md checklist item against +/// ImageRight for a client. Each audit run inserts new rows (history preserved); +/// the latest row per (clientId, specItemKey) is the current status. Optionally +/// linked to an existing generated Task, but not required — a spec item with no +/// matching Task is still auditable and surfaces as a gap. +model TaskAudit { + id String @id @default(cuid()) + clientId String @map("client_id") + taskId String? @map("task_id") + specItemKey String @map("spec_item_key") + targetDate DateTime @map("target_date") + status TaskAuditStatus + matchedDocId String? @map("matched_doc_id") + matchedDocName String? @map("matched_doc_name") + matchedDocDate DateTime? @map("matched_doc_date") + folderChecked String? @map("folder_checked") + errorMessage String? @map("error_message") @db.Text + runBy String? @map("run_by") + runAt DateTime @default(now()) @map("run_at") + + client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) + task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) + runByUser User? @relation(fields: [runBy], references: [id]) + + @@index([clientId]) + @@index([specItemKey]) + @@index([clientId, specItemKey, runAt]) + @@map("task_audits") +} + model TaskNote { id String @id @default(cuid()) taskId String @map("task_id") diff --git a/ondeck/scripts/backfill-ams-customer-number.ts b/ondeck/scripts/backfill-ams-customer-number.ts new file mode 100644 index 0000000..61e3e0c --- /dev/null +++ b/ondeck/scripts/backfill-ams-customer-number.ts @@ -0,0 +1,55 @@ +/** + * One-off backfill for Client.amsCustomerNumber (AFW_Customer.CustNo). + * + * This field was added after clients were already synced, so existing rows + * have it null until their AFW record naturally changes and re-syncs. This + * script backfills it directly from AFW (read-only SELECT) without going + * through the full sync engine's change-detection gating. + * + * Needed for the Task Audit / ImageRight feature: ImageRight's + * FileNumberPart1 expects this short numeric customer number, not the + * amsCustomerId GUID. + */ +import 'dotenv/config' +import { fetchAfwCustomers } from '../src/lib/sync/afw-queries' +import { prisma } from '../src/lib/db' + +async function main() { + console.log('Fetching customers from AFW...') + const afwCustomers = await fetchAfwCustomers() + console.log(`Fetched ${afwCustomers.length} AFW customers`) + + let updated = 0 + let skippedNoCustNo = 0 + let skippedNoMatch = 0 + + for (const afwCustomer of afwCustomers) { + if (afwCustomer.CustNo === null || afwCustomer.CustNo === undefined) { + skippedNoCustNo++ + continue + } + + const result = await prisma.client.updateMany({ + where: { amsCustomerId: afwCustomer.CustId }, + data: { amsCustomerNumber: afwCustomer.CustNo }, + }) + + if (result.count > 0) { + updated++ + } else { + skippedNoMatch++ + } + } + + console.log(`\n✅ Backfill complete`) + console.log(` Updated: ${updated}`) + console.log(` Skipped (no CustNo in AFW): ${skippedNoCustNo}`) + console.log(` Skipped (no matching local client): ${skippedNoMatch}`) +} + +main() + .catch((error) => { + console.error('Fatal error:', error) + process.exit(1) + }) + .finally(() => process.exit(0)) diff --git a/ondeck/src/app/api/admin/backfill-ams-customer-number/route.ts b/ondeck/src/app/api/admin/backfill-ams-customer-number/route.ts new file mode 100644 index 0000000..92a9e82 --- /dev/null +++ b/ondeck/src/app/api/admin/backfill-ams-customer-number/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { fetchAfwCustomers } from '@/lib/sync/afw-queries' + +/** + * POST /api/admin/backfill-ams-customer-number + * Admin-only, one-off maintenance action. + * + * Client.amsCustomerNumber (AFW_Customer.CustNo — the short numeric account + * number ImageRight's FileNumberPart1 expects) was added after clients were + * already synced, so existing rows have it null until their AFW record + * naturally changes. This backfills it directly from AFW (read-only SELECT), + * bypassing the normal sync engine's change-detection gating. + * + * Query param: ?dryRun=true — count only, no mutations. + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const userRoles = (session.user as any).roles || [] + if (!userRoles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const dryRun = new URL(request.url).searchParams.get('dryRun') === 'true' + + const afwCustomers = await fetchAfwCustomers() + const withCustNo = afwCustomers.filter((c) => c.CustNo !== null && c.CustNo !== undefined) + + if (dryRun) { + const matchable = await prisma.client.count({ + where: { amsCustomerId: { in: withCustNo.map((c) => c.CustId) } }, + }) + return NextResponse.json({ + dryRun: true, + afwCustomersFetched: afwCustomers.length, + afwCustomersWithCustNo: withCustNo.length, + localClientsMatchable: matchable, + }) + } + + let updated = 0 + for (const afwCustomer of withCustNo) { + const result = await prisma.client.updateMany({ + where: { amsCustomerId: afwCustomer.CustId }, + data: { amsCustomerNumber: afwCustomer.CustNo }, + }) + updated += result.count + } + + await prisma.auditLog.create({ + data: { + userId: (session.user as any).id, + action: 'BACKFILL_AMS_CUSTOMER_NUMBER', + entityType: 'System', + newValues: { afwCustomersFetched: afwCustomers.length, updated }, + }, + }) + + return NextResponse.json({ afwCustomersFetched: afwCustomers.length, updated }) + } catch (error: any) { + console.error('Backfill AMS customer number failed:', error.message) + return NextResponse.json({ error: error.message || 'Backfill failed' }, { status: 500 }) + } +} diff --git a/ondeck/src/app/api/clients/[id]/task-audit/route.ts b/ondeck/src/app/api/clients/[id]/task-audit/route.ts new file mode 100644 index 0000000..a5baad5 --- /dev/null +++ b/ondeck/src/app/api/clients/[id]/task-audit/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions, hasPermission } from '@/lib/auth' +import { prisma } from '@/lib/db' +import { runTaskAudit } from '@/lib/imageright/audit-engine' +import { ImageRightConfigError } from '@/lib/imageright/client' +import { SHAPE_DESIGNATION_NAMES } from '@/lib/imageright/shape-audit-spec' + +async function isShapeClient(clientId: string): Promise { + const client = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + designation: { select: { name: true } }, + designation2: { select: { name: true } }, + }, + }) + if (!client) return false + const names = [client.designation?.name, client.designation2?.name].filter(Boolean) as string[] + return names.some((n) => SHAPE_DESIGNATION_NAMES.includes(n.toLowerCase())) +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userPermissions = (session.user as any).permissions || {} + if (!hasPermission(userPermissions, 'clients.read')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { id } = await params + if (!(await isShapeClient(id))) { + return NextResponse.json({ error: 'Task Audit only applies to SHAPE/SHAPE2 clients' }, { status: 403 }) + } + + const audits = await prisma.taskAudit.findMany({ + where: { clientId: id }, + orderBy: { runAt: 'desc' }, + include: { runByUser: { select: { displayName: true, email: true } } }, + }) + + const latestByKey = new Map() + for (const audit of audits) { + if (!latestByKey.has(audit.specItemKey)) { + latestByKey.set(audit.specItemKey, audit) + } + } + + return NextResponse.json({ items: Array.from(latestByKey.values()) }) +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userPermissions = (session.user as any).permissions || {} + if (!hasPermission(userPermissions, 'tasks.write')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { id } = await params + if (!(await isShapeClient(id))) { + return NextResponse.json({ error: 'Task Audit only applies to SHAPE/SHAPE2 clients' }, { status: 403 }) + } + + try { + const result = await runTaskAudit(id, (session.user as any).id) + return NextResponse.json(result) + } catch (err: any) { + if (err instanceof ImageRightConfigError) { + return NextResponse.json({ error: err.message }, { status: 500 }) + } + console.error('Task audit run failed:', err.message) + return NextResponse.json({ error: err.message || 'Task audit failed' }, { status: 500 }) + } +} diff --git a/ondeck/src/components/clients/client-detail.tsx b/ondeck/src/components/clients/client-detail.tsx index eb1d2f9..e038bfd 100644 --- a/ondeck/src/components/clients/client-detail.tsx +++ b/ondeck/src/components/clients/client-detail.tsx @@ -30,14 +30,17 @@ import { SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2, ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown, GitBranch, ChevronRight, Settings2, MessageSquare } from 'lucide-react' +import { Building2, MapPin, Phone, Mail, FileText, CheckSquare, CalendarRange, Users, X, Plus, UserCheck, StickyNote, Pencil, Trash2, ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown, GitBranch, ChevronRight, Settings2, MessageSquare, FileSearch } from 'lucide-react' import { Combobox, type ComboboxGroup } from '@/components/ui/combobox' import { formatDate, formatRenewalDate, daysUntil } from '@/lib/utils' import { PolicyGroupManager } from '@/components/clients/policy-group-manager' import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal' import { TaskCard } from '@/components/tasks/task-card' +import { TaskAuditPanel } from '@/components/clients/task-audit-panel' import { toast } from 'sonner' +const SHAPE_DESIGNATION_NAMES = ['shape', 'shape 2'] + interface SimpleUser { id: string displayName: string | null @@ -56,6 +59,9 @@ interface ClientDetailProps { } export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false, canManageSetup = false, setupCompletedAt }: ClientDetailProps) { + const isShapeClient = [client.designation?.name, client.designation2?.name] + .filter(Boolean) + .some((name: string) => SHAPE_DESIGNATION_NAMES.includes(name.toLowerCase())) const [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '') const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '') const [saving, setSaving] = useState(false) @@ -500,6 +506,12 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic Assignment + {isShapeClient && ( + + + Document Audit + + )} @@ -1064,6 +1076,12 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic + + {isShapeClient && ( + + + + )} {/* Advocate reassignment dialog */} diff --git a/ondeck/src/components/clients/task-audit-panel.tsx b/ondeck/src/components/clients/task-audit-panel.tsx new file mode 100644 index 0000000..d6bb66f --- /dev/null +++ b/ondeck/src/components/clients/task-audit-panel.tsx @@ -0,0 +1,162 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { FileSearch, RefreshCw } from 'lucide-react' +import { formatDate } from '@/lib/utils' +import { toast } from 'sonner' + +interface TaskAuditItem { + id?: string + specItemKey: string + task?: string + targetDate: string + status: 'COMPLETE' | 'INCOMPLETE' | 'NOT_APPLICABLE' + matchedDocId?: string | null + matchedDocName?: string | null + matchedDocDate?: string | null + folderChecked?: string | null + errorMessage?: string | null + runAt?: string + runByUser?: { displayName: string | null; email: string } | null +} + +const STATUS_STYLES: Record = { + COMPLETE: 'bg-green-500/15 text-green-700 dark:text-green-400', + INCOMPLETE: 'bg-red-500/15 text-red-700 dark:text-red-400', + NOT_APPLICABLE: 'bg-muted text-muted-foreground', +} + +const STATUS_LABELS: Record = { + COMPLETE: 'Complete', + INCOMPLETE: 'Incomplete', + NOT_APPLICABLE: 'N/A', +} + +// Human-readable task titles for spec keys not returned inline by the run endpoint's GET path. +const SPEC_TITLES: Record = { + shape_onboarding_checklist: 'SHAPE Onboarding Checklist', + claim_review_90: 'Claim Review (90 days)', + review_reserves: 'Review reserves and negotiate adjustments where applicable', + claim_review_180: 'Claim Review (180 days)', + project_exp_mod_factor: 'Project experience modification factor; send to Account Executive', + request_120_day_loss_runs: 'Request 120 day loss runs', + captive_claims_worksheet: 'Assist with captive claims worksheet (if applicable)', + loss_summary_pre_renewal: 'Prepare loss summary/analysis for internal pre-renewal meeting', + request_90_day_loss_runs: 'Request 90 day loss runs', + claim_review_pre_renewal_meeting: 'Claim Review (pre-renewal meeting)', +} + +export function TaskAuditPanel({ clientId }: { clientId: string }) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [running, setRunning] = useState(false) + const [fileNotFound, setFileNotFound] = useState(false) + const [error, setError] = useState(null) + + const loadResults = async () => { + setLoading(true) + try { + const res = await fetch(`/api/clients/${clientId}/task-audit`) + if (res.ok) { + const data = await res.json() + setItems(data.items ?? []) + setFileNotFound( + data.items?.length > 0 && data.items.every((i: TaskAuditItem) => i.errorMessage === 'No ImageRight file found for this client') + ) + } + } catch { + // Non-fatal — panel just shows empty state + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadResults() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientId]) + + const runAudit = async () => { + setRunning(true) + setError(null) + try { + const res = await fetch(`/api/clients/${clientId}/task-audit`, { method: 'POST' }) + const data = await res.json() + if (!res.ok) { + throw new Error(data.error || 'Audit run failed') + } + setItems(data.items.map((i: any) => ({ ...i, targetDate: i.targetDate }))) + setFileNotFound(!data.fileFound) + toast.success('ImageRight audit complete') + } catch (err: any) { + setError(err.message) + toast.error(err.message || 'Audit run failed') + } finally { + setRunning(false) + } + } + + return ( + + + + + ImageRight Document Audit + + + + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +

Loading…

+ ) : fileNotFound ? ( +
+ No ImageRight file found for this client's account number. +
+ ) : items.length === 0 ? ( +
+ No audit has been run yet. Click "Run Audit" to check ImageRight against the SHAPE checklist. +
+ ) : ( +
+ {items.map((item) => ( +
+
+

{item.task || SPEC_TITLES[item.specItemKey] || item.specItemKey}

+

+ Target date {formatDate(item.targetDate)} · Checked {item.folderChecked || '—'} +

+ {item.matchedDocName && ( +

+ Matched: {item.matchedDocName} + {item.matchedDocDate ? ` (${formatDate(item.matchedDocDate)})` : ''} + {item.matchedDocId ? ` · IR doc ${item.matchedDocId}` : ''} +

+ )} + {item.errorMessage && item.status !== 'COMPLETE' && ( +

{item.errorMessage}

+ )} +
+ {STATUS_LABELS[item.status]} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts b/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts new file mode 100644 index 0000000..f76e53c --- /dev/null +++ b/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts @@ -0,0 +1,121 @@ +import { + computeWindow, + matchesKeywords, + matchesDocType, + findPolicyTermFolder, + findChildFolder, + type ImageRightFolder, +} from '../audit-matching' +import { SHAPE_AUDIT_SPEC } from '../shape-audit-spec' + +describe('computeWindow', () => { + it('computes a symmetric +/- window', () => { + const spec = SHAPE_AUDIT_SPEC.find((s) => s.key === 'shape_onboarding_checklist')! + const target = new Date('2026-06-18T00:00:00Z') + const { start, end } = computeWindow(spec, target) + expect(start?.toISOString().slice(0, 10)).toBe('2026-06-03') + expect(end?.toISOString().slice(0, 10)).toBe('2026-07-03') + }) + + it('computes an asymmetric window (Task 9: +4 days only)', () => { + const spec = SHAPE_AUDIT_SPEC.find((s) => s.key === 'request_90_day_loss_runs')! + const target = new Date('2027-02-19T00:00:00Z') + const { start, end } = computeWindow(spec, target) + expect(start?.toISOString().slice(0, 10)).toBe('2027-02-19') + expect(end?.toISOString().slice(0, 10)).toBe('2027-02-23') + }) + + it('returns null start/end for ALL_TIME items', () => { + const spec = SHAPE_AUDIT_SPEC.find((s) => s.key === 'review_reserves')! + const { start, end } = computeWindow(spec, new Date()) + expect(start).toBeNull() + expect(end).toBeNull() + }) +}) + +describe('matchesKeywords', () => { + it('matches case-insensitively', () => { + expect(matchesKeywords('Client ONBOARDING email', ['onboarding'])).toBe(true) + }) + + it('matches any keyword in the list', () => { + expect(matchesKeywords('Q3 Open Claim Summary.xlsx', ['QUARTERLY CLAIM SUMMARY', 'OPEN CLAIM SUMMARY'])).toBe(true) + }) + + it('returns false when no keyword matches', () => { + expect(matchesKeywords('Unrelated document', ['ONBOARDING'])).toBe(false) + }) + + it('returns true when keyword list is empty (doc type alone suffices)', () => { + expect(matchesKeywords('Anything', [])).toBe(true) + }) +}) + +describe('matchesDocType', () => { + it('matches when actual type is contained in an expected type', () => { + expect(matchesDocType('EMAIL', ['EMAIL'])).toBe(true) + }) + + it('is case-insensitive', () => { + expect(matchesDocType('email', ['EMAIL'])).toBe(true) + }) + + it('returns false for non-matching types', () => { + expect(matchesDocType('PDF', ['EMAIL'])).toBe(false) + }) + + it('returns true when expected type list is empty', () => { + expect(matchesDocType('ANYTHING', [])).toBe(true) + }) + + it('ignores parenthetical qualifiers in the expected type (real IR type is shorter)', () => { + expect(matchesDocType('Loss Run - Loss Run', ['LOSS RUN (PDF or Excel)'])).toBe(true) + }) +}) + +/** Sample folder tree shaped like a real getSortedFolders response (Passavant Memorial Homes). */ +const SAMPLE_FOLDERS: ImageRightFolder[] = [ + { id: 5629503, parentFolderId: null, folderTypeName: 'New Mail', folderTypeDescription: 'New Mail', description: '' }, + { id: 12706202, parentFolderId: null, folderTypeName: 'Policy Term', folderTypeDescription: 'Policy Term - Policy Term', description: '2027' }, + { id: 12179859, parentFolderId: null, folderTypeName: 'Policy Term', folderTypeDescription: 'Policy Term - Policy Term', description: '2026' }, + { id: 12658406, parentFolderId: 12179859, folderTypeName: 'Submission/Quote', folderTypeDescription: 'Submission/Quote - Submission/Quote', description: 'Submission/Quote' }, + { id: 12822969, parentFolderId: 12179859, folderTypeName: 'General Correspondence', folderTypeDescription: 'General Correspondence', description: 'General Correspondence' }, + { id: 10777040, parentFolderId: null, folderTypeName: 'Policy Term', folderTypeDescription: 'Policy Term - Policy Term', description: '2025' }, +] + +describe('findPolicyTermFolder', () => { + it('finds the top-level Policy Term folder for a given year', () => { + const folder = findPolicyTermFolder(SAMPLE_FOLDERS, 2026) + expect(folder?.id).toBe(12179859) + }) + + it('returns undefined when no Policy Term folder matches the year', () => { + expect(findPolicyTermFolder(SAMPLE_FOLDERS, 1999)).toBeUndefined() + }) +}) + +describe('findChildFolder', () => { + it('fuzzily matches a real folder name against a spec label (SUBMISSION -> Submission/Quote)', () => { + const folder = findChildFolder(SAMPLE_FOLDERS, 12179859, 'SUBMISSION') + expect(folder?.id).toBe(12658406) + }) + + it('returns undefined when no child folder matches', () => { + expect(findChildFolder(SAMPLE_FOLDERS, 12179859, 'PRERENEWAL')).toBeUndefined() + }) + + it('only matches direct children of the given parent', () => { + expect(findChildFolder(SAMPLE_FOLDERS, 10777040, 'SUBMISSION')).toBeUndefined() + }) +}) + +describe('SHAPE_AUDIT_SPEC', () => { + it('has 10 checklist items matching the spec doc', () => { + expect(SHAPE_AUDIT_SPEC).toHaveLength(10) + }) + + it('has unique spec item keys', () => { + const keys = SHAPE_AUDIT_SPEC.map((s) => s.key) + expect(new Set(keys).size).toBe(keys.length) + }) +}) diff --git a/ondeck/src/lib/imageright/__tests__/client.test.ts b/ondeck/src/lib/imageright/__tests__/client.test.ts new file mode 100644 index 0000000..89690b8 --- /dev/null +++ b/ondeck/src/lib/imageright/__tests__/client.test.ts @@ -0,0 +1,174 @@ +import { ImageRightClient, unwrapValues, ImageRightConfigError, ImageRightApiError } from '../client' + +const BASE_URL = 'https://1100080.wsol.vertafore.com/iisapp' +const TENANT_ID = '7f84449d-f0ad-42e6-876f-d2152fd28af1' + +/** Minimal fetch Response stand-in — avoids depending on jsdom's global Response. */ +function fakeResponse(body: string, status: number) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => body, + json: async () => JSON.parse(body), + } +} + +function mockFetch(implementation: (url: string, init?: RequestInit) => Promise>) { + global.fetch = jest.fn(implementation) as unknown as typeof fetch +} + +describe('unwrapValues', () => { + it('unwraps Newtonsoft-style $values wrappers', () => { + expect(unwrapValues({ $values: [1, 2, 3] })).toEqual([1, 2, 3]) + }) + + it('passes through plain arrays unchanged', () => { + expect(unwrapValues([1, 2, 3])).toEqual([1, 2, 3]) + }) + + it('passes through non-wrapper objects unchanged', () => { + const obj = { id: 1, name: 'test' } + expect(unwrapValues(obj)).toBe(obj) + }) +}) + +describe('ImageRightClient.fromEnv', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + }) + afterEach(() => { + process.env = originalEnv + }) + + it('throws ImageRightConfigError when credentials are missing', () => { + delete process.env.VERTAFORE_IMAGERIGHT_BASE_URL + delete process.env.VERTAFORE_IMAGERIGHT_TENANT_ID + delete process.env.VERTAFORE_APIUSER + delete process.env.VERTAFORE_APIUSER_PASSWORD + expect(() => ImageRightClient.fromEnv()).toThrow(ImageRightConfigError) + }) + + it('builds a client when all credentials are present', () => { + process.env.VERTAFORE_IMAGERIGHT_BASE_URL = BASE_URL + process.env.VERTAFORE_IMAGERIGHT_TENANT_ID = TENANT_ID + process.env.VERTAFORE_APIUSER = 'user' + process.env.VERTAFORE_APIUSER_PASSWORD = 'pass' + expect(() => ImageRightClient.fromEnv()).not.toThrow() + }) +}) + +describe('ImageRightClient.authenticate', () => { + it('sends the Tenant-ID header with exact casing', async () => { + let capturedHeaders: Record = {} + mockFetch(async (_url, init) => { + capturedHeaders = init?.headers as Record + return fakeResponse('secret-token-value', 200) + }) + + const client = new ImageRightClient(BASE_URL, TENANT_ID, 'user', 'pass') + await client.authenticate() + + expect(capturedHeaders['Tenant-ID']).toBe(TENANT_ID) + expect(capturedHeaders['TenantId']).toBeUndefined() + expect(capturedHeaders['TenantID']).toBeUndefined() + }) + + it('uses the AccessToken authorization scheme (not Bearer) on subsequent calls', async () => { + let authHeader = '' + mockFetch(async (url, init) => { + if (String(url).endsWith('/api/authenticate')) { + return fakeResponse('the-token', 200) + } + authHeader = (init?.headers as Record)?.Authorization ?? '' + return fakeResponse(JSON.stringify({ $values: [] }), 200) + }) + + const client = new ImageRightClient(BASE_URL, TENANT_ID, 'user', 'pass') + await client.authenticate() + await client.getContainerChildren(123) + + expect(authHeader).toBe('AccessToken the-token') + expect(authHeader).not.toMatch(/^Bearer/) + }) + + it('throws ImageRightApiError on non-2xx auth response', async () => { + mockFetch(async () => fakeResponse('', 401)) + const client = new ImageRightClient(BASE_URL, TENANT_ID, 'user', 'pass') + await expect(client.authenticate()).rejects.toBeInstanceOf(ImageRightApiError) + }) + + it('never includes credentials in thrown error messages', async () => { + mockFetch(async () => fakeResponse('', 401)) + const client = new ImageRightClient(BASE_URL, TENANT_ID, 'super-secret-user', 'super-secret-pass') + try { + await client.authenticate() + fail('expected authenticate() to throw') + } catch (err: any) { + expect(err.message).not.toContain('super-secret-user') + expect(err.message).not.toContain('super-secret-pass') + } + }) +}) + +describe('ImageRightClient.findFilesByFileNumber', () => { + it('unwraps $values and posts FileNumberPart1', async () => { + let capturedBody: any = null + mockFetch(async (url, init) => { + if (String(url).endsWith('/api/authenticate')) return fakeResponse('tok', 200) + if (String(url).endsWith('/api/files/find')) { + capturedBody = JSON.parse(init?.body as string) + return fakeResponse(JSON.stringify({ $values: [{ id: 1 }, { id: 2 }] }), 200) + } + return fakeResponse('{}', 404) + }) + + const client = new ImageRightClient(BASE_URL, TENANT_ID, 'user', 'pass') + await client.authenticate() + const files = await client.findFilesByFileNumber('5610') + + expect(capturedBody).toEqual({ FileNumberPart1: '5610' }) + expect(files).toEqual([{ id: 1 }, { id: 2 }]) + }) +}) + +describe('ImageRightClient.findDocuments', () => { + it('posts only FileId when no parentId is given (searches whole file)', async () => { + let capturedBody: any = null + mockFetch(async (url, init) => { + if (String(url).endsWith('/api/authenticate')) return fakeResponse('tok', 200) + if (String(url).endsWith('/api/documents/find')) { + capturedBody = JSON.parse(init?.body as string) + return fakeResponse(JSON.stringify([{ id: 1 }]), 200) + } + return fakeResponse('{}', 404) + }) + + const client = new ImageRightClient(BASE_URL, TENANT_ID, 'user', 'pass') + await client.authenticate() + const docs = await client.findDocuments(5590836) + + expect(capturedBody).toEqual({ FileId: 5590836 }) + expect(docs).toEqual([{ id: 1 }]) + }) + + it('posts FileId and ParentId when scoping to a folder', async () => { + let capturedBody: any = null + mockFetch(async (url, init) => { + if (String(url).endsWith('/api/authenticate')) return fakeResponse('tok', 200) + if (String(url).endsWith('/api/documents/find')) { + capturedBody = JSON.parse(init?.body as string) + return fakeResponse(JSON.stringify({ $values: [{ id: 2 }] }), 200) + } + return fakeResponse('{}', 404) + }) + + const client = new ImageRightClient(BASE_URL, TENANT_ID, 'user', 'pass') + await client.authenticate() + const docs = await client.findDocuments(5590836, 12658406) + + expect(capturedBody).toEqual({ FileId: 5590836, ParentId: 12658406 }) + expect(docs).toEqual([{ id: 2 }]) + }) +}) diff --git a/ondeck/src/lib/imageright/audit-engine.ts b/ondeck/src/lib/imageright/audit-engine.ts new file mode 100644 index 0000000..20bacfe --- /dev/null +++ b/ondeck/src/lib/imageright/audit-engine.ts @@ -0,0 +1,275 @@ +import { prisma } from '@/lib/db' +import { ImageRightClient, ImageRightConfigError, type ImageRightFile } from './client' +import { SHAPE_AUDIT_SPEC, type ShapeAuditSpecItem } from './shape-audit-spec' +import { + computeWindow, + matchesKeywords, + matchesDocType, + findPolicyTermFolder, + findChildFolder, + type ImageRightFolder, +} from './audit-matching' + +const DAY_MS = 24 * 60 * 60 * 1000 + +export interface TaskAuditItemResult { + specItemKey: string + task: string + targetDate: Date + status: 'COMPLETE' | 'INCOMPLETE' | 'NOT_APPLICABLE' + matchedDocId?: string + matchedDocName?: string + matchedDocDate?: Date + folderChecked: string + errorMessage?: string + linkedTaskId?: string +} + +export interface TaskAuditRunResult { + clientId: string + fileFound: boolean + items: TaskAuditItemResult[] +} + +/** + * ImageRight's folder/document JSON field names, verified against live API + * responses (see IMAGERIGHT_guide.md and Vertafore's REST v1 reference). + */ +function pick(obj: unknown, keys: string[]): unknown { + if (!obj || typeof obj !== 'object') return undefined + const rec = obj as Record + for (const key of keys) { + if (rec[key] !== undefined && rec[key] !== null) return rec[key] + } + return undefined +} + +function docIdOf(doc: unknown): string { + return String(pick(doc, ['id', 'documentId']) ?? '') +} +/** Search both the document's specific title (`description`) and its generic type label (`documentName`). */ +function docNameOf(doc: unknown): string { + const description = pick(doc, ['description']) + const documentName = pick(doc, ['documentName', 'name', 'fileName']) + return [description, documentName].filter((v) => v != null && v !== '').join(' ') +} +function docTypeOf(doc: unknown): string { + return String(pick(doc, ['documentTypeDescription', 'documentType', 'docType', 'type']) ?? '') +} +function docDateOf(doc: unknown): Date | null { + const raw = pick(doc, ['documentDate', 'receivedDate', 'dateCreated', 'dateLastModified']) + if (!raw) return null + const d = new Date(raw as string) + return isNaN(d.getTime()) ? null : d +} + +/** + * Resolve the ImageRight ParentId to scope a document search to, per spec.folderPath. + * + * ImageRight files are organized as: Policy Term (one per renewal year, at the file + * root) > folders like "Submission/Quote", "Policy Correspondence", etc. The spec + * doc's folder_path labels (e.g. "SUBMISSION") don't exactly match real folder names + * (e.g. "Submission/Quote") so the first segment is matched fuzzily under the + * renewal-year's Policy Term folder. Only the first segment is used for navigation — + * real files have no nested "PRERENEWAL" folder; a second+ segment (if present) is + * surfaced in the returned label for visibility but not used to narrow the folder. + */ +async function resolveParentId( + client: ImageRightClient, + fileId: string | number, + folderPath: string[], + renewalYear: number +): Promise<{ parentId: string | number | undefined; label: string; resolved: boolean }> { + if (folderPath.length === 0) { + return { parentId: undefined, label: 'ALL_TIME (whole file)', resolved: true } + } + + const folders = (await client.getSortedFolders(fileId)) as ImageRightFolder[] + const policyTerm = findPolicyTermFolder(folders, renewalYear) + if (!policyTerm) { + return { parentId: undefined, label: `Policy Term ${renewalYear} (not found in file)`, resolved: false } + } + + const target = findChildFolder(folders, policyTerm.id, folderPath[0]) + if (!target) { + return { + parentId: undefined, + label: `${policyTerm.description ?? renewalYear} > ${folderPath[0]} (no matching folder)`, + resolved: false, + } + } + + let label = `${policyTerm.description ?? renewalYear} > ${target.description || target.folderTypeName || folderPath[0]}` + if (folderPath.length > 1) { + label += ` (spec also lists "${folderPath.slice(1).join(' > ')}" — no matching subfolder found; not applied)` + } + return { parentId: target.id, label, resolved: true } +} + +async function findBestMatch( + client: ImageRightClient, + files: ImageRightFile[], + spec: ShapeAuditSpecItem, + windowStart: Date | null, + windowEnd: Date | null, + renewalYear: number +): Promise<{ doc: unknown | null; folderChecked: string }> { + let lastLabel = spec.folderPath.join(' > ') || 'ALL_TIME (whole file)' + + for (const file of files) { + const { parentId, label, resolved } = await resolveParentId(client, file.id, spec.folderPath, renewalYear) + lastLabel = label + if (!resolved) continue + + const candidates = await client.findDocuments(file.id, parentId) + + for (const doc of candidates) { + if (!matchesDocType(docTypeOf(doc), spec.docTypes)) continue + if (!matchesKeywords(docNameOf(doc), spec.keywords)) continue + const docDate = docDateOf(doc) + if (windowStart && windowEnd) { + if (!docDate || docDate < windowStart || docDate > windowEnd) continue + } + return { doc, folderChecked: label } + } + } + + return { doc: null, folderChecked: lastLabel } +} + +/** Best-effort link to an existing Task: same client, title match, closest dueDate to targetDate. */ +async function findLinkedTask(clientId: string, taskTitle: string, targetDate: Date): Promise { + const candidates = await prisma.task.findMany({ + where: { clientId, title: taskTitle }, + select: { id: true, dueDate: true }, + }) + if (candidates.length === 0) return undefined + candidates.sort( + (a, b) => Math.abs(a.dueDate.getTime() - targetDate.getTime()) - Math.abs(b.dueDate.getTime() - targetDate.getTime()) + ) + return candidates[0].id +} + +async function resolveEffectiveDate(clientId: string): Promise { + const client = await prisma.client.findUnique({ + where: { id: clientId }, + select: { renewalDate: true }, + }) + if (client?.renewalDate) return client.renewalDate + + const earliestGroup = await prisma.policyGroup.findFirst({ + where: { clientId }, + orderBy: { renewalDate: 'asc' }, + select: { renewalDate: true }, + }) + return earliestGroup?.renewalDate ?? null +} + +export async function runTaskAudit(clientId: string, runByUserId?: string): Promise { + const client = await prisma.client.findUnique({ + where: { id: clientId }, + select: { id: true, amsCustomerNumber: true }, + }) + if (!client) throw new Error(`Client ${clientId} not found`) + if (!client.amsCustomerNumber) { + throw new Error( + 'Client is missing its AMS customer number (needed to look up the ImageRight file) — re-sync from AMS to populate it' + ) + } + + const effectiveDate = await resolveEffectiveDate(clientId) + if (!effectiveDate) { + throw new Error('Client has no renewal date configured — cannot compute audit target dates') + } + + const irClient = ImageRightClient.fromEnv() + await irClient.authenticate() + + const files = await irClient.findFilesByFileNumber(String(client.amsCustomerNumber)) + const fileFound = files.length > 0 + const renewalYear = effectiveDate.getFullYear() + + const items: TaskAuditItemResult[] = [] + + for (const spec of SHAPE_AUDIT_SPEC) { + const targetDate = new Date(effectiveDate.getTime() + spec.daysAfterRenewal * DAY_MS) + const { start, end } = computeWindow(spec, targetDate) + + let result: TaskAuditItemResult + try { + if (!fileFound) { + result = { + specItemKey: spec.key, + task: spec.task, + targetDate, + status: 'INCOMPLETE', + folderChecked: spec.folderPath.join(' > ') || 'ALL_TIME (whole file)', + errorMessage: 'No ImageRight file found for this client', + } + } else { + const { doc, folderChecked } = await findBestMatch(irClient, files, spec, start, end, renewalYear) + if (doc) { + result = { + specItemKey: spec.key, + task: spec.task, + targetDate, + status: 'COMPLETE', + matchedDocId: docIdOf(doc), + matchedDocName: docNameOf(doc), + matchedDocDate: docDateOf(doc) ?? undefined, + folderChecked, + } + } else if (spec.condition?.toLowerCase().includes('applicable') || spec.condition?.toLowerCase().includes('conditional')) { + result = { + specItemKey: spec.key, + task: spec.task, + targetDate, + status: 'NOT_APPLICABLE', + folderChecked, + errorMessage: 'No matching document found; item is conditional — confirm applicability manually', + } + } else { + result = { + specItemKey: spec.key, + task: spec.task, + targetDate, + status: 'INCOMPLETE', + folderChecked, + } + } + } + } catch (err: any) { + result = { + specItemKey: spec.key, + task: spec.task, + targetDate, + status: 'INCOMPLETE', + folderChecked: spec.folderPath.join(' > ') || 'ALL_TIME (whole file)', + errorMessage: `Audit check failed: ${err.message}`, + } + } + + result.linkedTaskId = await findLinkedTask(clientId, spec.task, targetDate) + items.push(result) + } + + await prisma.taskAudit.createMany({ + data: items.map((item) => ({ + clientId, + taskId: item.linkedTaskId ?? null, + specItemKey: item.specItemKey, + targetDate: item.targetDate, + status: item.status, + matchedDocId: item.matchedDocId ?? null, + matchedDocName: item.matchedDocName ?? null, + matchedDocDate: item.matchedDocDate ?? null, + folderChecked: item.folderChecked, + errorMessage: item.errorMessage ?? null, + runBy: runByUserId ?? null, + })), + }) + + return { clientId, fileFound, items } +} + +export { ImageRightConfigError } diff --git a/ondeck/src/lib/imageright/audit-matching.ts b/ondeck/src/lib/imageright/audit-matching.ts new file mode 100644 index 0000000..621cac8 --- /dev/null +++ b/ondeck/src/lib/imageright/audit-matching.ts @@ -0,0 +1,73 @@ +import type { ShapeAuditSpecItem } from './shape-audit-spec' + +const DAY_MS = 24 * 60 * 60 * 1000 + +export function computeWindow(spec: ShapeAuditSpecItem, targetDate: Date): { start: Date | null; end: Date | null } { + if (spec.windowBeforeDays === null || spec.windowAfterDays === null) { + return { start: null, end: null } + } + return { + start: new Date(targetDate.getTime() - spec.windowBeforeDays * DAY_MS), + end: new Date(targetDate.getTime() + spec.windowAfterDays * DAY_MS), + } +} + +export function matchesKeywords(docName: string, keywords: string[]): boolean { + if (keywords.length === 0) return true + const lower = docName.toLowerCase() + return keywords.some((kw) => lower.includes(kw.toLowerCase())) +} + +/** Strip parenthetical qualifiers, e.g. "LOSS RUN (PDF or Excel)" -> "LOSS RUN". */ +function stripParenthetical(s: string): string { + return s.replace(/\([^)]*\)/g, '').trim() +} + +export function matchesDocType(actualType: string, expectedTypes: string[]): boolean { + if (expectedTypes.length === 0) return true + if (!actualType) return false + const lower = actualType.toLowerCase() + return expectedTypes.some((t) => lower.includes(stripParenthetical(t).toLowerCase())) +} + +/** Minimal shape of an ImageRight folder as returned by getSortedFolders — real API fields only. */ +export interface ImageRightFolder { + id: number | string + parentFolderId: number | string | null + folderTypeName?: string | null + folderTypeDescription?: string | null + description?: string | null +} + +/** + * Find the top-level "Policy Term" folder for a given renewal year. + * ImageRight files are organized with a `Policy Term` folder per year at the + * root (parentFolderId === null), whose `description` is the year (e.g. "2026"). + */ +export function findPolicyTermFolder(folders: ImageRightFolder[], year: number): ImageRightFolder | undefined { + return folders.find( + (f) => + (f.parentFolderId === null || f.parentFolderId === undefined) && + `${f.folderTypeDescription ?? ''} ${f.folderTypeName ?? ''}`.toUpperCase().includes('POLICY TERM') && + String(f.description ?? '').trim() === String(year) + ) +} + +/** + * Find a direct child folder under `parentId` whose type name/description fuzzily + * matches `segment` (case-insensitive substring). Real folder names don't exactly + * match the spec doc's folder_path labels (e.g. spec says "SUBMISSION", the real + * folder is "Submission/Quote") so this is intentionally a substring match. + */ +export function findChildFolder( + folders: ImageRightFolder[], + parentId: number | string, + segment: string +): ImageRightFolder | undefined { + const needle = segment.toUpperCase() + return folders.find((f) => { + if (String(f.parentFolderId) !== String(parentId)) return false + const label = `${f.folderTypeDescription ?? ''} ${f.folderTypeName ?? ''} ${f.description ?? ''}`.toUpperCase() + return label.includes(needle) + }) +} diff --git a/ondeck/src/lib/imageright/client.ts b/ondeck/src/lib/imageright/client.ts new file mode 100644 index 0000000..0788b99 --- /dev/null +++ b/ondeck/src/lib/imageright/client.ts @@ -0,0 +1,167 @@ +/** + * Minimal REST client for Vertafore ImageRight, per IMAGERIGHT_guide.md. + * + * Auth quirks (do not "fix" these — they are the working, verified behavior): + * - Header must be spelled exactly `Tenant-ID` (not `TenantId`/`TenantID`). + * - Auth response body is plaintext token material, not JSON. + * - Subsequent calls use `Authorization: AccessToken ` — NOT `Bearer`. + * - List endpoints may wrap arrays as `{ "$values": [...] }` (Newtonsoft-style). + * + * Never log credentials or tokens. + */ + +export class ImageRightConfigError extends Error {} +export class ImageRightApiError extends Error { + constructor( + message: string, + public status?: number + ) { + super(message) + } +} + +/** Unwrap ImageRight's Newtonsoft-style `{ "$values": [...] }` array wrapper. */ +export function unwrapValues(obj: unknown): T { + if (obj && typeof obj === 'object' && '$values' in (obj as Record)) { + return (obj as Record)['$values'] as T + } + return obj as T +} + +export interface ImageRightFile { + id: number | string + [key: string]: unknown +} + +export interface ImageRightDocument { + id: number | string + [key: string]: unknown +} + +export class ImageRightClient { + private token: string | null = null + + constructor( + private baseUrl: string, + private tenantId: string, + private username: string, + private password: string + ) {} + + /** Build a client from env vars, throwing a clear config error if any are missing. */ + static fromEnv(): ImageRightClient { + const baseUrl = process.env.VERTAFORE_IMAGERIGHT_BASE_URL + const tenantId = process.env.VERTAFORE_IMAGERIGHT_TENANT_ID + const username = process.env.VERTAFORE_APIUSER + const password = process.env.VERTAFORE_APIUSER_PASSWORD + + if (!baseUrl || !tenantId || !username || !password) { + throw new ImageRightConfigError( + 'ImageRight is not configured. Set VERTAFORE_IMAGERIGHT_BASE_URL, ' + + 'VERTAFORE_IMAGERIGHT_TENANT_ID, VERTAFORE_APIUSER, and VERTAFORE_APIUSER_PASSWORD.' + ) + } + return new ImageRightClient(baseUrl, tenantId, username, password) + } + + private baseHeaders(): Record { + return { 'Tenant-ID': this.tenantId } + } + + async authenticate(): Promise { + const res = await fetch(`${this.baseUrl}/api/authenticate`, { + method: 'POST', + headers: { + ...this.baseHeaders(), + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ username: this.username, password: this.password }), + }) + + if (!res.ok) { + throw new ImageRightApiError(`ImageRight authentication failed (HTTP ${res.status})`, res.status) + } + + const token = (await res.text()).trim() + if (!token) { + throw new ImageRightApiError('ImageRight authentication succeeded but returned an empty token') + } + this.token = token + } + + private authHeaders(accept = 'application/json'): Record { + if (!this.token) { + throw new Error('ImageRightClient: call authenticate() before making requests') + } + return { + ...this.baseHeaders(), + Authorization: `AccessToken ${this.token}`, + Accept: accept, + } + } + + private async getJson(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { headers: this.authHeaders() }) + if (!res.ok) { + throw new ImageRightApiError(`ImageRight GET ${path} failed (HTTP ${res.status})`, res.status) + } + return res.json() + } + + private async postJson(path: string, body: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { ...this.authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + throw new ImageRightApiError(`ImageRight POST ${path} failed (HTTP ${res.status})`, res.status) + } + return res.json() + } + + /** Find ImageRight files by AMS account/client number (FileNumberPart1). */ + async findFilesByFileNumber(fileNumberPart1: string): Promise { + const result = await this.postJson('/api/files/find', { FileNumberPart1: fileNumberPart1 }) + return unwrapValues(result) ?? [] + } + + /** + * Find documents within a file, optionally scoped to a folder. + * Per Vertafore's REST v1 docs (Documents > POST /api/documents/find), `FileId` is the + * only required property; pass `parentId` (a folder id) to scope the search to that + * folder, or omit it to search the entire file. + */ + async findDocuments(fileId: number | string, parentId?: number | string): Promise { + const body: Record = { FileId: Number(fileId) } + if (parentId !== undefined) body.ParentId = Number(parentId) + const result = await this.postJson('/api/documents/find', body) + return unwrapValues(result) ?? [] + } + + /** + * NOTE: verified against live data to return HTTP 404 for both file-root and real + * folder ids on this tenant — do not use for folder/document traversal. Kept only + * because it may map to a different resource shape than assumed here; use + * `getSortedFolders` (folders) and `findDocuments` (documents) instead. + */ + async getContainerChildren(containerId: number | string): Promise { + const result = await this.getJson(`/api/containers/${containerId}`) + return unwrapValues(result) ?? [] + } + + async getSortedFolders(fileId: number | string): Promise { + const result = await this.getJson(`/api/containers/${fileId}/sortedfolders`) + return unwrapValues(result) ?? [] + } + + async getDocument(documentId: number | string): Promise { + return this.getJson(`/api/documents/${documentId}`) + } + + async getDocumentPages(documentId: number | string): Promise { + const result = await this.getJson(`/api/documents/${documentId}/pages`) + return unwrapValues(result) ?? [] + } +} diff --git a/ondeck/src/lib/imageright/shape-audit-spec.ts b/ondeck/src/lib/imageright/shape-audit-spec.ts new file mode 100644 index 0000000..da53eea --- /dev/null +++ b/ondeck/src/lib/imageright/shape-audit-spec.ts @@ -0,0 +1,134 @@ +/** Designation names (lowercase) that this audit applies to. */ +export const SHAPE_DESIGNATION_NAMES = ['shape', 'shape 2'] + +/** + * SHAPE program ImageRight filing checklist, ported from + * `SHAPE_IR_Filing_Audit_Spec.md`. This is a fixed configuration for v1 — + * editing requires a code change, matching the spec doc as source of truth. + * + * `windowBeforeDays`/`windowAfterDays` are null for the one ALL_TIME item + * (search the entire file history instead of a date window). + */ +export interface ShapeAuditSpecItem { + key: string + task: string + condition?: string + daysAfterRenewal: number + windowBeforeDays: number | null + windowAfterDays: number | null + /** Empty array means "search the whole file" (no folder scoping). */ + folderPath: string[] + docTypes: string[] + /** Case-insensitive substring match against document name/description. Empty = doc type alone is sufficient. */ + keywords: string[] + windowNote?: string +} + +export const SHAPE_AUDIT_SPEC: ShapeAuditSpecItem[] = [ + { + key: 'shape_onboarding_checklist', + task: 'SHAPE Onboarding Checklist', + condition: 'Required after first renewal or when changing carriers', + daysAfterRenewal: 30, + windowBeforeDays: 15, + windowAfterDays: 15, + folderPath: ['SUBMISSION'], + docTypes: ['EMAIL'], + keywords: ['ONBOARDING'], + }, + { + key: 'claim_review_90', + task: 'Claim Review', + daysAfterRenewal: 90, + windowBeforeDays: 30, + windowAfterDays: 30, + folderPath: ['SUBMISSION', 'PRERENEWAL'], + docTypes: ['EXCEL DOC'], + keywords: ['OPEN CLAIM SUMMARY', 'QUARTERLY CLAIM SUMMARY', 'CLAIM REVIEW'], + }, + { + key: 'review_reserves', + task: 'Review reserves and negotiate adjustments where applicable', + daysAfterRenewal: 120, + windowBeforeDays: null, + windowAfterDays: null, + folderPath: [], + docTypes: ['EMAIL'], + keywords: ['RESERVE'], + }, + { + key: 'claim_review_180', + task: 'Claim Review', + daysAfterRenewal: 180, + windowBeforeDays: 30, + windowAfterDays: 30, + folderPath: ['SUBMISSION', 'PRERENEWAL'], + docTypes: ['EXCEL DOC'], + keywords: ['OPEN CLAIM SUMMARY', 'QUARTERLY CLAIM SUMMARY', 'CLAIM REVIEW'], + }, + { + key: 'project_exp_mod_factor', + task: 'Project experience modification factor; send to Account Executive', + daysAfterRenewal: 210, + windowBeforeDays: 15, + windowAfterDays: 15, + folderPath: ['SUBMISSION', 'PRERENEWAL'], + docTypes: ['EMAIL', 'PDF'], + keywords: ['MOD'], + }, + { + key: 'request_120_day_loss_runs', + task: 'Request 120 day loss runs', + daysAfterRenewal: 240, + windowBeforeDays: 7, + windowAfterDays: 7, + folderPath: ['SUBMISSION'], + docTypes: ['PDF', 'EXCEL DOC'], + keywords: [], + }, + { + key: 'captive_claims_worksheet', + task: 'Assist with captive claims worksheet (if applicable)', + condition: 'Conditional — only applies if client participates in a captive program', + daysAfterRenewal: 245, + windowBeforeDays: 21, + windowAfterDays: 21, + folderPath: ['SUBMISSION', 'PRERENEWAL'], + docTypes: ['EMAIL', 'EXCEL'], + keywords: ['CAPTIVE'], + }, + { + key: 'loss_summary_pre_renewal', + task: 'Prepare loss summary/analysis for internal pre-renewal meeting', + daysAfterRenewal: 250, + windowBeforeDays: 14, + windowAfterDays: 14, + folderPath: ['SUBMISSION', 'PRERENEWAL'], + docTypes: ['EMAIL', 'EXCEL'], + keywords: ['LOSS SUMMARY', 'LOSS ANALYSIS', '5 YEAR'], + }, + { + key: 'request_90_day_loss_runs', + task: 'Request 90 day loss runs', + daysAfterRenewal: 276, + windowBeforeDays: 0, + windowAfterDays: 4, + folderPath: ['SUBMISSION'], + docTypes: ['PDF', 'EXCEL DOC'], + keywords: [], + windowNote: + 'Source spec gives an asymmetric "+4 DAYS" window (unlike the +/- windows used elsewhere). Taken literally as target_date through target_date+4.', + }, + { + key: 'claim_review_pre_renewal_meeting', + task: 'Claim Review', + condition: 'Can be included with client pre-renewal meeting if attending', + daysAfterRenewal: 275, + windowBeforeDays: 30, + windowAfterDays: 30, + folderPath: ['SUBMISSION', 'PRERENEWAL'], + docTypes: ['EXCEL DOC'], + keywords: ['OPEN CLAIM SUMMARY', 'QUARTERLY CLAIM SUMMARY', 'CLAIM REVIEW'], + windowNote: 'Source cell literally reads "=/-30 DAYS" — treated as a typo for "+/- 30 DAYS".', + }, +] diff --git a/ondeck/src/lib/sync/afw-queries.ts b/ondeck/src/lib/sync/afw-queries.ts index c78c1d6..4677b7f 100644 --- a/ondeck/src/lib/sync/afw-queries.ts +++ b/ondeck/src/lib/sync/afw-queries.ts @@ -5,6 +5,7 @@ import { executeAfwQuery } from './afw-connection' */ export interface AfwCustomer { CustId: string + CustNo: number | null FirmNameCust: string | null LastName: string | null FirstName: string | null @@ -156,6 +157,7 @@ export async function fetchAfwCustomers( let query = ` SELECT CustId, + CustNo, FirmNameCust, LastName, FirstName, diff --git a/ondeck/src/lib/sync/mappers.ts b/ondeck/src/lib/sync/mappers.ts index 882b424..4903974 100644 --- a/ondeck/src/lib/sync/mappers.ts +++ b/ondeck/src/lib/sync/mappers.ts @@ -18,6 +18,7 @@ export function mapAfwCustomerToClient( return { amsCustomerId: afwCustomer.CustId, + amsCustomerNumber: afwCustomer.CustNo, name: customerName, addressLine1: afwCustomer.Addr1, addressLine2: afwCustomer.Addr2,