SDK Reference

Management

Staff role/permission definitions, company invitations, member grouping, and generic resource tagging.

Role / Permission

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.

ts
/** 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;
}
FieldTypeNotesLazy?
idstring✓
companyIDstring?✓
namestring✓
stuckboolean?marks a built-in role (e.g. Owner) that can't be deleted/renamed by company admins✓
permissionsPermissionData[]omitted from the lazy shape — see gotcha belowfull only
createdAt / updatedAtDatefull only
ts
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}).

Gotcha: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.

Helper functions

ts
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 ids

hasPermission 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.

loadPermissions / MeInfo

ts
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:

  1. company.GetUser(user) → the Member row. If none exists, returns { permissions: [], member: undefined, isStaff: false } (with a console.warn).
  2. Queries 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.
  3. If a 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.
ts
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).

Invite

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.

Gotcha:Immutable once sent — there is no edit, only accept or delete.
ts
type InviteStatus = "active" | "accepted" | "expired";
FieldTypeNotesLazy?
idstring✓
userEmailstring✓
companyIDstring?✓
invitedByIDstring?✓
roleIDstring?if set, accepting the invite creates a Staff row with this role✓
expireAtstring?✓
statusInviteStatusfull only
createdAt / updatedAtDatefull only
ts
interface CreateInviteData {
  userEmail: string;
  roleID?: string;
  expireAt?: string;
}

Endpoint: /company/{cID}/invites. Has icon/viewHref (/company/{cID}/invites/{id}).

ts
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.

Group

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.

FieldTypeNotes
idstring
companyIDstring?
namestring
parentIDstring | null?optionally nests this group under another, forming a hierarchy
createdAt / updatedAtDatefull data only
deletedAtDate | null?full data only
ts
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.

Convenience wrappers (parent → children)

ts
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" }])
Gotcha: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.

GroupMember

zensile-sdk/company/management/group/member.ts — a membership record tying a Member to a Group.

FieldTypeNotes
idstring
groupIDstring
memberIDstring
isLeaderbooleangrants the ability to be charged on behalf of the group (see Checkout's payerUserID)
expiresAtstring | null?allows time-limited membership
joinedAtDate?
firstName / lastName / userEmailstring?denormalized onto this row by the API so list views don't need a second fetch per row
ts
interface CreateGroupMemberData {
  groupID: string;
  memberID: string;
  isLeader?: boolean;
  expiresAt?: string | null;
}

Endpoint: /company/{cID}/groups/members. No icon/viewHref override.

ts
get groupMember.group: Group | undefined    // cached getter, resolves groupID
get groupMember.user: Member | undefined    // cached getter, resolves memberID

GroupAccessLevel

zensile-sdk/company/management/group/accessLevel.ts — join record between a Group and an AccessLevel: the group's members inherit this access level.

FieldTypeNotes
idstring
groupIDstring
accessLevelIDstring

Lazy and full shapes are identical — this join record carries no extra fields beyond the two ids.

ts
interface CreateGroupAccessLevelData {
  groupID: string;
  accessLevelID: string;
}

Endpoint: /company/{cID}/groups/access. No icon/viewHref override.

ts
get groupAccessLevel.group: Group | undefined             // cached getter, resolves groupID
get groupAccessLevel.accessLevel: AccessLevel | undefined // cached getter, resolves accessLevelID

Tag

zensile-sdk/company/management/tag/tag.ts — a named, colored label attachable to groups, users, and other taggable resources via TagAssignment.

FieldTypeNotes
idstring
companyIDstring?
namestring
colorstring?
showOnPosbooleancontrols visibility in the point-of-sale UI
createdAt / updatedAtDatefull data only
deletedAtDate | null?full data only
ts
interface CreateTagData {
  name: string;
  color?: string;
}
Note: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.

ts
async tag.delete(): Promise<SimpleResult<EventData>>
Note:Soft-deletes the tag and cascades to its TagAssignments — there is no separate hard-delete path exposed here.

TagAssignment

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.

ts
type ResourceType = "group" | "user" | "product" | "class" | "service" | "location" | "accessLevel";
FieldTypeNotes
idstring
resourceIDstring
resourceTypeResourceType
tagIDstring
expiresAtstring | null?allows a time-limited tag
createdAtstringfull data only — note: string, not Date, unlike most other classes' timestamp fields
ts
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.

ts
get tagAssignment.tag: Tag | undefined   // cached getter, resolves tagID

async tagAssignment.delete(): Promise<SimpleResult<EventData>>
Gotcha:Unlike every other 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.

TagAssignment.QueryByResources — batched tag lookup

ts
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.

  • Returns a 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.
  • If resourceIDs is empty, returns the (empty) pre-seeded map immediately without making a request.
  • Otherwise queries 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.
  • Groups the results by each assignment's LazyData.resourceID, skipping any assignment whose resourceID isn't in the pre-seeded map.
ts
const tagsByMember = await TagAssignment.QueryByResources(company, "user", memberIDs);
const tagsForThisMember = tagsByMember.get(member.id) ?? [];