From 8c956c54955f817af98752f24427b57822b83e8b Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 1 May 2026 12:05:42 -0400 Subject: [PATCH] Show organization name for contacts without a personal name Contacts that represent companies rather than individuals have no name value, causing blank labels across the UI and in search results. Add a getContactDisplayName utility that falls back to organization when name is absent, and apply it to the wells list, contacts list, contact show page, and the well detail contacts card. Document the logic and deferred follow-up work in feature_files/contact-display-name.md. --- feature_files/contact-display-name.md | 120 ++++++++++++++++++++++++++ src/components/WellShow/Contacts.tsx | 28 ++++-- src/pages/ocotillo/contact/list.tsx | 2 + src/pages/ocotillo/contact/show.tsx | 3 +- src/pages/ocotillo/thing/list.tsx | 5 +- src/utils/contactDisplayName.ts | 34 ++++++++ 6 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 feature_files/contact-display-name.md create mode 100644 src/utils/contactDisplayName.ts diff --git a/feature_files/contact-display-name.md b/feature_files/contact-display-name.md new file mode 100644 index 00000000..338e280c --- /dev/null +++ b/feature_files/contact-display-name.md @@ -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` | diff --git a/src/components/WellShow/Contacts.tsx b/src/components/WellShow/Contacts.tsx index 9b66d172..1e0bacc8 100644 --- a/src/components/WellShow/Contacts.tsx +++ b/src/components/WellShow/Contacts.tsx @@ -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 @@ -40,6 +41,13 @@ 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 ( {roleType && ( @@ -47,16 +55,16 @@ const ContactBlock = ({ contact }: { contact: IContact }) => { {roleType} )} - {contact.name && ( + {displayName && ( - Contact name + {nameLabel} )} - {contact.name && contact.id && ( + {displayName && contact.id && ( { '&:hover': { textDecoration: 'underline' }, }} > - {contact.name} + {displayName} )} - {contact.name && !contact.id && ( + {displayName && !contact.id && ( - {contact.name} + {displayName} + + )} + {!isOrgOnlyContact && ( + + {contact.organization || 'No organization listed'} )} - - {contact.organization || 'No organization listed'} - {emails.length > 0 && ( { type: 'string', minWidth: 160, flex: 1, + valueGetter: (_: unknown, row: IContact) => getContactDisplayName(row), }, { field: 'organization', diff --git a/src/pages/ocotillo/contact/show.tsx b/src/pages/ocotillo/contact/show.tsx index fbfdab20..f084129f 100644 --- a/src/pages/ocotillo/contact/show.tsx +++ b/src/pages/ocotillo/contact/show.tsx @@ -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' @@ -47,7 +48,7 @@ export const ContactShow = () => { }} > - {contact?.name ?? ''} + {getContactDisplayName(contact ?? {})} {contact?.role && ( diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index b29c4f7b..f9bbf16a 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -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 = () => { @@ -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 ( @@ -247,7 +248,7 @@ export const WellList: React.FC = () => { }} onClick={(e) => e.stopPropagation()} > - {contact.name} + {getContactDisplayName(contact)} ))} diff --git a/src/utils/contactDisplayName.ts b/src/utils/contactDisplayName.ts new file mode 100644 index 00000000..21fe8f46 --- /dev/null +++ b/src/utils/contactDisplayName.ts @@ -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(', ') +}