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
| Field | Type | Description |
|---|---|---|
companyId | UUID? | Owner — the company that created this contact |
userId | UUID | The user account representing the contact |
responsibleUserId | UUID? | Who manages this contact within the owner company |
userCompanyRoleId | UUID? | Links to the contact's employment at another company (resolves to UserCompanyRole with role name and company) |
display | JSON? | Denormalized display fields for list views (firstName, lastName, email, title, location, phoneNumber, companyName, etc.) |
additionalEmails | JSON? | Array of {value, label} pairs for multiple email addresses |
additionalPhones | JSON? | Array of {value, label} pairs for multiple phone numbers |
contactTypes | ContactType[] | Tags for classification (e.g., "Client", "Partner") via ContactToContactTypes join table |
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:
- Validates the company and responsible user exist
- Creates or finds the
Userby email (new users:status: PENDING,isVerified: false,isOnboarding: false,isActive: false) - Checks for duplicate contact per company (same user + same company = rejected)
- Builds denormalized
displayJSON from input fields - Creates the
Contactrecord with all relations (types, clients, employment role) - 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
| Field | Type | Required | Description |
|---|---|---|---|
firstName | String | Yes | Contact's first name |
lastName | String | Yes | Contact's last name |
email | String | Yes | Contact's email (used to find/create User) |
responsibleUserId | UUID | Yes | Who manages this contact |
middleName | String | No | Middle name |
companyId | UUID | No | Owner company ID |
title | String | No | Job title |
location | String | No | Location |
phoneNumber | String | No | Primary phone number |
profileImageUrl | String | No | Avatar URL |
companyName | String | No | Display company name (stored in display JSON) |
companyImageUrl | String | No | Company logo URL |
contactCompanyId | String | No | Non-FK company identifier |
contactTypeIds | [UUID] | No | Contact type tags to assign |
clientCompanyIds | [UUID] | No | Client company connections |
sendInvitation | Boolean | No | Send setup email to new users (default: true) |
userCompanyRoleId | UUID | No | Link to contact's employment role |
additionalEmails | [{value, label}] | No | Extra email addresses with labels |
additionalPhones | [{value, label}] | No | Extra 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
displayfield 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 contactfindByAuthor(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
valuefield (secondary entries added to primary if not already present) - Captured in merge snapshots — preserved in
ContactMergeHistory.secondaryContactSnapshotsfor audit - Stored as JSON in PostgreSQL — not separate tables
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).
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 Field | Description |
|---|---|
FIRST_NAME | Sort by user's first name |
LAST_NAME | Sort by user's last name |
COMPANY_NAME | Sort by the contact's associated company name |
CREATED_AT | Sort 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
| Column | Required | Aliases |
|---|---|---|
email | Yes | — |
firstName | Yes | first_name |
lastName | Yes | last_name |
phone | No | phoneNumber, phone_number |
title | No | — |
middleName | No | middle_name |
location | No | — |
companyName | No | company_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:
| Check | Error 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 exist | BadRequestException (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
displayJSON (firstName, lastName, email, title, location, phoneNumber, companyName)
- 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
importContactsmutation 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:
- Snapshot — each secondary contact's pre-merge state is captured (display, types, clients, notes count, shares count, additionalEmails, additionalPhones, createdAt)
- Notes — all
ContactNoterecords from secondaries are reassigned to the primary (contactId updated) - Contact Types — unique types from secondaries are added to the primary (deduplicates by type ID, skips existing)
- Client Companies — client connections from secondaries are connected to the primary (deduplicates by company ID)
- Additional Emails/Phones — entries from secondaries are merged into the primary, deduplicated by
valuefield (label preserved from first occurrence) - External Shares —
ContactExternalSharetokens from secondaries are reassigned to the primary (share URLs stay valid) - Cleanup — secondary contacts' join table entries are deleted, then the secondary contacts themselves are permanently deleted
- Audit — a
ContactMergeHistoryrecord is created with the snapshots, the performer's identity, and the count of merged contacts - Return — the merged primary contact with all relations
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:
| Field | Type | Description |
|---|---|---|
primaryContactId | UUID | The contact that received merged data |
companyId | UUID? | The owner company at time of merge |
performedById | UUID | The user who performed the merge |
performedByName | String? | Display name of the performer (denormalized) |
secondaryContactSnapshots | JSON | Array of pre-merge snapshots (see below) |
secondaryContactCount | Int | Number of contacts merged |
mergedAt | DateTime | When 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
| Parameter | Default | Min | Max | Description |
|---|---|---|---|---|
skip | 0 | 0 | — | Number of records to skip |
take | 20 | 1 | 100 | Number 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,
companyIdis 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:
| Field | Type | Constraints |
|---|---|---|
contactId | UUID | Required, must belong to user's selected company |
email | String | Required, valid email format |
expirationHours | Int | Optional, default 168 (7 days), min 1, max 8760 (1 year) |
How It Works
- Validate ownership —
contact.companyIdmust matchcurrentUser.selectedCompanyId - Generate token — two UUIDs concatenated (68 characters):
{uuid-with-hyphens}{uuid-without-hyphens}viacrypto.randomUUID() - Create record —
ContactExternalSharewith token, expiry date,accessCount: 0,isActive: true - 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 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:
- Look up
ContactExternalShareby token - Validate:
isActive === trueANDexpiresAt > now(strictly greater, not equal) - Increment
accessCount(raw SQL, fire-and-forget) - 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"
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
| State | isActive | expiresAt | Accessible |
|---|---|---|---|
| Active | true | Future | Yes |
| Expired | true | Past | No (validation blocks) |
| Revoked | false | Any | No |
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 companycontact,updateContact,removeContact— verify the contact belongs to the user's companyimportContacts— verifies the target company matches the user's selected companycreateContactExternalShare,revokeContactExternalShare,contactExternalShares— validate contact ownership via company match
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:
| Decorator | Limit | Controls |
|---|---|---|
@CheckPlanLimit('maxContacts') | Per company | Total contacts the company can create |
@CheckPlanLimit('maxContactNotes') | Per company | Total 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).
| Field | Type | Description |
|---|---|---|
status | String? | PENDING, ACCEPTED, DECLINED, EXPIRED, CANCELLED, ACTIVE, INACTIVE, LEFT |
collaboratorId | UUID? | ID of the Collaborator record |
invitationId | UUID? | ID of the CollaborationInvitation record |
direction | String? | INVITED_BY_ME or INVITED_BY_THEM |
invitedAt | Date? | When the invitation was sent |
acceptedAt | Date? | When the invitation was accepted |
unavailableReason | String? | 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.
| Field | Type | Description |
|---|---|---|
hasRelationship | Boolean | Whether an established partnership exists |
relationshipType | String? | PARTNERSHIP, ACQUISITION, STRATEGIC_ALLIANCE, BROKERAGE, CLIENT, RECRUITER |
requestStatus | String? | PENDING, ACCEPTED, DECLINED, EXPIRED, CANCELLED |
requestId | UUID? | ID of the CompanyConnectionRequest |
requestDirection | String? | SENT or RECEIVED |
createdAt | Date? | When the relationship/request was created |
unavailableReason | String? | 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):
| Name | Label |
|---|---|
Client | Client |
Partner | Partner |
Supplier | Supplier |
Prospect | Prospect |
Lead | Lead |
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
Related Entities
ContactType— Global classification tags (many-to-many viaContactToContactTypes)ContactNote— Internal notes with author tracking, cascade-deletes with parent contactContactExternalShare— External sharing tokens with access tracking (cascade-deletes with parent contact)ContactMergeHistory— Audit trail for merge operations, with pre-deletion snapshots of secondary contactsUserCompanyRole— 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)
Related
- Company Collaboration — Connection requests, partner management, and sharing
- Talent Notes — Similar note pattern on talent profiles
- Interview Scheduling — Includes interview prep notifications