Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/2249-position-descriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'hotcrm': patch
---

Every position carries a one-sentence description of what it is for

**The twelve positions now say what they are for (#2249).** Each position is served with a
one-sentence business description, so an administrator can tell who belongs in it before assigning
anyone. For example, *Sales Manager* reads "Runs a sales team: approves new accounts, lead
conversions and large deals, and edits every active customer account.", and *NA Sales Team* reads
"Territory team for North America: edits every account in the North America territory." Nothing
about what a position grants has changed.

On ObjectStack 18.1.0 the Setup → Positions list shows each position's holders and permission sets
but no description column yet; the descriptions appear there once the console lists them.

The descriptions are in English in every language, as the position names already are: the platform
does not yet translate positions.
92 changes: 80 additions & 12 deletions src/sales/sharing/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,50 @@
* `position_name_fold_grant` on every boot and which the platform has ruled to
* retire (objectstack#13419, ruling B). A position added here that should grant
* capability declares its sets the same way.
*
* ### Every position says what it is for (#2249)
*
* Each position carries a one-sentence business `description`, served with
* the position's metadata (`GET /meta/position`) so Setup → Positions can tell
* an admin who belongs in it before anyone is assigned. Measured on 18.1.0:
* the list there shows Name, Label, Holders and Permission sets
* (objectstack#25333) and no Description column yet. It names the business
* job, not the rule wiring: the grants themselves are the sharing rules and
* `permissionSets`, and a description that restated those would drift.
* `test/position-descriptions.test.ts` pins that every position has one.
*
* The descriptions, like the labels, are English only: the platform's
* translation bundle has no namespace for positions, so the four locale packs
* translate neither (the same holds for the labels today).
*/
export const CrmPositions = [
{ name: 'executive', label: 'Executive' },
{ name: 'sales_director', label: 'Sales Director' },
{ name: 'sales_manager', label: 'Sales Manager', permissionSets: ['sales_manager'] },
{ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['sales_rep'] },
{ name: 'service_director', label: 'Service Director' },
{
name: 'executive',
label: 'Executive',
description: 'Company leadership: reads every large open deal across the pipeline, while the deal team keeps ownership.',
},
{
name: 'sales_director',
label: 'Sales Director',
description: 'Leads the sales organization, gives the final sign-off on the largest deals and reads every large open opportunity.',
},
{
name: 'sales_manager',
label: 'Sales Manager',
description: 'Runs a sales team: approves new accounts, lead conversions and large deals, and edits every active customer account.',
permissionSets: ['sales_manager'],
},
{
name: 'sales_rep',
label: 'Sales Representative',
description: 'Works leads and opportunities day to day and receives new leads by round-robin assignment.',
permissionSets: ['sales_rep'],
},
{
name: 'service_director',
label: 'Service Director',
description: 'Oversees customer service and reads every open critical case while a service manager handles it.',
},
// The escalation pool works tickets with the agent's own grants: the #1779
// ruling (director seat, decision batch #92, 2026-09-08) bound the EXISTING
// `service_agent` set to this position, and refused both a manager-specific
Expand All @@ -41,12 +78,43 @@ export const CrmPositions = [
// the name fold picked up; it is declared here instead and the copy is gone.
// A manager grant that DIFFERS from an agent's comes back as a product card
// with its puller named, and only then as a set of its own.
{ name: 'service_manager', label: 'Service Manager', permissionSets: ['service_agent'] },
{ name: 'service_agent', label: 'Service Agent', permissionSets: ['service_agent'] },
{ name: 'marketing_director', label: 'Marketing Director' },
{ name: 'marketing_manager', label: 'Marketing Manager' },
{ name: 'marketing_user', label: 'Marketing User', permissionSets: ['marketing_user'] },
{
name: 'service_manager',
label: 'Service Manager',
description: 'Works the escalation pool: escalated cases are handed to this position, which edits every open critical case.',
permissionSets: ['service_agent'],
},
{
name: 'service_agent',
label: 'Service Agent',
description: 'Handles customer cases, receives new cases by round-robin assignment and pulls unassigned cases from triage.',
permissionSets: ['service_agent'],
},
{
name: 'marketing_director',
label: 'Marketing Director',
description: 'Leads marketing and edits every live campaign, whoever owns it.',
},
{
name: 'marketing_manager',
label: 'Marketing Manager',
description: 'Manages marketing campaigns and edits every live campaign, whoever owns it.',
},
{
name: 'marketing_user',
label: 'Marketing User',
description: 'Plans and runs marketing campaigns and their members day to day.',
permissionSets: ['marketing_user'],
},
// Territory groupings referenced by the account sharing rules.
{ name: 'na_sales_team', label: 'NA Sales Team' },
{ name: 'eu_sales_team', label: 'EU Sales Team' },
{
name: 'na_sales_team',
label: 'NA Sales Team',
description: 'Territory team for North America: edits every account in the North America territory.',
},
{
name: 'eu_sales_team',
label: 'EU Sales Team',
description: 'Territory team for Europe: edits every account in the Europe (EMEA) territory.',
},
];
46 changes: 46 additions & 0 deletions test/position-descriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { PositionSchema } from '@objectstack/spec/identity';
import stack from './helpers/composed-stack';
import { CrmPositions } from '../src/sales/sharing/positions';

/**
* Every HotCRM position says what it is for (#2249).
*
* A position's `description` is served with its metadata for Setup →
* Positions to show. Before #2249 none of the twelve carried one, so Setup
* could not answer "who belongs in Sales Manager?". This pins the business
* fact that every declared position,
* as the composed app serves it, has a one-sentence description.
*
* A sentence here means: non-empty, starts with a capital letter, ends with
* one full stop and contains no other sentence break. It is deliberately not a
* copy of the wording, which is the source's to own.
*/
const served: Array<Record<string, unknown>> = ((stack as any).positions ?? []) as Array<Record<string, unknown>>;

describe('position descriptions (#2249)', () => {
it('serves all twelve positions declared in positions.ts', () => {
expect(CrmPositions).toHaveLength(12);
expect(served.map((p) => p.name).sort()).toEqual(CrmPositions.map((p) => p.name).sort());
});

it.each(CrmPositions.map((p) => [p.name]))('%s has a one-sentence business description', (name) => {
const position = served.find((p) => p.name === name);
const description = position?.description;
expect(typeof description).toBe('string');
const text = String(description).trim();
expect(text).toMatch(/^[A-Z].*\.$/);
// One sentence: the only full stop (or ? / !) followed by a space or the end is the last character.
expect(text.slice(0, -1)).not.toMatch(/[.!?](\s|$)/);
expect(text.length).toBeLessThanOrEqual(160);
// The platform's own schema keeps the key (an unknown or stripped key would read back undefined).
expect(PositionSchema.parse(position).description).toBe(description);
});

it('gives no two positions the same description', () => {
const texts = CrmPositions.map((p) => p.description);
expect(new Set(texts).size).toBe(texts.length);
});
});
Loading