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:
lorentz 2026-04-08 02:08:27 +00:00
parent 9dd30de358
commit 8aa20ea751
6 changed files with 238 additions and 6 deletions

38
ondeck/SERVER.md Normal file
View 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
View file

@ -0,0 +1,20 @@
Heres 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. Ive 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 didnt 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 dont appear as pending or overdue. Also, I want to make sure we are pulling in tasks on the currently active policies, Im 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.

View file

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

View file

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

View file

@ -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
*/

View file

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