Skip to main content

Company Collaboration

Secure, auditable company-to-company collaboration system for establishing business relationships, sharing resources, and managing cross-company permissions.

Core Capabilities

  • Connection Management -- Formal establishment of business relationships between companies
  • Partner Onboarding -- Collaborating partner invitations with default role assignment
  • Invitation Type Discrimination -- Clear distinction between collaborator invitations and connection requests
  • Bidirectional Relationships -- Company connections visible to both parties
  • Audit Compliance -- Complete traceability of all resource sharing activities
  • Communication Control -- Granular notification preferences for collaboration events

For the detailed source-reviewed rulebook, see Collaborators, Partners, and Other Collaborations.

Connection Request Workflow

Connection requests use token-based acceptance for email-based workflows:

Company A sends request  -->  Token generated + email sent to Company B
|
Company B clicks accept link --> Token validated --> Relationship created
| (bidirectional)
(or) Token expired / declined
Admin Approval Required

Only company administrators (Company Admin or Super Admin roles) can accept connection requests. Non-admin users who click the accept link receive a ForbiddenException. This ensures connection approval follows proper authorization channels.

Database Model

model CompanyConnectionRequest {
id String @id @default(uuid())
fromCompanyId String @db.Uuid
toCompanyEmail String
toCompanyId String? @db.Uuid
relationshipType RelationshipType
requestedById String @db.Uuid
approvedById String? @db.Uuid
token String @unique
status ConnectionRequestStatus @default(PENDING)
message String?
responseMessage String?
expiresAt DateTime
// ... timestamps

@@unique([fromCompanyId, toCompanyEmail, relationshipType])
}
info

The @@unique constraint on [fromCompanyId, toCompanyEmail, relationshipType] prevents duplicate requests of the same type to the same recipient.

Key GraphQL Operations

# Send a connection request
mutation CreateConnectionRequest($input: CreateConnectionRequestInput!) {
createCompanyConnectionRequest(input: $input) {
id
status
token
expiresAt
fromCompany { id name }
}
}

# Accept via token (requires authentication and target-company admin role)
mutation AcceptConnection($token: String!) {
acceptCompanyConnectionRequest(token: $token) {
id
status
approvedAt
}
}

# Accept from dashboard (by request ID)
mutation AcceptById($id: ID!) {
acceptCompanyConnectionRequestById(id: $id) {
id
status
approvedAt
}
}

Bidirectional Relationships

When a connection request is accepted, CompanyRelationship records are created in both directions (A→B and B→A), ensuring both companies see the relationship in their connections list.

Query Connected Companies

query {
companyRelationshipsByCompany(
companyId: "company-uuid"
filter: { relationshipType: PARTNERSHIP }
) {
id
fromCompanyId
toCompanyId
relationshipType
fromCompany { id companyName }
toCompany { id companyName }
createdAt
}
}

Collaborating Partner Invitations

When inviting a contact as a collaborating partner, the system:

  1. Creates the collaboration invitation
  2. Defaults the role to "External Sales" if no role is specified
  3. Sends an invitation email to the partner

When the partner accepts the invitation:

  • A collaborator record is created with the assigned role
  • A contact record is automatically created in the inviting company's contact directory (via the ContactAutoCreateListener)

Accepting Without a Company

The acceptCollaborationInvitation mutation treats selectedCompanyId as optional — invitees who don't yet have a company on AIQLick (e.g. a brand-new user invited as a Client/Partner contact) can accept without linking a company. The collaborator record is created with selectedCompanyId: null and status: ACTIVE, and the invitee can link a company later from their dashboard once they create one.

input AcceptCollaborationInvitationInput {
token: String!
selectedCompanyId: ID # nullable — omit if the invitee has no company yet
}

The /message/accept page in the frontend shows a friendly note when the invitee has no companies, instead of blocking acceptance:

You don't have a company on AiQlick yet. You can still accept this invitation now and link a company later from your dashboard.

Invitation Type Discrimination

All invitation and connection request outputs include an invitationType field to help the frontend route to the correct UI:

TypeDescription
COLLABORATORCollaborating partner invitation
CONNECTION_REQUESTCompany-to-company connection request

This field is also included in notification metadata for invitation events.

Job Sharing Permissions

When companies share jobs, three permission levels control access:

PermissionView JobView CandidatesAdd CandidatesManage Pipeline
READ_ONLYYes------
CANDIDATE_ONLYYesYesYes--
FULL_ACCESSYesYesYesYes

Jobs have three company role fields that define relationships:

  • ownerCompanyId -- The company that created the job
  • hiringCompanyId -- The company that is hiring (may differ from owner)
  • supplierCompanyId -- An external recruiter or staffing agency

Audit Logging

Every share operation is logged through CompanyShareAuditService. The base class pattern ensures all sharing services inherit consistent audit behavior:

abstract class CompanyShareBaseService {
protected async logShareAction(params: {
resourceType: ResourceType; // JOB, TALENT, PIPELINE, etc.
resourceId: string;
actionType: ShareActionType; // SHARED, REVOKED, PERMISSION_CHANGED
fromCompanyId: string;
toCompanyId: string;
performedById: string;
permissionLevel?: AccessPermission;
previousPermission?: AccessPermission;
metadata?: any;
}) {
return this.auditService.logShare(params);
}
}

Querying Audit Logs

query GetShareAuditLogs($filter: CompanyShareAuditFilterInput) {
companyShareAuditLogs(filter: $filter) {
id
resourceType
actionType
permissionLevel
previousPermission
createdAt
fromCompany { id name }
toCompany { id name }
performedBy { id firstName lastName }
}
}

query GetAuditStatistics($companyId: ID!) {
companyShareAuditStatistics(companyId: $companyId) {
totalShares
activeShares
revokedShares
byResourceType { resourceType count }
}
}

Collaboration Events

Nine collaboration events trigger notifications via CollaborationNotificationListener:

EventTriggerNotification
talent.sharedTalent(s) shared with user or companyRecipient notified with sharer name and talent details
talent.unsharedTalent share revokedAffected users notified
invitation.receivedCompany invitation sentInvitee receives email + in-app notification
invitation.acceptedInvitation acceptedInviter notified; contact auto-created
invitation.declinedInvitation declinedInviter notified
connection.request.receivedCompany connection request sentTarget company admins notified
connection.request.acceptedConnection request acceptedRequester company notified
permission.changedUser role/permission changedAffected user notified with old and new role
resource.sharedGeneric resource sharedRecipients notified
// Example: emitting a connection request event
this.eventEmitter.emit('connection.request.received', {
requestId: request.id,
requesterCompanyId: fromCompany.id,
targetCompanyId: toCompany.id,
requestMessage: input.message,
});

Security

All collaboration resolvers require authentication via @UseGuards(JwtAuthGuard). Key security measures:

  • Admin-only acceptance -- Connection request acceptance requires Company Admin or Super Admin role
  • Token-based acceptance -- Connection requests use cryptographically secure tokens for email-based workflows
  • Company context validation -- Guards verify the user belongs to the relevant company before allowing operations
  • Plan limit enforcement -- @CheckPlanLimit('maxCollaborationInvites') restricts invitations based on subscription tier
  • Bidirectional duplicate checking -- Connection requests check both directions (A→B and B→A) before allowing new requests
  • Audit trail -- All share and permission change operations are logged with performer identity and timestamps
warning

Connection request tokens have an expiration date. Expired tokens cannot be used to accept requests. The unique constraint [fromCompanyId, toCompanyEmail, relationshipType] prevents duplicate pending requests.

Related entities (fromCompany, toCompany, performedBy) on audit logs and connection requests are loaded via GraphQL field resolvers, keeping queries efficient by only loading relations when explicitly requested by the client.

Job Seeker Profile Invitations

Job seekers can invite employers to view their profile. The flow:

When an employer accepts:

  1. A Talent record is created with source: INVITED
  2. The talent is assigned to the company's Default talent pool (created automatically if it doesn't exist)
  3. The job seeker's active CV is linked to the talent record
  4. A confirmation email is sent to the job seeker
info

Invited talents are immediately visible on the company's talents page. When they become candidates, they receive automatic email notifications for pipeline status changes and interview events — non-invited candidates do not.