All files / src/components/crm lead-write-scope.ts

100% Statements 4/4
100% Branches 2/2
100% Functions 1/1
100% Lines 4/4

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106                                                                                                                7x                   7x                                     187x                                     7x  
/**
 * What a signed-in member may change on a lead — the UI half of the server's
 * staff write-scope allowlist.
 *
 * WHY THIS EXISTS
 * `requireProAccess` (`apps/api/src/middleware/auth.middleware.ts`) now limits
 * `staff` to a ten-row write allowlist. Exactly one of those rows is a lead
 * route:
 *
 *     { methods: ["POST"], path: /^\/crm\/leads\/[^/]+\/notes$/ }
 *
 * Every other lead write — `PATCH …/stage`, `PATCH …/quote-value`,
 * `PATCH …/order-value`, `PATCH …/:leadId`, `POST …/leads`, archive,
 * unarchive, reminders, documents — returns 403 for staff before the handler
 * runs. A UI that offers a button the API refuses is worse than one that does
 * not, so this module is how the screens agree with the middleware.
 *
 * **It is a mirror, not a second source of truth.** The allowlist is the
 * access control; this only decides what to draw. If the allowlist gains or
 * loses a lead row, change this file in the same commit.
 *
 * THE SHARP EDGE, NOW GUARDED ON BOTH SIDES
 * `POST …/crm/leads/:leadId/notes` accepts an optional `newStageId` and moves
 * the lead, so the one allowlisted route could reach the one capability staff
 * are not meant to have. The server now refuses that field for staff
 * (`routes/pro/crm/notes.routes.ts`), which is where the access control
 * belongs. `canChangeStage` still gates the stage-change control inside the
 * note forms, because a button the API will refuse is worse than no button —
 * but it is the courtesy, not the gate.
 */
 
/** The roles `/api/pro/me` can report. `admin` is a platform admin. */
export type ProRole = "owner" | "manager" | "staff" | "admin";
 
export interface LeadWriteScope {
	/** Move a lead between pipeline stages (incl. marking it Won or Lost). */
	canChangeStage: boolean;
	/** Edit the lead's own fields: name, phone, email, locality, scope. */
	canEditLead: boolean;
	/** Set the quote value or the order value. */
	canEditValue: boolean;
	/** Create a lead, archive or unarchive one, set reminders, add documents. */
	canManageLeads: boolean;
	/**
	 * Add, rename, reorder or delete a pipeline stage, and add or retire a lead
	 * source — `/settings/pipeline`. Separate from `canChangeStage`, which is
	 * about moving ONE lead: these are different capabilities and conflating
	 * them is how a settings page ends up editable by someone who can only
	 * drag a card.
	 */
	canConfigurePipeline: boolean;
	/** Write a note, and mark the lead contacted. The staff capability. */
	canWriteNotes: boolean;
}
 
/** Everything a lead can be written to — owner, manager, platform admin. */
const FULL: LeadWriteScope = {
	canChangeStage: true,
	canEditLead: true,
	canEditValue: true,
	canManageLeads: true,
	canConfigurePipeline: true,
	canWriteNotes: true,
};
 
/** Staff: notes only. One capability, matching the one allowlisted route. */
const NOTES_ONLY: LeadWriteScope = {
	canChangeStage: false,
	canEditLead: false,
	canEditValue: false,
	canManageLeads: false,
	canConfigurePipeline: false,
	canWriteNotes: true,
};
 
/**
 * What `proRole` may write.
 *
 * An unknown or not-yet-loaded role gets the full scope on purpose: the server
 * is the gate, and locking an owner out of their own pipeline for the one
 * render before `/api/pro/me` resolves — or forever, if a future role is added
 * to the API and not to this list — is the worse failure. Only the role the
 * middleware actually restricts is restricted here.
 */
export function leadWriteScope(proRole: string | null | undefined): LeadWriteScope {
	return proRole === "staff" ? NOTES_ONLY : FULL;
}
 
/**
 * What staff are told on the leads screens when a control is missing.
 *
 * NOT the server's string, deliberately. `STAFF_WRITE_DENIED_MESSAGE` in
 * `apps/api/src/middleware/auth.middleware.ts` answers a refused request from
 * anywhere in the pro API, so it has to name both capabilities ("add and
 * manage project photos and write lead notes") in the third person. This is a
 * standing line on one screen, where project photos are not on offer and the
 * reader is the member themselves — so it is second-person and scoped to
 * leads. Two audiences, two sentences.
 *
 * What they must agree on is the SCOPE, not the words: notes and logging
 * contact, yes; moving a lead or changing its value, no. If the allowlist's
 * lead row changes, both strings change.
 */
export const STAFF_LEAD_SCOPE_NOTE =
	"You can add notes and log contact on a lead. Moving it, or changing what it is worth, needs a manager or owner.";