Skip to main content

Companies & Company Shells

A Company on AIQLick is normally a live workspace — a billing owner, admin roles, a plan, and members. But recruitment and CRM workflows often need to reference an organization before it has joined the platform: a client you invoice, a partner agency you supply, or a prospect you're tracking. A company shell fills that gap.

A company shell is an inactive, CRM-only Company record that stands in for an organization that is not yet on the platform. You can attach contacts, jobs, and CRM relationship links to a shell today, and when that organization eventually onboards, the shell is claimed in place — the same row is upgraded to a live workspace, so everything already attached to it stays attached.

Add-company UI is behind a rollout flag

The backend company-shell behaviour described here (addInactiveCompany, org-number dedup, claim-on-onboarding) is live in production. The frontend "Add company / company shell" UI flow is gated behind the build flag INACTIVE_COMPANIES_ENABLED = process.env.NEXT_PUBLIC_INACTIVE_COMPANIES === "true". Where the flag is off, the add-company entry points are hidden even though the underlying API and claim logic are active.

Company Lifecycle

StatusMeaning
UNCLAIMEDA shell — CRM-only, private, no billing owner or workspace
ActiveA live company workspace with a billing owner, admin role, and plan

Creating a Shell

addInactiveCompany(input: AddInactiveCompanyInput!): Company is a find-or-create keyed by the normalized organization number:

  • No match → creates a Company with status = 'UNCLAIMED', isPublic = false, size defaulting to 'Unknown', and createdByCompanyId set to the caller's company. A shell is not a workspace: no billing owner, no Company Admin role, no workspace/plan-limit provisioning.
  • Match, still unclaimed → links to the existing shell instead of creating a duplicate.
  • Match, already live (a billingOwnerId is set) → throws ConflictException: "A company with this organization number is already active on the platform. Send a connection request instead." This steers you toward the real connection request flow rather than creating a shadow record.

Either way, the caller's company is linked to the shell through a PENDING CompanyConnectionRequest (toCompanyId = shell.id, ~100-year expiry, upsert/idempotent). This pending request is the per-org CRM link. When the shell has no email, a synthetic address shell-<id>@unregistered.aiqlick.local is used so the connection-request model (which keys on toCompanyEmail) stays consistent.

mutation {
addInactiveCompany(input: {
companyName: "Northwind Staffing AS"
orgNo: "NO 998 877 665"
relationshipType: CLIENT
}) {
id
companyName
status # UNCLAIMED
isPublic # false
orgNoNormalized # "NO998877665"
createdByCompanyId
}
}

Relationship Types

The relationshipType on the CRM link (and on connection requests generally) is one of:

ValueMeaning
CLIENTA company you recruit for / invoice
PARTNERSHIPA general business partnership
RECRUITERAn external recruiter you work with
BROKERAGEA brokerage relationship
STRATEGIC_ALLIANCEA strategic alliance
ACQUISITIONAn acquisition relationship

Organization-Number Deduplication

To prevent duplicate shells (and duplicate live companies) for the same organization, Company carries a normalized org-number column:

model Company {
// ...
orgNo String?
orgNoNormalized String? @unique
}

The normalizer runs the same transformation on both sides of the stack — trim → strip all non-alphanumeric characters → uppercase — so "NO 998 877 665", "no-998877665", and "998877665 " all collapse to the same key. The implementations are byte-identical:

  • Frontend: lib/utils/normalizeOrgNo.ts
  • Backend: common/utils/org-no-normalizer.util.ts

Dedup Probe

The companyByOrgNo(orgNo): Company query looks up a company by normalized org number and ignores isPublic — it can surface an existing private shell so a second company doesn't recreate it.

query {
companyByOrgNo(orgNo: "NO 998 877 665") {
id
companyName
status
billingOwnerId # null on a shell, set on a live company
}
}

The frontend AddCompanyCard makes Org No a required field and fires a debounced (450 ms) GET_COMPANY_BY_ORG_NO probe as the user types, showing one of three inline states:

Probe resultInline stateSubmit
No matchavailableAllowed — creates a new shell
Match, unclaimedalready exists… will linkAllowed — links to the existing shell
Match, already activealready active… send a connection requestBlocked
note

The old free-text "Responsible" field was removed from the add-company form. Identity is now anchored on the normalized org number, not a typed-in responsible name.

Claim-on-Onboarding

When an organization that already has a shell finally onboards, createCompany checks orgNoNormalized first:

The critical property is that claimShell() preserves the same row id. Contacts, jobs, and the pending CRM connection requests that other companies already attached to the shell stay linked — nothing is orphaned or re-pointed. If the matched row already has a billingOwnerId, it is genuinely live and the call throws a ConflictException ("already active") rather than double-provisioning.

New Company Fields

Claim-on-onboarding and shells introduce these fields on Company:

FieldTypeDescription
orgNoNormalizedString? @uniqueNormalized org number used for dedup and claim matching
isPublicBoolean (default true)Discovery visibility; shells stay false
statusenum-like StringUNCLAIMED for shells, Active once claimed/created
claimedAtDateTime?When an UNCLAIMED shell was claimed in place
createdByCompanyIdString? (FK)The company that first created the shell

Visibility & Discovery

The Company.isPublic default was flipped from false to true — newly onboarded companies are discoverable by default. Shells are the deliberate exception: they are created with isPublic = false and stay private to the orgs that are linked to them.

The companies() query is scoped through findAllVisibleTo(userId, selectedCompanyId), which returns:

  1. Public companies (isPublic = true), plus
  2. Companies your selected company is linked to — via an established relationship or a pending connection request (this is what surfaces shells you created or share), plus
  3. Your own companies.

So an UNCLAIMED shell with isPublic = false remains private to the linked orgs — it never leaks into the global directory.

Connections Directory

The frontend connections directory shows an unconnected company only if it is isPublic or you already have a link/request to it. It renders two badges:

  • "Your company" — on companies you own (these get no Connect button)
  • "Not on platform yet" — on UNCLAIMED shells

Visibility Toggles

Discovery is user-controllable at several points:

SurfaceToggleDefault
Company onboarding"Visible to other companies"ON
Company edit → Discovery panelDiscovery visibility(persists current value)
Job-seeker onboarding"Public profile"OFF
User personal pagePublic profile togglepersists immediately
Email prefill uses only public data

When a form prefills company details from an email address, it pulls only PUBLIC data. Private shells and non-public companies are never exposed through prefill.

  • Company Collaboration — connection requests, the PENDING-request CRM link, and the "already active → send a connection request" rule
  • Contact Management — attaching phone-book contacts to shells and live companies
  • Job Management — filtered company/contact dropdowns and the unregistered-employer save gate
  • Entity Relationships — the Company entity and its new shell fields