Skip to main content

Contact Management

Contacts represent people that a company works with — recruiters, hiring managers, clients, or external partners. Each contact links a User to the owner Company, with optional employment tracking and collaboration status.

Data Model

Key Fields

FieldTypeDescription
companyIdUUID?Owner — the company that created this contact
userIdUUIDThe user account representing the contact
responsibleUserIdUUID?Who manages this contact within the owner company
userCompanyRoleIdUUID?Links to the contact's employment at another company (resolves to UserCompanyRole with role name and company)
displayJSON?Denormalized display fields for list views (firstName, lastName, email, title, location, phoneNumber, companyName, etc.)
additionalEmailsJSON?Array of {value, label} pairs for multiple email addresses
additionalPhonesJSON?Array of {value, label} pairs for multiple phone numbers
contactTypesContactType[]Tags for classification (e.g., "Client", "Partner") via ContactToContactTypes join table
Contact vs User

A Contact is a relationship between a company and a user, not the user themselves. The same user can be a contact of multiple companies. The User entity holds the person's actual profile data (name, email, phone).

Contact Creation

When a contact is created with sendInvitation: true (the default), the system:

  1. Validates the company and responsible user exist
  2. Creates or finds the User by email (new users: status: PENDING, isVerified: false, isOnboarding: false, isActive: false)
  3. Checks for duplicate contact per company (same user + same company = rejected)
  4. Builds denormalized display JSON from input fields
  5. Creates the Contact record with all relations (types, clients, employment role)
  6. Sends a password setup invitation email for new users

Guards: JwtAuthGuard, PlanLimitGuard with @CheckPlanLimit('maxContacts')

mutation {
createContact(createContactInput: {
email: "jane@example.com"
firstName: "Jane"
lastName: "Doe"
companyId: "owner-company-uuid"
responsibleUserId: "manager-uuid"
sendInvitation: true
contactTypeIds: ["type-uuid"]
clientCompanyIds: ["client-company-uuid"]
userCompanyRoleId: "role-uuid"
additionalEmails: [{ value: "jane.alt@example.com", label: "Personal" }]
additionalPhones: [{ value: "+1234567890", label: "Mobile" }]
}) {
id
user { id email }
company { id companyName }
responsibleUser { id firstName lastName }
contactTypes { id name }
clients { id companyName }
additionalEmails { value label }
additionalPhones { value label }
}
}

Full Input Fields

FieldTypeRequiredDescription
firstNameStringYesContact's first name
lastNameStringYesContact's last name
emailStringYesContact's email (used to find/create User)
responsibleUserIdUUIDYesWho manages this contact
middleNameStringNoMiddle name
companyIdUUIDNoOwner company ID
titleStringNoJob title
locationStringNoLocation
phoneNumberStringNoPrimary phone number
profileImageUrlStringNoAvatar URL
companyNameStringNoDisplay company name (stored in display JSON)
companyImageUrlStringNoCompany logo URL
contactCompanyIdStringNoNon-FK company identifier
contactTypeIds[UUID]NoContact type tags to assign
clientCompanyIds[UUID]NoClient company connections
sendInvitationBooleanNoSend setup email to new users (default: true)
userCompanyRoleIdUUIDNoLink to contact's employment role
additionalEmails[{value, label}]NoExtra email addresses with labels
additionalPhones[{value, label}]NoExtra phone numbers with labels

Auto-Creation from Partner Onboarding

When a collaborating partner invitation is accepted, a contact record is automatically created for the accepted partner in the inviting company's context. This is handled by the ContactAutoCreateListener which listens to the invitation.accepted event.

The auto-created contact:

  • Links to the accepted partner's user account (acceptedByUserId)
  • Sets the inviter as the responsible user (invitedByUserId)
  • Populates the display field from the accepted user's profile (firstName, lastName, middleName, email, title, location, phoneNumber, profileImageUrl)
  • Uses the invitation's company context (falls back to the inviter's selectedCompanyId)
  • Skips creation if a contact already exists for that user in the company
  • Uses a fire-and-forget pattern — failures are logged but don't block the invitation flow

Contact Notes

Internal notes on contacts for tracking interactions, follow-ups, or relationship context. Similar to Talent Notes.

# Create a note (gated by @CheckPlanLimit('maxContactNotes'))
mutation {
createContactNote(createContactNoteInput: {
contactId: "contact-uuid"
authorId: "current-user-uuid"
title: "Initial call"
content: "Discussed partnership opportunity. Follow up next week."
}) {
id title content createdAt
Author { id firstName lastName }
}
}

# List notes for a contact (ordered by createdAt DESC)
query {
contactNotes(contactId: "contact-uuid") {
id title content createdAt
Author { id firstName lastName }
}
}

# Update
mutation {
updateContactNote(id: "note-uuid", updateContactNoteInput: {
title: "Updated title"
content: "Updated content"
}) { id title content updatedAt }
}

# Delete
mutation {
removeContactNote(id: "note-uuid") { success message }
}

The contactNotes query accepts an optional contactId parameter — if omitted, returns all notes (no scoping). Notes are always ordered by createdAt DESC.

Service-level methods also support:

  • findByContact(contactId) — notes for a specific contact
  • findByAuthor(authorId) — notes by a specific author

Notes cascade-delete when the parent Contact is deleted (Prisma onDelete: Cascade).

Multi-Value Emails & Phones

Contacts support multiple email addresses and phone numbers via the additionalEmails and additionalPhones JSON fields. Each entry is an object with value and label:

[
{ "value": "jane@work.com", "label": "Work" },
{ "value": "jane@personal.com", "label": "Personal" }
]

These fields are:

  • Set on creation via CreateContactInput.additionalEmails / additionalPhones
  • Updated on edit via UpdateContactInput (merged with existing entries in service layer)
  • Merged during contact merge — deduplicated by value field (secondary entries added to primary if not already present)
  • Captured in merge snapshots — preserved in ContactMergeHistory.secondaryContactSnapshots for audit
  • Stored as JSON in PostgreSQL — not separate tables
Primary vs Additional

The primary email comes from the linked User.email field. The primary phone comes from User.phoneNumber. The additionalEmails and additionalPhones fields store extra contact methods beyond the primary ones.

Filter by ContactType label (SUP-00121/122, 2026-04-21; phone-book contacts 2026-07)

contactsByLabel(label: String!) returns the current company's contacts that carry a given ContactType name. The Job form (useCompanyContacts) uses it to populate two company + contact pickers:

  • Hiring Company ← contacts labelled "Client"
  • Supplied By ← contacts labelled "Supplier" and "Partner"
query { contactsByLabel(label: "Client") { id user { id firstName lastName email } userCompanyRole { companyId companyName } clients { id companyName } } }

The query is scoped to the caller's selectedCompanyId. The frontend resolves each Contact to a Company id via userCompanyRole.companyId (primary) or clients[].id (fallback), so the Job mutation still stores hiringCompanyId / suppliedById as plain Company FKs and hiringCompanyContactId / suppliedByContactId as User FKs (contact.user.id).

Contacts are a phone-book — no active account required

When you pick a company in the Job form, its contact dropdown is built from the phone-book Contact records for that company (from contactsByLabel), merged with the company's registered employees (company.users) and de-duplicated by user id. So a contact appears even if they are not an active platform user, and even if the company is an inactive shell with no registered employees.

Before 2026-07 the contact dropdown was populated only from company.users, so inactive-shell companies and non-registered (phone-book) contacts showed nothing. The current logic sources contacts from the Contact records first, which is why it works regardless of account/company status.

An inactive shell is a CRM-only company record for an org that isn't on the platform yet. Note that while a contact whose employer is only free-text (no registered Company FK) still appears in the picker, selecting it blocks the Job from saving — see Unregistered employers & the save gate. Register the company (as a shell) first.

Sorting

The contactsByCompany query supports sorting by multiple fields:

Sort FieldDescription
FIRST_NAMESort by user's first name
LAST_NAMESort by user's last name
COMPANY_NAMESort by the contact's associated company name
CREATED_ATSort by creation date (default)
query {
contactsByCompany(
companyId: "uuid"
query: {
sortBy: FIRST_NAME
sortOrder: ASC
}
) {
id
user { firstName lastName }
}
}

Default sort is CREATED_AT DESC when no sort options are provided.

Bulk Import (CSV)

Import contacts in bulk from a CSV file uploaded to S3. The import supports both camelCase and snake_case column headers. Parsed with PapaParse (header: true, skipEmptyLines: true, headers auto-trimmed).

Guard: JwtAuthGuard (no plan limit check — see limitations below)

CSV Format

ColumnRequiredAliases
emailYes
firstNameYesfirst_name
lastNameYeslast_name
phoneNophoneNumber, phone_number
titleNo
middleNameNomiddle_name
locationNo
companyNameNocompany_name, company

Import Flow

Stage 1 — Get a presigned upload URL:

mutation {
upload {
generateUploadUrl(filename: "contacts.csv") {
uploadUrl # S3 presigned PUT URL (15 min expiry)
objectPath # "uploads/{uuid}/contacts.csv"
}
}
}

Stage 2 — Upload the CSV directly to S3 using the presigned URL (HTTP PUT from client).

Stage 3 — Trigger the import:

mutation {
importContacts(input: {
objectPath: "uploads/{uuid}/contacts.csv"
companyId: "company-uuid"
responsibleUserId: "manager-uuid"
}) {
totalRows
created
skipped
failed
errors {
row
email
reason
}
}
}

Validation Rules

Each row is validated independently — failures don't stop the import:

CheckError Reason
Email missing"Missing email address"
Email invalid (regex)"Invalid email format"
Both firstName and lastName empty"Missing first name or last name"
Company or responsibleUser doesn't existBadRequestException (aborts entire import)

Row numbers in errors are 1-based (row 2 = first data row after header).

Duplicate Handling

Duplicates are detected per company: if a contact already exists for the same user (by email) in the same company, the row is skipped (counted in skipped, not in errors). Same user in a different company creates a new contact.

Concurrent imports are safe — Prisma P2002 (unique constraint) on user creation is caught and the existing user is re-fetched.

What Import Creates

For each new contact:

  • User record (if email not found): status: PENDING, isVerified: false, isOnboarding: false, isActive: false, empty password
  • Contact record: linked to company and responsible user, with denormalized display JSON (firstName, lastName, email, title, location, phoneNumber, companyName)
Limitations
  • No invitation emails — imported contacts do not receive setup invitations. Users remain inactive until they set a password through another flow.
  • No contactTypeIds support — contacts must be tagged separately after import using updateContact.
  • No additionalEmails/additionalPhones support — only the primary email from the CSV is used.
  • No plan limit check — the importContacts mutation does not enforce @CheckPlanLimit('maxContacts'), so imports can exceed the company's plan limit.

Merge Contacts

Combine multiple contacts into one, merging notes, types, and client connections into the primary contact. Every merge creates an audit trail with snapshots of the deleted contacts.

mutation {
mergeContacts(input: {
primaryContactId: "keep-this-contact-uuid"
secondaryContactIds: ["merge-from-uuid-1", "merge-from-uuid-2"]
}) {
id
ContactNote { id }
contactTypes { id name }
clients { id companyName }
}
}

Validation

  • Primary contact cannot appear in the secondary contacts list (ConflictException)
  • Primary contact must exist (NotFoundException)
  • All secondary contacts must exist — error message lists the specific missing IDs

Merge Steps (transactional)

The entire operation runs inside a Prisma $transaction. If any step fails, everything rolls back:

  1. Snapshot — each secondary contact's pre-merge state is captured (display, types, clients, notes count, shares count, additionalEmails, additionalPhones, createdAt)
  2. Notes — all ContactNote records from secondaries are reassigned to the primary (contactId updated)
  3. Contact Types — unique types from secondaries are added to the primary (deduplicates by type ID, skips existing)
  4. Client Companies — client connections from secondaries are connected to the primary (deduplicates by company ID)
  5. Additional Emails/Phones — entries from secondaries are merged into the primary, deduplicated by value field (label preserved from first occurrence)
  6. External SharesContactExternalShare tokens from secondaries are reassigned to the primary (share URLs stay valid)
  7. Cleanup — secondary contacts' join table entries are deleted, then the secondary contacts themselves are permanently deleted
  8. Audit — a ContactMergeHistory record is created with the snapshots, the performer's identity, and the count of merged contacts
  9. Return — the merged primary contact with all relations
warning

Merge is destructive — secondary contacts are permanently deleted. However, the ContactMergeHistory audit trail preserves a snapshot of each deleted contact for review.

Merge History (Audit Trail)

Every merge operation creates a ContactMergeHistory record inside the same transaction. This guarantees that if the merge succeeds, the audit trail exists — and if the transaction rolls back, no orphaned history is left behind.

Each history record contains:

FieldTypeDescription
primaryContactIdUUIDThe contact that received merged data
companyIdUUID?The owner company at time of merge
performedByIdUUIDThe user who performed the merge
performedByNameString?Display name of the performer (denormalized)
secondaryContactSnapshotsJSONArray of pre-merge snapshots (see below)
secondaryContactCountIntNumber of contacts merged
mergedAtDateTimeWhen the merge occurred

Secondary Contact Snapshot

Each entry in the secondaryContactSnapshots array captures:

{
"id": "deleted-contact-uuid",
"userId": "user-uuid",
"display": { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com" },
"contactTypeNames": ["Client", "Partner"],
"clientCompanyIds": ["company-uuid-1"],
"notesCount": 3,
"externalSharesCount": 1,
"createdAt": "2025-06-15T10:30:00.000Z"
}

Query Merge History

Per-contact history — view all merges where a specific contact was the primary:

query {
contactMergeHistory(
contactId: "primary-contact-uuid"
pagination: { skip: 0, take: 10 }
) {
mergeHistories {
id
mergedAt
secondaryContactCount
performedByName
performedBy { id firstName lastName email }
secondaryContactSnapshots {
id
display
contactTypeNames
clientCompanyIds
notesCount
externalSharesCount
}
}
totalCount
hasMore
}
}

Company-wide history — view all merges across the company with optional filtering:

query {
contactMergeHistories(
filter: {
performedById: "user-uuid" # optional: filter by who merged
primaryContactId: "uuid" # optional: filter by target contact
startDate: "2025-01-01" # optional: date range start
endDate: "2025-12-31" # optional: date range end
}
pagination: { skip: 0, take: 20 }
) {
mergeHistories {
id
mergedAt
secondaryContactCount
performedByName
primaryContact { id display }
}
totalCount
hasMore
}
}

Both queries are company-scoped — they only return history for the authenticated user's selectedCompanyId.

Pagination

ParameterDefaultMinMaxDescription
skip00Number of records to skip
take201100Number of records to return

Results are always ordered by mergedAt DESC (newest first).

Cascade Behavior

  • If the primary contact is deleted, its merge history records are cascade-deleted
  • If the company is deleted, companyId is set to null (history preserved)
  • If the performing user is deleted, merge history records are cascade-deleted

External Sharing

Share contacts externally via secure, time-limited tokens. Follows the same pattern used for talent pools, pipelines, and other shared entities. Uses the shared ExternalSharingService in src/common/external-sharing/.

Create a Share

mutation {
createContactExternalShare(input: {
contactId: "contact-uuid"
email: "external-user@partner.com"
expirationHours: 168 # 7 days (default)
}) {
id
token
email
expiresAt
isActive
}
}

Input validation:

FieldTypeConstraints
contactIdUUIDRequired, must belong to user's selected company
emailStringRequired, valid email format
expirationHoursIntOptional, default 168 (7 days), min 1, max 8760 (1 year)

How It Works

  1. Validate ownershipcontact.companyId must match currentUser.selectedCompanyId
  2. Generate token — two UUIDs concatenated (68 characters): {uuid-with-hyphens}{uuid-without-hyphens} via crypto.randomUUID()
  3. Create recordContactExternalShare with token, expiry date, accessCount: 0, isActive: true
  4. Send email notification (non-blocking) — HTML email with "View Contact" button linking to {FRONTEND_URL}/shared-contact/{token}. Includes contact name, title, company name, and expiry notice in days
Email Delivery

Email notification is fire-and-forget — if the email fails to send (SMTP error, missing FRONTEND_URL env var), the share is still created and the token remains usable. The email failure is logged but does not block share creation. The FRONTEND_URL environment variable must be set for email links to work.

View Active Shares

query {
contactExternalShares(contactId: "contact-uuid") {
id
email
token
expiresAt
accessCount
isActive
createdAt
}
}

Returns all shares for the contact ordered by createdAt DESC. Validates contact ownership.

Revoke a Share

mutation {
revokeContactExternalShare(id: "share-uuid") {
success
message
}
}

Sets isActive = false (soft delete). The share record is preserved for audit purposes but the token becomes invalid. Validates contact ownership before revoking.

Public Access (no authentication)

The shared contact is accessible at /shared-contact/{token} — a public visitor route (app/(visitor)/shared-contact/[token]/page.tsx) that requires no authentication.

query {
sharedContact(token: "secure-token-string") {
id
user { firstName lastName email title profileImageUrl phoneNumber linkedinUrl }
company { id companyName }
contactTypes { name }
}
}

Access flow:

  1. Look up ContactExternalShare by token
  2. Validate: isActive === true AND expiresAt > now (strictly greater, not equal)
  3. Increment accessCount (raw SQL, fire-and-forget)
  4. Return the contact with user profile, company, and contact types

Error responses:

  • Token not found: NotFoundException — "Contact not found or access link is invalid"
  • Token expired or revoked: ForbiddenException — "Access link has expired or been revoked"
No PII Masking

Unlike the CV external sharing system (which masks sensitive fields for public access), contact sharing returns full user data including email, phone, and LinkedIn URL. This is intentional — the contact owner explicitly chose to share this information.

Token Lifecycle

StateisActiveexpiresAtAccessible
ActivetrueFutureYes
ExpiredtruePastNo (validation blocks)
RevokedfalseAnyNo
Expired Token Cleanup

The ExternalSharingService has a cleanupExpiredShares() method that sets isActive = false on expired tokens across all 7 sharing entity types. However, no cron job is currently scheduled to call this method. Expired tokens are still blocked by the isTokenValid() check at query time, but they remain with isActive = true in the database until manually cleaned up.

Authorization

All contact operations are company-scoped. The authenticated user's selectedCompanyId must match the contact's companyId:

  • contactsByCompany — only returns contacts for the user's selected company
  • contact, updateContact, removeContact — verify the contact belongs to the user's company
  • importContacts — verifies the target company matches the user's selected company
  • createContactExternalShare, revokeContactExternalShare, contactExternalShares — validate contact ownership via company match
warning

Attempting to access contacts from a different company returns a ForbiddenException. This prevents IDOR (Insecure Direct Object Reference) attacks.

Plan Limits

Contact operations are gated by subscription plan limits:

DecoratorLimitControls
@CheckPlanLimit('maxContacts')Per companyTotal contacts the company can create
@CheckPlanLimit('maxContactNotes')Per companyTotal contact notes across all contacts

Connection Status

Each contact exposes two computed fields that track the relationship between the authenticated user and the contact person:

Collaboration Status

collaborationStatus — user-to-user collaboration status between the current user and the contact's user. Resolved via DataLoader (batched per request to prevent N+1).

FieldTypeDescription
statusString?PENDING, ACCEPTED, DECLINED, EXPIRED, CANCELLED, ACTIVE, INACTIVE, LEFT
collaboratorIdUUID?ID of the Collaborator record
invitationIdUUID?ID of the CollaborationInvitation record
directionString?INVITED_BY_ME or INVITED_BY_THEM
invitedAtDate?When the invitation was sent
acceptedAtDate?When the invitation was accepted
unavailableReasonString?Human-readable reason if unavailable (e.g., self-check)

Returns null if the current user IS the contact user (prevents self-collaboration check).

Company Partnership Status

companyPartnershipStatus — company-to-company relationship between the owner company and the contact's employment company. Also resolved via DataLoader.

FieldTypeDescription
hasRelationshipBooleanWhether an established partnership exists
relationshipTypeString?PARTNERSHIP, ACQUISITION, STRATEGIC_ALLIANCE, BROKERAGE, CLIENT, RECRUITER
requestStatusString?PENDING, ACCEPTED, DECLINED, EXPIRED, CANCELLED
requestIdUUID?ID of the CompanyConnectionRequest
requestDirectionString?SENT or RECEIVED
createdAtDate?When the relationship/request was created
unavailableReasonString?Human-readable reason if unavailable (e.g., no employment company, same company)

Returns { hasRelationship: false, unavailableReason: "..." } when the contact has no linked employment company or when the owner and employment companies are the same.

Key Queries

# Contacts for a company (with sorting)
query {
contactsByCompany(
companyId: "uuid"
query: { sortBy: LAST_NAME, sortOrder: ASC }
) {
id
display
user { id firstName lastName email }
responsibleUser { id firstName lastName }
contactTypes { id name }
ContactNote { id title }
additionalEmails { value label }
additionalPhones { value label }
userCompanyRole { id roleName companyName }
collaborationStatus { status direction collaboratorId invitationId }
companyPartnershipStatus { hasRelationship relationshipType requestStatus requestDirection }
}
}

# Single contact with full relations
query {
contact(id: "uuid") {
id
display
user { id firstName lastName email phoneNumber }
company { id companyName }
responsibleUser { id firstName lastName }
clients { id companyName }
ContactNote { id title content createdAt Author { id firstName lastName } }
additionalEmails { value label }
additionalPhones { value label }
userCompanyRole { id roleName roleDescription companyName }
collaborationStatus { status direction invitedAt acceptedAt }
companyPartnershipStatus { hasRelationship relationshipType }
}
}

# All contacts (super admin only)
query {
contacts {
id user { id email }
}
}

Contact Types

Contact types are classification tags applied to contacts via a many-to-many relationship (ContactToContactTypes join table). Types are global (not company-scoped) — all companies share the same set of types.

Schema

model ContactType {
id String @id @default(uuid())
name String @unique
label String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Default Types

Seeded at setup (via upsert, safe for re-runs):

NameLabel
ClientClient
PartnerPartner
SupplierSupplier
ProspectProspect
LeadLead

Additional types can be created by any user.

CRUD Operations

# List all types (sorted by name ASC)
query { contactTypes { id name label } }

# Get single type
query { contactType(id: "type-uuid") { id name label } }

# Create (name must be unique)
mutation {
createContactType(input: { name: "Vendor", label: "Vendor" }) {
id name label
}
}

# Update
mutation {
updateContactType(id: "type-uuid", input: { label: "Updated Label" }) {
id name label
}
}

# Delete (blocked if type is in use)
mutation {
removeContactType(id: "type-uuid") { success message }
}

Deletion safety: removeContactType checks if the type is assigned to any contacts. If in use, it throws BadRequestException with the count: "Cannot delete contact type because it is in use by X contact(s)".

Error handling: Prisma error codes are mapped — P2002 (duplicate name) → ConflictException, P2025 (not found) → NotFoundException, P2003 (foreign key) → BadRequestException.

Module Structure

The contacts feature is implemented in src/connections/contacts/:

contacts/
├── contacts.module.ts
├── contacts.resolver.ts # Main Contact CRUD + merge + import
├── contacts.service.ts # Core business logic
├── contact-notes.resolver.ts # ContactNote CRUD
├── contact-notes.service.ts
├── contact-type.resolver.ts # ContactType CRUD
├── contact-types.service.ts
├── contact-external-share.resolver.ts # External sharing
├── contact-merge-history.resolver.ts # Merge audit trail queries
├── services/
│ ├── contact-external-share.service.ts
│ ├── contact-merge-history.service.ts
│ ├── contact-connection-dataloader.service.ts # DataLoader for status fields
│ └── contact-import.service.ts # CSV import processing
├── listeners/
│ └── contact-auto-create.listener.ts # invitation.accepted handler
├── entities/ # GraphQL ObjectTypes
├── dto/ # Input types & output types
└── __tests__/

Exported services: ContactsService, ContactNotesService, ContactTypesService, ContactConnectionDataLoaderService

Module imports: PrismaModule, ExternalSharingModule

  • ContactType — Global classification tags (many-to-many via ContactToContactTypes)
  • ContactNote — Internal notes with author tracking, cascade-deletes with parent contact
  • ContactExternalShare — External sharing tokens with access tracking (cascade-deletes with parent contact)
  • ContactMergeHistory — Audit trail for merge operations, with pre-deletion snapshots of secondary contacts
  • UserCompanyRole — Links a contact to their employment at a company (with role name and description)
  • Collaborator / CollaborationInvitation — User-to-user collaboration tracking (resolved via DataLoader)
  • CompanyRelationship / CompanyConnectionRequest — Company-to-company partnership tracking (resolved via DataLoader)