d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
756 lines
24 KiB
TypeScript
756 lines
24 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import {
|
|
keepPreviousData,
|
|
type UseQueryResult,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from '@tanstack/react-query'
|
|
import { ApiClientError } from '@/lib/api/client/errors'
|
|
import { requestJson } from '@/lib/api/client/request'
|
|
import type { ContractBodyInput } from '@/lib/api/contracts'
|
|
import {
|
|
cancelInvitationContract,
|
|
resendInvitationContract,
|
|
updateInvitationContract,
|
|
} from '@/lib/api/contracts/invitations'
|
|
import {
|
|
createOrganizationContract,
|
|
getMyMemberCreditsContract,
|
|
getOrganizationMemberUsageLimitContract,
|
|
getOrganizationRosterContract,
|
|
inviteOrganizationMembersContract,
|
|
listOrganizationMembersContract,
|
|
type MyMemberCreditsData,
|
|
type OrganizationMembersResponse,
|
|
type OrganizationMemberUsageLimitData,
|
|
type OrganizationRoster,
|
|
type RosterMember,
|
|
type RosterPendingInvitation,
|
|
type RosterWorkspaceAccess,
|
|
removeOrganizationMemberContract,
|
|
transferOwnershipContract,
|
|
updateOrganizationContract,
|
|
updateOrganizationMemberRoleContract,
|
|
updateOrganizationMemberUsageLimitContract,
|
|
updateOrganizationUsageLimitContract,
|
|
} from '@/lib/api/contracts/organization'
|
|
import {
|
|
getOrganizationBillingContract,
|
|
type OrganizationBillingApiResponse,
|
|
} from '@/lib/api/contracts/subscription'
|
|
import { client } from '@/lib/auth/auth-client'
|
|
import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
|
|
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
|
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
|
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
|
|
import { workspaceKeys } from '@/hooks/queries/workspace'
|
|
|
|
const logger = createLogger('OrganizationQueries')
|
|
const invitationListsKey = ['invitations', 'list'] as const
|
|
|
|
export const ORGANIZATION_ROSTER_STALE_TIME = 30 * 1000
|
|
export const ORGANIZATION_LIST_STALE_TIME = 30 * 1000
|
|
export const ORGANIZATION_DETAIL_STALE_TIME = 30 * 1000
|
|
export const ORGANIZATION_SUBSCRIPTION_STALE_TIME = 30 * 1000
|
|
export const ORGANIZATION_BILLING_STALE_TIME = 30 * 1000
|
|
export const ORGANIZATION_MEMBERS_STALE_TIME = 30 * 1000
|
|
export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000
|
|
export const ORGANIZATION_MY_MEMBER_CREDITS_STALE_TIME = 30 * 1000
|
|
|
|
type OrganizationSubscriptionCandidate = {
|
|
id: string
|
|
referenceId: string
|
|
status: string
|
|
plan: string
|
|
cancelAtPeriodEnd?: boolean
|
|
periodEnd?: number | Date
|
|
trialEnd?: number | Date
|
|
}
|
|
|
|
type OrganizationBillingQueryResult = UseQueryResult<OrganizationBillingApiResponse | null, Error>
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
function isOrganizationSubscriptionCandidate(
|
|
value: unknown
|
|
): value is OrganizationSubscriptionCandidate {
|
|
if (!isRecord(value)) return false
|
|
return (
|
|
typeof value.id === 'string' &&
|
|
typeof value.referenceId === 'string' &&
|
|
typeof value.status === 'string' &&
|
|
typeof value.plan === 'string' &&
|
|
(value.cancelAtPeriodEnd === undefined || typeof value.cancelAtPeriodEnd === 'boolean') &&
|
|
(value.periodEnd === undefined ||
|
|
typeof value.periodEnd === 'number' ||
|
|
value.periodEnd instanceof Date) &&
|
|
(value.trialEnd === undefined ||
|
|
typeof value.trialEnd === 'number' ||
|
|
value.trialEnd instanceof Date)
|
|
)
|
|
}
|
|
|
|
function readNumber(value: unknown): number | undefined {
|
|
if (typeof value === 'number') return value
|
|
if (typeof value === 'string') {
|
|
const parsed = Number.parseFloat(value)
|
|
return Number.isFinite(parsed) ? parsed : undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Query key factories for organization-related queries
|
|
* This ensures consistent cache invalidation across the app
|
|
*/
|
|
export const organizationKeys = {
|
|
all: ['organizations'] as const,
|
|
lists: () => [...organizationKeys.all, 'list'] as const,
|
|
details: () => [...organizationKeys.all, 'detail'] as const,
|
|
detail: (id: string) => [...organizationKeys.details(), id] as const,
|
|
subscription: (id: string) => [...organizationKeys.detail(id), 'subscription'] as const,
|
|
billing: (id: string) => [...organizationKeys.detail(id), 'billing'] as const,
|
|
members: (id: string) => [...organizationKeys.detail(id), 'members'] as const,
|
|
memberUsage: (id: string) => [...organizationKeys.detail(id), 'member-usage'] as const,
|
|
memberUsageLimit: (id: string, userId: string) =>
|
|
[...organizationKeys.detail(id), 'member-usage-limit', userId] as const,
|
|
roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const,
|
|
myMemberCredits: (workspaceId: string) =>
|
|
[...organizationKeys.all, 'my-member-credits', workspaceId] as const,
|
|
}
|
|
|
|
export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess }
|
|
|
|
async function fetchOrganizationRoster(
|
|
orgId: string,
|
|
signal?: AbortSignal
|
|
): Promise<OrganizationRoster | null> {
|
|
if (!orgId) return null
|
|
|
|
try {
|
|
const payload = await requestJson(getOrganizationRosterContract, {
|
|
params: { id: orgId },
|
|
signal,
|
|
})
|
|
return payload.data
|
|
} catch (error) {
|
|
if (error instanceof ApiClientError && (error.status === 403 || error.status === 404)) {
|
|
return null
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export function useOrganizationRoster(orgId: string | undefined | null) {
|
|
return useQuery({
|
|
queryKey: organizationKeys.roster(orgId ?? ''),
|
|
queryFn: ({ signal }) => fetchOrganizationRoster(orgId as string, signal),
|
|
enabled: !!orgId,
|
|
staleTime: ORGANIZATION_ROSTER_STALE_TIME,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetch all organizations for the current user
|
|
* Note: Billing data is fetched separately via useSubscriptionData() to avoid duplicate calls
|
|
* Note: better-auth client does not support AbortSignal, so signal is accepted but not forwarded
|
|
*/
|
|
async function fetchOrganizations(_signal?: AbortSignal) {
|
|
const [orgsResponse, activeOrgResponse] = await Promise.all([
|
|
client.organization.list(),
|
|
client.organization.getFullOrganization(),
|
|
])
|
|
|
|
return {
|
|
organizations: orgsResponse.data || [],
|
|
activeOrganization: activeOrgResponse.data,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch all organizations
|
|
*/
|
|
export function useOrganizations() {
|
|
return useQuery({
|
|
queryKey: organizationKeys.lists(),
|
|
queryFn: ({ signal }) => fetchOrganizations(signal),
|
|
staleTime: ORGANIZATION_LIST_STALE_TIME,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetch a specific organization by ID.
|
|
*
|
|
* `getFullOrganization` defaults to the active organization when no
|
|
* `organizationId` is supplied; passing `orgId` through scopes the result to the
|
|
* requested org so it is cached under the correct `organizationKeys.detail(orgId)`
|
|
* (no cross-org cache collision). The active-org caller passes the active org's
|
|
* id, so its behavior is unchanged.
|
|
*/
|
|
async function fetchOrganization(orgId: string, _signal?: AbortSignal) {
|
|
const response = await client.organization.getFullOrganization({
|
|
query: { organizationId: orgId },
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch a specific organization
|
|
*/
|
|
export function useOrganization(orgId: string) {
|
|
return useQuery({
|
|
queryKey: organizationKeys.detail(orgId),
|
|
queryFn: ({ signal }) => fetchOrganization(orgId, signal),
|
|
enabled: !!orgId,
|
|
staleTime: ORGANIZATION_DETAIL_STALE_TIME,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetch organization subscription data
|
|
*/
|
|
async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSignal) {
|
|
if (!orgId) {
|
|
return null
|
|
}
|
|
|
|
const response = await client.subscription.list({
|
|
query: { referenceId: orgId },
|
|
})
|
|
|
|
if (response.error) {
|
|
logger.error('Error fetching organization subscription', { error: response.error })
|
|
return null
|
|
}
|
|
|
|
// Any paid subscription attached to the org counts as its active sub.
|
|
// Priority: Enterprise > Team > Pro (matches `getHighestPrioritySubscription`).
|
|
// This intentionally includes `pro_*` plans that have been transferred
|
|
// to the org — they are pooled org-scoped subscriptions.
|
|
const rawSubscriptions: unknown = response.data
|
|
const entitled = (Array.isArray(rawSubscriptions) ? rawSubscriptions : [])
|
|
.filter(isOrganizationSubscriptionCandidate)
|
|
.filter((sub) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan))
|
|
const enterpriseSubscription = entitled.find((sub) => isEnterprise(sub.plan))
|
|
const teamSubscription = entitled.find((sub) => isTeam(sub.plan))
|
|
const proSubscription = entitled.find((sub) => !isEnterprise(sub.plan) && !isTeam(sub.plan))
|
|
const activeSubscription = enterpriseSubscription || teamSubscription || proSubscription
|
|
|
|
return activeSubscription || null
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch organization subscription
|
|
*/
|
|
export function useOrganizationSubscription(orgId: string) {
|
|
return useQuery({
|
|
queryKey: organizationKeys.subscription(orgId),
|
|
queryFn: ({ signal }) => fetchOrganizationSubscription(orgId, signal),
|
|
enabled: !!orgId,
|
|
retry: false,
|
|
staleTime: ORGANIZATION_SUBSCRIPTION_STALE_TIME,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetch organization billing data
|
|
*/
|
|
async function fetchOrganizationBilling(
|
|
orgId: string,
|
|
signal?: AbortSignal
|
|
): Promise<OrganizationBillingApiResponse | null> {
|
|
try {
|
|
return await requestJson(getOrganizationBillingContract, {
|
|
query: { context: 'organization', id: orgId },
|
|
signal,
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof ApiClientError && error.status === 404) {
|
|
return null
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch organization billing data
|
|
*/
|
|
export function useOrganizationBilling(
|
|
orgId: string,
|
|
options?: { enabled?: boolean }
|
|
): OrganizationBillingQueryResult {
|
|
return useQuery({
|
|
queryKey: organizationKeys.billing(orgId),
|
|
queryFn: ({ signal }) => fetchOrganizationBilling(orgId, signal),
|
|
enabled: !!orgId && (options?.enabled ?? true),
|
|
retry: false,
|
|
staleTime: ORGANIZATION_BILLING_STALE_TIME,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetch organization member usage data
|
|
*/
|
|
async function fetchOrganizationMembers(
|
|
orgId: string,
|
|
signal?: AbortSignal
|
|
): Promise<OrganizationMembersResponse> {
|
|
try {
|
|
return await requestJson(listOrganizationMembersContract, {
|
|
params: { id: orgId },
|
|
query: { include: 'usage' },
|
|
signal,
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof ApiClientError && error.status === 404) {
|
|
return {
|
|
success: true,
|
|
data: [],
|
|
total: 0,
|
|
userRole: 'member',
|
|
hasAdminAccess: false,
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch organization members with usage data
|
|
*/
|
|
export function useOrganizationMembers(orgId: string) {
|
|
return useQuery({
|
|
queryKey: organizationKeys.memberUsage(orgId),
|
|
queryFn: ({ signal }) => fetchOrganizationMembers(orgId, signal),
|
|
enabled: !!orgId,
|
|
staleTime: ORGANIZATION_MEMBERS_STALE_TIME,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Update organization usage limit mutation with optimistic updates
|
|
*/
|
|
type UpdateOrganizationUsageLimitParams = Pick<
|
|
ContractBodyInput<typeof updateOrganizationUsageLimitContract>,
|
|
'organizationId' | 'limit'
|
|
>
|
|
|
|
export function useUpdateOrganizationUsageLimit() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ organizationId, limit }: UpdateOrganizationUsageLimitParams) => {
|
|
return requestJson(updateOrganizationUsageLimitContract, {
|
|
body: { context: 'organization', organizationId, limit },
|
|
})
|
|
},
|
|
onMutate: async ({ organizationId, limit }) => {
|
|
await queryClient.cancelQueries({ queryKey: organizationKeys.billing(organizationId) })
|
|
await queryClient.cancelQueries({ queryKey: organizationKeys.subscription(organizationId) })
|
|
|
|
const previousBillingData = queryClient.getQueryData(organizationKeys.billing(organizationId))
|
|
const previousSubscriptionData = queryClient.getQueryData(
|
|
organizationKeys.subscription(organizationId)
|
|
)
|
|
|
|
queryClient.setQueryData<unknown>(
|
|
organizationKeys.billing(organizationId),
|
|
(old: unknown) => {
|
|
if (!isRecord(old) || !isRecord(old.data)) return old
|
|
const usage = isRecord(old.data.usage) ? old.data.usage : {}
|
|
const currentUsage =
|
|
readNumber(old.data.currentUsage) ??
|
|
readNumber(usage.current) ??
|
|
readNumber(old.data.totalCurrentUsage) ??
|
|
0
|
|
const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0
|
|
|
|
return {
|
|
...old,
|
|
data: {
|
|
...old.data,
|
|
totalUsageLimit: limit,
|
|
usage: {
|
|
...usage,
|
|
limit,
|
|
percentUsed: newPercentUsed,
|
|
},
|
|
percentUsed: newPercentUsed,
|
|
},
|
|
}
|
|
}
|
|
)
|
|
|
|
return { previousBillingData, previousSubscriptionData, organizationId }
|
|
},
|
|
onError: (_err, _variables, context) => {
|
|
if (context?.previousBillingData && context?.organizationId) {
|
|
queryClient.setQueryData(
|
|
organizationKeys.billing(context.organizationId),
|
|
context.previousBillingData
|
|
)
|
|
}
|
|
if (context?.previousSubscriptionData && context?.organizationId) {
|
|
queryClient.setQueryData(
|
|
organizationKeys.subscription(context.organizationId),
|
|
context.previousSubscriptionData
|
|
)
|
|
}
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: organizationKeys.billing(variables.organizationId),
|
|
})
|
|
queryClient.invalidateQueries({
|
|
queryKey: organizationKeys.subscription(variables.organizationId),
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Invite member mutation
|
|
*/
|
|
type InviteMemberParams = Pick<
|
|
ContractBodyInput<typeof inviteOrganizationMembersContract>,
|
|
'emails' | 'workspaceInvitations'
|
|
> & {
|
|
orgId: string
|
|
}
|
|
|
|
export function useInviteMember() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ emails, workspaceInvitations, orgId }: InviteMemberParams) => {
|
|
/**
|
|
* Partial batches return HTTP 207 with `success: false` and a `data`
|
|
* payload (some invited/added, some failed). `requestJson` only throws on
|
|
* >= 400 (e.g. the total-failure 502 / validation 400 paths), so partials
|
|
* resolve here and the caller reports successes + per-email failures from
|
|
* `data` instead of surfacing a single generic error.
|
|
*/
|
|
return requestJson(inviteOrganizationMembersContract, {
|
|
params: { id: orgId },
|
|
query: { batch: true },
|
|
body: {
|
|
emails,
|
|
workspaceInvitations,
|
|
},
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
|
// Existing members may have been added directly to selected workspaces.
|
|
for (const grant of variables.workspaceInvitations ?? []) {
|
|
queryClient.invalidateQueries({
|
|
queryKey: workspaceKeys.permissions(grant.workspaceId),
|
|
})
|
|
queryClient.invalidateQueries({
|
|
queryKey: workspaceKeys.members(grant.workspaceId),
|
|
})
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Remove member mutation
|
|
*/
|
|
interface RemoveMemberParams {
|
|
memberId: string
|
|
orgId: string
|
|
}
|
|
|
|
export function useRemoveMember() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ memberId, orgId }: RemoveMemberParams) => {
|
|
return requestJson(removeOrganizationMemberContract, {
|
|
params: { id: orgId, memberId },
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.all })
|
|
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all })
|
|
queryClient.invalidateQueries({ queryKey: invitationListsKey })
|
|
},
|
|
})
|
|
}
|
|
|
|
interface UpdateMemberRoleParams {
|
|
orgId: string
|
|
userId: string
|
|
role: ContractBodyInput<typeof updateOrganizationMemberRoleContract>['role']
|
|
}
|
|
|
|
export function useUpdateOrganizationMemberRole() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ orgId, userId, role }: UpdateMemberRoleParams) => {
|
|
return requestJson(updateOrganizationMemberRoleContract, {
|
|
params: { id: orgId, memberId: userId },
|
|
body: { role },
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
|
},
|
|
})
|
|
}
|
|
|
|
async function fetchOrganizationMemberUsageLimit(
|
|
orgId: string,
|
|
userId: string,
|
|
signal?: AbortSignal
|
|
): Promise<OrganizationMemberUsageLimitData> {
|
|
const response = await requestJson(getOrganizationMemberUsageLimitContract, {
|
|
params: { id: orgId, memberId: userId },
|
|
signal,
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch a single member's per-org credit usage + cap (values in credits).
|
|
* Lazily enabled so it only fires while the Manage Credits modal is open.
|
|
*/
|
|
export function useOrganizationMemberUsageLimit(orgId?: string, userId?: string, enabled = true) {
|
|
return useQuery({
|
|
queryKey: organizationKeys.memberUsageLimit(orgId ?? '', userId ?? ''),
|
|
queryFn: ({ signal }) =>
|
|
fetchOrganizationMemberUsageLimit(orgId as string, userId as string, signal),
|
|
enabled: Boolean(orgId) && Boolean(userId) && enabled,
|
|
staleTime: ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME,
|
|
})
|
|
}
|
|
|
|
interface UpdateMemberUsageLimitParams {
|
|
orgId: string
|
|
userId: string
|
|
creditLimit: ContractBodyInput<typeof updateOrganizationMemberUsageLimitContract>['creditLimit']
|
|
}
|
|
|
|
export function useUpdateOrganizationMemberUsageLimit() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ orgId, userId, creditLimit }: UpdateMemberUsageLimitParams) => {
|
|
return requestJson(updateOrganizationMemberUsageLimitContract, {
|
|
params: { id: orgId, memberId: userId },
|
|
body: { creditLimit },
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: organizationKeys.memberUsageLimit(variables.orgId, variables.userId),
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
async function fetchMyMemberCredits(
|
|
workspaceId: string,
|
|
signal?: AbortSignal
|
|
): Promise<MyMemberCreditsData> {
|
|
const response = await requestJson(getMyMemberCreditsContract, {
|
|
query: { workspaceId },
|
|
signal,
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
/**
|
|
* The caller's OWN per-member credit usage + cap for a workspace's organization.
|
|
* `creditLimit` is null when no per-member cap applies (non-hosted, non-org
|
|
* workspace, or no cap set) — callers then fall back to the plan-level view.
|
|
*/
|
|
export function useMyMemberCredits(workspaceId?: string) {
|
|
return useQuery({
|
|
queryKey: organizationKeys.myMemberCredits(workspaceId ?? ''),
|
|
queryFn: ({ signal }) => fetchMyMemberCredits(workspaceId as string, signal),
|
|
enabled: Boolean(workspaceId),
|
|
staleTime: ORGANIZATION_MY_MEMBER_CREDITS_STALE_TIME,
|
|
})
|
|
}
|
|
|
|
type TransferOwnershipParams = {
|
|
orgId: string
|
|
} & ContractBodyInput<typeof transferOwnershipContract>
|
|
|
|
export function useTransferOwnership() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ orgId, newOwnerUserId, alsoLeave = false }: TransferOwnershipParams) => {
|
|
return requestJson(transferOwnershipContract, {
|
|
params: { id: orgId },
|
|
body: { newOwnerUserId, alsoLeave },
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
|
},
|
|
})
|
|
}
|
|
|
|
type UpdateInvitationParams = {
|
|
orgId: string
|
|
invitationId: string
|
|
} & ContractBodyInput<typeof updateInvitationContract>
|
|
|
|
export function useUpdateInvitation() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ invitationId, role, grants }: UpdateInvitationParams) => {
|
|
return requestJson(updateInvitationContract, {
|
|
params: { id: invitationId },
|
|
body: { role, grants },
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Cancel invitation mutation
|
|
*/
|
|
interface CancelInvitationParams {
|
|
invitationId: string
|
|
orgId: string
|
|
}
|
|
|
|
export function useCancelInvitation() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ invitationId }: CancelInvitationParams) => {
|
|
return requestJson(cancelInvitationContract, {
|
|
params: { id: invitationId },
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: invitationListsKey })
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Resend invitation mutation
|
|
*/
|
|
interface ResendInvitationParams {
|
|
invitationId: string
|
|
orgId: string
|
|
}
|
|
|
|
export function useResendInvitation() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ invitationId }: ResendInvitationParams) => {
|
|
return requestJson(resendInvitationContract, {
|
|
params: { id: invitationId },
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) })
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Update organization settings mutation
|
|
*/
|
|
type UpdateOrganizationParams = {
|
|
orgId: string
|
|
} & ContractBodyInput<typeof updateOrganizationContract>
|
|
|
|
export function useUpdateOrganization() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ orgId, ...updates }: UpdateOrganizationParams) => {
|
|
return requestJson(updateOrganizationContract, {
|
|
params: { id: orgId },
|
|
body: updates,
|
|
})
|
|
},
|
|
onSettled: (_data, _error, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Create organization mutation
|
|
*/
|
|
type CreateOrganizationParams = Pick<
|
|
ContractBodyInput<typeof createOrganizationContract>,
|
|
'slug'
|
|
> & {
|
|
name: string
|
|
}
|
|
|
|
export function useCreateOrganization() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ name, slug }: CreateOrganizationParams) => {
|
|
const data = await requestJson(createOrganizationContract, {
|
|
body: {
|
|
name,
|
|
slug: slug || name.toLowerCase().replace(/\s+/g, '-'),
|
|
},
|
|
})
|
|
|
|
await client.organization.setActive({
|
|
organizationId: data.organizationId,
|
|
})
|
|
|
|
return data
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
|
},
|
|
})
|
|
}
|