Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions feature_files/contact-display-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
id: contact-display-name
title: Contact Display Name
status: partial
created: 2026-05-01
updated: 2026-05-01
related_files:
- src/utils/contactDisplayName.ts
- src/components/WellShow/Contacts.tsx
- src/pages/ocotillo/contact/list.tsx
- src/pages/ocotillo/contact/show.tsx
- src/pages/ocotillo/thing/list.tsx
- api/search.py # OcotilloAPI repo
deferred_items:
- id: org-contact-type
title: "Mark contacts as org type"
description: >
Requires a new contact_type lexicon term ("Organization"), an Alembic
migration, API filter support, and UI filtering/display changes in both
repos. Would make org-only contacts filterable and visually distinct
from person contacts.
effort: medium
priority: low
- id: confidential-org-masking
title: "Mask organization as well as name for confidential contacts"
description: >
sanitizeContact currently replaces name with "Confidential Contact" but
leaves organization visible. For org-only contacts the org becomes the
display name, which may expose sensitive information. sanitizeContact
should be updated to also blank organization when release_status is
private and the viewer lacks confidential access. As of 2026-05-01,
2,052 of 2,126 contacts (96%) are private, so this gap affects nearly
all contact records for viewers without elevated access.
effort: low
priority: medium
---

# Contact Display Name

## Problem

The `Contact` data model allows `name` to be null when `organization` is
present (the API enforces that at least one of the two fields is set). This
means org-only contacts have always had a blank display name everywhere in
the UI: the wells list contacts column, the well detail Contacts card, the
contacts list page, and contact search results.

## Resolution logic

A single utility function in `src/utils/contactDisplayName.ts` resolves the
display name for any contact object:

```
getContactDisplayName(contact):
1. name (non-empty after trim) → return name
2. organization (non-empty) → return organization
3. neither → return "" (guarded by API validation)
```

For a list of contacts (for example the Contacts column on the wells list):

```
getContactsLabel(contacts[]):
map each contact through getContactDisplayName,
filter out blank results,
join with ", "
```

## All edge cases

| Scenario | name | organization | Display value |
|---|---|---|---|
| Person only | "Matt Zwager" | null | "Matt Zwager" |
| Person + org | "Matt Zwager" | "NMBGMR" | "Matt Zwager" |
| Org only | null | "NPS" | "NPS" |
| Both null | null | null | "" (blocked by API) |
| Empty string name | "" | "NPS" | "NPS" (trim treats "" as null) |
| Confidential person | "Confidential Contact" | "NMBGMR" | "Confidential Contact" |
| Confidential org-only | "Confidential Contact" | "NPS" | "Confidential Contact" |

**Important:** `getContactDisplayName` must be called on the contact value
*after* `sanitizeContact` has run. `sanitizeContact` sets `name` to
`"Confidential Contact"` for private contacts when the viewer lacks
confidential access. Calling `getContactDisplayName` first and then
`sanitizeContact` would cause org-only private contacts to fall through
to their organization value and expose it.

## Current contact_type values

The `contact_type` lexicon category currently has three terms:

- **Primary** — the main contact for a well
- **Secondary** — an additional contact for a well
- **Field Event Participant** — a person who participated in a field event

None of these distinguish a person from an organization. This is tracked as a
deferred item (`org-contact-type`) for a future cycle.

## Why the utility function approach was chosen

The two data-level alternatives were considered and rejected:

- **Copy org name into the name field**: Would conflict with the
`uq_contact_name_organization` unique constraint for rows where org already
equals name. Also creates duplicated data that must stay in sync.
- **Mark contacts as org type**: Valid longer-term but requires schema changes,
a migration, and API and UI filtering work in both repos.

The utility function fixes all four display surfaces immediately with no data
migration, no schema change, and one small API label fix.

## Where the logic is applied

| Surface | File | Change |
|---|---|---|
| Search results label | `api/search.py` (OcotilloAPI) | `c.name` → `c.name or c.organization` |
| Wells list Contacts column | `src/pages/ocotillo/thing/list.tsx` | `valueGetter` and `renderCell` use `getContactDisplayName` |
| Well detail Contacts card | `src/components/WellShow/Contacts.tsx` | name gates use `getContactDisplayName`; org line suppressed when display name equals org |
| Contacts list Name column | `src/pages/ocotillo/contact/list.tsx` | `valueGetter` uses `getContactDisplayName` |
| Contact show page title | `src/pages/ocotillo/contact/show.tsx` | title uses `getContactDisplayName` |
28 changes: 19 additions & 9 deletions src/components/WellShow/Contacts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Directions } from '@mui/icons-material'
import { Link as RouterLink } from 'react-router'
import type { IContact } from '@/interfaces/ocotillo'
import { formatPhone, formatContactAddress, formatAddress } from '@/utils'
import { getContactDisplayName } from '@/utils/contactDisplayName'

const getGoogleMapsAddressUrl = (address: string) => {
if (!address || address === 'N/A') return null
Expand Down Expand Up @@ -40,23 +41,30 @@ const ContactBlock = ({ contact }: { contact: IContact }) => {
const phones = contact.phones ?? []
const addresses = contact.addresses ?? []

const displayName = getContactDisplayName(contact)
// When the contact has no personal name and the org is used as the display
// name, suppress the org line below to avoid showing it twice.
const isOrgOnlyContact =
!contact.name?.trim() && !!contact.organization?.trim()
const nameLabel = isOrgOnlyContact ? 'Organization' : 'Contact name'

return (
<Stack spacing={0.5} component="div">
{roleType && (
<Typography variant="body2" component="div">
{roleType}
</Typography>
)}
{contact.name && (
{displayName && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600, display: 'block', letterSpacing: 0.3 }}
>
Contact name
{nameLabel}
</Typography>
)}
{contact.name && contact.id && (
{displayName && contact.id && (
<Typography
variant="body2"
component={RouterLink}
Expand All @@ -67,17 +75,19 @@ const ContactBlock = ({ contact }: { contact: IContact }) => {
'&:hover': { textDecoration: 'underline' },
}}
>
{contact.name}
{displayName}
</Typography>
)}
{contact.name && !contact.id && (
{displayName && !contact.id && (
<Typography variant="body2" component="div">
{contact.name}
{displayName}
</Typography>
)}
{!isOrgOnlyContact && (
<Typography variant="body2" color="text.secondary" component="div">
{contact.organization || 'No organization listed'}
</Typography>
)}
<Typography variant="body2" color="text.secondary" component="div">
{contact.organization || 'No organization listed'}
</Typography>
{emails.length > 0 && (
<Typography
variant="caption"
Expand Down
2 changes: 2 additions & 0 deletions src/pages/ocotillo/contact/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AddIcon from '@mui/icons-material/Add'
import { useLink, useNavigation } from '@refinedev/core'
import { settings } from '@/settings'
import { formatAppDateTime, formatPhone } from '@/utils'
import { getContactDisplayName } from '@/utils/contactDisplayName'
import { ListPage } from '@/components'
import { useAccessCapabilities } from '@/hooks'
import { filterConfidentialRows, sanitizeContacts } from '@/utils'
Expand Down Expand Up @@ -42,6 +43,7 @@ export const ContactList: React.FC = () => {
type: 'string',
minWidth: 160,
flex: 1,
valueGetter: (_: unknown, row: IContact) => getContactDisplayName(row),
},
{
field: 'organization',
Expand Down
3 changes: 2 additions & 1 deletion src/pages/ocotillo/contact/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useShow } from '@refinedev/core'
import { Show } from '@refinedev/mui'
import { useAccessCapabilities } from '@/hooks'
import { sanitizeContact } from '@/utils'
import { getContactDisplayName } from '@/utils/contactDisplayName'
import { Box, Chip, Stack, Typography } from '@mui/material'
import Grid from '@mui/material/Grid2'
import { IContact } from '@/interfaces/ocotillo'
Expand Down Expand Up @@ -47,7 +48,7 @@ export const ContactShow = () => {
}}
>
<Typography variant="h3" fontWeight={700}>
{contact?.name ?? ''}
{getContactDisplayName(contact ?? {})}
</Typography>
{contact?.role && (
<Chip label={contact.role} size="small" variant="outlined" />
Expand Down
5 changes: 3 additions & 2 deletions src/pages/ocotillo/thing/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PictureAsPdf } from '@mui/icons-material'
import { ListPage } from '@/components/ListPage'
import { ISpring, IWell } from '@/interfaces/ocotillo'
import { displayWellSiteName, formatAppDate, formatAppDateTime } from '@/utils'
import { getContactDisplayName } from '@/utils/contactDisplayName'
import { WellListColumnLabels } from '@/well-list/wellListColumnLabels'

export const SpringList: React.FC = () => {
Expand Down Expand Up @@ -223,7 +224,7 @@ export const WellList: React.FC = () => {
minWidth: 180,
flex: 1,
valueGetter: (_: unknown, row: IWell) =>
row.contacts?.map((c) => c.name ?? '').join(', ') ?? '',
row.contacts?.map((c) => getContactDisplayName(c)).join(', ') ?? '',
renderCell: (params) => {
const contacts = params.row.contacts ?? []
return (
Expand All @@ -247,7 +248,7 @@ export const WellList: React.FC = () => {
}}
onClick={(e) => e.stopPropagation()}
>
{contact.name}
{getContactDisplayName(contact)}
</Link>
</span>
))}
Expand Down
34 changes: 34 additions & 0 deletions src/utils/contactDisplayName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Utilities for resolving a consistent display name for a contact record.
*
* Full logic spec: feature_files/contact-display-name.md
*
* Resolution order (single contact):
* 1. name (non-empty after trim) → return name
* 2. organization (non-empty) → return organization
* 3. neither → return "" (guarded by API validation)
*
* Note: always call getContactDisplayName on the value *after* sanitizeContact
* has run so that confidential contacts surface as "Confidential Contact"
* rather than falling through to their organization field.
*/

export interface ContactDisplayFields {
name?: string | null
organization?: string | null
}

export function getContactDisplayName(contact: ContactDisplayFields): string {
const name = contact.name?.trim()
if (name) return name
return contact.organization?.trim() ?? ''
}

/**
* Returns a comma-separated label for a list of contacts.
* Each contact is resolved through getContactDisplayName; blank results are
* filtered out before joining.
*/
export function getContactsLabel(contacts: ContactDisplayFields[]): string {
return contacts.map(getContactDisplayName).filter(Boolean).join(', ')
}
Loading