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:
parent
835267c333
commit
fad9e18662
14 changed files with 1369 additions and 1 deletions
|
|
@ -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")
|
||||
|
|
|
|||
55
ondeck/scripts/backfill-ams-customer-number.ts
Normal file
55
ondeck/scripts/backfill-ams-customer-number.ts
Normal 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))
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
78
ondeck/src/app/api/clients/[id]/task-audit/route.ts
Normal file
78
ondeck/src/app/api/clients/[id]/task-audit/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
<UserCheck className="h-4 w-4 mr-2" />
|
||||
Assignment
|
||||
</TabsTrigger>
|
||||
{isShapeClient && (
|
||||
<TabsTrigger value="document-audit">
|
||||
<FileSearch className="h-4 w-4 mr-2" />
|
||||
Document Audit
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="policies" className="space-y-4">
|
||||
|
|
@ -1064,6 +1076,12 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{isShapeClient && (
|
||||
<TabsContent value="document-audit" className="space-y-4">
|
||||
<TaskAuditPanel clientId={client.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Advocate reassignment dialog */}
|
||||
|
|
|
|||
162
ondeck/src/components/clients/task-audit-panel.tsx
Normal file
162
ondeck/src/components/clients/task-audit-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
ondeck/src/lib/imageright/__tests__/audit-engine.test.ts
Normal file
121
ondeck/src/lib/imageright/__tests__/audit-engine.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
174
ondeck/src/lib/imageright/__tests__/client.test.ts
Normal file
174
ondeck/src/lib/imageright/__tests__/client.test.ts
Normal 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 }])
|
||||
})
|
||||
})
|
||||
275
ondeck/src/lib/imageright/audit-engine.ts
Normal file
275
ondeck/src/lib/imageright/audit-engine.ts
Normal 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 }
|
||||
73
ondeck/src/lib/imageright/audit-matching.ts
Normal file
73
ondeck/src/lib/imageright/audit-matching.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
167
ondeck/src/lib/imageright/client.ts
Normal file
167
ondeck/src/lib/imageright/client.ts
Normal 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) ?? []
|
||||
}
|
||||
}
|
||||
134
ondeck/src/lib/imageright/shape-audit-spec.ts
Normal file
134
ondeck/src/lib/imageright/shape-audit-spec.ts
Normal 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".',
|
||||
},
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export function mapAfwCustomerToClient(
|
|||
|
||||
return {
|
||||
amsCustomerId: afwCustomer.CustId,
|
||||
amsCustomerNumber: afwCustomer.CustNo,
|
||||
name: customerName,
|
||||
addressLine1: afwCustomer.Addr1,
|
||||
addressLine2: afwCustomer.Addr2,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue