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.
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
| Status | Meaning |
|---|---|
UNCLAIMED | A shell — CRM-only, private, no billing owner or workspace |
Active | A 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
Companywithstatus = 'UNCLAIMED',isPublic = false,sizedefaulting to'Unknown', andcreatedByCompanyIdset 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
billingOwnerIdis set) → throwsConflictException: "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:
| Value | Meaning |
|---|---|
CLIENT | A company you recruit for / invoice |
PARTNERSHIP | A general business partnership |
RECRUITER | An external recruiter you work with |
BROKERAGE | A brokerage relationship |
STRATEGIC_ALLIANCE | A strategic alliance |
ACQUISITION | An 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 result | Inline state | Submit |
|---|---|---|
| No match | available | Allowed — creates a new shell |
| Match, unclaimed | already exists… will link | Allowed — links to the existing shell |
| Match, already active | already active… send a connection request | Blocked |
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:
| Field | Type | Description |
|---|---|---|
orgNoNormalized | String? @unique | Normalized org number used for dedup and claim matching |
isPublic | Boolean (default true) | Discovery visibility; shells stay false |
status | enum-like String | UNCLAIMED for shells, Active once claimed/created |
claimedAt | DateTime? | When an UNCLAIMED shell was claimed in place |
createdByCompanyId | String? (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:
- Public companies (
isPublic = true), plus - 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
- 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
UNCLAIMEDshells
Visibility Toggles
Discovery is user-controllable at several points:
| Surface | Toggle | Default |
|---|---|---|
| Company onboarding | "Visible to other companies" | ON |
| Company edit → Discovery panel | Discovery visibility | (persists current value) |
| Job-seeker onboarding | "Public profile" | OFF |
| User personal page | Public profile toggle | persists immediately |
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.
Related Pages
- 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
Companyentity and its new shell fields