import { User } from "./User"; /** * Workspaces bundle interfaces (client subset). Mirrors the server's * `IWorkspace` / `IWorkspaceMember` / `IWorkspaceInvitation` shapes and the * workspace controller responses. * * `capabilities` is a CLOSED, Sublay-enforced vocabulary (cascades via reach); * `permissions` is opaque free-form developer strings Sublay never consumes * (per-node only, does NOT cascade). */ export type WorkspaceCapability = "view" | "invite" | "remove-member" | "edit-member-access" | "edit-member-profile" | "create-sub-workspace" | "edit-workspace"; export type WorkspaceInvitationStatus = "pending" | "accepted" | "declined" | "revoked"; export type WorkspaceAuthorityReason = "owner" | "ancestor-owner" | "member" | "reach-holder"; export type WorkspaceAuthorityReasonDetail = { type: "owner" | "member"; viaWorkspaceId?: never; } | { type: "ancestor-owner" | "reach-holder"; viaWorkspaceId: string; }; export type WorkspaceInclude = "memberCount"; export type WorkspaceIncludeArray = WorkspaceInclude[]; export type WorkspaceIncludeParam = string | WorkspaceIncludeArray; export interface Workspace { id: string; name: string; metadata: Record; ownerId: string; parentWorkspaceId: string | null; depth: number; inheritsFromParent: boolean; createdAt: string; updatedAt: string; memberCount?: number; } export interface WorkspaceMember { id: string; workspaceId: string; userId: string; capabilities: WorkspaceCapability[]; permissions: string[]; rank: number; title: string | null; metadata: Record; joinedAt: string; createdAt: string; } export interface WorkspaceInvitation { id: string; workspaceId: string; invitedBy: string; userId: string | null; email: string | null; capabilities: WorkspaceCapability[]; permissions: string[]; rank: number; title: string | null; status: WorkspaceInvitationStatus; expiresAt: string; createdAt: string; updatedAt: string; } export interface WorkspaceInvitationInviter { id: string; name: string | null; username: string | null; avatar: string | null; reputation: number; } export interface MyWorkspaceInvitation extends Omit { workspace: { id: string; name: string; }; invitedBy: WorkspaceInvitationInviter; } export interface WorkspaceRosterReason { type: "owner" | "member" | "ancestor-owner" | "reach-holder" | "descendant-member"; rank?: number; /** * The SAME ladder position, expressed as an offset from the CALLER: `1` = one * rung below you, `0` = your peer, `-3` = three rungs above you. The caller's * own anchor is their member row on this workspace if they hold one, and apex * (one step above rank 0) if they do not — so a rank-0 member reads back as * `relativeRank: 1` for an owner. * * Authority-bearing and fenced WITH `rank`, never beside it: it is `rank` * minus a number the caller already knows, so leaking it leaks `rank` exactly. * * Present on same-node `member` reasons only. `descendant-member` entries * carry `rank` but NEVER `relativeRank` — rank is per-workspace, so an offset * measured against your standing on THIS node would be arithmetic across two * different ladders. Use their absolute `rank` there. * * Note the deliberate asymmetry with the fence described above: the FENCE is * per node (a descendant row may or may not carry `rank` depending on your * standing there), but PRESENCE of `relativeRank` is not — it is absent on * every `descendant-member` row, unconditionally, however much authority you * hold. A field that appears and disappears by who is asking is worse to * consume than one that is uniformly absent. */ relativeRank?: number; capabilities?: WorkspaceCapability[]; permissions?: string[]; title?: string | null; metadata?: Record; viaWorkspaceId?: string; workspaceId?: string; } export interface WorkspaceRosterEntry { user: User; reasons: WorkspaceRosterReason[]; } export interface WorkspaceRosterResponse { data: WorkspaceRosterEntry[]; total: number; } export interface WorkspaceRosterCountsResponse { counts: { owner: number; member: number; ancestorOwner: number; reachHolder: number; descendantMember: number; }; total: number; distinctUsers: number; } /** * The `user` carried by a standing read. Normally the full user record, but the * server falls back to `{ id }` alone when the user row is gone (a deleted user * with a lingering membership row is a reachable case), so every field except * `id` may be absent. */ export type WorkspaceStandingUser = Pick & Partial>; export interface WorkspaceMemberStanding { user: WorkspaceStandingUser; reasons: WorkspaceAuthorityReasonDetail[]; capabilities?: WorkspaceCapability[]; permissions?: string[]; rank?: number | null; /** * `rank` expressed as an offset from the CALLER (negative = senior to you). * `null` exactly when `rank` is `null` — a target with no direct member row * here sits outside the ladder and has no position to measure. Fenced with * `rank`: absent (not null) for a caller who may not see it. */ relativeRank?: number | null; title: string | null; metadata: Record; } export interface WorkspaceAuthority { reasons: WorkspaceAuthorityReasonDetail[]; capabilities: WorkspaceCapability[]; permissions: string[]; rank: number | null; }