From 8bb6f2d90774bfb7c2e512475214ff2d5b4badd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:26:02 +0000 Subject: [PATCH] feat: give each of the twelve positions a one-sentence description (#2249) Every position in src/sales/sharing/positions.ts now carries a business `description`, served with the position metadata so Setup -> Positions can say what each one is for. English only: the platform translation bundle has no namespace for positions, and the locale packs translate no position label. test/position-descriptions.test.ts pins that every served position has a one-sentence description that the platform schema keeps. Fixes #2249 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014xanhPEmGTMTfVH4UokwJT --- .changeset/2249-position-descriptions.md | 18 +++++ src/sales/sharing/positions.ts | 92 ++++++++++++++++++++---- test/position-descriptions.test.ts | 46 ++++++++++++ 3 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 .changeset/2249-position-descriptions.md create mode 100644 test/position-descriptions.test.ts diff --git a/.changeset/2249-position-descriptions.md b/.changeset/2249-position-descriptions.md new file mode 100644 index 000000000..a83abf2bd --- /dev/null +++ b/.changeset/2249-position-descriptions.md @@ -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. diff --git a/src/sales/sharing/positions.ts b/src/sales/sharing/positions.ts index 79ac71f05..61073fe37 100644 --- a/src/sales/sharing/positions.ts +++ b/src/sales/sharing/positions.ts @@ -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 @@ -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.', + }, ]; diff --git a/test/position-descriptions.test.ts b/test/position-descriptions.test.ts new file mode 100644 index 000000000..b4e345119 --- /dev/null +++ b/test/position-descriptions.test.ts @@ -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> = ((stack as any).positions ?? []) as Array>; + +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); + }); +});