chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,755 @@
|
||||
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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user