feat(imageright): Task Audit feature for SHAPE clients

- Add TaskAudit/TaskAuditStatus Prisma models linked to Client/Task/User.
- Add ImageRightClient (auth, findFilesByFileNumber, findDocuments,
  getSortedFolders) per Vertafore REST v1 API.
- Add audit engine that resolves per-item target dates/windows from the
  SHAPE_IR_Filing_Audit_Spec.md checklist, navigates real ImageRight folder
  structure, and links results to existing Tasks where found.
- Add GET/POST /api/clients/[id]/task-audit (SHAPE/SHAPE2-gated) and a
  Document Audit tab in the client detail page.
- Add Client.amsCustomerNumber (AFW_Customer.CustNo) — ImageRight's
  FileNumberPart1 expects this AMS360 account number, not the amsCustomerId
  GUID. Backfilled via one-off /api/admin/backfill-ams-customer-number.
- Fix folder/document traversal against verified live ImageRight data:
  real folders are nested under a per-year "Policy Term" folder (not flat
  SUBMISSION/PRERENEWAL as in the source spec doc), and document listing
  requires POST /api/documents/find with {FileId, ParentId} — the
  previously assumed /api/containers/{id} endpoint 404s on real ids.
- Add jest coverage for client auth/find calls and folder/doc-type matching.

Known open items (need process-owner input, not resolved here):
- Real IR document types (Correspondences, Pre-Renewal Information, Loss
  Run) don't map 1:1 to the spec's generic labels (EMAIL, EXCEL DOC, PDF).
- Spec's second folder_path segment (e.g. PRERENEWAL) has no matching real
  subfolder for at least one tested client; currently ignored for folder
  navigation and only surfaced in the audit result label.
- Whether an audit item should be skipped when no open Task exists for it,
  rather than always running and reporting INCOMPLETE.
This commit is contained in:
lorentz 2026-07-08 10:39:12 +00:00
parent 835267c333
commit fad9e18662
14 changed files with 1369 additions and 1 deletions

View file

@ -41,6 +41,7 @@ model User {
advocateClients Client[] @relation("ClientAdvocate") advocateClients Client[] @relation("ClientAdvocate")
clientMemberships ClientMember[] clientMemberships ClientMember[]
shapeImportRuns ShapeImportRun[] shapeImportRuns ShapeImportRun[]
taskAudits TaskAudit[]
@@map("users") @@map("users")
} }
@ -109,6 +110,9 @@ model Designation {
model Client { model Client {
id String @id @default(cuid()) id String @id @default(cuid())
amsCustomerId String @unique @map("ams_customer_id") 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 name String
addressLine1 String? @map("address_line1") addressLine1 String? @map("address_line1")
addressLine2 String? @map("address_line2") addressLine2 String? @map("address_line2")
@ -142,6 +146,7 @@ model Client {
policyGroups PolicyGroup[] policyGroups PolicyGroup[]
members ClientMember[] members ClientMember[]
contacts ClientContact[] contacts ClientContact[]
taskAudits TaskAudit[]
@@index([name]) @@index([name])
@@index([designationId]) @@index([designationId])
@ -286,6 +291,12 @@ enum DepartmentType {
OTHER OTHER
} }
enum TaskAuditStatus {
COMPLETE
INCOMPLETE
NOT_APPLICABLE
}
model TaskTemplate { model TaskTemplate {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@ -347,6 +358,7 @@ model Task {
completer User? @relation("TaskCompletedBy", fields: [completedBy], references: [id]) completer User? @relation("TaskCompletedBy", fields: [completedBy], references: [id])
assignments TaskAssignment[] assignments TaskAssignment[]
taskNotes TaskNote[] taskNotes TaskNote[]
taskAudits TaskAudit[]
@@index([clientId]) @@index([clientId])
@@index([policyId]) @@index([policyId])
@ -370,6 +382,36 @@ model TaskAssignment {
@@map("task_assignments") @@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 { model TaskNote {
id String @id @default(cuid()) id String @id @default(cuid())
taskId String @map("task_id") taskId String @map("task_id")

View file

@ -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))

View file

@ -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 })
}
}

View file

@ -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<boolean> {
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<string, (typeof audits)[number]>()
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 })
}
}

View file

@ -30,14 +30,17 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' 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 { Combobox, type ComboboxGroup } from '@/components/ui/combobox'
import { formatDate, formatRenewalDate, daysUntil } from '@/lib/utils' import { formatDate, formatRenewalDate, daysUntil } from '@/lib/utils'
import { PolicyGroupManager } from '@/components/clients/policy-group-manager' import { PolicyGroupManager } from '@/components/clients/policy-group-manager'
import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal' import { AdditionalServiceModal } from '@/components/tasks/additional-service-modal'
import { TaskCard } from '@/components/tasks/task-card' import { TaskCard } from '@/components/tasks/task-card'
import { TaskAuditPanel } from '@/components/clients/task-audit-panel'
import { toast } from 'sonner' import { toast } from 'sonner'
const SHAPE_DESIGNATION_NAMES = ['shape', 'shape 2']
interface SimpleUser { interface SimpleUser {
id: string id: string
displayName: string | null displayName: string | null
@ -56,6 +59,9 @@ interface ClientDetailProps {
} }
export function ClientDetail({ client, designations, policyGroups = [], allPolicies, canManageGroups = false, canManageSetup = false, setupCompletedAt }: 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 [selectedDesignation, setSelectedDesignation] = useState(client.designationId || '')
const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '') const [selectedDesignation2, setSelectedDesignation2] = useState(client.designation2Id || '')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -500,6 +506,12 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
<UserCheck className="h-4 w-4 mr-2" /> <UserCheck className="h-4 w-4 mr-2" />
Assignment Assignment
</TabsTrigger> </TabsTrigger>
{isShapeClient && (
<TabsTrigger value="document-audit">
<FileSearch className="h-4 w-4 mr-2" />
Document Audit
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="policies" className="space-y-4"> <TabsContent value="policies" className="space-y-4">
@ -1064,6 +1076,12 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
{isShapeClient && (
<TabsContent value="document-audit" className="space-y-4">
<TaskAuditPanel clientId={client.id} />
</TabsContent>
)}
</Tabs> </Tabs>
{/* Advocate reassignment dialog */} {/* Advocate reassignment dialog */}

View file

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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<TaskAuditItem[]>([])
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState(false)
const [fileNotFound, setFileNotFound] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<FileSearch className="h-4 w-4" />
ImageRight Document Audit
</CardTitle>
<Button size="sm" onClick={runAudit} disabled={running} className="gap-1.5">
<RefreshCw className={`h-3.5 w-3.5 ${running ? 'animate-spin' : ''}`} />
{running ? 'Running…' : 'Run Audit'}
</Button>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{loading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : fileNotFound ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
No ImageRight file found for this client's account number.
</div>
) : items.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
No audit has been run yet. Click "Run Audit" to check ImageRight against the SHAPE checklist.
</div>
) : (
<div className="space-y-2">
{items.map((item) => (
<div
key={item.specItemKey}
className="flex flex-col gap-1 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{item.task || SPEC_TITLES[item.specItemKey] || item.specItemKey}</p>
<p className="text-xs text-muted-foreground">
Target date {formatDate(item.targetDate)} · Checked {item.folderChecked || '—'}
</p>
{item.matchedDocName && (
<p className="text-xs text-muted-foreground">
Matched: {item.matchedDocName}
{item.matchedDocDate ? ` (${formatDate(item.matchedDocDate)})` : ''}
{item.matchedDocId ? ` · IR doc ${item.matchedDocId}` : ''}
</p>
)}
{item.errorMessage && item.status !== 'COMPLETE' && (
<p className="text-xs text-muted-foreground italic">{item.errorMessage}</p>
)}
</div>
<Badge className={STATUS_STYLES[item.status]}>{STATUS_LABELS[item.status]}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -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)
})
})

View file

@ -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<ReturnType<typeof fakeResponse>>) {
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<string, string> = {}
mockFetch(async (_url, init) => {
capturedHeaders = init?.headers as Record<string, string>
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<string, string>)?.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 }])
})
})

View file

@ -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<string, unknown>
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<string | undefined> {
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<Date | null> {
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<TaskAuditRunResult> {
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 }

View file

@ -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)
})
}

View file

@ -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 <token>` 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<T = unknown>(obj: unknown): T {
if (obj && typeof obj === 'object' && '$values' in (obj as Record<string, unknown>)) {
return (obj as Record<string, unknown>)['$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<string, string> {
return { 'Tenant-ID': this.tenantId }
}
async authenticate(): Promise<void> {
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<string, string> {
if (!this.token) {
throw new Error('ImageRightClient: call authenticate() before making requests')
}
return {
...this.baseHeaders(),
Authorization: `AccessToken ${this.token}`,
Accept: accept,
}
}
private async getJson<T>(path: string): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
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<ImageRightFile[]> {
const result = await this.postJson<unknown>('/api/files/find', { FileNumberPart1: fileNumberPart1 })
return unwrapValues<ImageRightFile[]>(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<ImageRightDocument[]> {
const body: Record<string, unknown> = { FileId: Number(fileId) }
if (parentId !== undefined) body.ParentId = Number(parentId)
const result = await this.postJson<unknown>('/api/documents/find', body)
return unwrapValues<ImageRightDocument[]>(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<unknown[]> {
const result = await this.getJson<unknown>(`/api/containers/${containerId}`)
return unwrapValues<unknown[]>(result) ?? []
}
async getSortedFolders(fileId: number | string): Promise<unknown[]> {
const result = await this.getJson<unknown>(`/api/containers/${fileId}/sortedfolders`)
return unwrapValues<unknown[]>(result) ?? []
}
async getDocument(documentId: number | string): Promise<ImageRightDocument> {
return this.getJson<ImageRightDocument>(`/api/documents/${documentId}`)
}
async getDocumentPages(documentId: number | string): Promise<unknown[]> {
const result = await this.getJson<unknown>(`/api/documents/${documentId}/pages`)
return unwrapValues<unknown[]>(result) ?? []
}
}

View file

@ -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".',
},
]

View file

@ -5,6 +5,7 @@ import { executeAfwQuery } from './afw-connection'
*/ */
export interface AfwCustomer { export interface AfwCustomer {
CustId: string CustId: string
CustNo: number | null
FirmNameCust: string | null FirmNameCust: string | null
LastName: string | null LastName: string | null
FirstName: string | null FirstName: string | null
@ -156,6 +157,7 @@ export async function fetchAfwCustomers(
let query = ` let query = `
SELECT SELECT
CustId, CustId,
CustNo,
FirmNameCust, FirmNameCust,
LastName, LastName,
FirstName, FirstName,

View file

@ -18,6 +18,7 @@ export function mapAfwCustomerToClient(
return { return {
amsCustomerId: afwCustomer.CustId, amsCustomerId: afwCustomer.CustId,
amsCustomerNumber: afwCustomer.CustNo,
name: customerName, name: customerName,
addressLine1: afwCustomer.Addr1, addressLine1: afwCustomer.Addr1,
addressLine2: afwCustomer.Addr2, addressLine2: afwCustomer.Addr2,