Staff role/permission definitions, company invitations, member grouping, and generic resource tagging.
zensile-sdk/company/management/role.ts — a company-defined named permission set, assigned to staff via one or more StaffRole grants — a staff member can hold several roles at once, each optionally scoped to a Location (see Company for Staff/StaffRole themselves). See the overview page for the PERMISSIONS/PERMISSION_MODULES bitmask constants (defined in types.ts, re-exported from role.ts) — this page only documents the helper functions built on top of them.
/** Public permission entry — one module + bitfield level. */
interface Permission {
permissionID: number; // a PERMISSION_MODULES id
level: number; // a PERMISSIONS bitmask
}
/** Internal shape returned by the API (includes roleID). */
interface PermissionData extends Permission {
roleID?: string;
}| Field | Type | Notes | Lazy? |
|---|---|---|---|
id | string | ✓ | |
companyID | string? | ✓ | |
name | string | ✓ | |
stuck | boolean? | marks a built-in role (e.g. Owner) that can't be deleted/renamed by company admins | ✓ |
permissions | PermissionData[] | omitted from the lazy shape — see gotcha below | full only |
createdAt / updatedAt | Date | full only |
interface CreateRoleData {
name: string;
stuck?: boolean;
permissions: PermissionData[];
}
/** Shape accepted by createRoles/updateRoles-style flows; id required for update. */
interface RoleInput {
id?: string;
name: string;
permissions: Permission[];
}Endpoint: /company/{cID}/roles. Has icon/viewHref (/company/{cID}/roles/{id}).
LazyRoleData omits permissions entirely. loadPermissions() (below) explicitly forces a full fetch (lazy: true on the query, then roleData?.data(true)on the returned instance) specifically to read the real permission levels — don't assume a lazily-loaded Role instance's LazyData.permissions is populated.function hasPermission(permissions: Permission[], module: number, level: number): boolean
function canRead(permissions: Permission[], module: number): boolean // hasPermission(..., PERMISSIONS.READ)
function canWrite(permissions: Permission[], module: number): boolean // any of EDIT | CREATE | REMOVE
function getLevel(permissions: Permission[], module: number): number // combined level for module, or 0
function ownerPermissions(): Permission[] // ALL access across every PERMISSION_MODULES id
function readOnlyPermissions(modules: number[]): Permission[] // READ-only for the given module idshasPermission checks that the module's level is nonzero and that all bits of level are set ((current & level) === level) — so hasPermission(perms, MEMBERS, PERMISSIONS.EDIT | PERMISSIONS.CREATE) requires both bits, not either. canRead/canWriteare the two convenience checks used by the app's sidebar navigation to gate each button, and by every company page to decide what a signed-in staff member can see and do.
interface MeInfo {
permissions: Permission[];
member?: Member;
isStaff: boolean;
}
async function loadPermissions(company: Company, user: User): Promise<MeInfo>Resolves the signed-in User's effective permissions at company:
company.GetUser(user) → the Member row. If none exists, returns { permissions: [], member: undefined, isStaff: false } (with a console.warn).Staff filtered by memberID (limit 1). A Member with no Staff row is not staff and gets no permissions — Role/permissions are strictly a Staff concern, never stored on Member directly.Staff row exists, loads every StaffRole grant it holds via staff.roles(), then force-fetches each distinct Role (full data, per the lazy-omission gotcha above) and returns { permissions, member, isStaff: true } where permissions is the bitwise-OR union, per module, of every held role's levels. A Staff row with zero role grants still counts as isStaff: true, just with no permissions. Location scoping on a StaffRoleisn't consulted here — permissions are company-wide once unioned.async function resolveCompanyEntryRoute(company: Company, user: User): Promise<string>Calls loadPermissions and returns where to send the user after they pick company: their own Member detail page (/company/{cID}/members/{memberID}) if they're a non-staff member, otherwise the company dashboard (/company/{cID}/dashboard).
zensile-sdk/company/management/invite.ts — an email invitation to join a company, optionally pre-assigning a Role (i.e. inviting someone as staff) once accepted.
type InviteStatus = "active" | "accepted" | "expired";| Field | Type | Notes | Lazy? |
|---|---|---|---|
id | string | ✓ | |
userEmail | string | ✓ | |
companyID | string? | ✓ | |
invitedByID | string? | ✓ | |
roleID | string? | if set, accepting the invite creates a Staff row with this role | ✓ |
expireAt | string? | ✓ | |
status | InviteStatus | full only | |
createdAt / updatedAt | Date | full only |
interface CreateInviteData {
userEmail: string;
roleID?: string;
expireAt?: string;
}Endpoint: /company/{cID}/invites. Has icon/viewHref (/company/{cID}/invites/{id}).
invite.save(): Promise<SimpleResult<EventData>> // always returns { status: 400, message: "Invites cannot be updated directly." }
async invite.Accept(): Promise<SimpleResult<EventData>>
// POST /company/{cID}/invites/accept with { id: this.id } — accepts as the signed-in user,
// joining the company and, if roleID is set, granting staff access via that role.
get invite.role: Role | undefined // cached getter, resolves roleID
get invite.IsPending: boolean // status === "active"
get invite.IsAccepted: boolean // status === "accepted"
get invite.IsExpired: boolean // status === "expired"An invite itself never directly creates a Member/Staff row — Accept() is the only mutation path, and the actual member/staff creation is a backend side effect of that call.
zensile-sdk/company/management/group/group.ts — a grouping of company users. Membership (GroupMember) and access-level grants (GroupAccessLevel) are split into sibling classes rather than living directly on Group— the same "parent + associated records" pattern used by the coupon and staff-payroll systems.
| Field | Type | Notes |
|---|---|---|
id | string | |
companyID | string? | |
name | string | |
parentID | string | null? | optionally nests this group under another, forming a hierarchy |
createdAt / updatedAt | Date | full data only |
deletedAt | Date | null? | full data only |
interface CreateGroupData {
name: string;
parentID?: string | null;
}Endpoint: /company/{cID}/groups. No icon/viewHref override — unlike most other routed entities, Groupdoesn't declare its own detail-route getters.
async group.GetListOfSubgroups({ limit = 100, offset = 0, lazy = true } = {}): Promise<SimpleResult<Group[]>>
// Group.query filtered by parentID = this.id
async group.GetListOfMembers({ limit = 100, offset = 0, lazy = true }): Promise<SimpleResult<GroupMember[]>>
// GroupMember.query filtered by groupID = this.id
async group.GetListOfAccessLevels({ limit = 100, offset = 0, lazy = true }): Promise<SimpleResult<GroupAccessLevel[]>>
// GroupAccessLevel.query filtered by groupID = this.id
async group.GetListOfTagAssignments({ limit = 100, offset = 0, lazy = true }): Promise<SimpleResult<TagAssignment[]>>
// TagAssignment.query filtered by resourceType = "group" AND resourceID = this.id
async group.AssignTag(tag: Tag)
// TagAssignment.create(company, [{ tagID: tag.id, resourceID: this.id, resourceType: "group" }])GetListOfSubgroups defaults its options object to {}— every other convenience method here requires the options object to be passed, even when individual keys have defaults. A minor asymmetry worth noting if you're calling these positionally.zensile-sdk/company/management/group/member.ts — a membership record tying a Member to a Group.
| Field | Type | Notes |
|---|---|---|
id | string | |
groupID | string | |
memberID | string | |
isLeader | boolean | grants the ability to be charged on behalf of the group (see Checkout's payerUserID) |
expiresAt | string | null? | allows time-limited membership |
joinedAt | Date? | |
firstName / lastName / userEmail | string? | denormalized onto this row by the API so list views don't need a second fetch per row |
interface CreateGroupMemberData {
groupID: string;
memberID: string;
isLeader?: boolean;
expiresAt?: string | null;
}Endpoint: /company/{cID}/groups/members. No icon/viewHref override.
get groupMember.group: Group | undefined // cached getter, resolves groupID
get groupMember.user: Member | undefined // cached getter, resolves memberIDzensile-sdk/company/management/group/accessLevel.ts — join record between a Group and an AccessLevel: the group's members inherit this access level.
| Field | Type | Notes |
|---|---|---|
id | string | |
groupID | string | |
accessLevelID | string |
Lazy and full shapes are identical — this join record carries no extra fields beyond the two ids.
interface CreateGroupAccessLevelData {
groupID: string;
accessLevelID: string;
}Endpoint: /company/{cID}/groups/access. No icon/viewHref override.
get groupAccessLevel.group: Group | undefined // cached getter, resolves groupID
get groupAccessLevel.accessLevel: AccessLevel | undefined // cached getter, resolves accessLevelIDzensile-sdk/company/management/tag/tag.ts — a named, colored label attachable to groups, users, and other taggable resources via TagAssignment.
| Field | Type | Notes |
|---|---|---|
id | string | |
companyID | string? | |
name | string | |
color | string? | |
showOnPos | boolean | controls visibility in the point-of-sale UI |
createdAt / updatedAt | Date | full data only |
deletedAt | Date | null? | full data only |
interface CreateTagData {
name: string;
color?: string;
}CreateTagData doesn't include showOnPos— it isn't settable at creation via this typed payload (set via save() after creating, if needed).Endpoint: /company/{cID}/tags. No icon/viewHref override.
async tag.delete(): Promise<SimpleResult<EventData>>TagAssignments — there is no separate hard-delete path exposed here.zensile-sdk/company/management/tag/assignment.ts — the generic tagging join table: links a Tag to a resource identified by resourceType + resourceID, shared across resource types.
type ResourceType = "group" | "user" | "product" | "class" | "service" | "location" | "accessLevel";| Field | Type | Notes |
|---|---|---|
id | string | |
resourceID | string | |
resourceType | ResourceType | |
tagID | string | |
expiresAt | string | null? | allows a time-limited tag |
createdAt | string | full data only — note: string, not Date, unlike most other classes' timestamp fields |
interface CreateTagAssignmentData {
resourceID: string;
resourceType: ResourceType;
tagID: string;
expiresAt?: string | null;
}Lazy and full shapes are identical (TagAssignmentData extends LazyTagAssignmentData adds only createdAt). Endpoint: /company/{cID}/tags/assignments. No icon/viewHref override.
get tagAssignment.tag: Tag | undefined // cached getter, resolves tagID
async tagAssignment.delete(): Promise<SimpleResult<EventData>>delete() in this codebase (which DELETE via a ?ids= query param), TagAssignment.delete() sends DELETE /company/{cID}/tags/assignments with [this._data] as the request body instead. Don't assume the ?ids= pattern is universal when writing new callers against this endpoint directly.static async QueryByResources(
company: Company,
resourceType: ResourceType,
resourceIDs: string[],
): Promise<Map<string, TagAssignment[]>>Batches tag-assignment lookups for a page of resources into a single query, instead of one GetListOfTagAssignments-style call per resource. This is how list pages (e.g. Members, Classes) populate the tag column efficiently — call it once with all the resource ids currently on-screen rather than N+1 queries.
Map pre-seeded with every id in resourceIDs mapped to [], so every input id is guaranteed to be a key in the result — even ones with zero tags.resourceIDs is empty, returns the (empty) pre-seeded map immediately without making a request.TagAssignment with resourceType = <resourceType> AND resourceID in <resourceIDs>, limit: resourceIDs.length * 25 — it assumes up to 25 tags per resource is enough headroom; a resource with more tags than that on one page could be silently truncated.LazyData.resourceID, skipping any assignment whose resourceID isn't in the pre-seeded map.const tagsByMember = await TagAssignment.QueryByResources(company, "user", memberIDs);
const tagsForThisMember = tagsByMember.get(member.id) ?? [];