seubert-claims/ondeck/scripts/import-shape-historical.ts

1541 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SHAPE Historical Data Import Script
*
* Imports historical task completion data from SharePoint Excel files into Horizon.
* Each team member has a folder in Claims/SHAPE Accounts/{MEMBER_FOLDER}/{Client}.xlsx
*
* Usage:
* npx tsx scripts/import-shape-historical.ts # dry run
* npx tsx scripts/import-shape-historical.ts --execute # write to DB
*
* Output is written to /tmp/shape-import-report.txt (override with --output=/path)
*/
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
import * as zlib from 'zlib'
import * as fs from 'fs'
import * as dotenv from 'dotenv'
dotenv.config()
// ─── Configuration ────────────────────────────────────────────────────────────
const DRY_RUN = !process.argv.includes('--execute')
const OUTPUT_FILE =
process.argv.find((a) => a.startsWith('--output='))?.split('=')[1] ??
'/tmp/shape-import-report.txt'
const DRIVE_ID =
'b!OYuzIexQkkOvfEPyMJPzzZHfzTrOCOdPhTWgTlzKs6M0ZWVrAc6LR4LjWl4QFEzm'
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',
},
}
// Template name aliases: old/variant names → canonical name (after fix)
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',
}
// ─── DB Setup ─────────────────────────────────────────────────────────────────
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
// ─── Output / Logging ─────────────────────────────────────────────────────────
const outputLines: string[] = []
function log(line = '') {
console.log(line)
outputLines.push(line)
}
function writeOutput() {
fs.writeFileSync(OUTPUT_FILE, outputLines.join('\n') + '\n', 'utf-8')
console.log(`\nReport written to ${OUTPUT_FILE}`)
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface ParsedRow {
taskName: string
daysAfterRenewal: number
dateCompleted: string // raw: numeric string | 'n/a' | '??' | ''
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 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
}>
}
// ─── 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(itemId: string): Promise<Buffer> {
const token = await getGraphToken()
const url = `https://graph.microsoft.com/v1.0/drives/${DRIVE_ID}/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))
}
interface DriveItem {
id: string
name: string
file?: { mimeType: string }
folder?: { childCount: number }
}
async function listChildren(itemId: string): Promise<DriveItem[]> {
const data = (await graphGet(
`https://graph.microsoft.com/v1.0/drives/${DRIVE_ID}/items/${itemId}/children?$top=200`
)) as { value: DriveItem[] }
return data.value ?? []
}
async function discoverFiles(): Promise<
Array<{ folder: string; itemId: string; fileName: string }>
> {
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/${DRIVE_ID}/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
log(` Scanning folder: ${item.name}`)
const children = await listChildren(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
// Skip template/master files
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 })
}
}
log(` Found ${files.length} Excel files`)
return files
}
// ─── ZIP Parser ───────────────────────────────────────────────────────────────
function parseZip(buffer: Buffer): Map<string, Buffer> {
const entries = new Map<string, Buffer>()
// Find End of Central Directory (EOCD) record — signature 0x06054b50
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')
// Read local file header
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 // stored
} else if (method === 8) {
data = zlib.inflateRawSync(compData) // deflate
} else {
data = Buffer.alloc(0) // unsupported, skip
}
// Store with lowercase key for case-insensitive lookup
entries.set(fileName.toLowerCase(), data)
pos += 46 + fileNameLen + extraLen + commentLen
}
return entries
}
// ─── XLSX Parsing ─────────────────────────────────────────────────────────────
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 siContent = match[1]
const texts: string[] = []
const tRegex = /<t(?:\s[^>]*)?>([^<]*)<\/t>/g
let tm: RegExpExecArray | null
while ((tm = tRegex.exec(siContent)) !== 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 numToColLetter(n: number): string {
let s = ''
while (n > 0) {
s = String.fromCharCode(((n - 1) % 26) + 65) + s
n = Math.floor((n - 1) / 26)
}
return s
}
function parseSheetToGrid(
sheetBuf: Buffer,
sharedStrings: string[]
): { get: (row: number, col: number) => string; maxRow: number } {
const cells = new Map<string, string>()
let maxRow = 0
// Strip self-closing <c .../> tags first — they represent empty cells and must not
// be confused with the opening tag of a non-empty cell that follows.
const xml = sheetBuf.toString('utf-8').replace(/<c\b[^>]*\/>/g, '')
// Match each <c r="..." ...>...</c> block
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] // number/date
}
}
const col = colLetterToNum(colStr)
cells.set(`${rowNum}:${col}`, value)
}
return {
get(row: number, col: number): string {
return cells.get(`${row}:${col}`) ?? ''
},
maxRow,
}
}
function findTargetSheet(
zipEntries: Map<string, Buffer>
): Buffer | null {
// Parse workbook.xml to find sheet r:ids, then map via rels
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')
// Build r:id → file path map from rels
// Paths in xl/_rels/workbook.xml.rels are relative to xl/
// e.g. Target="worksheets/sheet1.xml" → actual path is "xl/worksheets/sheet1.xml"
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) // absolute paths: strip leading /
} else if (!target.startsWith('xl/')) {
target = 'xl/' + target // relative to xl/: prepend xl/
}
ridToPath.set(rm[1], target)
}
}
// Find sheet entries — attributes may appear in any order, parse flexibly
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
}
// Prefer SHAPE2 > SHAPE > CLIENT, then fall back to first sheet
const priority = ['shape2', 'shape', 'client']
for (const p of priority) {
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 {
// Excel epoch: Jan 0, 1900 (effectively Dec 30, 1899)
// But Excel has a leap year bug treating 1900 as leap year, so offset is 25569
return new Date((serial - 25569) * 86400 * 1000)
}
// ─── Block Parser ─────────────────────────────────────────────────────────────
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) {
// Scan up to 10 columns for "Client Name:"
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
}
// Client name: first try the cell to the right, then up to 3 columns right
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
}
}
// Fallback: extract from filename (strip known suffixes)
if (!clientName) {
clientName = fileName
.replace(/^SHAPE\S*\s*[-]\s*/i, '') // strip leading "SHAPE2 - " prefix
.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++
// Find effective date (within next 5 rows)
// Strategy 1: look for "Effective Date:" label, take numeric value in adjacent cell
// Strategy 2 (fallback): look for any Excel-range serial (4000060000) in next rows
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')) {
// Date value is in next column(s)
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
}
// Fallback: any valid date serial in this row (not the first col which may be a number label)
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
}
}
// If no effective date found, use current date as fallback (for files like Minniefield
// where date is stored as display-only formatting, not cell value)
const effectiveDate = effectiveDateSerial > 0
? excelSerialToDate(effectiveDateSerial)
: new Date()
row = foundDateRow + 1
// Skip "Policies:" row if present
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
}
// Check if this is the header row — stop skipping
let hasTask = false
for (let c = 1; c <= 8; c++) {
if (grid.get(r, c).trim().toLowerCase() === 'task') {
hasTask = true
break
}
}
if (hasTask) break
}
// Find header row containing "Task"
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
}
// Map header columns (scan right of taskCol)
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
// Parse data rows
const taskRows: ParsedRow[] = []
const additionalServices: string[] = []
let inAdditional = false
while (row <= grid.maxRow) {
// Check if next block starts
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
}
// Detect "Additional Services Provided" section header
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 no numeric days value, treat as additional service / free text
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 sharedStringsBuf = zipEntries.get('xl/sharedstrings.xml')
const sharedStrings = parseSharedStrings(sharedStringsBuf)
const sheetBuf = findTargetSheet(zipEntries)
if (!sheetBuf) return []
const grid = parseSheetToGrid(sheetBuf, sharedStrings)
return parseExcelBlocks(grid, fileName)
}
// ─── Client Name Normalization / 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
// 1. Exact normalized match
const exactCandidates = dbState.clientsByNormalizedName.get(normalized)
if (exactCandidates && exactCandidates.length > 0) {
return { client: exactCandidates[0], method: 'exact' }
}
// 2. Contains match
for (const [key, clients] of dbState.clientsByNormalizedName) {
if (key.includes(normalized) || normalized.includes(key)) {
if (clients.length > 0) {
return { client: clients[0], method: 'contains' }
}
}
}
// 3. Token Jaccard similarity ≥ 0.65
const aTokens = tokenize(normalized)
let bestScore = 0
let bestClient: DbClient | null = null
for (const [key, clients] of dbState.clientsByNormalizedName) {
const bTokens = tokenize(key)
const score = jaccardSimilarity(aTokens, bTokens)
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)
// 1. Exact normalized name match
for (const t of templates) {
if (normalizeTemplateName(t.name) === normalized) return t
}
// 2. Contains match
for (const t of templates) {
const tn = normalizeTemplateName(t.name)
if (tn.includes(normalized) || normalized.includes(tn)) return t
}
// 3. Days-based fallback (approximate offset from daysAfterRenewal)
// daysOffset ≈ -(365 - daysAfterRenewal) but let's use a 15-day window
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 Loading ─────────────────────────────────────────────────────────
async function loadDbState(): Promise<DbState> {
log('Loading database state...')
// Load designations
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(', ')}`
)
}
// Load templates
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
)
log(
` Loaded ${shapeTemplates.length} Shape templates, ${shape2Templates.length} Shape2 templates`
)
// Load SHAPE clients (those with shape designation on either designation slot)
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 },
})
log(` Loaded ${clients.length} SHAPE clients`)
// Build lookup map
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
): Promise<void> {
log('\nFixing template data...')
// Fix 1: "Request 125 day loss runs" → "Request 120 day loss runs" at -120
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)
log(` Fixed ${fix1.count} template(s): "Request 125..." → "Request 120..."`)
// Fix 2: "Request 89 day loss runs" → "Request 90 day loss runs" at -90
const fix2 = await prisma.taskTemplate.updateMany({
where: { daysOffset: -89 },
data: { daysOffset: -90 },
})
const fix2b = await prisma.taskTemplate.updateMany({
where: {
name: { in: ['Request 89 day loss runs', 'Request 89 day loss runs (if being marketed)'] },
},
data: { name: undefined }, // name update handled separately since two variants exist
})
// Update names specifically
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)
log(` Fixed ${fix2.count} template(s): offset -89 → -90, updated names`)
// Fix 3: Add missing "Claim Review" at -185 for Shape designation
const existing185 = await prisma.taskTemplate.findFirst({
where: {
name: 'Claim Review',
daysOffset: -185,
designationId: shapeDesignationId,
},
})
if (!existing185) {
if (!DRY_RUN) {
await prisma.taskTemplate.create({
data: {
name: 'Claim Review',
department: 'CLAIMS',
timing: 'PRE_RENEWAL',
daysOffset: -185,
defaultPriority: 'MEDIUM',
isActive: true,
displayOrder: 4, // after 90-day review (order 3), before mod factor (order 5)
designationId: shapeDesignationId,
},
})
log(' Created missing "Claim Review" template at -185 days (Shape)')
} else {
log(
' [DRY RUN] Would create missing "Claim Review" template at -185 days (Shape)'
)
}
} else {
log(' "Claim Review" at -185 already exists — skipping')
}
}
// ─── Task Processing ──────────────────────────────────────────────────────────
async function findMatchingTasks(
clientId: string,
templateId: string,
dueDate: Date
): Promise<{ match: string | null; duplicateIds: string[] }> {
const windowMs = 5 * 86400 * 1000 // ±5 days
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, status: true, notes: true },
})
if (candidates.length === 0) return { match: null, duplicateIds: [] }
return {
match: candidates[0].id,
duplicateIds: candidates.slice(1).map((c) => c.id),
}
}
interface DateCompletedResult {
status: 'COMPLETED' | 'NA' | 'NOT_STARTED'
completedAt: Date | null
naReason: string | null
}
function interpretDateCompleted(
raw: string,
advocateUserId: 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',
}
}
// Try to parse as Excel serial date
const serial = parseFloat(raw)
if (!isNaN(serial) && serial > 40000 && serial < 60000) {
return {
status: 'COMPLETED',
completedAt: excelSerialToDate(serial),
naReason: null,
}
}
// Unknown value — treat as not started
return { status: 'NOT_STARTED', completedAt: null, naReason: null }
}
async function processTaskRow(
clientId: string,
row: ParsedRow,
template: DbTemplate,
effectiveDate: Date,
advocateUserId: string,
stats: ImportStats
): Promise<void> {
// Calculate expected due date: effectiveDate + daysAfterRenewal
const dueDate = new Date(effectiveDate)
dueDate.setDate(dueDate.getDate() + Math.round(row.daysAfterRenewal))
const { status, completedAt, naReason } = interpretDateCompleted(
row.dateCompleted,
advocateUserId
)
const { match: taskId, duplicateIds } = await findMatchingTasks(
clientId,
template.id,
dueDate
)
// Delete duplicates
if (duplicateIds.length > 0) {
if (!DRY_RUN) {
await prisma.task.deleteMany({ where: { id: { in: duplicateIds } } })
}
stats.duplicatesDeleted += duplicateIds.length
}
if (taskId) {
// Assign advocate to existing task (regardless of completion status)
await ensureTaskAssignment(taskId, advocateUserId, stats)
// Update task if it has completion data
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 (!DRY_RUN) {
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 {
// Task doesn't exist — create it if it has completion data
if (status === 'COMPLETED' || status === 'NA') {
if (!DRY_RUN) {
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)
} else {
stats.tasksAssigned++ // count the would-be assignment
}
stats.tasksCreated++
} else {
// NOT_STARTED and no existing task — this shouldn't happen for SHAPE clients
// but if it does, create the task and assign it
if (!DRY_RUN) {
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)
} else {
stats.tasksAssigned++
}
stats.tasksCreated++
}
}
}
async function ensureTaskAssignment(
taskId: string,
userId: string,
stats: ImportStats
): Promise<void> {
if (DRY_RUN) {
stats.tasksAssigned++
return
}
try {
await prisma.taskAssignment.upsert({
where: { taskId_userId: { taskId, userId } },
create: { taskId, userId },
update: {},
})
stats.tasksAssigned++
} catch {
// Unique constraint violation means it already exists — fine
}
}
async function createAdHocTask(
clientId: string,
text: string,
effectiveDate: Date,
advocateUserId: string,
stats: ImportStats
): Promise<void> {
const title = text.slice(0, 500)
// Idempotency check
const existing = await prisma.task.findFirst({
where: { clientId, title, isAdHoc: true },
})
if (existing) return
if (!DRY_RUN) {
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)
} else {
stats.tasksAssigned++
}
stats.adHocCreated++
}
async function assignAdvocate(
clientId: string,
advocateUserId: string,
currentAdvocateId: string | null,
stats: ImportStats
): Promise<void> {
if (currentAdvocateId) return // Never overwrite existing assignment
if (!DRY_RUN) {
await prisma.client.update({
where: { id: clientId },
data: { claimsAdvocateId: advocateUserId },
})
}
stats.advocatesAssigned++
}
// ─── File Processing ──────────────────────────────────────────────────────────
async function processFile(
folder: string,
itemId: string,
fileName: string,
dbState: DbState,
stats: ImportStats
): Promise<void> {
const advocateInfo = TEAM_MEMBER_MAP[folder]!
let buf: Buffer
try {
buf = await downloadFile(itemId)
await sleep(150)
} catch (err) {
const msg = `${folder}/${fileName}: Download failed — ${(err as Error).message}`
stats.errors.push(msg)
return
}
let blocks: ParsedBlock[]
try {
blocks = parseExcelFile(buf, fileName)
} catch (err) {
const msg = `${folder}/${fileName}: Parse failed — ${(err as Error).message}`
stats.errors.push(msg)
return
}
if (blocks.length === 0) {
stats.errors.push(`${folder}/${fileName}: No data blocks found`)
return
}
stats.filesProcessed++
for (const block of blocks) {
// Match client
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,
})
}
// Assign advocate at client level
await assignAdvocate(
client.id,
advocateInfo.userId,
client.claimsAdvocateId,
stats
)
// Update our cached advocate status so subsequent blocks don't re-assign
client.claimsAdvocateId = client.claimsAdvocateId ?? advocateInfo.userId
// Choose templates based on SHAPE vs SHAPE2
const templates = block.isShape2
? dbState.shape2Templates
: dbState.shapeTemplates
// Process each task row
for (const row of block.rows) {
const template = findTemplate(row, templates)
if (!template) {
// Unmatched template — add as additional service if it has completion data
const { status } = interpretDateCompleted(row.dateCompleted, advocateInfo.userId)
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
)
}
continue
}
try {
await processTaskRow(
client.id,
row,
template,
block.effectiveDate,
advocateInfo.userId,
stats
)
} catch (err) {
stats.errors.push(
`${folder}/${fileName} / ${block.clientName} / "${row.taskName}": ${(err as Error).message}`
)
}
}
// Create ad-hoc tasks for additional services
for (const svc of block.additionalServices) {
try {
await createAdHocTask(
client.id,
svc,
block.effectiveDate,
advocateInfo.userId,
stats
)
} catch (err) {
stats.errors.push(
`${folder}/${fileName} / ${block.clientName} / adHoc "${svc.slice(0, 40)}": ${(err as Error).message}`
)
}
}
}
}
// ─── Report ───────────────────────────────────────────────────────────────────
function printReport(stats: ImportStats): void {
log('')
log('═'.repeat(60))
log('SHAPE Historical Import Report')
log('═'.repeat(60))
log(`Mode: ${DRY_RUN ? 'DRY RUN (no changes written)' : 'EXECUTE (changes written to DB)'}`)
log('')
log(`Files processed: ${stats.filesProcessed}`)
log(`Clients matched: ${stats.clientsMatched}`)
log(` - exact: ${stats.clientsMatched - stats.fuzzyMatches.length}`)
log(` - fuzzy: ${stats.fuzzyMatches.length}`)
log(`Clients unmatched: ${stats.clientsUnmatched}`)
log(`Tasks updated: ${stats.tasksUpdated} (completed/NA status set)`)
log(`Tasks assigned: ${stats.tasksAssigned} (advocate linked)`)
log(`Tasks created: ${stats.tasksCreated} (missing from DB)`)
log(`Duplicates deleted: ${stats.duplicatesDeleted}`)
log(`Ad-hoc created: ${stats.adHocCreated}`)
log(`Advocates assigned: ${stats.advocatesAssigned} (client-level)`)
log(`Errors: ${stats.errors.length}`)
if (stats.fuzzyMatches.length > 0) {
log('')
log('── Fuzzy Client Matches (review for correctness) ──────────────')
for (const m of stats.fuzzyMatches) {
log(` [${m.folder}] "${m.excelName}" → "${m.dbName}" (${m.method})`)
}
}
if (stats.unmatchedClients.length > 0) {
log('')
log('── Unmatched Clients (manual action required) ──────────────────')
for (const u of stats.unmatchedClients) {
log(` [${u.folder}] ${u.file} → "${u.excelName}"`)
}
}
if (stats.errors.length > 0) {
log('')
log('── Errors ──────────────────────────────────────────────────────')
for (const e of stats.errors) {
log(` ERROR: ${e}`)
}
}
log('')
log('═'.repeat(60))
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
log(`SHAPE Historical Import — ${new Date().toISOString()}`)
log(
`Mode: ${DRY_RUN ? 'DRY RUN (pass --execute to write)' : 'EXECUTE'}`
)
const stats: ImportStats = {
filesProcessed: 0,
clientsMatched: 0,
clientsUnmatched: 0,
tasksUpdated: 0,
tasksAssigned: 0,
tasksCreated: 0,
duplicatesDeleted: 0,
adHocCreated: 0,
advocatesAssigned: 0,
errors: [],
unmatchedClients: [],
fuzzyMatches: [],
}
// Step 1: Fix template data
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)
// Step 2: Load DB state
const dbState = await loadDbState()
// Step 3: Discover SharePoint files
const files = await discoverFiles()
// Step 4: Process each file
log(`\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)
process.stdout.write(
`\r [${pct}%] ${i + 1}/${files.length}${folder}/${fileName.slice(0, 40)}`
)
await processFile(folder, itemId, fileName, dbState, stats)
}
process.stdout.write('\n')
// Step 5: Print report
printReport(stats)
writeOutput()
}
main()
.catch((e) => {
console.error('\nFatal error:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
await pool.end()
})