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.
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.
better-supabase sql add partiesThe module needs tenant and access.
import { createParties } from "better-supabase/blocks/parties";
const parties = createParties({ 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
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
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:
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
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). The company.merged and person.merged events carry mergedId, so a consumer moves the app's own references, such as deals or comments, to the kept record. A merge is written to the audit log.
Use them as subjects
Register the tables in config.subjects with partiesSubjects(), so comments, tags, attachments, pipelines and the activity feed attach to companies and people:
import { partiesSubjects } from "better-supabase/blocks/parties";
const { company, person } = partiesSubjects({
url: { company: "/companies/{id}", person: "/people/{id}" },
});
export default defineConfig({
subjects: { company, person },
});With the notifications module installed, the owner watches a company or person when they become its owner, and a level they already chose stays.
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:
sql: {
modules: {
parties: { options: { extraColumns: { segment: "text", employees: "integer" } } },
},
},People take app fields through custom fields with the person subject.
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
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).
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 block for many at once). Its CSV import maps rows to savePerson, and an email that is taken points at the existing record.
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 |
Last updated on
Pipelines
Tenant-editable pipelines for any record type, with ordered stages whose categories map to open, success or failure, a stage history with time in stage, board totals per currency, and a status the stage sets through the state machine.
Engagements
Calls, emails, meetings, notes and tasks logged on one or more records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.