feat: sync contacts from AMS 360 AFW_PolContact into ClientContact
- Add source, title, mobilePhone fields to ClientContact schema - Add composite unique constraint for dedup (clientId, name, label, source) - Add fetchAfwContacts() query with dedup by (CustId, Name, Responsibility) - Add syncContacts() phase to sync engine with retry logic - Update contacts UI to show title, mobile phone, AMS badge - Disable edit/delete on AMS-synced contacts (source='ams')
This commit is contained in:
parent
9dd30de358
commit
8aa20ea751
6 changed files with 238 additions and 6 deletions
38
ondeck/SERVER.md
Normal file
38
ondeck/SERVER.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Horizon Dev Server
|
||||
|
||||
## How it runs
|
||||
The app runs as a systemd service (`horizon.service`) and will automatically start on boot and restart on crash.
|
||||
|
||||
## Managing the server
|
||||
```bash
|
||||
systemctl start horizon # start
|
||||
systemctl stop horizon # stop
|
||||
systemctl restart horizon # restart
|
||||
systemctl status horizon # check status
|
||||
```
|
||||
|
||||
## Logs
|
||||
```bash
|
||||
tail -f /var/log/horizon.log
|
||||
```
|
||||
|
||||
## Details
|
||||
- **Port:** 3000 (proxied via Pangolin/Nginx to https://horizon.seubert.cloud)
|
||||
- **Working directory:** `/opt/projects/OnDeck/ondeck`
|
||||
- **Mode:** `npm run dev` (Next.js with Turbopack)
|
||||
- **Service file:** `/etc/systemd/system/horizon.service`
|
||||
|
||||
## After code changes
|
||||
Changes hot-reload automatically. If the server gets into a bad state:
|
||||
```bash
|
||||
systemctl restart horizon
|
||||
```
|
||||
|
||||
## After schema changes
|
||||
Run these before restarting:
|
||||
```bash
|
||||
cd /opt/projects/OnDeck/ondeck
|
||||
npx prisma db push
|
||||
npx prisma generate
|
||||
systemctl restart horizon
|
||||
```
|
||||
20
ondeck/docs/fixes.md
Normal file
20
ondeck/docs/fixes.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
Here’s what we came up with :
|
||||
1. Priority Fixes Needed Before Launch
|
||||
These are the items that are currently causing the biggest issues and somethings that we have come up with in our meeting. I’ve grouped them for clarity:
|
||||
Renewal date logic: The renewal date field under the client is currently pulling the policy expiration date instead of the actual renewal date. It should read 1/1/2027 instead of 12/31/2026. I think this is a small fix, but I didn’t want to launch without it.
|
||||
Old / expired policy tasks: Everything is currently showing as overdue (including tasks due over a year ago). We need to suppress or archive tasks from old policies so they don’t appear as pending or overdue. Also, I want to make sure we are pulling in tasks on the currently active policies, I’m not sure I am seeing any as the overdue list is the only one that is populating. This may be the biggest hurdle keeping us from a soft go live.
|
||||
Active policies only: Under the Client tab, we should only display active policies (no old/renewed ones). The snippet is where I am talking about Image I think this is as simple as pulling the count based off of status.
|
||||
Parent and subsidiary linking: We need a way to tie parent companies and subsidiaries together, so tasks and views make sense across related accounts.
|
||||
Task groups on templates: Add the ability to set Task Group at the master task template level so it auto-applies when new tasks are generated.
|
||||
N/A button with required note: Add an N/A option on tasks and requires the user to enter a note when selected before moving to completed list.
|
||||
Additional prompts when a task is completed: “Did you file in imageright?” (Yes/No) and “Did you complete this?” (Yes/No), plus the ability to set reminders.
|
||||
Client Contact information: Is there a way for us to have the client contact information laid out under their tab and if possible, can we label those contacts, i.e Main Contact, Claims Contact etc. I believe most of this information is in AMS 360 but having the option for us to add our own would be helpful.
|
||||
Client note field: I would still like to have a client level note field, this way if there are any specific needs of the client or a specific way they do things we can note it there. For example, a note may look like – “Client reports all claims directly”, “ All claims are reported through Seubert”, or “To report auto claims you must call 1-800-claims-4”. Now those are uses for the claims team, but I could see this expanding to the other teams as a way to communicate important pertinent information to the entire team.
|
||||
|
||||
My take:
|
||||
|
||||
The goal of this app is to help a claims department at an insurance brokerage manage their clients and their policies, tasks, and contacts. The clients should have active policies if not they don't neet to be shown. Clients should be able to have a child/parent relationship so that all tasks roll up to the parent for display or have the detail at the child / policy level.
|
||||
|
||||
Some task are Client level, like "Claims Review", others will be applied to a group and not individual policies. The lowest level would be a task assigned to an individual policy.
|
||||
|
||||
Polices will arrive via sync with AMS 360 and will be stored in the database. If the client already has a Claims advocate assigned, we should use that person's name and email address to assign the task to them. If those new policies have a common renewal/expiration date or there was a group last year/time they should have a new renewal group for the tasks to be assigned to. Please review the existing task templates to see if this makes sense.
|
||||
|
|
@ -153,14 +153,18 @@ model ClientContact {
|
|||
clientId String @map("client_id")
|
||||
label String
|
||||
name String
|
||||
title String?
|
||||
phone String?
|
||||
mobilePhone String? @map("mobile_phone")
|
||||
email String?
|
||||
notes String? @db.Text
|
||||
source String @default("manual")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([clientId, name, label, source], name: "client_contact_dedup")
|
||||
@@index([clientId])
|
||||
@@map("client_contacts")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -805,13 +805,22 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
|||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">{contact.label}</Badge>
|
||||
{contact.source === 'ams' && <Badge variant="outline" className="text-xs shrink-0">AMS</Badge>}
|
||||
<span className="font-medium text-sm truncate">{contact.name}</span>
|
||||
</div>
|
||||
{contact.title && (
|
||||
<p className="text-xs text-muted-foreground">{contact.title}</p>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Phone className="h-3 w-3" />{contact.phone}
|
||||
</div>
|
||||
)}
|
||||
{contact.mobilePhone && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Phone className="h-3 w-3" />{contact.mobilePhone} (mobile)
|
||||
</div>
|
||||
)}
|
||||
{contact.email && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />{contact.email}
|
||||
|
|
@ -821,6 +830,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
|||
<p className="text-xs text-muted-foreground mt-1">{contact.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
{contact.source !== 'ams' && (
|
||||
<div className="flex gap-1 shrink-0 ml-2">
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -841,6 +851,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
|||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -91,6 +91,61 @@ export interface AfwPRCode {
|
|||
Description: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* AFW Policy Contact data structure (deduplicated per customer)
|
||||
*/
|
||||
export interface AfwContact {
|
||||
CustId: string
|
||||
Name: string
|
||||
Responsibility: string | null
|
||||
Title: string | null
|
||||
AreaCode: string | null
|
||||
Phone: string | null
|
||||
MobileAreaCode: string | null
|
||||
MobilePhone: string | null
|
||||
EMail: string | null
|
||||
Notes: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch deduplicated contacts from AFW_PolContact, joined to customers.
|
||||
* Deduplicates by (CustId, Name, Responsibility) keeping the most recently entered.
|
||||
*/
|
||||
export async function fetchAfwContacts(): Promise<AfwContact[]> {
|
||||
const query = `
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
c.CustId,
|
||||
pc.Name,
|
||||
pc.Responsibility,
|
||||
pc.Title,
|
||||
pc.AreaCode,
|
||||
pc.Phone,
|
||||
pc.MobileAreaCode,
|
||||
pc.MobilePhone,
|
||||
pc.EMail,
|
||||
pc.Notes,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.CustId, pc.Name, pc.Responsibility
|
||||
ORDER BY pc.EnteredDate DESC
|
||||
) AS rn
|
||||
FROM AFW_PolContact pc
|
||||
INNER JOIN AFW_BasicPolInfo p ON p.PolId = pc.PolId
|
||||
INNER JOIN AFW_Customer c ON c.CustId = p.CustId
|
||||
WHERE pc.Status = 'A'
|
||||
AND pc.Name IS NOT NULL
|
||||
AND pc.Name != ''
|
||||
)
|
||||
SELECT CustId, Name, Responsibility, Title, AreaCode, Phone,
|
||||
MobileAreaCode, MobilePhone, EMail, Notes
|
||||
FROM ranked
|
||||
WHERE rn = 1
|
||||
ORDER BY CustId, Responsibility, Name
|
||||
`
|
||||
|
||||
return executeAfwQuery<AfwContact>(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch customers from AFW database
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
fetchAfwCustomers,
|
||||
fetchAfwPolicies,
|
||||
fetchAfwEmployees,
|
||||
fetchAfwContacts,
|
||||
} from './afw-queries'
|
||||
import {
|
||||
mapAfwCustomerToClient,
|
||||
|
|
@ -25,6 +26,9 @@ export interface SyncResult {
|
|||
policiesProcessed: number
|
||||
policiesInserted: number
|
||||
policiesUpdated: number
|
||||
contactsProcessed: number
|
||||
contactsInserted: number
|
||||
contactsUpdated: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
|
@ -54,6 +58,9 @@ export async function runSync(
|
|||
policiesProcessed: 0,
|
||||
policiesInserted: 0,
|
||||
policiesUpdated: 0,
|
||||
contactsProcessed: 0,
|
||||
contactsInserted: 0,
|
||||
contactsUpdated: 0,
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -106,6 +113,14 @@ export async function runSync(
|
|||
console.log(`✅ Customers: ${customerResult.inserted} inserted, ${customerResult.updated} updated`)
|
||||
console.log(`✅ Policies: ${policyResult.inserted} inserted, ${policyResult.updated} updated`)
|
||||
|
||||
// Phase 2: Sync contacts (depends on customers existing)
|
||||
console.log('🚀 Phase 2: Syncing contacts...')
|
||||
const contactResult = await syncContacts()
|
||||
stats.contactsProcessed = contactResult.processed
|
||||
stats.contactsInserted = contactResult.inserted
|
||||
stats.contactsUpdated = contactResult.updated
|
||||
console.log(`✅ Contacts: ${contactResult.inserted} inserted, ${contactResult.updated} updated`)
|
||||
|
||||
// Update sync log
|
||||
await prisma.syncLog.update({
|
||||
where: { id: syncLog.id },
|
||||
|
|
@ -113,11 +128,11 @@ export async function runSync(
|
|||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
rowsProcessed:
|
||||
stats.employeesProcessed + stats.customersProcessed + stats.policiesProcessed,
|
||||
stats.employeesProcessed + stats.customersProcessed + stats.policiesProcessed + stats.contactsProcessed,
|
||||
rowsInserted:
|
||||
stats.employeesInserted + stats.customersInserted + stats.policiesInserted,
|
||||
stats.employeesInserted + stats.customersInserted + stats.policiesInserted + stats.contactsInserted,
|
||||
rowsUpdated:
|
||||
stats.employeesUpdated + stats.customersUpdated + stats.policiesUpdated,
|
||||
stats.employeesUpdated + stats.customersUpdated + stats.policiesUpdated + stats.contactsUpdated,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -140,11 +155,11 @@ export async function runSync(
|
|||
completedAt: new Date(),
|
||||
errorMessage,
|
||||
rowsProcessed:
|
||||
stats.employeesProcessed + stats.customersProcessed + stats.policiesProcessed,
|
||||
stats.employeesProcessed + stats.customersProcessed + stats.policiesProcessed + stats.contactsProcessed,
|
||||
rowsInserted:
|
||||
stats.employeesInserted + stats.customersInserted + stats.policiesInserted,
|
||||
stats.employeesInserted + stats.customersInserted + stats.policiesInserted + stats.contactsInserted,
|
||||
rowsUpdated:
|
||||
stats.employeesUpdated + stats.customersUpdated + stats.policiesUpdated,
|
||||
stats.employeesUpdated + stats.customersUpdated + stats.policiesUpdated + stats.contactsUpdated,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -338,3 +353,92 @@ async function syncPolicies(
|
|||
|
||||
throw lastError || new Error('Policy sync failed after retries')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync contacts from AFW_PolContact into ClientContact with retry logic.
|
||||
* Uses upsert with the dedup composite key (clientId, name, label, source).
|
||||
* Only touches source='ams' records — manual contacts are never modified.
|
||||
*/
|
||||
async function syncContacts(
|
||||
retries: number = 3
|
||||
): Promise<{ processed: number; inserted: number; updated: number }> {
|
||||
let attempt = 0
|
||||
let lastError: Error | null = null
|
||||
|
||||
while (attempt < retries) {
|
||||
try {
|
||||
const afwContacts = await fetchAfwContacts()
|
||||
let inserted = 0
|
||||
let updated = 0
|
||||
|
||||
// Build a lookup of amsCustomerId → local client id
|
||||
const clients = await prisma.client.findMany({
|
||||
select: { id: true, amsCustomerId: true },
|
||||
})
|
||||
const clientMap = new Map(clients.map((c) => [c.amsCustomerId, c.id]))
|
||||
|
||||
for (const contact of afwContacts) {
|
||||
const clientId = clientMap.get(contact.CustId)
|
||||
if (!clientId) continue
|
||||
|
||||
const label = (contact.Responsibility || 'Contact').trim()
|
||||
const phone = contact.AreaCode && contact.Phone
|
||||
? `${contact.AreaCode}${contact.Phone}`
|
||||
: contact.Phone || null
|
||||
const mobilePhone = contact.MobileAreaCode && contact.MobilePhone
|
||||
? `${contact.MobileAreaCode}${contact.MobilePhone}`
|
||||
: contact.MobilePhone || null
|
||||
|
||||
const data = {
|
||||
title: contact.Title || null,
|
||||
phone,
|
||||
mobilePhone,
|
||||
email: contact.EMail || null,
|
||||
notes: contact.Notes || null,
|
||||
}
|
||||
|
||||
const result = await prisma.clientContact.upsert({
|
||||
where: {
|
||||
client_contact_dedup: {
|
||||
clientId,
|
||||
name: contact.Name,
|
||||
label,
|
||||
source: 'ams',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
clientId,
|
||||
name: contact.Name,
|
||||
label,
|
||||
source: 'ams',
|
||||
...data,
|
||||
},
|
||||
update: data,
|
||||
})
|
||||
|
||||
if (result.createdAt.getTime() === result.updatedAt.getTime()) {
|
||||
inserted++
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processed: afwContacts.length,
|
||||
inserted,
|
||||
updated,
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
attempt++
|
||||
|
||||
if (attempt < retries) {
|
||||
const backoffMs = Math.pow(2, attempt) * 1000
|
||||
console.log(`⚠️ Contact sync attempt ${attempt} failed, retrying in ${backoffMs}ms...`)
|
||||
await sleep(backoffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Contact sync failed after retries')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue