feat: SHAPE historical import admin UI

- New DB tables: shape_import_runs, shape_import_logs (raw SQL applied)
- src/lib/shape-import/run-import.ts: importable core logic extracted from CLI script
- POST /api/admin/shape-import: starts run async, returns runId immediately
- GET /api/admin/shape-import: lists last 20 runs
- GET /api/admin/shape-import/[id]: polls log lines and run status
- /admin/shape-import page: drive ID config, dry run / execute (with confirmation),
  live log panel (2s poll), stats summary, run history with log replay
- Admin index: added SHAPE Historical Import card
This commit is contained in:
lorentz 2026-04-08 11:19:37 +00:00
parent 25d1aa493c
commit ca2c9c701d
7 changed files with 1369 additions and 1 deletions

View file

@ -40,6 +40,7 @@ model User {
createdPolicyGroups PolicyGroup[]
advocateClients Client[] @relation("ClientAdvocate")
clientMemberships ClientMember[]
shapeImportRuns ShapeImportRun[]
@@map("users")
}
@ -446,6 +447,36 @@ model Notification {
@@map("notifications")
}
model ShapeImportRun {
id String @id @default(cuid())
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
status String // pending | running | completed | failed
dryRun Boolean @default(true) @map("dry_run")
driveId String @map("drive_id")
triggeredBy String? @map("triggered_by")
stats Json?
user User? @relation(fields: [triggeredBy], references: [id])
logLines ShapeImportLog[]
@@index([startedAt])
@@map("shape_import_runs")
}
model ShapeImportLog {
id String @id @default(cuid())
runId String @map("run_id")
seq Int
line String @db.Text
createdAt DateTime @default(now()) @map("created_at")
run ShapeImportRun @relation(fields: [runId], references: [id], onDelete: Cascade)
@@index([runId, seq])
@@map("shape_import_logs")
}
model NotificationPreference {
id String @id @default(cuid())
userId String @map("user_id")

View file

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Shapes, FileText, Users, Database, Settings, ClipboardList } from 'lucide-react'
import { Shapes, FileText, Users, Database, Settings, ClipboardList, History } from 'lucide-react'
export default async function AdminPage() {
const session = await getServerSession(authOptions)
@ -67,6 +67,14 @@ export default async function AdminPage() {
color: 'text-red-600',
bgColor: 'bg-red-100',
},
{
title: 'SHAPE Historical Import',
description: 'Import historical task completion data from SharePoint SHAPE Excel files',
icon: History,
href: '/admin/shape-import',
color: 'text-teal-600',
bgColor: 'bg-teal-100',
},
]
return (

View file

@ -0,0 +1,46 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
import { ShapeImportPanel } from '@/components/admin/shape-import-panel'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
export default async function ShapeImportPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/auth/signin')
const roles = (session.user as any)?.roles || []
if (!roles.includes('Admin')) redirect('/dashboard')
const runs = await prisma.shapeImportRun.findMany({
orderBy: { startedAt: 'desc' },
take: 20,
include: { user: { select: { displayName: true, email: true } } },
})
const serialized = runs.map((r) => ({
id: r.id,
startedAt: r.startedAt.toISOString(),
completedAt: r.completedAt?.toISOString() ?? null,
status: r.status,
dryRun: r.dryRun,
driveId: r.driveId,
stats: r.stats as Record<string, any> | null,
user: r.user ?? null,
}))
return (
<div className="container mx-auto py-8 max-w-4xl">
<div className="mb-6">
<Link href="/admin" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4">
<ArrowLeft className="h-4 w-4" /> Back to Admin
</Link>
<h1 className="text-3xl font-bold">SHAPE Historical Import</h1>
<p className="text-muted-foreground mt-2">
Import historical task completion data from SharePoint SHAPE Excel files into Horizon.
</p>
</div>
<ShapeImportPanel initialRuns={serialized} />
</div>
)
}

View file

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const roles = (session.user as any)?.roles || []
if (!roles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { id } = await params
const afterParam = req.nextUrl.searchParams.get('after')
const after = afterParam !== null ? parseInt(afterParam) : -1
const run = await prisma.shapeImportRun.findUnique({
where: { id },
select: { id: true, status: true, dryRun: true, startedAt: true, completedAt: true, stats: true },
})
if (!run) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const logs = await prisma.shapeImportLog.findMany({
where: { runId: id, seq: { gt: after } },
orderBy: { seq: 'asc' },
take: 500,
select: { seq: true, line: true },
})
return NextResponse.json({ run, logs })
}

View file

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { runShapeImport } from '@/lib/shape-import/run-import'
import { randomBytes } from 'crypto'
const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
function cuid() {
return 'c' + randomBytes(11).toString('base64url').slice(0, 24)
}
export async function GET() {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const roles = (session.user as any)?.roles || []
if (!roles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const runs = await prisma.shapeImportRun.findMany({
orderBy: { startedAt: 'desc' },
take: 20,
include: { user: { select: { displayName: true, email: true } } },
})
return NextResponse.json({ runs })
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const roles = (session.user as any)?.roles || []
if (!roles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const dryRun: boolean = body.dryRun !== false
const driveId: string = body.driveId || DEFAULT_DRIVE_ID
const userId = (session.user as any).id as string
const run = await prisma.shapeImportRun.create({
data: { id: cuid(), status: 'running', dryRun, driveId, triggeredBy: userId },
})
let seq = 0
const onLog = async (line: string) => {
await prisma.shapeImportLog.create({
data: { id: cuid(), runId: run.id, seq: seq++, line },
})
}
// Run async — don't await so the response returns immediately
;(async () => {
try {
const stats = await runShapeImport({ dryRun, driveId, onLog })
await prisma.shapeImportRun.update({
where: { id: run.id },
data: { status: 'completed', completedAt: new Date(), stats: stats as any },
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
await onLog(`FATAL ERROR: ${msg}`)
await prisma.shapeImportRun.update({
where: { id: run.id },
data: { status: 'failed', completedAt: new Date() },
})
}
})()
return NextResponse.json({ runId: run.id })
}

View file

@ -0,0 +1,409 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
PlayCircle,
Loader2,
CheckCircle2,
XCircle,
Clock,
FileText,
ChevronDown,
ChevronUp,
AlertTriangle,
} from 'lucide-react'
const DEFAULT_DRIVE_ID = 'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
interface Run {
id: string
startedAt: string
completedAt: string | null
status: string
dryRun: boolean
driveId: string
stats: Record<string, any> | null
user?: { displayName: string | null; email: string } | null
}
interface LogLine {
seq: number
line: string
}
function StatusBadge({ status }: { status: string }) {
if (status === 'completed') return <Badge className="bg-green-600">Completed</Badge>
if (status === 'failed') return <Badge variant="destructive">Failed</Badge>
if (status === 'running') return <Badge className="bg-blue-600 animate-pulse">Running</Badge>
return <Badge variant="secondary">Pending</Badge>
}
function StatLine({ label, value }: { label: string; value: number | string }) {
return (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
export function ShapeImportPanel({ initialRuns }: { initialRuns: Run[] }) {
const [runs, setRuns] = useState<Run[]>(initialRuns)
const [driveId, setDriveId] = useState(DEFAULT_DRIVE_ID)
const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [activeRunStatus, setActiveRunStatus] = useState<string>('idle')
const [logLines, setLogLines] = useState<LogLine[]>([])
const [lastSeq, setLastSeq] = useState(-1)
const [viewingRunId, setViewingRunId] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [starting, setStarting] = useState(false)
const logRef = useRef<HTMLPreElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const scrollToBottom = useCallback(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [])
useEffect(() => {
scrollToBottom()
}, [logLines, scrollToBottom])
const stopPolling = useCallback(() => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
}, [])
const pollRun = useCallback(async (runId: string, currentSeq: number) => {
try {
const res = await fetch(`/api/admin/shape-import/${runId}?after=${currentSeq}`)
if (!res.ok) return
const data = await res.json()
if (data.logs && data.logs.length > 0) {
setLogLines((prev) => {
const existing = new Set(prev.map((l) => l.seq))
const newLines = (data.logs as LogLine[]).filter((l) => !existing.has(l.seq))
return [...prev, ...newLines]
})
const maxSeq = Math.max(...(data.logs as LogLine[]).map((l) => l.seq))
setLastSeq(maxSeq)
currentSeq = maxSeq
}
if (data.run?.status === 'completed' || data.run?.status === 'failed') {
setActiveRunStatus(data.run.status)
stopPolling()
setRuns((prev) =>
prev.map((r) => (r.id === runId ? { ...r, status: data.run.status, completedAt: data.run.completedAt, stats: data.run.stats } : r))
)
}
} catch {
// transient — keep polling
}
}, [stopPolling])
const startPolling = useCallback((runId: string) => {
stopPolling()
let seq = -1
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/admin/shape-import/${runId}?after=${seq}`)
if (!res.ok) return
const data = await res.json()
if (data.logs && data.logs.length > 0) {
setLogLines((prev) => {
const existing = new Set(prev.map((l) => l.seq))
const newLines = (data.logs as LogLine[]).filter((l) => !existing.has(l.seq))
return [...prev, ...newLines]
})
seq = Math.max(...(data.logs as LogLine[]).map((l) => l.seq))
setLastSeq(seq)
}
if (data.run?.status === 'completed' || data.run?.status === 'failed') {
setActiveRunStatus(data.run.status)
stopPolling()
setRuns((prev) =>
prev.map((r) => (r.id === runId ? { ...r, status: data.run.status, completedAt: data.run.completedAt, stats: data.run.stats } : r))
)
if (data.run.status === 'completed') toast.success('Import completed')
else toast.error('Import failed — check the log for details')
}
} catch { /* keep polling */ }
}, 2000)
}, [stopPolling])
useEffect(() => () => stopPolling(), [stopPolling])
const startRun = async (dryRun: boolean) => {
setStarting(true)
setLogLines([])
setLastSeq(-1)
try {
const res = await fetch('/api/admin/shape-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dryRun, driveId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to start')
const newRun: Run = { id: data.runId, startedAt: new Date().toISOString(), completedAt: null, status: 'running', dryRun, driveId, stats: null }
setRuns((prev) => [newRun, ...prev])
setActiveRunId(data.runId)
setViewingRunId(data.runId)
setActiveRunStatus('running')
startPolling(data.runId)
toast.info(dryRun ? 'Dry run started — results will appear in the log' : 'Import started')
} catch (err: any) {
toast.error(err.message || 'Failed to start import')
} finally {
setStarting(false)
}
}
const loadRunLog = async (runId: string) => {
setViewingRunId(runId)
setLogLines([])
setLastSeq(-1)
try {
const res = await fetch(`/api/admin/shape-import/${runId}?after=-1`)
if (!res.ok) return
const data = await res.json()
setLogLines(data.logs || [])
if (data.logs?.length) setLastSeq(data.logs[data.logs.length - 1].seq)
} catch { /* ignore */ }
}
const isRunning = activeRunStatus === 'running' && activeRunId !== null
return (
<div className="space-y-6">
{/* Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
SHAPE Historical Import
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Import historical task completion data from SHAPE Excel files on SharePoint into Horizon.
Always run a <strong>dry run first</strong> it reads SharePoint and shows what would be
created/updated without writing anything to the database.
</p>
<div className="space-y-1.5">
<Label htmlFor="driveId">SharePoint Drive ID</Label>
<Input
id="driveId"
value={driveId}
onChange={(e) => setDriveId(e.target.value)}
disabled={isRunning}
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Path: <code>Claims/SHAPE Accounts/</code> within this drive. Default is the current production drive.
</p>
</div>
<div className="flex gap-3">
<Button
onClick={() => startRun(true)}
disabled={isRunning || starting || !driveId}
variant="outline"
className="gap-2"
>
{isRunning && viewingRunId === activeRunId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PlayCircle className="h-4 w-4" />
)}
Run Dry Run
</Button>
<Button
onClick={() => setConfirmOpen(true)}
disabled={isRunning || starting || !driveId}
variant="destructive"
className="gap-2"
>
<AlertTriangle className="h-4 w-4" />
Execute Import
</Button>
</div>
{isRunning && (
<div className="flex items-center gap-2 text-sm text-blue-600">
<Loader2 className="h-4 w-4 animate-spin" />
Import running log updating live below
</div>
)}
</CardContent>
</Card>
{/* Log viewer */}
{viewingRunId && (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Import Log
{isRunning && viewingRunId === activeRunId && (
<Badge className="bg-blue-600 animate-pulse text-xs">Live</Badge>
)}
</span>
{(() => {
const run = runs.find((r) => r.id === viewingRunId)
return run ? <StatusBadge status={run.status} /> : null
})()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats summary (completed runs) */}
{(() => {
const run = runs.find((r) => r.id === viewingRunId)
if (!run?.stats || run.status === 'running') return null
const s = run.stats
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 bg-muted/40 rounded-lg">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Clients</p>
<StatLine label="Matched" value={s.clientsMatched ?? 0} />
<StatLine label="Unmatched" value={s.clientsUnmatched ?? 0} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Tasks</p>
<StatLine label="Created" value={s.tasksCreated ?? 0} />
<StatLine label="Updated" value={s.tasksUpdated ?? 0} />
<StatLine label="Assigned" value={s.tasksAssigned ?? 0} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Other</p>
<StatLine label="Ad-hoc" value={s.adHocCreated ?? 0} />
<StatLine label="Duplicates deleted" value={s.duplicatesDeleted ?? 0} />
<StatLine label="Files" value={s.filesProcessed ?? 0} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Issues</p>
<StatLine label="Errors" value={(s.errors as any[])?.length ?? 0} />
<StatLine label="Fuzzy matches" value={(s.fuzzyMatches as any[])?.length ?? 0} />
</div>
</div>
)
})()}
{/* Log */}
<pre
ref={logRef}
className="bg-zinc-950 text-zinc-100 text-xs font-mono rounded-lg p-4 overflow-auto max-h-[500px] whitespace-pre-wrap leading-relaxed"
>
{logLines.length === 0
? '(waiting for output...)'
: logLines.map((l) => l.line).join('\n')}
</pre>
</CardContent>
</Card>
)}
{/* Run history */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Run History
</CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No runs yet</p>
) : (
<div className="space-y-2">
{runs.map((run) => (
<button
key={run.id}
onClick={() => loadRunLog(run.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors hover:bg-accent ${viewingRunId === run.id ? 'bg-accent border-primary' : ''}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
{run.status === 'completed' ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : run.status === 'failed' ? (
<XCircle className="h-4 w-4 text-red-600 shrink-0" />
) : (
<Loader2 className="h-4 w-4 text-blue-600 animate-spin shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{run.dryRun ? 'Dry Run' : 'Execute'} {new Date(run.startedAt).toLocaleString()}
</p>
{run.user && (
<p className="text-xs text-muted-foreground">by {run.user.displayName || run.user.email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={run.status} />
{run.stats && (
<span className="text-xs text-muted-foreground tabular-nums">
{(run.stats as any).tasksCreated ?? 0} created · {(run.stats as any).errors?.length ?? 0} errors
</span>
)}
</div>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Execute confirmation dialog */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Execute SHAPE Import
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p>This will <strong>write data to the database</strong>:</p>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Create or update tasks for matched SHAPE clients</li>
<li>Mark historical tasks as Completed or N/A</li>
<li>Assign claims advocates to clients (if not already set)</li>
<li>Delete duplicate tasks</li>
</ul>
<p className="text-muted-foreground">
Run a <strong>dry run first</strong> and review the log if you haven't already.
This action cannot be automatically undone.
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => { setConfirmOpen(false); startRun(false) }}
>
Yes, Execute Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,770 @@
/**
* SHAPE Historical Import core logic, importable by both CLI and API.
*
* Call runShapeImport({ dryRun, driveId, onLog }) to execute.
*/
import * as zlib from 'zlib'
import { prisma } from '@/lib/db'
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ImportStats {
filesProcessed: number
clientsMatched: number
clientsUnmatched: number
tasksUpdated: number
tasksAssigned: number
tasksCreated: number
duplicatesDeleted: number
adHocCreated: number
advocatesAssigned: number
errors: string[]
unmatchedClients: Array<{ folder: string; file: string; excelName: string }>
fuzzyMatches: Array<{
folder: string
file: string
excelName: string
dbName: string
method: string
}>
}
interface ParsedRow {
taskName: string
daysAfterRenewal: number
dateCompleted: string
notes: string
}
interface ParsedBlock {
clientName: string
effectiveDate: Date
isShape2: boolean
rows: ParsedRow[]
additionalServices: string[]
}
interface DbTemplate {
id: string
name: string
daysOffset: number
designationId: string | null
}
interface DbClient {
id: string
name: string
claimsAdvocateId: string | null
}
interface DbState {
clientsByNormalizedName: Map<string, DbClient[]>
allClients: DbClient[]
shapeTemplates: DbTemplate[]
shape2Templates: DbTemplate[]
shapeDesignationId: string
shape2DesignationId: string
}
interface DriveItem {
id: string
name: string
file?: { mimeType: string }
folder?: { childCount: number }
}
// ─── Team member map ──────────────────────────────────────────────────────────
export const TEAM_MEMBER_MAP: Record<string, { userId: string; displayName: string }> = {
CHRIS: { userId: 'cmkm36yc90021y7vb9783nitl', displayName: 'Christine Gove' },
DAWN: { userId: 'cmkm36xfr000ny7vbcajejjux', displayName: 'Dawn Boland' },
Jeanne: { userId: 'cmkm370f80065y7vbrugc9jub', displayName: 'Jeanne Strong' },
LUKE: { userId: 'cmkm36xd4000jy7vbhanp66s9', displayName: 'Luke Billman' },
MIMI: { userId: 'cmkm36zr0004wy7vbztyynj35', displayName: 'Mimi Rawlings' },
}
const TEMPLATE_NAME_ALIASES: Record<string, string> = {
'request 125 day loss runs': 'request 120 day loss runs',
'request 89 day loss runs': 'request 90 day loss runs',
'request 89 day loss runs (if being marketed)': 'request 90 day loss runs (if being marketed)',
'claim review (six month)': 'claim review',
'claims review': 'claim review',
}
// ─── Graph API ────────────────────────────────────────────────────────────────
let _cachedToken: { token: string; expiresAt: number } | null = null
async function getGraphToken(): Promise<string> {
if (_cachedToken && Date.now() < _cachedToken.expiresAt - 30_000) return _cachedToken.token
const tenantId = process.env.AZURE_AD_TENANT_ID!
const clientId = process.env.AZURE_AD_CLIENT_ID!
const clientSecret = process.env.AZURE_AD_CLIENT_SECRET!
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: 'https://graph.microsoft.com/.default',
})
const res = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`)
const data = (await res.json()) as { access_token: string; expires_in: number }
_cachedToken = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 }
return _cachedToken.token
}
async function graphGet(url: string): Promise<unknown> {
const token = await getGraphToken()
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) {
const text = await res.text()
throw new Error(`Graph API error ${res.status}: ${text.slice(0, 200)}`)
}
return res.json()
}
async function downloadFile(driveId: string, itemId: string): Promise<Buffer> {
const token = await getGraphToken()
const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' })
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
const ab = await res.arrayBuffer()
return Buffer.from(ab)
}
async function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}
async function listChildren(driveId: string, itemId: string): Promise<DriveItem[]> {
const data = (await graphGet(
`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/children?$top=200`
)) as { value: DriveItem[] }
return data.value ?? []
}
async function discoverFiles(
driveId: string,
log: (line: string) => Promise<void>
): Promise<Array<{ folder: string; itemId: string; fileName: string }>> {
await log('Discovering files from SharePoint...')
const files: Array<{ folder: string; itemId: string; fileName: string }> = []
const rootData = (await graphGet(
`https://graph.microsoft.com/v1.0/drives/${driveId}/root:/Claims%2FSHAPE%20Accounts:/children`
)) as { value: DriveItem[] }
const rootChildren = rootData.value ?? []
for (const item of rootChildren) {
if (!item.folder) continue
const memberInfo = TEAM_MEMBER_MAP[item.name]
if (!memberInfo) continue
await log(` Scanning folder: ${item.name}`)
const children = await listChildren(driveId, item.id)
await sleep(150)
for (const child of children) {
if (!child.file) continue
if (!child.name.toLowerCase().endsWith('.xlsx')) continue
if (child.name.toLowerCase().startsWith('archive')) continue
if (
child.name.toLowerCase().includes('template') ||
child.name.toLowerCase().includes('master workbook') ||
child.name.toLowerCase().startsWith('all shape')
) continue
files.push({ folder: item.name, itemId: child.id, fileName: child.name })
}
}
await log(` Found ${files.length} Excel files`)
return files
}
// ─── ZIP / XLSX Parser ────────────────────────────────────────────────────────
function parseZip(buffer: Buffer): Map<string, Buffer> {
const entries = new Map<string, Buffer>()
let eocdOffset = -1
const searchStart = Math.max(0, buffer.length - 65558)
for (let i = buffer.length - 22; i >= searchStart; i--) {
if (buffer.readUInt32LE(i) === 0x06054b50) { eocdOffset = i; break }
}
if (eocdOffset < 0) throw new Error('Not a valid ZIP file (EOCD not found)')
const cdOffset = buffer.readUInt32LE(eocdOffset + 16)
const cdEntries = buffer.readUInt16LE(eocdOffset + 10)
let pos = cdOffset
for (let i = 0; i < cdEntries; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error(`Invalid central directory entry at offset ${pos}`)
const method = buffer.readUInt16LE(pos + 10)
const compSize = buffer.readUInt32LE(pos + 20)
const fileNameLen = buffer.readUInt16LE(pos + 28)
const extraLen = buffer.readUInt16LE(pos + 30)
const commentLen = buffer.readUInt16LE(pos + 32)
const localOffset = buffer.readUInt32LE(pos + 42)
const fileName = buffer.slice(pos + 46, pos + 46 + fileNameLen).toString('utf-8')
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error(`Invalid local header for ${fileName}`)
const localFileNameLen = buffer.readUInt16LE(localOffset + 26)
const localExtraLen = buffer.readUInt16LE(localOffset + 28)
const dataStart = localOffset + 30 + localFileNameLen + localExtraLen
const compData = buffer.slice(dataStart, dataStart + compSize)
let data: Buffer
if (method === 0) data = compData
else if (method === 8) data = zlib.inflateRawSync(compData)
else data = Buffer.alloc(0)
entries.set(fileName.toLowerCase(), data)
pos += 46 + fileNameLen + extraLen + commentLen
}
return entries
}
function decodeXmlEntities(s: string): string {
return s
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&apos;/g, "'")
.replace(/&#x([0-9A-Fa-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(parseInt(d, 10)))
}
function parseSharedStrings(xmlBuf: Buffer | undefined): string[] {
if (!xmlBuf || xmlBuf.length === 0) return []
const xml = xmlBuf.toString('utf-8')
const strings: string[] = []
const siRegex = /<si>([\s\S]*?)<\/si>/g
let match: RegExpExecArray | null
while ((match = siRegex.exec(xml)) !== null) {
const texts: string[] = []
const tRegex = /<t(?:\s[^>]*)?>([^<]*)<\/t>/g
let tm: RegExpExecArray | null
while ((tm = tRegex.exec(match[1])) !== null) texts.push(decodeXmlEntities(tm[1]))
strings.push(texts.join(''))
}
return strings
}
function colLetterToNum(letters: string): number {
let n = 0
for (let i = 0; i < letters.length; i++) n = n * 26 + (letters.charCodeAt(i) - 64)
return n
}
function parseSheetToGrid(sheetBuf: Buffer, sharedStrings: string[]): { get: (row: number, col: number) => string; maxRow: number } {
const cells = new Map<string, string>()
let maxRow = 0
const xml = sheetBuf.toString('utf-8').replace(/<c\b[^>]*\/>/g, '')
const cRegex = /<c\s+r="([A-Z]+)(\d+)"([^>]*)>([\s\S]*?)<\/c>/g
let m: RegExpExecArray | null
while ((m = cRegex.exec(xml)) !== null) {
const colStr = m[1]; const rowNum = parseInt(m[2]); const attrs = m[3]; const inner = m[4]
if (rowNum > maxRow) maxRow = rowNum
const typeMatch = attrs.match(/\bt="([^"]+)"/); const type = typeMatch ? typeMatch[1] : 'n'
let value = ''
const vMatch = inner.match(/<v>([^<]*)<\/v>/)
const isMatch = inner.match(/<is><t(?:[^>]*)?>([^<]*)<\/t><\/is>/)
if (isMatch) value = decodeXmlEntities(isMatch[1])
else if (vMatch) {
if (type === 's') value = sharedStrings[parseInt(vMatch[1])] ?? ''
else if (type === 'str') value = decodeXmlEntities(vMatch[1])
else if (type === 'b') value = vMatch[1] === '1' ? 'TRUE' : 'FALSE'
else value = vMatch[1]
}
cells.set(`${rowNum}:${colLetterToNum(colStr)}`, value)
}
return { get(row, col) { return cells.get(`${row}:${col}`) ?? '' }, maxRow }
}
function findTargetSheet(zipEntries: Map<string, Buffer>): Buffer | null {
const wbBuf = zipEntries.get('xl/workbook.xml')
if (!wbBuf) return zipEntries.get('xl/worksheets/sheet1.xml') ?? null
const wb = wbBuf.toString('utf-8')
const relsBuf = zipEntries.get('xl/_rels/workbook.xml.rels')
const ridToPath = new Map<string, string>()
if (relsBuf) {
const rels = relsBuf.toString('utf-8')
const relRegex = /<Relationship\s+Id="([^"]+)"[^>]+Target="([^"]+)"/g
let rm: RegExpExecArray | null
while ((rm = relRegex.exec(rels)) !== null) {
let target = rm[2]
if (target.startsWith('/')) target = target.slice(1)
else if (!target.startsWith('xl/')) target = 'xl/' + target
ridToPath.set(rm[1], target)
}
}
const sheetEntries: Array<{ name: string; rid: string }> = []
const sheetTagRegex = /<sheet\b([^>]*\/?>)/g
let sm: RegExpExecArray | null
while ((sm = sheetTagRegex.exec(wb)) !== null) {
const attrs = sm[1]
const nameMatch = attrs.match(/\bname="([^"]+)"/)
const ridMatch = attrs.match(/\br:id="([^"]+)"/)
if (nameMatch && ridMatch) sheetEntries.push({ name: nameMatch[1], rid: ridMatch[1] })
}
const resolveSheet = (rid: string): Buffer | null => {
const path = ridToPath.get(rid); if (!path) return null
return zipEntries.get(path.toLowerCase()) ?? null
}
for (const p of ['shape2', 'shape', 'client']) {
const match = sheetEntries.find((s) => s.name.toLowerCase() === p)
if (match) { const buf = resolveSheet(match.rid); if (buf) return buf }
}
if (sheetEntries.length > 0) { const buf = resolveSheet(sheetEntries[0].rid); if (buf) return buf }
return zipEntries.get('xl/worksheets/sheet1.xml') ?? null
}
function excelSerialToDate(serial: number): Date {
return new Date((serial - 25569) * 86400 * 1000)
}
function parseExcelBlocks(
grid: { get: (r: number, c: number) => string; maxRow: number },
fileName: string
): ParsedBlock[] {
const blocks: ParsedBlock[] = []
const isShape2 = /(SHAPE2|SHAPE 2)/i.test(fileName)
let row = 1
while (row <= grid.maxRow) {
let clientNameCol = -1
for (let c = 1; c <= 5; c++) {
if (grid.get(row, c).trim().toLowerCase().includes('client name')) { clientNameCol = c; break }
}
if (clientNameCol < 0) { row++; continue }
let clientName = ''
for (let dc = 1; dc <= 3; dc++) {
const candidate = grid.get(row, clientNameCol + dc).trim()
if (candidate && !candidate.toLowerCase().includes('client name')) { clientName = candidate; break }
}
if (!clientName) {
clientName = fileName
.replace(/^SHAPE\S*\s*[-]\s*/i, '').replace(/\s*[-]\s*SHAPE.*$/i, '')
.replace(/\s*SHAPE\s*Services?\s*Checklist.*$/i, '').replace(/\s*SHAPE\b.*/i, '')
.replace(/\.xlsx$/i, '').trim()
}
if (!clientName) { row++; continue }
row++
let effectiveDateSerial = 0; let foundDateRow = row
const EXCEL_DATE_MIN = 40000; const EXCEL_DATE_MAX = 60000
for (let r = row; r <= Math.min(row + 4, grid.maxRow); r++) {
let foundDateInRow = false
for (let c = 1; c <= 8; c++) {
const cellVal = grid.get(r, c).trim()
if (cellVal.toLowerCase().includes('effective date')) {
for (let dc = 1; dc <= 4; dc++) {
const val = grid.get(r, c + dc).trim(); const serial = parseFloat(val)
if (!isNaN(serial) && serial > EXCEL_DATE_MIN && serial < EXCEL_DATE_MAX) {
effectiveDateSerial = serial; foundDateInRow = true; break
}
}
if (foundDateInRow) break
}
if (!foundDateInRow && c > 1) {
const serial = parseFloat(cellVal)
if (!isNaN(serial) && serial > EXCEL_DATE_MIN && serial < EXCEL_DATE_MAX) {
effectiveDateSerial = serial; foundDateInRow = true; break
}
}
}
if (foundDateInRow) { foundDateRow = r; break }
}
const effectiveDate = effectiveDateSerial > 0 ? excelSerialToDate(effectiveDateSerial) : new Date()
row = foundDateRow + 1
for (let r = row; r <= Math.min(row + 3, grid.maxRow); r++) {
let foundPolicies = false
for (let c = 1; c <= 5; c++) {
if (grid.get(r, c).trim().toLowerCase().startsWith('policies')) { foundPolicies = true; break }
}
if (foundPolicies) { row = r + 1; break }
let hasTask = false
for (let c = 1; c <= 8; c++) {
if (grid.get(r, c).trim().toLowerCase() === 'task') { hasTask = true; break }
}
if (hasTask) break
}
let headerRow = -1; let taskCol = 0
for (let r = row; r <= Math.min(row + 5, grid.maxRow); r++) {
for (let c = 1; c <= 8; c++) {
if (grid.get(r, c).trim().toLowerCase() === 'task') { headerRow = r; taskCol = c; break }
}
if (headerRow >= 0) break
}
if (headerRow < 0) { row++; continue }
let daysAfterCol = 0; let dateCompletedCol = 0; let notesCol = 0
for (let c = taskCol + 1; c <= taskCol + 8; c++) {
const hdr = grid.get(headerRow, c).trim().toLowerCase()
if (hdr.includes('days after')) daysAfterCol = c
else if (hdr.includes('date completed') || hdr === 'completed' || hdr === 'date complete') dateCompletedCol = c
else if (hdr.includes('notes')) notesCol = c
}
row = headerRow + 1
const taskRows: ParsedRow[] = []; const additionalServices: string[] = []; let inAdditional = false
while (row <= grid.maxRow) {
let nextBlock = false
for (let c = 1; c <= 5; c++) {
if (grid.get(row, c).trim().toLowerCase().includes('client name')) { nextBlock = true; break }
}
if (nextBlock) break
const taskName = grid.get(row, taskCol).trim()
if (!taskName) { row++; continue }
if (taskName.toLowerCase().includes('additional services')) { inAdditional = true; row++; continue }
const daysAfterRaw = daysAfterCol > 0 ? grid.get(row, daysAfterCol).trim() : ''
const isDaysNumeric = daysAfterRaw !== '' && !isNaN(parseFloat(daysAfterRaw))
if (!isDaysNumeric) { if (taskName.length > 3) additionalServices.push(taskName); row++; continue }
if (inAdditional) { additionalServices.push(taskName); row++; continue }
const daysAfterRenewal = parseFloat(daysAfterRaw)
const dateCompletedRaw = dateCompletedCol > 0 ? grid.get(row, dateCompletedCol).trim() : ''
const notes = notesCol > 0 ? grid.get(row, notesCol).trim() : ''
taskRows.push({ taskName, daysAfterRenewal, dateCompleted: dateCompletedRaw, notes })
row++
}
blocks.push({ clientName, effectiveDate, isShape2, rows: taskRows, additionalServices })
}
return blocks
}
function parseExcelFile(buf: Buffer, fileName: string): ParsedBlock[] {
const zipEntries = parseZip(buf)
const sharedStrings = parseSharedStrings(zipEntries.get('xl/sharedstrings.xml'))
const sheetBuf = findTargetSheet(zipEntries)
if (!sheetBuf) return []
return parseExcelBlocks(parseSheetToGrid(sheetBuf, sharedStrings), fileName)
}
// ─── Client name matching ─────────────────────────────────────────────────────
function normalizeClientName(name: string): string {
return name.toLowerCase().replace(/^the\s+/, '').replace(/&/g, 'and')
.replace(/\b(incorporated|inc|llc|l\.l\.c|corp|corporation|co|ltd|limited|lp|l\.p|company|enterprises|enterprise|services|group|associates|solutions|management|consulting)\b\.?/gi, '')
.replace(/[.,;:'"()\-_#@!?]/g, '').replace(/\s+/g, ' ').trim()
}
function tokenize(s: string): Set<string> {
return new Set(s.split(/\s+/).filter((t) => t.length > 1))
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 && b.size === 0) return 1
let intersection = 0
for (const t of a) if (b.has(t)) intersection++
const union = a.size + b.size - intersection
return union === 0 ? 0 : intersection / union
}
function matchClient(excelName: string, dbState: DbState): { client: DbClient; method: string } | null {
const normalized = normalizeClientName(excelName)
if (!normalized) return null
const exactCandidates = dbState.clientsByNormalizedName.get(normalized)
if (exactCandidates && exactCandidates.length > 0) return { client: exactCandidates[0], method: 'exact' }
for (const [key, clients] of dbState.clientsByNormalizedName) {
if (key.includes(normalized) || normalized.includes(key)) {
if (clients.length > 0) return { client: clients[0], method: 'contains' }
}
}
const aTokens = tokenize(normalized); let bestScore = 0; let bestClient: DbClient | null = null
for (const [key, clients] of dbState.clientsByNormalizedName) {
const score = jaccardSimilarity(aTokens, tokenize(key))
if (score > bestScore && score >= 0.65) { bestScore = score; bestClient = clients[0] }
}
if (bestClient) return { client: bestClient, method: `jaccard(${bestScore.toFixed(2)})` }
return null
}
// ─── Template matching ────────────────────────────────────────────────────────
function normalizeTemplateName(name: string): string {
const lowered = name.toLowerCase().trim()
return TEMPLATE_NAME_ALIASES[lowered] ?? lowered
}
function findTemplate(row: ParsedRow, templates: DbTemplate[]): DbTemplate | null {
const normalized = normalizeTemplateName(row.taskName)
for (const t of templates) if (normalizeTemplateName(t.name) === normalized) return t
for (const t of templates) {
const tn = normalizeTemplateName(t.name)
if (tn.includes(normalized) || normalized.includes(tn)) return t
}
if (row.daysAfterRenewal > 0) {
const approxOffset = -(365 - row.daysAfterRenewal)
let closest: DbTemplate | null = null; let closestDiff = Infinity
for (const t of templates) {
const diff = Math.abs(t.daysOffset - approxOffset)
if (diff < closestDiff && diff <= 15) { closestDiff = diff; closest = t }
}
if (closest) return closest
}
return null
}
// ─── DB state ────────────────────────────────────────────────────────────────
async function loadDbState(log: (l: string) => Promise<void>): Promise<DbState> {
await log('Loading database state...')
const designations = await prisma.designation.findMany({
where: { name: { in: ['Shape', 'Shape 2', 'Shape2'] } },
})
const shapeDes = designations.find((d) => d.name === 'Shape')
const shape2Des = designations.find((d) => d.name === 'Shape 2' || d.name === 'Shape2')
if (!shapeDes || !shape2Des) throw new Error(`Could not find Shape/Shape2 designations. Found: ${designations.map((d) => d.name).join(', ')}`)
const allTemplates = await prisma.taskTemplate.findMany({
where: { designationId: { in: [shapeDes.id, shape2Des.id] }, isActive: true },
})
const shapeTemplates = allTemplates.filter((t) => t.designationId === shapeDes.id)
const shape2Templates = allTemplates.filter((t) => t.designationId === shape2Des.id)
await log(` Loaded ${shapeTemplates.length} Shape templates, ${shape2Templates.length} Shape2 templates`)
const clients = await prisma.client.findMany({
where: { OR: [{ designationId: { in: [shapeDes.id, shape2Des.id] } }, { designation2Id: { in: [shapeDes.id, shape2Des.id] } }] },
select: { id: true, name: true, claimsAdvocateId: true },
})
await log(` Loaded ${clients.length} SHAPE clients`)
const clientsByNormalizedName = new Map<string, DbClient[]>()
for (const c of clients) {
const key = normalizeClientName(c.name)
if (!clientsByNormalizedName.has(key)) clientsByNormalizedName.set(key, [])
clientsByNormalizedName.get(key)!.push({ id: c.id, name: c.name, claimsAdvocateId: c.claimsAdvocateId })
}
return {
clientsByNormalizedName,
allClients: clients.map((c) => ({ id: c.id, name: c.name, claimsAdvocateId: c.claimsAdvocateId })),
shapeTemplates: shapeTemplates.map((t) => ({ id: t.id, name: t.name, daysOffset: t.daysOffset, designationId: t.designationId })),
shape2Templates: shape2Templates.map((t) => ({ id: t.id, name: t.name, daysOffset: t.daysOffset, designationId: t.designationId })),
shapeDesignationId: shapeDes.id,
shape2DesignationId: shape2Des.id,
}
}
// ─── Template fixes ───────────────────────────────────────────────────────────
async function fixTemplates(shapeDesignationId: string, shape2DesignationId: string, dryRun: boolean, log: (l: string) => Promise<void>): Promise<void> {
await log('\nFixing template data...')
const fix1 = await prisma.taskTemplate.updateMany({ where: { name: 'Request 125 day loss runs', daysOffset: -125 }, data: { name: 'Request 120 day loss runs', daysOffset: -120 } })
if (fix1.count > 0) await log(` Fixed ${fix1.count} template(s): "Request 125..." → "Request 120..."`)
const fix2 = await prisma.taskTemplate.updateMany({ where: { daysOffset: -89 }, data: { daysOffset: -90 } })
await prisma.taskTemplate.updateMany({ where: { name: 'Request 89 day loss runs' }, data: { name: 'Request 90 day loss runs' } })
await prisma.taskTemplate.updateMany({ where: { name: 'Request 89 day loss runs (if being marketed)' }, data: { name: 'Request 90 day loss runs (if being marketed)' } })
if (fix2.count > 0) await log(` Fixed ${fix2.count} template(s): offset -89 → -90`)
const existing185 = await prisma.taskTemplate.findFirst({ where: { name: 'Claim Review', daysOffset: -185, designationId: shapeDesignationId } })
if (!existing185) {
if (!dryRun) {
await prisma.taskTemplate.create({ data: { name: 'Claim Review', department: 'CLAIMS', timing: 'PRE_RENEWAL', daysOffset: -185, defaultPriority: 'MEDIUM', isActive: true, displayOrder: 4, designationId: shapeDesignationId } })
await log(' Created missing "Claim Review" template at -185 days (Shape)')
} else {
await log(' [DRY RUN] Would create missing "Claim Review" template at -185 days (Shape)')
}
} else {
await log(' "Claim Review" at -185 already exists — skipping')
}
}
// ─── Task processing ──────────────────────────────────────────────────────────
interface DateCompletedResult { status: 'COMPLETED' | 'NA' | 'NOT_STARTED'; completedAt: Date | null; naReason: string | null }
function interpretDateCompleted(raw: string): DateCompletedResult {
const normalized = raw.trim().toLowerCase()
if (normalized === '' || normalized === '??' || normalized === '?') return { status: 'NOT_STARTED', completedAt: null, naReason: null }
if (normalized === 'n/a' || normalized === 'na') return { status: 'NA', completedAt: null, naReason: 'Historical — marked N/A in SHAPE tracker' }
const serial = parseFloat(raw)
if (!isNaN(serial) && serial > 40000 && serial < 60000) return { status: 'COMPLETED', completedAt: excelSerialToDate(serial), naReason: null }
return { status: 'NOT_STARTED', completedAt: null, naReason: null }
}
async function findMatchingTasks(clientId: string, templateId: string, dueDate: Date): Promise<{ match: string | null; duplicateIds: string[] }> {
const windowMs = 5 * 86400 * 1000
const candidates = await prisma.task.findMany({
where: { clientId, templateId, dueDate: { gte: new Date(dueDate.getTime() - windowMs), lte: new Date(dueDate.getTime() + windowMs) } },
orderBy: { createdAt: 'asc' },
select: { id: true },
})
if (candidates.length === 0) return { match: null, duplicateIds: [] }
return { match: candidates[0].id, duplicateIds: candidates.slice(1).map((c) => c.id) }
}
async function ensureTaskAssignment(taskId: string, userId: string, stats: ImportStats, dryRun: boolean): Promise<void> {
if (dryRun) { stats.tasksAssigned++; return }
try {
await prisma.taskAssignment.upsert({ where: { taskId_userId: { taskId, userId } }, create: { taskId, userId }, update: {} })
stats.tasksAssigned++
} catch { /* already exists */ }
}
async function processTaskRow(clientId: string, row: ParsedRow, template: DbTemplate, effectiveDate: Date, advocateUserId: string, stats: ImportStats, dryRun: boolean): Promise<void> {
const dueDate = new Date(effectiveDate)
dueDate.setDate(dueDate.getDate() + Math.round(row.daysAfterRenewal))
const { status, completedAt, naReason } = interpretDateCompleted(row.dateCompleted)
const { match: taskId, duplicateIds } = await findMatchingTasks(clientId, template.id, dueDate)
if (duplicateIds.length > 0) {
if (!dryRun) await prisma.task.deleteMany({ where: { id: { in: duplicateIds } } })
stats.duplicatesDeleted += duplicateIds.length
}
if (taskId) {
await ensureTaskAssignment(taskId, advocateUserId, stats, dryRun)
if (status === 'COMPLETED' || status === 'NA') {
const existing = await prisma.task.findUnique({ where: { id: taskId }, select: { status: true, notes: true } })
if (existing && existing.status === 'NOT_STARTED') {
if (!dryRun) {
await prisma.task.update({ where: { id: taskId }, data: { status, completedAt: completedAt ?? undefined, completedBy: status === 'COMPLETED' ? advocateUserId : undefined, naReason: naReason ?? undefined, notes: row.notes && !existing.notes ? row.notes : existing.notes ?? undefined } })
}
stats.tasksUpdated++
}
}
} else {
if (status === 'COMPLETED' || status === 'NA') {
if (!dryRun) {
const newTask = await prisma.task.create({ data: { title: template.name, department: 'CLAIMS', timing: 'PRE_RENEWAL', daysOffset: template.daysOffset, dueDate, status, priority: 'MEDIUM', clientId, templateId: template.id, completedAt: completedAt ?? undefined, completedBy: status === 'COMPLETED' ? advocateUserId : undefined, naReason: naReason ?? undefined, notes: row.notes || undefined, isAdHoc: false, createdBy: advocateUserId } })
await ensureTaskAssignment(newTask.id, advocateUserId, stats, dryRun)
} else { stats.tasksAssigned++ }
stats.tasksCreated++
} else {
if (!dryRun) {
const newTask = await prisma.task.create({ data: { title: template.name, department: 'CLAIMS', timing: 'PRE_RENEWAL', daysOffset: template.daysOffset, dueDate, status: 'NOT_STARTED', priority: 'MEDIUM', clientId, templateId: template.id, notes: row.notes || undefined, isAdHoc: false, createdBy: advocateUserId } })
await ensureTaskAssignment(newTask.id, advocateUserId, stats, dryRun)
} else { stats.tasksAssigned++ }
stats.tasksCreated++
}
}
}
async function createAdHocTask(clientId: string, text: string, effectiveDate: Date, advocateUserId: string, stats: ImportStats, dryRun: boolean): Promise<void> {
const title = text.slice(0, 500)
const existing = await prisma.task.findFirst({ where: { clientId, title, isAdHoc: true } })
if (existing) return
if (!dryRun) {
const task = await prisma.task.create({ data: { title, department: 'CLAIMS', timing: 'PRE_RENEWAL', daysOffset: 0, dueDate: effectiveDate, status: 'COMPLETED', priority: 'MEDIUM', clientId, isAdHoc: true, completedAt: effectiveDate, completedBy: advocateUserId, notes: text, createdBy: advocateUserId } })
await ensureTaskAssignment(task.id, advocateUserId, stats, dryRun)
} else { stats.tasksAssigned++ }
stats.adHocCreated++
}
async function assignAdvocate(clientId: string, advocateUserId: string, currentAdvocateId: string | null, stats: ImportStats, dryRun: boolean): Promise<void> {
if (currentAdvocateId) return
if (!dryRun) await prisma.client.update({ where: { id: clientId }, data: { claimsAdvocateId: advocateUserId } })
stats.advocatesAssigned++
}
async function processFile(folder: string, itemId: string, fileName: string, driveId: string, dbState: DbState, stats: ImportStats, dryRun: boolean, log: (l: string) => Promise<void>): Promise<void> {
const advocateInfo = TEAM_MEMBER_MAP[folder]!
let buf: Buffer
try { buf = await downloadFile(driveId, itemId); await sleep(150) }
catch (err) { stats.errors.push(`${folder}/${fileName}: Download failed — ${(err as Error).message}`); return }
let blocks: ParsedBlock[]
try { blocks = parseExcelFile(buf, fileName) }
catch (err) { stats.errors.push(`${folder}/${fileName}: Parse failed — ${(err as Error).message}`); return }
if (blocks.length === 0) { stats.errors.push(`${folder}/${fileName}: No data blocks found`); return }
stats.filesProcessed++
for (const block of blocks) {
const matchResult = matchClient(block.clientName, dbState)
if (!matchResult) {
stats.clientsUnmatched++
stats.unmatchedClients.push({ folder, file: fileName, excelName: block.clientName })
continue
}
const { client, method } = matchResult
stats.clientsMatched++
if (method !== 'exact') stats.fuzzyMatches.push({ folder, file: fileName, excelName: block.clientName, dbName: client.name, method })
await assignAdvocate(client.id, advocateInfo.userId, client.claimsAdvocateId, stats, dryRun)
client.claimsAdvocateId = client.claimsAdvocateId ?? advocateInfo.userId
const templates = block.isShape2 ? dbState.shape2Templates : dbState.shapeTemplates
for (const row of block.rows) {
const template = findTemplate(row, templates)
if (!template) {
const { status } = interpretDateCompleted(row.dateCompleted)
if (status === 'COMPLETED' || status === 'NA') {
const adHocTitle = `[Unmatched Task] ${row.taskName}${row.notes ? ': ' + row.notes : ''}`
await createAdHocTask(client.id, adHocTitle, block.effectiveDate, advocateInfo.userId, stats, dryRun)
}
continue
}
try { await processTaskRow(client.id, row, template, block.effectiveDate, advocateInfo.userId, stats, dryRun) }
catch (err) { stats.errors.push(`${folder}/${fileName} / ${block.clientName} / "${row.taskName}": ${(err as Error).message}`) }
}
for (const svc of block.additionalServices) {
try { await createAdHocTask(client.id, svc, block.effectiveDate, advocateInfo.userId, stats, dryRun) }
catch (err) { stats.errors.push(`${folder}/${fileName} / ${block.clientName} / adHoc "${svc.slice(0, 40)}": ${(err as Error).message}`) }
}
}
}
// ─── Public entry point ───────────────────────────────────────────────────────
export async function runShapeImport(opts: {
dryRun: boolean
driveId: string
onLog: (line: string) => Promise<void>
}): Promise<ImportStats> {
const { dryRun, driveId, onLog } = opts
const stats: ImportStats = {
filesProcessed: 0, clientsMatched: 0, clientsUnmatched: 0,
tasksUpdated: 0, tasksAssigned: 0, tasksCreated: 0,
duplicatesDeleted: 0, adHocCreated: 0, advocatesAssigned: 0,
errors: [], unmatchedClients: [], fuzzyMatches: [],
}
await onLog(`SHAPE Historical Import — ${new Date().toISOString()}`)
await onLog(`Mode: ${dryRun ? 'DRY RUN (no changes written)' : 'EXECUTE'}`)
const designations = await prisma.designation.findMany({ where: { name: { in: ['Shape', 'Shape 2', 'Shape2'] } } })
const shapeDes = designations.find((d) => d.name === 'Shape')
const shape2Des = designations.find((d) => d.name === 'Shape 2' || d.name === 'Shape2')
if (!shapeDes || !shape2Des) throw new Error('Shape designations not found')
await fixTemplates(shapeDes.id, shape2Des.id, dryRun, onLog)
const dbState = await loadDbState(onLog)
const files = await discoverFiles(driveId, onLog)
await onLog(`\nProcessing ${files.length} files...`)
for (let i = 0; i < files.length; i++) {
const { folder, itemId, fileName } = files[i]
const pct = Math.round(((i + 1) / files.length) * 100)
await onLog(`[${pct}%] ${i + 1}/${files.length}${folder}/${fileName}`)
await processFile(folder, itemId, fileName, driveId, dbState, stats, dryRun, onLog)
}
await onLog('')
await onLog('═'.repeat(60))
await onLog('SHAPE Historical Import Report')
await onLog('═'.repeat(60))
await onLog(`Mode: ${dryRun ? 'DRY RUN (no changes written)' : 'EXECUTE (changes written to DB)'}`)
await onLog('')
await onLog(`Files processed: ${stats.filesProcessed}`)
await onLog(`Clients matched: ${stats.clientsMatched}`)
await onLog(` - exact: ${stats.clientsMatched - stats.fuzzyMatches.length}`)
await onLog(` - fuzzy: ${stats.fuzzyMatches.length}`)
await onLog(`Clients unmatched: ${stats.clientsUnmatched}`)
await onLog(`Tasks updated: ${stats.tasksUpdated}`)
await onLog(`Tasks assigned: ${stats.tasksAssigned}`)
await onLog(`Tasks created: ${stats.tasksCreated}`)
await onLog(`Duplicates deleted: ${stats.duplicatesDeleted}`)
await onLog(`Ad-hoc created: ${stats.adHocCreated}`)
await onLog(`Advocates assigned: ${stats.advocatesAssigned}`)
await onLog(`Errors: ${stats.errors.length}`)
if (stats.fuzzyMatches.length > 0) {
await onLog('')
await onLog('── Fuzzy Client Matches ──────────────────────────────────────')
for (const m of stats.fuzzyMatches) await onLog(` [${m.folder}] "${m.excelName}" → "${m.dbName}" (${m.method})`)
}
if (stats.unmatchedClients.length > 0) {
await onLog('')
await onLog('── Unmatched Clients (manual action required) ─────────────────')
for (const u of stats.unmatchedClients) await onLog(` [${u.folder}] ${u.file} → "${u.excelName}"`)
}
if (stats.errors.length > 0) {
await onLog('')
await onLog('── Errors ──────────────────────────────────────────────────────')
for (const e of stats.errors) await onLog(` ERROR: ${e}`)
}
await onLog('═'.repeat(60))
return stats
}