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(', ') +}