Field sales ops looks different once you grow past 20 people. At that size, you stop running one team and start running teams—plural. A residential crew focuses on storm damage. A commercial team chases contractors. Specialists handle high-value claims. They work different zones, follow different playbooks, compete on different leaderboards. A single-team ops setup doesn't scale; it breaks under the weight of cross-team complexity.
Velocity X was built for this. The architecture doesn't bolt multi-team support on top—it bakes it in from the schema up. Every route is assigned to a team. Every zone belongs to a team. The Brain—our AI context engine—runs separate per-team and knows only that team's leads, jobs, and history. Leaderboards are team-scoped. RLS policies gate who sees what. Share the infrastructure, partition the data.
This is how field-sales orgs actually work. This is how we scaled it.
Why Single-Team Setups Don't Scale to Mid-Size Orgs
A single-team app works great when everyone shares the same map, same lead pool, same KPIs. All jobs flow to one queue. All reps compete on one leaderboard. The ops person runs one report. Clean. Simple.
But at 15–20 reps, you hire a second team. Suddenly you have to answer: Does the residential crew see commercial leads? (No. They don't know how to close them.) Do both teams share the same zone map? (Not really—commercial has different routing.) Do their reps see each other on the same leaderboard? (Absolutely not—different skill, different payout.)
If your app stores "team_id" only on the rep's profile, you hit walls fast. A rep switches teams mid-week—do they see the old team's jobs? (Unclear.) A manager runs a report across teams for head office—does the database even support it? (It shouldn't, for security.) Your single-team Brain context trained on 100% of your leads now has to filter to one team's leads on every query, slowing it down and poisoning the recommendations with cross-team noise.
The second team is when you realize you built for one team all along.
The Multi-Team Architecture
Velocity X stores team membership and team-owned resources explicitly. Every table that matters has a team_id. Queries partition by team. RLS policies enforce it. The database is the source of truth, not the app logic.
{`create table teams (
id uuid primary key,
org_id uuid references organizations(id) on delete cascade,
name text not null, -- "Residential", "Commercial", "Specialists"
zone_id uuid references zones(id), -- Each team owns a zone
created_at timestamp default now()
);
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
org_id uuid,
team_id uuid references teams(id) on delete set null,
role text, -- staff, manager, admin
name text
);
create table jobs (
id uuid primary key,
org_id uuid,
team_id uuid references teams(id) on delete cascade,
assigned_to uuid references profiles(id),
claim_amount decimal,
status text,
created_at timestamp
);
create table zones (
id uuid primary key,
team_id uuid references teams(id) on delete cascade,
geom geometry,
name text
);
create index on jobs(org_id, team_id);
create index on profiles(org_id, team_id);
create index on zones(team_id);`}
Notice: team_id lives on jobs, zones, profiles, and the explicit teams table itself. When a rep is assigned to the commercial team, their profiles.team_id points to the commercial team row. When you fetch that rep's jobs, the database filters to jobs with the same team_id. No manual filtering in the app. No queries that accidentally cross team boundaries.
RLS: The Boundary Enforcer
Once the schema is partitioned, RLS policies make it airtight. A staff member can only see jobs for their team. A manager can see their team's jobs and rollups. An admin sees all teams (within their org). The database enforces it; the app never has to think about it.
{`alter table jobs enable row level security;
-- Staff: see only their own jobs + team jobs
create policy "staff_see_team_jobs" on jobs
for select
using (
auth.jwt() ->> 'org_id' = org_id::text
and auth.jwt() ->> 'team_id' = team_id::text
);
-- Staff: can only update their own jobs
create policy "staff_update_own" on jobs
for update
using (
auth.jwt() ->> 'org_id' = org_id::text
and auth.jwt() ->> 'team_id' = team_id::text
and assigned_to = auth.uid()
);
-- Manager: see entire team
create policy "manager_see_team" on jobs
for select
using (
auth.jwt() ->> 'org_id' = org_id::text
and auth.jwt() ->> 'team_id' = team_id::text
and auth.jwt() ->> 'role' in ('manager', 'admin')
);
-- Admin: see all orgs + all teams
create policy "admin_bypass" on jobs
for all
using (
auth.jwt() ->> 'org_id' = org_id::text
and auth.jwt() ->> 'role' = 'admin'
);`}
A commercial rep cannot query for residential jobs. The RLS policy literally rejects the query. The database will return an empty set or an error before any data is exposed. This is hard security, not soft filtering.
Brain Contexts: Separate AI Models Per Team
Velocity X's Brain—the AI engine that surfaces next actions, predicts outcomes, recommends leads—runs per team. It's trained on that team's historical jobs, their conversion rates, their playbook. A residential Brain knows which weather patterns trigger roof claims and which homeowner objections to expect. A commercial Brain knows contractor networks and supply-chain timelines.
If you merged all leads into one Brain, the recommendations would be noise. A commercial rep would get flagged for a residential claim in their zone. A residential playbook would be suggested for a contractor negotiation. The signal dies in the cross-team static.
So we partition the Brain by team:
{`create table brain_contexts (
id uuid primary key,
team_id uuid references teams(id) on delete cascade,
org_id uuid,
embedding_index text, -- Pinecone or similar vector DB index
training_data jsonb, -- Last N completed jobs for this team
updated_at timestamp default now()
);
-- When a rep completes a job, we add to their team's Brain context
create or replace function update_team_brain_on_job_completion()
returns trigger as $$
begin
if new.status = 'completed' then
update brain_contexts
set training_data = jsonb_set(
training_data,
array['jobs', (new.id::text)],
to_jsonb(new)
)
where team_id = new.team_id;
end if;
return new;
end;
$$ language plpgsql;
create trigger job_completion_updates_brain
after update on jobs
for each row execute function update_team_brain_on_job_completion();`}
Every team's Brain is live-trained. When a rep closes a residential claim with a roof-damage client, the residential Brain learns that profile. When a commercial rep lands a contractor, the commercial Brain updates its network graph. The AI is sharp because it's focused.
Leaderboards: Team-Specific, Cross-Team Visibility
Staff see only their team's leaderboard. A residential rep doesn't care that the commercial team closed 40 claims last week—that's not their fight. But ops and execs see all teams' performance stacked side-by-side. "Residential: 28 jobs, 94% close rate. Commercial: 12 jobs, 87% close rate. Specialists: 3 jobs, 100% close rate."
{`create table leaderboards (
id uuid primary key,
team_id uuid references teams(id),
org_id uuid,
period text, -- 'week', 'month', 'all_time'
rep_id uuid references profiles(id),
jobs_completed integer,
total_claim_value decimal,
close_rate decimal,
calculated_at timestamp
);
-- Team-scoped leaderboard query
select rep_id, jobs_completed, total_claim_value
from leaderboards
where team_id = auth.jwt() ->> 'team_id'
and period = 'week'
order by jobs_completed desc;
-- Ops / exec view: all teams
select
t.name as team,
count(distinct l.rep_id) as reps,
sum(l.jobs_completed) as total_jobs,
sum(l.total_claim_value) as revenue,
avg(l.close_rate) as avg_close_rate
from leaderboards l
join teams t on l.team_id = t.id
where l.org_id = auth.jwt() ->> 'org_id'
and period = 'week'
group by t.id
order by total_jobs desc;`}
The same leaderboards table serves both views. RLS filters by team for staff. Admins see everything. Calculated at schedule time (e.g., weekly) to avoid expensive aggregations on read.
Cross-Team Handoffs: When a Lead Changes Hands
A lead comes in as residential. The homeowner mentions they also own a rental property—now it's commercial. You need to move the job to the commercial team. How?
{`-- Create audit trail
create table job_transfers (
id uuid primary key,
job_id uuid references jobs(id),
from_team_id uuid references teams(id),
to_team_id uuid references teams(id),
reason text, -- "Cross-category", "Capacity", "Specialist escalation"
transferred_by uuid references profiles(id),
transferred_at timestamp default now()
);
-- Update the job
update jobs
set team_id = $1
where id = $2
returning *;
-- Log the transfer
insert into job_transfers (job_id, from_team_id, to_team_id, reason, transferred_by)
values ($2, $3, $1, $4, auth.uid());
-- Notify the receiving team's manager
-- (via Edge Function or trigger + notification queue)`}
The job's team_id changes. Both teams have an audit trail. The receiving team's Brain is notified to retrain on the new context. The old team's Brain removes it. Handoffs are tracked and audited, not silent data moves.
Zone Management: Maps per Team
Each team has its own zone. Geospatial data lives in the zones table, keyed to team_id. When a rep opens the app, their map renders only their team's zone—no other territory is visible. It's faster (smaller geom data), clearer (no zone confusion), and safer (no temptation to poach neighboring territory).
{`-- Fetch rep's team zone
select z.geom, z.name
from zones z
join profiles p on z.team_id = p.team_id
where p.id = auth.uid();
-- Map renders this boundary and all jobs within it
-- Rep cannot see or edit zones outside their team`}
If a team needs to expand, you update the geom. If two teams share a boundary region, you create a shared-zone table or a "dispute resolution" workflow—but by default, zones are team-owned.
Scaling Across Multiple Orgs with Multiple Teams
An org_id column ties everything to a customer account. A customer runs 3 teams. A second customer runs 2 teams. Your database holds it all. Org-level queries are trivial—every table has org_id. Team-level queries add one more filter.
{`-- Get all teams for an org (for an admin dashboard)
select * from teams where org_id = auth.jwt() ->> 'org_id';
-- Get all jobs for a team in an org
select * from jobs
where org_id = auth.jwt() ->> 'org_id'
and team_id = $1;
-- Get all zones for an org
select * from zones
where team_id in (
select id from teams where org_id = auth.jwt() ->> 'org_id'
);`}
The partitioning cascades: org → teams → jobs, zones, leaderboards. Add a new team, and RLS policies automatically scope it. No schema changes. No new tables. One partition model scales to 100 orgs × 10 teams each.
Performance at Scale
Partitioning by team makes your queries faster, not slower. A query that filters to a team's jobs (indexed on team_id) scans 5% of the table instead of 100%. Your geospatial zone queries run on one team's boundary, not all boundaries. Your Brain training loops are smaller, tighter.
Index everything by org_id and team_id:
{`create index idx_jobs_org_team on jobs(org_id, team_id);
create index idx_zones_team on zones(team_id);
create index idx_profiles_org_team on profiles(org_id, team_id);
create index idx_leaderboards_org_team on leaderboards(org_id, team_id);`}
RLS policies are deterministic—Postgres caches their results. A rep's JWT claim for team_id is constant per session. Policies hit index scans, not full table scans. At 100K jobs across 10 teams, a rep still queries in <20ms.
Frequently Asked Questions
Can a rep belong to multiple teams?
Not in the schema above—profiles.team_id is a single reference. If you need multi-team reps (rare), make team_id an array or create a team_memberships junction table. This adds complexity; avoid it unless the business demands it.
What if I want cross-team collaboration?
Create a shared_jobs table or flag certain jobs as "cross-team". Update RLS policies to allow managers (not staff) to query across teams. Or create a separate "escalation" workflow where a job moves up to a manager who sees all teams. Keep staff siloed; let ops connect the dots.
How do I migrate from single-team to multi-team?
Add the team_id column to jobs, zones, and profiles. Write a migration that assigns all existing data to a "legacy" team. Update RLS policies. Test in staging. Deploy. Staff see no change because they're all on the same team; ops can now add new teams and partition them forward.
Can I merge two teams?
Yes. Update all jobs and zones to point to the receiving team. Delete the old team row (cascade handles profiles). Merged data stays in the database under the new team's partition. No re-training of the Brain—just point its brain_contexts.team_id to the new team.
How do I handle admins who need to see all teams?
Set their role = 'admin' and team_id = null. Update RLS policies: if role is admin, return all data regardless of team_id. They see the union of all teams.
What about team budgets or quotas?
Add a quotas table keyed to (team_id, period) with target_jobs_completed and target_revenue. Calculate actual numbers from leaderboards. Same partitioning logic.
The Verdict
Multi-team architecture looks complex on paper. In practice, it's the same pattern repeated: add team_id everywhere, index it, gate it with RLS, let the database do the work. You build once. You scale to 50 teams without touching the code. A residential crew, a commercial crew, a specialist team—each with their own zone, their own Brain, their own leaderboard—all sharing one database, one codebase, one ops backend. That's how you scale field sales from startup to enterprise.
See how this fits into our role-gated dashboards model, and check pricing to run this on Velocity X.