# Parties

> Companies and people an organization works with, with lifecycle stages, owners, who works where with a role and a primary contact, dedupe by domain and email, merges and keyset-paged search.

Source: https://bettersupabase.com/docs/blocks/contacts

`parties` keeps the companies and people an organization sells to, supports or hires: the counterparty that deals, quotes, invoices, tickets and applications point at. A company has a name, a domain, a website, a VAT id, an address, an owner and a lifecycle stage. A person has a name, an email, a phone, a title, an owner, a lifecycle stage and optionally the user they sign in as. `company_people` records who works where, with a role and one primary contact per company.

Reading needs `parties.read`, creating, changing, linking and archiving needs `parties.write`, and merging needs `parties.manage`. Admins get every permission, and members get `parties.read` and `parties.write`.

```bash
better-supabase add contacts
```

The module needs `memberships` and `access`.

```ts
import { createContacts } from "better-supabase/blocks/contacts";

const parties = createContacts({ transport });
const acme = await parties
  .saveCompany(organizationId, {
    name: "Acme",
    domain: "acme.com",
    ownerId: userId,
  })
  .orThrow();
const ada = await parties
  .savePerson(organizationId, {
    name: "Ada Lovelace",
    email: "ada@acme.com",
    title: "CTO",
  })
  .orThrow();
await parties
  .linkPerson(acme.id, ada.id, { role: "Champion", primary: true })
  .orThrow();
```

## Companies and people [#companies-and-people]

`saveCompany` and `savePerson` create a record when the input has no `id`, and change the keys the input names otherwise: a key left out keeps its value, and `null` clears it. A new record needs a `name` (`PARTY_NAME_REQUIRED`). Domains and emails are stored in lower case, and an active record can't share its domain or email with another in the organization (`PARTY_DOMAIN_TAKEN`, `PARTY_EMAIL_TAKEN`), so an import finds the existing record instead of a duplicate.

A lifecycle stage is one of `options.companyLifecycles` (default `lead`, `prospect`, `customer`, `churned`) or `options.personLifecycles` (default `lead`, `mql`, `sql`, `opportunity`, `customer`); a new record starts at the first (`PARTY_LIFECYCLE_INVALID` for another value). `archiveCompany(id)` and `archivePerson(id)` hide a record from the lists, and `archived: false` restores it.

`linkPerson(companyId, personId, { role, primary })` links a person to a company or changes the link; a primary link replaces the company's previous primary contact. `peopleOf(companyId)` returns the company's people, primary contact first, and `unlinkPerson` removes a link.

## Search and pages [#search-and-pages]

`listCompanies` and `listPeople` return the active records by name, 50 per page (at most 200). `search` matches the name and the domain or email, and `lifecycle` and `ownerId` filter. Pass the page's `next` as `cursor` for the next page:

```ts
const page = await parties
  .listPeople(organizationId, { search: "acme", limit: 25 })
  .orThrow();
const more = page.next
  ? await parties
      .listPeople(organizationId, { search: "acme", cursor: page.next })
      .orThrow()
  : undefined;
```

The lists, `peopleOf` and the tables all run as the caller: a member reads the organization's records through the `parties.read` policy, and PostgREST reads the tables directly.

## Merge duplicates [#merge-duplicates]

`mergeCompanies(keep, drop)` and `mergePeople(keep, drop)` fill the kept record's empty fields from the dropped one, move the dropped record's links to it, and archive the dropped record with `merged_into` set. Merging needs `parties.manage` (`PARTY_MERGE_INVALID` for records of two organizations, one record twice, or one already merged). In the same transaction the merge moves the dropped record's comments, attachments, reactions, tags, relations and guest grants to the kept record ([Merging subject records](/docs/blocks/sql#merging-subject-records)). The `company.merged` and `person.merged` events carry `mergedId`, so a consumer can move the app's own references, such as deals, to the kept record, or the app can add its own merge handler in SQL. A merge is written to the audit log.

## Use them as subjects [#use-them-as-subjects]

Installing the block registers `company` and `person` as subjects, so [comments](/docs/blocks/comments), [tags](/docs/blocks/tags), [attachments](/docs/blocks/attachments), [pipelines](/docs/blocks/pipelines) and the activity feed attach to them. Set their pages on the entities, or `subject: false` to register one yourself:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    contacts: {
      names: {
        company: { url: "/companies/{id}" },
        person: { url: "/people/{id}" },
      },
    },
  },
});
```

With the [notifications](/docs/blocks/notifications) module installed, the owner watches a company or person when they become its owner, and a level they already chose stays.

## Inbox contacts [#inbox-contacts]

With the [inbox](/docs/blocks/inbox) module installed, parties keeps which person each inbox contact is, so a conversation shows the person's company, deals and history. One person has many contacts (an email address, a WhatsApp number); a contact has one person.

```ts
const person = await parties
  .personForInboxContact(conversation.contactId, { create: true })
  .orThrow();
```

`personForInboxContact(contactId)` returns the linked person. Without a link, it links the active person with the contact's email; with `create: true` and no match, it saves the contact as a new lead first. `linkInboxContact(personId, contactId)` links by hand (a contact linked to someone else moves), and `unlinkInboxContact(contactId)` removes the link.

## Extra columns [#extra-columns]

`options.extraColumns` adds columns to `companies`, such as a segment or an industry. `saveCompany` writes them from `fields`, and every company it returns carries them in `fields`:

```ts title="better-supabase.config.ts"
blocks: {
  contacts: { options: { extraColumns: { segment: "text", employees: "integer" } } },
},
```

Pass a Standard Schema as `fields` to type them: `saveCompany` validates the fields it writes (a `validation` error, before the database), and `saveCompany`, `mergeCompanies` and `listCompanies` parse them on read.

```ts
const parties = createContacts({
  transport,
  fields: v.object({
    segment: v.optional(v.string()),
    employees: v.optional(v.number()),
  }),
});
await parties.saveCompany(organizationId, {
  name: "Acme",
  fields: { segment: "smb" },
});
```

People take app fields through [custom fields](/docs/blocks/custom-fields) with the `person` subject.

## Events [#events]

| Event                                                    | Data                                              |
| -------------------------------------------------------- | ------------------------------------------------- |
| `company.created`, `company.updated`, `company.archived` | `organizationId`, `companyId`, `name`             |
| `company.merged`                                         | `organizationId`, `companyId`, `name`, `mergedId` |
| `company.person_linked`, `company.person_unlinked`       | `organizationId`, `companyId`, `personId`, `role` |
| `person.created`, `person.updated`, `person.archived`    | `organizationId`, `personId`, `name`              |
| `person.merged`                                          | `organizationId`, `personId`, `name`, `mergedId`  |

## Hooks [#hooks]

The module calls `before_company_save(tenant, company uuid, attrs jsonb)`, `after_company_save(company uuid, created boolean)`, `before_person_save` and `after_person_save` around its saves. A `before_` hook raises to refuse the write. The TypeScript client takes `hooks` for its methods (see [extending blocks](/docs/extending/blocks)).

## Wire it to ReUI [#wire-it-to-reui]

The ReUI CRM Contacts Directory reads `listPeople` for its grid, with the Filters `search` and lifecycle values passed through, and its bulk owner action calls `savePerson` with `ownerId` per selected row (or the [bulk](/docs/blocks/bulk-actions) block for many at once). Its CSV import maps rows to `savePerson`, and an email that is taken points at the existing record.

## Errors [#errors]

| Hint                      | Cause                                                             |
| ------------------------- | ----------------------------------------------------------------- |
| `PARTY_FORBIDDEN`         | the caller lacks `parties.write`, or `parties.manage` for a merge |
| `PARTY_ATTRS_INVALID`     | the attributes aren't an object                                   |
| `PARTY_NAME_REQUIRED`     | a new record has no name                                          |
| `PARTY_LIFECYCLE_INVALID` | not a configured lifecycle stage                                  |
| `PARTY_DOMAIN_TAKEN`      | another active company has the domain                             |
| `PARTY_EMAIL_TAKEN`       | another active person has the email                               |
| `PARTY_NOT_FOUND`         | no such record                                                    |
| `PARTY_MERGE_INVALID`     | the records can't be merged                                       |