Files
twenty/packages
martmull d8082d86ca Add Last contact on Companies and Opportunities to last-contact app (#22720)
## After
Example with google company, 2 contacts, 2 opportunities:

<img width="1512" height="332" alt="image"
src="https://github.com/user-attachments/assets/9dac3ac1-bbbb-41a4-b6d6-5f2180e33c13"/>

<img width="1512" height="313" alt="image"
src="https://github.com/user-attachments/assets/e6e326db-942f-45c2-a696-05fa4c32619b"/>

<img width="1309" height="313" alt="image"
src="https://github.com/user-attachments/assets/64ed84ab-f7f0-47e5-b48c-42572c7d2534"/>

## What

Extends the `twenty-last-contact` app so **Last contact** is also
surfaced on **Companies** and **Opportunities**, not just People.

Feedback: Companies and Opportunities already show emails and meetings
from their related Person records on their timeline, so they should
expose the most recent touch as fields too. Terminology and mechanism
intentionally mirror what the app already does on People (no
email-specific fields).

## Changes

- **New fields**, identical in name/label/semantics to the People
headline columns:
- `Company.lastContactAt` and `Opportunity.lastContactAt` (datetime,
"Last contact")
- `Company.lastContactItemMessage` / `lastContactItemCalendarEvent` and
the same pair on Opportunity (morph relation, "Last contact item"), with
inverse `lastContactForCompanies` / `lastContactForOpportunities`
relations on Message and Calendar event
  - All app fields are read-only in the UI (`isUIEditable: false`)
- **View fields** on the All Companies and All Opportunities views (Last
contact + Last contact item, visible).
- **Live updates**: the new `updateRelatedLastContact` util propagates a
person's interaction to their company and their point-of-contact
opportunities, guarded so an older interaction never overwrites a newer
one. It is called from both the email handler (`on-email-interaction`)
and the shared calendar path (`updatePersonLastContactFromCalendar`,
used by `on-calendar-interaction` and `on-calendar-event-started`), so
emails and meetings both count.
- **Backfill** (`backfill-last-contact`): aggregates each person's last
contact up to their company, and each opportunity's from its point of
contact.

Related-record scope: a company's last contact comes from its people; an
opportunity's from its point of contact.

## Not included (deliberately)

The directional fields (`lastInboundAt`, `lastOutboundAt`,
`lastContactBy`) and the `lastEmail`/`lastMeeting` shortcuts are not
mirrored: aggregated across many people they get semantically fuzzy, and
they would double the write amplification on every synced email for
little added signal.

## Tests

- Unit tests for `updateRelatedLastContact` (email and meeting
propagation, recency guard, no-company case).
- Integration tests: a related person's email sets company + opportunity
last contact; a later meeting supersedes an email; an older interaction
does not overwrite a newer one.
- Existing unit + integration tests updated and passing; typecheck and
lint clean.
2026-07-09 18:44:00 +00:00
..