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 add contactsThe module needs memberships and access.
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
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). 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). 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
Installing the block registers company and person as subjects, so comments, tags, attachments, pipelines and the activity feed attach to them. Set their pages on the entities, or subject: false to register one yourself:
export default defineConfig({
blocks: {
contacts: {
names: {
company: { url: "/companies/{id}" },
person: { url: "/people/{id}" },
},
},
},
});With the notifications module installed, the owner watches a company or person when they become its owner, and a level they already chose stays.
Inbox contacts
With the 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.
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
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:
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.
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 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.