diff --git a/.gitignore b/.gitignore index 45bcd91155..25428ac89a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,9 @@ supabase/.temp # linter cache .eslintcache +# Expo cache (any Expo app in the repo) +**/.expo/ + # husky generated hook shims .husky/_/ @@ -117,3 +120,11 @@ run-milvus-test.sh .beads/ .env*.local .tests/ + +# Ignore stray .env-prefixed files but preserve committed example/test/envrc files. +.env +.env.* +!.env.example +!.env.*.example +!.env.test +!.envrc diff --git a/apps/web/src/app/admin/api/organizations/hooks.ts b/apps/web/src/app/admin/api/organizations/hooks.ts index f89a1885fc..bd5359ab49 100644 --- a/apps/web/src/app/admin/api/organizations/hooks.ts +++ b/apps/web/src/app/admin/api/organizations/hooks.ts @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useInvalidateAllOrganizationData } from '@/app/api/organizations/hooks'; import { useTRPC } from '@/lib/trpc/utils'; import type { OrganizationPlan } from '@/lib/organizations/organization-types'; +import type { StripeSubscriptionStatusValue } from '@/lib/admin/stripe-subscription-statuses'; export function useDeleteOrganization() { const queryClient = useQueryClient(); @@ -69,9 +70,9 @@ type UseOrganizationsListParams = { sortBy: OrganizationSortableField; sortOrder: 'asc' | 'desc'; search: string; - seatsRequired?: string; - hasBalance?: string; - status?: string; + mode?: 'paying' | 'trial' | 'all'; + include_deleted?: boolean; + stripe_status?: string; plan?: string; }; @@ -84,9 +85,9 @@ export function useOrganizationsList(params: UseOrganizationsListParams) { sortBy: params.sortBy, sortOrder: params.sortOrder, search: params.search, - seatsRequired: params.seatsRequired as '' | 'true' | 'false' | undefined, - hasBalance: params.hasBalance as '' | 'true' | 'false' | undefined, - status: params.status as 'active' | 'all' | 'incomplete' | 'deleted' | undefined, + mode: params.mode, + include_deleted: params.include_deleted ?? false, + stripe_status: params.stripe_status as StripeSubscriptionStatusValue | '' | undefined, plan: params.plan as '' | OrganizationPlan | undefined, }) ); diff --git a/apps/web/src/app/admin/components/AppSidebar.tsx b/apps/web/src/app/admin/components/AppSidebar.tsx index e0f0881db5..d1ed1c5916 100644 --- a/apps/web/src/app/admin/components/AppSidebar.tsx +++ b/apps/web/src/app/admin/components/AppSidebar.tsx @@ -5,6 +5,7 @@ import { Users, DollarSign, Building2, + Clock, Shield, Ban, Database, @@ -66,6 +67,11 @@ const userManagementItems: MenuItem[] = [ url: '/admin/organizations', icon: () => , }, + { + title: () => 'Trial Organizations', + url: '/admin/organizations/trials', + icon: () => , + }, { title: () => 'Bulk Block', url: '/admin/bulk-block', diff --git a/apps/web/src/app/admin/components/OrganizationFilters.tsx b/apps/web/src/app/admin/components/OrganizationFilters.tsx index 9fbc89ceb0..e13b30b753 100644 --- a/apps/web/src/app/admin/components/OrganizationFilters.tsx +++ b/apps/web/src/app/admin/components/OrganizationFilters.tsx @@ -3,6 +3,7 @@ import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, @@ -13,17 +14,21 @@ import { import { UserSearchInput } from './UserSearchInput'; import { X, Filter } from 'lucide-react'; +import { + STRIPE_SUBSCRIPTION_STATUSES, + getStripeStatusLabel, +} from '@/lib/admin/stripe-subscription-statuses'; + interface OrganizationFiltersProps { search: string; onSearchChange: (searchTerm: string) => void; isLoading: boolean; - seatsRequired?: string; - hasBalance?: string; - status?: string; - plan?: string; - onSeatsRequiredChange: (value: string) => void; - onHasBalanceChange: (value: string) => void; - onStatusChange: (value: string) => void; + includeDeleted: boolean; + stripeStatus: string; + plan: string; + showStripeStatus?: boolean; + onIncludeDeletedChange: (value: boolean) => void; + onStripeStatusChange: (value: string) => void; onPlanChange: (value: string) => void; onResetFilters: () => void; totalCount?: number; @@ -34,31 +39,26 @@ export function OrganizationFilters({ search, onSearchChange, isLoading, - seatsRequired, - hasBalance, - status, + includeDeleted, + stripeStatus, plan, - onSeatsRequiredChange, - onHasBalanceChange, - onStatusChange, + showStripeStatus = true, + onIncludeDeletedChange, + onStripeStatusChange, onPlanChange, onResetFilters, totalCount, filteredCount, }: OrganizationFiltersProps) { - const activeFiltersCount = [ - seatsRequired, - hasBalance, - status !== 'all', - plan && plan !== 'all', - ].filter(Boolean).length; - const hasActiveFilters = activeFiltersCount > 0; + const hasActiveFilters = includeDeleted || !!stripeStatus || (!!plan && plan !== 'all'); + + const stripeStatusLabel = stripeStatus ? getStripeStatusLabel(stripeStatus) : undefined; return (
{/* Filter Controls Row */}
- {/* Main Search - Leftmost */} + {/* Main Search */}
@@ -71,57 +71,28 @@ export function OrganizationFilters({
- {/* Seats Required Filter */} -
- - -
- - {/* Has Balance Filter */} -
- - -
- - {/* Status Filter */} -
- - -
+ {/* Stripe Status Filter */} + {showStripeStatus && ( +
+ + +
+ )} {/* Plan Filter */}
@@ -141,10 +112,21 @@ export function OrganizationFilters({
+ {/* Include Deleted Checkbox */} +
+ onIncludeDeletedChange(checked === true)} + /> + +
+ {/* Reset Filters Button */} {hasActiveFilters && ( -
- +
)} - {hasBalance && ( - - Has Balance: {hasBalance === 'true' ? 'Yes' : 'No'} - - - )} - {status && status !== 'all' && ( + {plan && plan !== 'all' && ( - Status:{' '} - {status === 'active' - ? 'Subscribed' - : status.charAt(0).toUpperCase() + status.slice(1)} + Plan: {plan.charAt(0).toUpperCase() + plan.slice(1)} )} - {plan && plan !== 'all' && ( + {includeDeleted && ( - Plan: {plan.charAt(0).toUpperCase() + plan.slice(1)} + Includes deleted
)} - {/* Results Count */} {totalCount !== undefined && filteredCount !== undefined && (
Showing {filteredCount.toLocaleString()} of {totalCount.toLocaleString()}{' '} diff --git a/apps/web/src/app/admin/components/OrganizationMetricCards.tsx b/apps/web/src/app/admin/components/OrganizationMetricCards.tsx index 69ada9ee3e..71cbac25ce 100644 --- a/apps/web/src/app/admin/components/OrganizationMetricCards.tsx +++ b/apps/web/src/app/admin/components/OrganizationMetricCards.tsx @@ -4,41 +4,41 @@ import { Card, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { formatLargeNumber } from '@/lib/utils'; import { useOrganizationMetrics } from '@/app/admin/api/organizations/metrics/hooks'; -import { Building2, Building, Clock } from 'lucide-react'; +import { Building2, Building, Users, LayoutGrid } from 'lucide-react'; export function OrganizationMetricCards() { const { data, isLoading, error } = useOrganizationMetrics(); const cards = [ { - title: 'Teams', - count: data?.teamCount ?? 0, - members: data?.teamMemberCount ?? 0, - icon: Building2, + title: 'Active Orgs', + value: data?.activeOrgCount ?? 0, + label: 'paying customers', + icon: LayoutGrid, }, { - title: 'Trial Teams', - count: data?.trialingTeamCount ?? 0, - members: data?.trialingTeamMemberCount ?? 0, - icon: Clock, + title: 'Teams', + value: data?.teamsCount ?? 0, + label: 'organizations', + icon: Building2, }, { - title: 'Enterprises', - count: data?.enterpriseCount ?? 0, - members: data?.enterpriseMemberCount ?? 0, + title: 'Enterprise', + value: data?.enterpriseCount ?? 0, + label: 'organizations', icon: Building, }, { - title: 'Trial Enterprises', - count: data?.trialingEnterpriseCount ?? 0, - members: data?.trialingEnterpriseMemberCount ?? 0, - icon: Clock, + title: 'Total Seats', + value: data?.totalSeats ?? 0, + label: 'seats', + icon: Users, }, ]; if (error) { return ( -
+
{cards.map((card, index) => ( @@ -56,7 +56,7 @@ export function OrganizationMetricCards() { if (isLoading) { return ( -
+
{cards.map((card, index) => ( @@ -82,11 +82,8 @@ export function OrganizationMetricCards() { {card.title}
- {formatLargeNumber(card.count)} - - {' '} - / {formatLargeNumber(card.members)} members - + {formatLargeNumber(card.value)} + {card.label}
diff --git a/apps/web/src/app/admin/components/OrganizationTableBody.tsx b/apps/web/src/app/admin/components/OrganizationTableBody.tsx index f0f40dcd1b..a359c0c452 100644 --- a/apps/web/src/app/admin/components/OrganizationTableBody.tsx +++ b/apps/web/src/app/admin/components/OrganizationTableBody.tsx @@ -2,30 +2,267 @@ import { TableBody, TableCell, TableRow } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; -import { BooleanBadge } from '@/components/ui/boolean-badge'; import { Skeleton } from '@/components/ui/skeleton'; import { useRouter } from 'next/navigation'; -import { formatMicrodollars, formatRelativeTime } from '@/lib/admin-utils'; +import { formatMicrodollars } from '@/lib/admin-utils'; import type { AdminOrganizationSchema } from '@/types/admin'; import type { z } from 'zod'; -import Link from 'next/link'; +import { ExternalLink } from 'lucide-react'; +import type { TableVariant } from './OrganizationTableHeader'; +import { + getStripeStatusLabel, + getStripeStatusStyle, +} from '@/lib/admin/stripe-subscription-statuses'; type AdminOrganization = z.infer; +function StripeStatusBadge({ status }: { status: string }) { + return ( + + {getStripeStatusLabel(status)} + + ); +} + +function FeaturePill({ active, label }: { active: boolean; label: string }) { + if (!active) return null; + return ( + + {label} + + ); +} + +function IntegrationPill({ active, label }: { active: boolean; label: string }) { + if (!active) return null; + return ( + + {label} + + ); +} + +function PillGroup({ activeCount, children }: { activeCount: number; children: React.ReactNode }) { + if (activeCount === 0) { + return ; + } + return
{children}
; +} + +function LinksCell({ organization }: { organization: AdminOrganization }) { + return ( +
e.stopPropagation()}> + {organization.stripe_customer_id && ( + + Stripe + + + )} + + Pylon + + + e.stopPropagation()} + > + View Org + +
+ ); +} + type OrganizationTableBodyProps = { + variant: TableVariant; organizations: AdminOrganization[]; isLoading: boolean; searchTerm?: string; showDeleted?: boolean; + showStripeStatus?: boolean; }; +function getColumnCount(variant: TableVariant, showDeleted?: boolean, showStripeStatus?: boolean) { + const base = variant === 'entitlements' ? (showStripeStatus ? 6 : 5) : 9; + return showDeleted ? base + 1 : base; +} + +function EntitlementsRow({ + organization, + showDeleted, + showStripeStatus = true, +}: { + organization: AdminOrganization; + showDeleted?: boolean; + showStripeStatus?: boolean; +}) { + return ( + <> + + {organization.name} + + + {organization.plan ? ( + + {organization.plan} + + ) : ( + - + )} + + + {organization.kilo_pass_tier ? ( + + {organization.kilo_pass_tier.replace(/_/g, ' ')} + + ) : ( + + )} + + {showStripeStatus && ( + + {organization.latest_stripe_status ? ( + + ) : ( + + )} + + )} + + {organization.subscription_amount_usd ? ( + + ${organization.subscription_amount_usd.toFixed(2)} + + ) : ( + - + )} + + + + + {showDeleted && ( + + + {organization.deleted_at ? 'Yes' : 'No'} + + + )} + + ); +} + +function UsageRow({ + organization, + showDeleted, +}: { + organization: AdminOrganization; + showDeleted?: boolean; +}) { + return ( + <> + + {organization.name} + + + + {formatMicrodollars(organization.microdollars_used)} + + + + + {formatMicrodollars( + organization.total_microdollars_acquired - organization.microdollars_used + )} + + + + + {organization.member_count} + {organization.seat_count > 0 && ( + / {organization.seat_count} + )} + + + + + + + + + + + + + + + + + + {organization.kiloclaw_count} + + + {organization.auto_top_up_enabled ? ( + + On + + ) : ( + + Off + + )} + + + + + {showDeleted && ( + + + {organization.deleted_at ? 'Yes' : 'No'} + + + )} + + ); +} + export function OrganizationTableBody({ + variant, organizations, isLoading, searchTerm, showDeleted, + showStripeStatus = true, }: OrganizationTableBodyProps) { const router = useRouter(); + const colSpan = getColumnCount(variant, showDeleted, showStripeStatus); const handleRowClick = (organizationId: string) => { router.push(`/admin/organizations/${encodeURIComponent(organizationId)}`); @@ -36,41 +273,11 @@ export function OrganizationTableBody({ {Array.from({ length: 10 }).map((_, index) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - {showDeleted && ( - + {Array.from({ length: colSpan }).map((__, ci) => ( + - )} - - - + ))} ))} @@ -85,7 +292,7 @@ export function OrganizationTableBody({ return ( - +

{message}

{searchTerm && ( @@ -108,85 +315,15 @@ export function OrganizationTableBody({ className="hover:bg-muted/50 cursor-pointer transition-colors" onClick={() => handleRowClick(organization.id)} > - -
- {organization.name} -
-
- -
- {formatRelativeTime(organization.created_at)} -
-
- - - {formatMicrodollars(organization.microdollars_used)} - - - - - {formatMicrodollars( - organization.total_microdollars_acquired - organization.microdollars_used - )} - - - - {organization.member_count} - - - - {organization.require_seats ? 'Yes' : 'No'} - - - - {organization.plan ? ( - - {organization.plan} - - ) : ( - - - )} - - - {organization.subscription_amount_usd ? ( - - ${organization.subscription_amount_usd.toFixed(2)} - - ) : ( - - - )} - - - {organization.created_by_kilo_user_id && organization.created_by_user_email ? ( - e.stopPropagation()} - > - {organization.created_by_user_email} - - ) : ( - Not set - )} - - {showDeleted && ( - - - {organization.deleted_at ? 'Yes' : 'No'} - - + {variant === 'entitlements' ? ( + + ) : ( + )} - - e.stopPropagation()} - > - View Org - - ))} diff --git a/apps/web/src/app/admin/components/OrganizationTableHeader.tsx b/apps/web/src/app/admin/components/OrganizationTableHeader.tsx index 260122a2d0..c06cd0823d 100644 --- a/apps/web/src/app/admin/components/OrganizationTableHeader.tsx +++ b/apps/web/src/app/admin/components/OrganizationTableHeader.tsx @@ -4,22 +4,48 @@ import { TableHead, TableHeader, TableRow } from '@/components/ui/table'; import type { OrganizationSortableField } from '@/types/admin'; import { SortableButton } from './SortableButton'; +export type TableVariant = 'entitlements' | 'usage'; + type OrganizationSortConfig = { field: OrganizationSortableField; direction: 'asc' | 'desc'; }; interface OrganizationTableHeaderProps { + variant: TableVariant; sortConfig: OrganizationSortConfig | null; onSort: (field: OrganizationSortableField) => void; showDeleted?: boolean; + showStripeStatus?: boolean; } export function OrganizationTableHeader({ + variant, sortConfig, onSort, showDeleted, + showStripeStatus = true, }: OrganizationTableHeaderProps) { + if (variant === 'entitlements') { + return ( + + + + + Name + + + Plan + Kilo Pass + {showStripeStatus && Stripe Status} + Subscription + Links + {showDeleted && Deleted} + + + ); + } + return ( @@ -28,11 +54,6 @@ export function OrganizationTableHeader({ Name - - - Created - - Usage @@ -45,15 +66,15 @@ export function OrganizationTableHeader({ - Members + Users / Seats - Seats Required - Plan - Subscription Amount - Created By + Tier Features + Integrations + KiloClaw + Auto Top-Up + Links {showDeleted && Deleted} - Actions ); diff --git a/apps/web/src/app/admin/components/OrganizationsTable.tsx b/apps/web/src/app/admin/components/OrganizationsTable.tsx index 73cfb987b6..a7016e38f9 100644 --- a/apps/web/src/app/admin/components/OrganizationsTable.tsx +++ b/apps/web/src/app/admin/components/OrganizationsTable.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Table } from '@/components/ui/table'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { OrganizationTableHeader } from './OrganizationTableHeader'; import { OrganizationTableBody } from './OrganizationTableBody'; import { OrganizationTablePagination } from './OrganizationTablePagination'; @@ -12,6 +13,7 @@ import { OrganizationMetricCards } from './OrganizationMetricCards'; import { useOrganizationsList } from '@/app/admin/api/organizations/hooks'; import type { OrganizationSortableField } from '@/types/admin'; import type { PageSize } from '@/types/pagination'; +import type { TableVariant } from './OrganizationTableHeader'; import AdminPage from '@/app/admin/components/AdminPage'; import { Button } from '@/components/ui/button'; import { Plus } from 'lucide-react'; @@ -22,20 +24,43 @@ type OrganizationSortConfig = { direction: 'asc' | 'desc'; }; -export function OrganizationsTable() { +// `create` is required only when the page wants the create button rendered; +// callers that omit `create` get no button. This avoids a never-rendered default +// label and keeps the create-button-label and click-target wired together. +type CreateButtonConfig = { + label: string; +}; + +type OrganizationsTableProps = { + mode?: 'paying' | 'trial' | 'all'; + showMetrics?: boolean; + showStripeStatus?: boolean; + pageTitle?: string; + create?: CreateButtonConfig; + defaultTab?: TableVariant; +}; + +export function OrganizationsTable({ + mode = 'paying', + showMetrics = true, + showStripeStatus = true, + pageTitle = 'Organizations', + create, + defaultTab = 'entitlements', +}: OrganizationsTableProps) { const router = useRouter(); const searchParams = useSearchParams(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const currentPage = parseInt(searchParams.get('page') || '1'); const currentPageSize = parseInt(searchParams.get('limit') || '25') as PageSize; - const currentSortBy = (searchParams.get('sortBy') || 'created_at') as OrganizationSortableField; + const currentSortBy = (searchParams.get('sortBy') || 'name') as OrganizationSortableField; const currentSortOrder = searchParams.get('sortOrder') || 'desc'; const currentSearch = searchParams.get('search') || ''; - const currentSeatsRequired = searchParams.get('seatsRequired') || ''; - const currentHasBalance = searchParams.get('hasBalance') || ''; - const currentStatus = searchParams.get('status') || 'all'; + const currentIncludeDeleted = searchParams.get('include_deleted') === 'true'; + const currentStripeStatus = searchParams.get('stripe_status') || ''; const currentPlan = searchParams.get('plan') || ''; + const currentTab = (searchParams.get('tab') || defaultTab) as TableVariant; const sortConfig: OrganizationSortConfig = useMemo( () => ({ @@ -51,9 +76,9 @@ export function OrganizationsTable() { sortBy: currentSortBy, sortOrder: currentSortOrder as 'asc' | 'desc', search: currentSearch, - seatsRequired: currentSeatsRequired, - hasBalance: currentHasBalance, - status: currentStatus, + mode, + include_deleted: currentIncludeDeleted, + stripe_status: currentStripeStatus, plan: currentPlan, }); @@ -69,293 +94,195 @@ export function OrganizationsTable() { } }); - router.push(`/admin/organizations?${newSearchParams.toString()}`); + router.push(`?${newSearchParams.toString()}`); }, [router, searchParams] ); - const handleSearchChange = useCallback( - (searchTerm: string) => { - const params = { - search: searchTerm, - page: '1', // Reset to first page when searching - limit: currentPageSize.toString(), - sortBy: currentSortBy, - sortOrder: currentSortOrder, - seatsRequired: currentSeatsRequired, - hasBalance: currentHasBalance, - status: currentStatus === 'all' ? '' : currentStatus, - plan: currentPlan === 'all' ? '' : currentPlan, - }; - - updateUrl(params); - }, + const sharedParams = useCallback( + () => ({ + limit: currentPageSize.toString(), + sortBy: currentSortBy, + sortOrder: currentSortOrder, + include_deleted: currentIncludeDeleted ? 'true' : '', + stripe_status: currentStripeStatus, + plan: currentPlan === 'all' ? '' : currentPlan, + tab: currentTab, + }), [ currentPageSize, currentSortBy, currentSortOrder, - currentSeatsRequired, - currentHasBalance, - currentStatus, + currentIncludeDeleted, + currentStripeStatus, currentPlan, - updateUrl, + currentTab, ] ); - const handleSeatsRequiredChange = useCallback( - (value: string) => { - const params = { - search: currentSearch, - page: '1', // Reset to first page when filtering - limit: currentPageSize.toString(), - sortBy: currentSortBy, - sortOrder: currentSortOrder, - seatsRequired: value, - hasBalance: currentHasBalance, - status: currentStatus === 'all' ? '' : currentStatus, - plan: currentPlan === 'all' ? '' : currentPlan, - }; - - updateUrl(params); + const handleSearchChange = useCallback( + (searchTerm: string) => { + updateUrl({ ...sharedParams(), search: searchTerm, page: '1' }); }, - [ - currentSearch, - currentPageSize, - currentSortBy, - currentSortOrder, - currentHasBalance, - currentStatus, - currentPlan, - updateUrl, - ] + [sharedParams, updateUrl] ); - const handleHasBalanceChange = useCallback( - (value: string) => { - const params = { + const handleIncludeDeletedChange = useCallback( + (value: boolean) => { + updateUrl({ + ...sharedParams(), + include_deleted: value ? 'true' : '', search: currentSearch, - page: '1', // Reset to first page when filtering - limit: currentPageSize.toString(), - sortBy: currentSortBy, - sortOrder: currentSortOrder, - seatsRequired: currentSeatsRequired, - hasBalance: value, - status: currentStatus === 'all' ? '' : currentStatus, - plan: currentPlan === 'all' ? '' : currentPlan, - }; - - updateUrl(params); + page: '1', + }); }, - [ - currentSearch, - currentPageSize, - currentSortBy, - currentSortOrder, - currentSeatsRequired, - currentStatus, - currentPlan, - updateUrl, - ] + [sharedParams, currentSearch, updateUrl] ); - const handleStatusChange = useCallback( + const handleStripeStatusChange = useCallback( (value: string) => { - const params = { - search: currentSearch, - page: '1', // Reset to first page when filtering - limit: currentPageSize.toString(), - sortBy: currentSortBy, - sortOrder: currentSortOrder, - seatsRequired: currentSeatsRequired, - hasBalance: currentHasBalance, - status: value === 'all' ? '' : value, - plan: currentPlan === 'all' ? '' : currentPlan, - }; - - updateUrl(params); + updateUrl({ ...sharedParams(), stripe_status: value, search: currentSearch, page: '1' }); }, - [ - currentSearch, - currentPageSize, - currentSortBy, - currentSortOrder, - currentSeatsRequired, - currentHasBalance, - currentPlan, - updateUrl, - ] + [sharedParams, currentSearch, updateUrl] ); const handlePlanChange = useCallback( (value: string) => { - const params = { - search: currentSearch, - page: '1', // Reset to first page when filtering - limit: currentPageSize.toString(), - sortBy: currentSortBy, - sortOrder: currentSortOrder, - seatsRequired: currentSeatsRequired, - hasBalance: currentHasBalance, - status: currentStatus === 'all' ? '' : currentStatus, + updateUrl({ + ...sharedParams(), plan: value === 'all' ? '' : value, - }; - - updateUrl(params); + search: currentSearch, + page: '1', + }); }, - [ - currentSearch, - currentPageSize, - currentSortBy, - currentSortOrder, - currentSeatsRequired, - currentHasBalance, - currentStatus, - updateUrl, - ] + [sharedParams, currentSearch, updateUrl] ); const handleResetFilters = useCallback(() => { - const params = { + updateUrl({ search: currentSearch, page: '1', limit: currentPageSize.toString(), sortBy: currentSortBy, sortOrder: currentSortOrder, - seatsRequired: '', - hasBalance: '', - status: '', + include_deleted: '', + stripe_status: '', plan: '', - }; - - updateUrl(params); - }, [currentSearch, currentPageSize, currentSortBy, currentSortOrder, updateUrl]); + tab: currentTab, + }); + }, [currentSearch, currentPageSize, currentSortBy, currentSortOrder, currentTab, updateUrl]); - // Handle sorting const handleSort = useCallback( (field: OrganizationSortableField) => { const newDirection = sortConfig.field === field && sortConfig.direction === 'asc' ? 'desc' : 'asc'; - - const params = { + updateUrl({ + ...sharedParams(), search: currentSearch, - page: '1', // Reset to first page when sorting - limit: currentPageSize.toString(), + page: '1', sortBy: field, sortOrder: newDirection, - seatsRequired: currentSeatsRequired, - hasBalance: currentHasBalance, - status: currentStatus === 'all' ? '' : currentStatus, - plan: currentPlan === 'all' ? '' : currentPlan, - }; - - updateUrl(params); + }); }, - [ - sortConfig, - currentPageSize, - currentSearch, - currentSeatsRequired, - currentHasBalance, - currentStatus, - currentPlan, - updateUrl, - ] + [sortConfig, sharedParams, currentSearch, updateUrl] ); - // Handle page change const handlePageChange = useCallback( (page: number) => { - const params = { - search: currentSearch, - page: page.toString(), - limit: currentPageSize.toString(), - sortBy: currentSortBy, - sortOrder: currentSortOrder, - seatsRequired: currentSeatsRequired, - hasBalance: currentHasBalance, - status: currentStatus === 'all' ? '' : currentStatus, - plan: currentPlan === 'all' ? '' : currentPlan, - }; - - updateUrl(params); + updateUrl({ ...sharedParams(), search: currentSearch, page: page.toString() }); }, - [ - currentPageSize, - currentSortBy, - currentSortOrder, - currentSearch, - currentSeatsRequired, - currentHasBalance, - currentStatus, - currentPlan, - updateUrl, - ] + [sharedParams, currentSearch, updateUrl] ); const handlePageSizeChange = useCallback( (pageSize: PageSize) => { - const params = { + updateUrl({ + ...sharedParams(), search: currentSearch, - page: '1', // Reset to first page when changing page size + page: '1', limit: pageSize.toString(), - sortBy: currentSortBy, - sortOrder: currentSortOrder, - seatsRequired: currentSeatsRequired, - hasBalance: currentHasBalance, - status: currentStatus === 'all' ? '' : currentStatus, - plan: currentPlan === 'all' ? '' : currentPlan, - }; - - updateUrl(params); + }); }, - [ - currentSortBy, - currentSortOrder, - currentSearch, - currentSeatsRequired, - currentHasBalance, - currentStatus, - currentPlan, - updateUrl, - ] + [sharedParams, currentSearch, updateUrl] ); - const buttons = ( - <> - {' '} - + const handleTabChange = useCallback( + (tab: TableVariant) => { + updateUrl({ ...sharedParams(), tab, page: '1' }); + }, + [sharedParams, updateUrl] ); + const buttons = create ? ( + + ) : null; + const breadcrumbs = ( + + {pageTitle} + + ); + + const tableContent = (variant: TableVariant) => ( <> - - Organizations - +
+ + + +
+
+ +
+ +
); return (
- {/* Organization Metrics */} - + {showMetrics && }
-
- - - -
-
- -
- -
+ handleTabChange(v as TableVariant)}> + + Entitlements + Usage + + {tableContent('entitlements')} + {tableContent('usage')} +
diff --git a/apps/web/src/app/admin/organizations/trials/page.tsx b/apps/web/src/app/admin/organizations/trials/page.tsx new file mode 100644 index 0000000000..356b399614 --- /dev/null +++ b/apps/web/src/app/admin/organizations/trials/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react'; +import { OrganizationsTable } from '../../components/OrganizationsTable'; + +export default async function TrialOrganizationsPage() { + return ( + Loading trial organizations...
}> + + + ); +} diff --git a/apps/web/src/lib/admin/stripe-subscription-statuses.ts b/apps/web/src/lib/admin/stripe-subscription-statuses.ts new file mode 100644 index 0000000000..f7d3cac5fb --- /dev/null +++ b/apps/web/src/lib/admin/stripe-subscription-statuses.ts @@ -0,0 +1,34 @@ +// Source of truth for Stripe subscription statuses surfaced in the admin +// organizations table. Used to (a) populate the Stripe Status filter dropdown, +// (b) render the per-row status badge, and (c) constrain the admin list router +// input so the three layers cannot drift independently. + +export const STRIPE_SUBSCRIPTION_STATUSES = [ + { value: 'active', label: 'Active', style: 'bg-green-100 text-green-800' }, + { value: 'past_due', label: 'Past due', style: 'bg-yellow-100 text-yellow-800' }, + { value: 'canceled', label: 'Canceled', style: 'bg-red-100 text-red-800' }, + { value: 'ended', label: 'Ended', style: 'bg-gray-100 text-gray-700' }, + { value: 'incomplete', label: 'Incomplete', style: 'bg-orange-100 text-orange-800' }, + { value: 'incomplete_expired', label: 'Incomplete expired', style: 'bg-red-100 text-red-700' }, + { value: 'trialing', label: 'Trialing', style: 'bg-blue-100 text-blue-800' }, + { value: 'unpaid', label: 'Unpaid', style: 'bg-red-100 text-red-800' }, + { value: 'paused', label: 'Paused', style: 'bg-purple-100 text-purple-800' }, +] as const; + +export type StripeSubscriptionStatusValue = (typeof STRIPE_SUBSCRIPTION_STATUSES)[number]['value']; + +export const STRIPE_SUBSCRIPTION_STATUS_VALUES = STRIPE_SUBSCRIPTION_STATUSES.map( + s => s.value +) as readonly StripeSubscriptionStatusValue[]; + +const stripeStatusByValue = new Map( + STRIPE_SUBSCRIPTION_STATUSES.map(s => [s.value, s]) +); + +export function getStripeStatusLabel(value: string): string { + return stripeStatusByValue.get(value)?.label ?? value.replace(/_/g, ' '); +} + +export function getStripeStatusStyle(value: string): string { + return stripeStatusByValue.get(value)?.style ?? 'bg-gray-100 text-gray-700'; +} diff --git a/apps/web/src/routers/organizations/organization-admin-router.test.ts b/apps/web/src/routers/organizations/organization-admin-router.test.ts index e6fdfccf0c..211d4068b0 100644 --- a/apps/web/src/routers/organizations/organization-admin-router.test.ts +++ b/apps/web/src/routers/organizations/organization-admin-router.test.ts @@ -1,6 +1,10 @@ import { createCallerForUser } from '@/routers/test-utils'; import { db } from '@/lib/drizzle'; -import { organizations, credit_transactions } from '@kilocode/db/schema'; +import { + organizations, + credit_transactions, + organization_seats_purchases, +} from '@kilocode/db/schema'; import { eq, and } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createOrganization } from '@/lib/organizations/organizations'; @@ -527,4 +531,50 @@ describe('organization admin router', () => { expect(updatedOrg.microdollars_balance).toBe(0); }); }); + + // Regression: applying a stripe_status filter must not crash the count query. + // Previously the `countQuery` did not join `latestSubscriptions`, so any value + // for `stripe_status` referenced an alias missing from the FROM clause and + // Postgres rejected the request, blanking the entire admin table. + describe('list — stripe_status filter', () => { + it('does not throw when stripe_status filter is set', async () => { + const [purchase] = await db + .insert(organization_seats_purchases) + .values({ + organization_id: testOrganization.id, + subscription_stripe_id: 'sub_test_admin_list_stripe_status', + subscription_status: 'active', + seat_count: 2, + amount_usd: 42, + starts_at: '2026-04-01T00:00:00.000Z', + expires_at: '2027-04-01T00:00:00.000Z', + billing_cycle: 'yearly', + }) + .returning(); + + try { + const caller = await createCallerForUser(adminUser.id); + const result = await caller.organizations.admin.list({ + page: 1, + limit: 25, + sortBy: 'name', + sortOrder: 'desc', + search: '', + mode: 'all', + include_deleted: false, + stripe_status: 'active', + }); + + expect(result.organizations).toBeDefined(); + expect(result.pagination).toBeDefined(); + expect(typeof result.pagination.total).toBe('number'); + } finally { + if (purchase) { + await db + .delete(organization_seats_purchases) + .where(eq(organization_seats_purchases.id, purchase.id)); + } + } + }); + }); }); diff --git a/apps/web/src/routers/organizations/organization-admin-router.ts b/apps/web/src/routers/organizations/organization-admin-router.ts index c8318cf371..e2cb2a3315 100644 --- a/apps/web/src/routers/organizations/organization-admin-router.ts +++ b/apps/web/src/routers/organizations/organization-admin-router.ts @@ -6,10 +6,13 @@ import { kilocode_users, organization_seats_purchases, credit_transactions, + platform_integrations, } from '@kilocode/db/schema'; -import { ilike, or, asc, desc, count, eq, and, gt, isNull, isNotNull, sql } from 'drizzle-orm'; +import { ilike, or, asc, desc, count, eq, and, isNull, sql, type SQL } from 'drizzle-orm'; +import type { PgColumn } from 'drizzle-orm/pg-core'; import * as z from 'zod'; import { OrganizationsApiGetResponseSchema } from '@/types/admin'; +import { STRIPE_SUBSCRIPTION_STATUS_VALUES } from '@/lib/admin/stripe-subscription-statuses'; import { isValidUUID, toMicrodollars } from '@/lib/utils'; import { millisecondsInHour } from 'date-fns/constants'; import { @@ -28,14 +31,18 @@ import { getMostRecentSeatPurchase } from '@/lib/organizations/organization-seat const OrganizationListInputSchema = z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100_000).default(25), - sortBy: z - .enum(['name', 'created_at', 'microdollars_used', 'balance', 'member_count']) - .default('created_at'), + sortBy: z.enum(['name', 'microdollars_used', 'balance', 'member_count']).default('name'), sortOrder: z.enum(['asc', 'desc']).default('desc'), search: z.string().optional().default(''), - seatsRequired: z.enum(['true', 'false', '']).optional(), - hasBalance: z.enum(['true', 'false', '']).optional(), - status: z.enum(['active', 'deleted', 'incomplete', 'all']).default('active'), + // mode controls which broad set of orgs to show (page-level, not user-facing) + // paying = has ever had a seats purchase (active or churned customers) + // trial = has never had a seats purchase + mode: z.enum(['paying', 'trial', 'all']).default('paying'), + // User-facing filters + include_deleted: z.boolean().default(false), + // Filter by latest subscription_status value. Values match the canonical + // Stripe status registry; '' clears the filter. + stripe_status: z.union([z.enum(STRIPE_SUBSCRIPTION_STATUS_VALUES), z.literal('')]).optional(), plan: z.enum(['enterprise', 'teams', '']).optional(), }); @@ -118,14 +125,10 @@ const NullifyCreditsOutputSchema = z.object({ }); const OrganizationMetricsSchema = z.object({ - teamCount: z.number(), - teamMemberCount: z.number(), + activeOrgCount: z.number(), + teamsCount: z.number(), enterpriseCount: z.number(), - enterpriseMemberCount: z.number(), - trialingTeamCount: z.number(), - trialingTeamMemberCount: z.number(), - trialingEnterpriseCount: z.number(), - trialingEnterpriseMemberCount: z.number(), + totalSeats: z.number(), }); const AddMemberInputSchema = z.object({ @@ -411,97 +414,40 @@ export const organizationAdminRouter = createTRPCRouter({ }), getMetrics: adminProcedure.output(OrganizationMetricsSchema).query(async () => { - // Get team metrics (organizations with plan = team AND active subscription) - const teamMetrics = await db - .select({ - orgCount: count(sql`DISTINCT ${organizations.id}`), - memberCount: count(organization_memberships.id), - }) + // "Paying" = has at least one seats purchase record, not deleted + const payingCondition = and( + isNull(organizations.deleted_at), + sql`EXISTS ( + SELECT 1 FROM ${organization_seats_purchases} + WHERE ${organization_seats_purchases.organization_id} = ${organizations.id} + )` + ); + + const [activeResult] = await db + .select({ orgCount: count() }) .from(organizations) - .leftJoin( - organization_memberships, - eq(organizations.id, organization_memberships.organization_id) - ) - .where( - and( - eq(organizations.plan, 'teams'), - isNull(organizations.deleted_at), - sql`EXISTS ( - SELECT 1 FROM ${organization_seats_purchases} - WHERE ${organization_seats_purchases.organization_id} = ${organizations.id} - AND ${organization_seats_purchases.subscription_status} = 'active' - )` - ) - ); - - // Get enterprise metrics (organizations with require_seats = false) - const enterpriseMetrics = await db - .select({ - orgCount: count(sql`DISTINCT ${organizations.id}`), - memberCount: count(organization_memberships.id), - }) + .where(payingCondition); + + const [teamsResult] = await db + .select({ orgCount: count() }) .from(organizations) - .leftJoin( - organization_memberships, - eq(organizations.id, organization_memberships.organization_id) - ) - .where(and(eq(organizations.plan, 'enterprise'), isNull(organizations.deleted_at))); - - // Get trialing team metrics - // (plan = 'teams', created within 30 days, has members, no seats purchase) - const trialingTeamMetrics = await db - .select({ - orgCount: count(sql`DISTINCT ${organizations.id}`), - memberCount: count(organization_memberships.id), - }) + .where(and(payingCondition, eq(organizations.plan, 'teams'))); + + const [enterpriseResult] = await db + .select({ orgCount: count() }) .from(organizations) - .innerJoin( - organization_memberships, - eq(organizations.id, organization_memberships.organization_id) - ) - .where( - and( - eq(organizations.plan, 'teams'), - isNull(organizations.deleted_at), - sql`NOT EXISTS ( - SELECT 1 FROM ${organization_seats_purchases} - WHERE ${organization_seats_purchases.organization_id} = ${organizations.id} - )` - ) - ); - - // Get trialing enterprise metrics - // (plan = 'enterprise', created within 30 days, has members, no seats purchase) - const trialingEnterpriseMetrics = await db - .select({ - orgCount: count(sql`DISTINCT ${organizations.id}`), - memberCount: count(organization_memberships.id), - }) + .where(and(payingCondition, eq(organizations.plan, 'enterprise'))); + + const [seatsResult] = await db + .select({ totalSeats: sql`COALESCE(SUM(${organizations.seat_count}), 0)::int` }) .from(organizations) - .innerJoin( - organization_memberships, - eq(organizations.id, organization_memberships.organization_id) - ) - .where( - and( - eq(organizations.plan, 'enterprise'), - isNull(organizations.deleted_at), - sql`NOT EXISTS ( - SELECT 1 FROM ${organization_seats_purchases} - WHERE ${organization_seats_purchases.organization_id} = ${organizations.id} - )` - ) - ); + .where(payingCondition); return { - teamCount: teamMetrics[0]?.orgCount ?? 0, - teamMemberCount: teamMetrics[0]?.memberCount ?? 0, - enterpriseCount: enterpriseMetrics[0]?.orgCount ?? 0, - enterpriseMemberCount: enterpriseMetrics[0]?.memberCount ?? 0, - trialingTeamCount: trialingTeamMetrics[0]?.orgCount ?? 0, - trialingTeamMemberCount: trialingTeamMetrics[0]?.memberCount ?? 0, - trialingEnterpriseCount: trialingEnterpriseMetrics[0]?.orgCount ?? 0, - trialingEnterpriseMemberCount: trialingEnterpriseMetrics[0]?.memberCount ?? 0, + activeOrgCount: activeResult?.orgCount ?? 0, + teamsCount: teamsResult?.orgCount ?? 0, + enterpriseCount: enterpriseResult?.orgCount ?? 0, + totalSeats: seatsResult?.totalSeats ?? 0, }; }), @@ -587,7 +533,16 @@ export const organizationAdminRouter = createTRPCRouter({ .input(OrganizationListInputSchema) .output(OrganizationsApiGetResponseSchema) .query(async ({ input }) => { - const { page, limit, sortBy, sortOrder, search, seatsRequired, hasBalance, status, plan } = + // Single-source-of-truth for "has platform X integration" — keeps the + // active/pending status set defined in one place across github, gitlab, + // slack so future status rule changes can't drift between platforms. + const hasPlatformIntegrationSql = ( + platform: 'github' | 'gitlab' | 'slack', + orgIdColumn: PgColumn + ): SQL => + sql`EXISTS (SELECT 1 FROM ${platform_integrations} pi WHERE pi.owned_by_organization_id = ${orgIdColumn} AND pi.platform = ${platform} AND pi.integration_status IN ('active', 'pending'))`; + + const { page, limit, sortBy, sortOrder, search, mode, include_deleted, stripe_status, plan } = input; const searchTerm = search.trim(); @@ -608,43 +563,14 @@ export const organizationAdminRouter = createTRPCRouter({ conditions.push(or(...searchConditions)); } - if (seatsRequired === 'true') { - conditions.push(eq(organizations.require_seats, true)); - } else if (seatsRequired === 'false') { - conditions.push(eq(organizations.require_seats, false)); - } - - if (hasBalance === 'true') { - conditions.push( - gt(organizations.total_microdollars_acquired, organizations.microdollars_used) - ); - } else if (hasBalance === 'false') { - conditions.push( - eq(organizations.total_microdollars_acquired, organizations.microdollars_used) - ); - } - if (plan === 'enterprise') { conditions.push(eq(organizations.plan, 'enterprise')); } else if (plan === 'teams') { conditions.push(eq(organizations.plan, 'teams')); } - // Handle status-based filtering - if (status === 'deleted') { - conditions.push(isNotNull(organizations.deleted_at)); - } else if (status === 'incomplete') { - // For incomplete: require_seats = true, not deleted (subscription check done later) - conditions.push(eq(organizations.require_seats, true)); - conditions.push(isNull(organizations.deleted_at)); - } else if (status === 'active') { - // For active: not deleted (subscription check done later) - conditions.push(isNull(organizations.deleted_at)); - } else if (status === 'all') { - // For all: no deleted_at filter - show both active and deleted - // Don't add any deleted_at condition - } else { - // Default to active if no status specified + // Deleted filter: unless include_deleted is true, hide soft-deleted orgs + if (!include_deleted) { conditions.push(isNull(organizations.deleted_at)); } @@ -662,18 +588,18 @@ export const organizationAdminRouter = createTRPCRouter({ orderCondition = orderFunction(organizations[sortField]); } - // Subquery to get the latest active subscription per organization + // Subquery to get the latest subscription per organization (any status) const latestSubscriptions = db .select({ organization_id: organization_seats_purchases.organization_id, amount_usd: organization_seats_purchases.amount_usd, + subscription_status: organization_seats_purchases.subscription_status, row_num: sql`ROW_NUMBER() OVER (PARTITION BY ${organization_seats_purchases.organization_id} ORDER BY ${organization_seats_purchases.created_at} DESC)`.as( 'row_num' ), }) .from(organization_seats_purchases) - .where(eq(organization_seats_purchases.subscription_status, 'active')) .as('latest_subscriptions'); const organizationFields = { @@ -691,14 +617,51 @@ export const organizationAdminRouter = createTRPCRouter({ seat_count: organizations.seat_count, require_seats: organizations.require_seats, created_by_kilo_user_id: organizations.created_by_kilo_user_id, - created_by_user_email: kilocode_users.google_user_email, - created_by_user_name: kilocode_users.google_user_name, deleted_at: organizations.deleted_at, sso_domain: organizations.sso_domain, plan: organizations.plan, free_trial_end_at: organizations.free_trial_end_at, company_domain: organizations.company_domain, - subscription_amount_usd: latestSubscriptions.amount_usd, + // Null out subscription_amount_usd for non-billable statuses so the + // "Subscription" column doesn't display the dollar amount of a churned + // plan as if it were current MRR. Reading "latest_stripe_status" tells + // admins the lifecycle state separately. Cast to float8 so the JSON + // payload matches the column's `mode: 'number'` declaration. + subscription_amount_usd: sql< + number | null + >`CASE WHEN ${latestSubscriptions.subscription_status} IN ('active','trialing','past_due') THEN ${latestSubscriptions.amount_usd}::float8 ELSE NULL END`.as( + 'subscription_amount_usd' + ), + latest_stripe_status: latestSubscriptions.subscription_status, + kilo_pass_tier: sql< + string | null + >`(SELECT kps.tier FROM organization_memberships om2 JOIN kilo_pass_subscriptions kps ON kps.kilo_user_id = om2.kilo_user_id WHERE om2.organization_id = ${organizations.id} AND kps.status = 'active' ORDER BY kps.tier LIMIT 1)`.as( + 'kilo_pass_tier' + ), + kiloclaw_count: + sql`(SELECT COUNT(*) FROM kiloclaw_instances ki WHERE ki.organization_id = ${organizations.id} AND ki.destroyed_at IS NULL)::int`.as( + 'kiloclaw_count' + ), + has_github_integration: hasPlatformIntegrationSql('github', organizations.id).as( + 'has_github_integration' + ), + has_gitlab_integration: hasPlatformIntegrationSql('gitlab', organizations.id).as( + 'has_gitlab_integration' + ), + has_slack_integration: hasPlatformIntegrationSql('slack', organizations.id).as( + 'has_slack_integration' + ), + has_sso_configured: sql`${organizations.sso_domain} IS NOT NULL`.as( + 'has_sso_configured' + ), + has_provider_controls: + sql`(${organizations.settings} -> 'provider_allow_list' IS NOT NULL OR ${organizations.settings} -> 'model_deny_list' IS NOT NULL)`.as( + 'has_provider_controls' + ), + has_data_privacy: + sql`${organizations.settings} -> 'data_collection' IS NOT NULL`.as( + 'has_data_privacy' + ), }; // Build base query without status-specific joins @@ -709,7 +672,6 @@ export const organizationAdminRouter = createTRPCRouter({ organization_memberships, eq(organizations.id, organization_memberships.organization_id) ) - .leftJoin(kilocode_users, eq(organizations.created_by_kilo_user_id, kilocode_users.id)) .leftJoin( latestSubscriptions, and( @@ -718,34 +680,31 @@ export const organizationAdminRouter = createTRPCRouter({ ) ); - // Add status-specific conditions using subqueries + // Add mode-based and stripe_status conditions const statusConditions = whereCondition ? [whereCondition] : []; - if (status === 'incomplete') { - // Incomplete: require_seats = true AND no active subscription - statusConditions.push(eq(organizations.require_seats, true)); + if (mode === 'paying') { + // Paying: has at least one seats purchase record (active or churned customers) statusConditions.push( - sql`NOT EXISTS ( + sql`EXISTS ( SELECT 1 FROM ${organization_seats_purchases} WHERE ${organization_seats_purchases.organization_id} = ${organizations.id} - AND ${organization_seats_purchases.subscription_status} = 'active' )` ); - } else if (status === 'active' || !status) { - // Active: require_seats = false OR has active subscription + } else if (mode === 'trial') { + // Trial: has never had a seats purchase statusConditions.push( - sql`( - ${organizations.require_seats} = false OR - EXISTS ( - SELECT 1 FROM ${organization_seats_purchases} - WHERE ${organization_seats_purchases.organization_id} = ${organizations.id} - AND ${organization_seats_purchases.subscription_status} = 'active' - ) + sql`NOT EXISTS ( + SELECT 1 FROM ${organization_seats_purchases} + WHERE ${organization_seats_purchases.organization_id} = ${organizations.id} )` ); - } else if (status === 'all') { - // All: no additional subscription-based filtering - // Don't add any subscription conditions + } + // mode === 'all': no subscription filter + + // Filter by Stripe subscription status (latest subscription for this org) + if (stripe_status) { + statusConditions.push(sql`${latestSubscriptions.subscription_status} = ${stripe_status}`); } const finalWhereCondition = @@ -754,12 +713,18 @@ export const organizationAdminRouter = createTRPCRouter({ // Execute main query with pagination const filteredOrganizations = await baseQuery .where(finalWhereCondition) - .groupBy(organizations.id, kilocode_users.id, latestSubscriptions.amount_usd) + .groupBy( + organizations.id, + latestSubscriptions.amount_usd, + latestSubscriptions.subscription_status + ) .orderBy(orderCondition) .limit(limit) .offset((page - 1) * limit); - // Get total count using the same filtering logic + // Get total count using the same filtering logic. Must mirror baseQuery's + // joins so finalWhereCondition references (e.g. latestSubscriptions for the + // stripe_status filter) resolve. const countQuery = db .select({ count: count() }) .from(organizations) @@ -767,6 +732,13 @@ export const organizationAdminRouter = createTRPCRouter({ organization_memberships, eq(organizations.id, organization_memberships.organization_id) ) + .leftJoin( + latestSubscriptions, + and( + eq(organizations.id, latestSubscriptions.organization_id), + eq(latestSubscriptions.row_num, 1) + ) + ) .where(finalWhereCondition) .groupBy(organizations.id); diff --git a/apps/web/src/types/admin.ts b/apps/web/src/types/admin.ts index 466e354028..0bfb8316f3 100644 --- a/apps/web/src/types/admin.ts +++ b/apps/web/src/types/admin.ts @@ -47,9 +47,16 @@ export type UsersApiResponse = { export const AdminOrganizationSchema = OrganizationSchema.extend({ member_count: z.number(), - created_by_user_email: z.string().nullable(), - created_by_user_name: z.string().nullable(), subscription_amount_usd: z.number().nullable(), + latest_stripe_status: z.string().nullable(), + kilo_pass_tier: z.string().nullable(), + kiloclaw_count: z.number(), + has_github_integration: z.boolean(), + has_gitlab_integration: z.boolean(), + has_slack_integration: z.boolean(), + has_sso_configured: z.boolean(), + has_provider_controls: z.boolean(), + has_data_privacy: z.boolean(), }); export const OrganizationsApiGetResponseSchema = z.object({ @@ -94,12 +101,7 @@ export type SortableField = (typeof sortableFields)[number]; export const ascendingFirstFields: SortableField[] = ['google_user_email']; -export type OrganizationSortableField = - | 'name' - | 'created_at' - | 'microdollars_used' - | 'balance' - | 'member_count'; +export type OrganizationSortableField = 'name' | 'microdollars_used' | 'balance' | 'member_count'; export type CreditCategorySortableField = | 'credit_category' diff --git a/dev/seed/app/org-dashboard.ts b/dev/seed/app/org-dashboard.ts new file mode 100644 index 0000000000..6f7d3cf99e --- /dev/null +++ b/dev/seed/app/org-dashboard.ts @@ -0,0 +1,470 @@ +/** + * Seed fixture for the admin organizations dashboard. + * + * Creates a representative set of organizations covering every filter/column + * visible on /admin/organizations and /admin/organizations/trials: + * + * - Paying Teams orgs: active, past_due, canceled + * - Paying Enterprise orgs: active, ended (churned) + * - Trial orgs (no seats purchase): teams + enterprise + * - A deleted org + * - Orgs with KiloClaw instances + * - Orgs whose members have active Kilo Pass subscriptions + * - Varying member counts and seat counts + * + * Idempotent: deletes all orgs/users matching the seed prefix before recreating. + * + * Usage: pnpm dev:seed app:org-dashboard + */ + +import { randomUUID } from 'node:crypto'; + +import { + kilocode_users, + organizations, + organization_memberships, + organization_seats_purchases, + kiloclaw_instances, + kilo_pass_subscriptions, + platform_integrations, +} from '@kilocode/db/schema'; +import { ilike, inArray } from 'drizzle-orm'; + +import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; + +const ORG_PREFIX = '[seed:org-dashboard]'; +const USER_EMAIL_PATTERN = 'seed-org-dashboard-%@example.com'; +const SANDBOX_PREFIX = 'seed-org-dash-'; + +// --------------------------------------------------------------------------- +// Time helpers +// --------------------------------------------------------------------------- + +function daysAgo(n: number) { + return new Date(Date.now() - n * 86_400_000).toISOString(); +} + +function daysFromNow(n: number) { + return new Date(Date.now() + n * 86_400_000).toISOString(); +} + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +async function cleanup() { + const db = getSeedDb(); + + const seedOrgs = await db + .select({ id: organizations.id }) + .from(organizations) + .where(ilike(organizations.name, `${ORG_PREFIX}%`)); + + const orgIds = seedOrgs.map(o => o.id); + + if (orgIds.length > 0) { + await db.delete(kiloclaw_instances).where(inArray(kiloclaw_instances.organization_id, orgIds)); + await db + .delete(organization_seats_purchases) + .where(inArray(organization_seats_purchases.organization_id, orgIds)); + await db + .delete(platform_integrations) + .where(inArray(platform_integrations.owned_by_organization_id, orgIds)); + await db + .delete(organization_memberships) + .where(inArray(organization_memberships.organization_id, orgIds)); + await db.delete(organizations).where(inArray(organizations.id, orgIds)); + } + + const seedUsers = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(ilike(kilocode_users.google_user_email, USER_EMAIL_PATTERN)); + + if (seedUsers.length > 0) { + const userIds = seedUsers.map(u => u.id); + await db + .delete(kilo_pass_subscriptions) + .where(inArray(kilo_pass_subscriptions.kilo_user_id, userIds)); + await db.delete(kilocode_users).where(inArray(kilocode_users.id, userIds)); + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +async function createUser(handle: string, displayName: string) { + const db = getSeedDb(); + const id = randomUUID(); + const email = `seed-org-dashboard-${handle}@example.com`; + await db.insert(kilocode_users).values({ + id, + google_user_email: email, + google_user_name: displayName, + google_user_image_url: `https://example.com/${id}.png`, + stripe_customer_id: `cus_seed_${id.replace(/-/g, '').slice(0, 14)}`, + normalized_email: email, + has_validation_stytch: true, + customer_source: 'dev-seed', + }); + return id; +} + +type OrgSettingsOverrides = { + provider_allow_list?: string[]; + model_deny_list?: string[]; + data_collection?: 'allow' | 'deny'; +}; + +async function createOrg( + label: string, + plan: 'teams' | 'enterprise', + opts: { + seatCount?: number; + requireSeats?: boolean; + deleted?: boolean; + freeTrialEndAt?: string | null; + withStripeCustomer?: boolean; + autoTopUp?: boolean; + ssoDomain?: string; + settings?: OrgSettingsOverrides; + } = {} +) { + const db = getSeedDb(); + const id = randomUUID(); + const stripeCustomerId = opts.withStripeCustomer + ? `cus_seed_${id.replace(/-/g, '').slice(0, 14)}` + : null; + await db.insert(organizations).values({ + id, + name: `${ORG_PREFIX} ${label}`, + plan, + seat_count: opts.seatCount ?? 0, + require_seats: opts.requireSeats ?? plan === 'teams', + deleted_at: opts.deleted ? daysAgo(5) : null, + free_trial_end_at: opts.freeTrialEndAt ?? null, + microdollars_used: Math.floor(Math.random() * 30_000_000), + total_microdollars_acquired: Math.floor(Math.random() * 60_000_000) + 40_000_000, + stripe_customer_id: stripeCustomerId, + auto_top_up_enabled: opts.autoTopUp ?? false, + sso_domain: opts.ssoDomain ?? null, + settings: opts.settings ?? {}, + }); + return id; +} + +type IntegrationStatus = 'active' | 'pending' | 'suspended'; +type IntegrationPlatform = 'github' | 'gitlab' | 'slack'; + +async function addPlatformIntegration( + orgId: string, + platform: IntegrationPlatform, + status: IntegrationStatus = 'active' +) { + const db = getSeedDb(); + await db.insert(platform_integrations).values({ + id: randomUUID(), + owned_by_organization_id: orgId, + platform, + integration_type: platform === 'github' || platform === 'gitlab' ? 'app' : 'oauth', + integration_status: status, + platform_installation_id: `seed_${platform}_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + }); +} + +async function addMember(orgId: string, userId: string, role: 'owner' | 'member' = 'member') { + const db = getSeedDb(); + await db.insert(organization_memberships).values({ + organization_id: orgId, + kilo_user_id: userId, + role, + }); +} + +type SubStatus = 'active' | 'past_due' | 'canceled' | 'ended' | 'incomplete' | 'unpaid'; + +async function addSubscription( + orgId: string, + status: SubStatus, + opts: { + amountUsd?: number; + seatCount?: number; + billingCycle?: 'monthly' | 'yearly'; + createdDaysAgo?: number; + } = {} +) { + const db = getSeedDb(); + const subId = `sub_seed_${randomUUID().replace(/-/g, '').slice(0, 20)}`; + const daysBack = opts.createdDaysAgo ?? 90; + await db.insert(organization_seats_purchases).values({ + organization_id: orgId, + subscription_stripe_id: subId, + seat_count: opts.seatCount ?? 5, + amount_usd: opts.amountUsd ?? 299, + subscription_status: status, + billing_cycle: opts.billingCycle ?? 'monthly', + starts_at: daysAgo(daysBack), + expires_at: daysFromNow(30), + idempotency_key: randomUUID(), + }); +} + +async function addKiloClawInstance(orgId: string, userId: string) { + const db = getSeedDb(); + await db.insert(kiloclaw_instances).values({ + id: randomUUID(), + user_id: userId, + organization_id: orgId, + sandbox_id: `${SANDBOX_PREFIX}${randomUUID().replace(/-/g, '').slice(0, 10)}`, + provider: 'fly', + }); +} + +async function addKiloPass(userId: string) { + const db = getSeedDb(); + const subId = `sub_seed_kp_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + await db.insert(kilo_pass_subscriptions).values({ + kilo_user_id: userId, + payment_provider: 'stripe', + provider_subscription_id: subId, + stripe_subscription_id: subId, + tier: 'tier_49', + cadence: 'monthly', + status: 'active', + started_at: daysAgo(60), + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +export async function run(): Promise { + console.log('Cleaning up existing seed data...'); + await cleanup(); + + // --- Users --- + console.log('Creating seed users...'); + const alice = await createUser('alice', 'Alice Chen'); + const bob = await createUser('bob', 'Bob Martinez'); + const carol = await createUser('carol', 'Carol Zhang'); + const david = await createUser('david', 'David Okonkwo'); + const eve = await createUser('eve', 'Eve Johansson'); + const frank = await createUser('frank', 'Frank Nguyen'); + const grace = await createUser('grace', 'Grace Patel'); + const henry = await createUser('henry', 'Henry Silva'); + const iris = await createUser('iris', 'Iris Müller'); + const jack = await createUser('jack', 'Jack Thompson'); + const kate = await createUser('kate', 'Kate Williams'); + const leo = await createUser('leo', 'Leo Nakamura'); + + // Give three users active Kilo Pass — they'll appear in Kilo Pass column + await addKiloPass(alice); + await addKiloPass(carol); + await addKiloPass(grace); + + // --- Paying Teams: active --- + console.log('Creating paying Teams orgs...'); + + const acme = await createOrg('Acme Corp (Teams active)', 'teams', { + seatCount: 10, + withStripeCustomer: true, + autoTopUp: true, + }); + await addMember(acme, alice, 'owner'); + await addMember(acme, bob); + await addMember(acme, carol); + await addSubscription(acme, 'active', { amountUsd: 990, seatCount: 10, createdDaysAgo: 120 }); + await addKiloClawInstance(acme, alice); + await addKiloClawInstance(acme, bob); + await addPlatformIntegration(acme, 'github', 'active'); + await addPlatformIntegration(acme, 'slack', 'active'); + + const buildco = await createOrg('BuildCo (Teams active yearly)', 'teams', { + seatCount: 25, + withStripeCustomer: true, + }); + await addMember(buildco, david, 'owner'); + await addMember(buildco, eve); + await addSubscription(buildco, 'active', { + amountUsd: 7_500, + seatCount: 25, + billingCycle: 'yearly', + createdDaysAgo: 180, + }); + await addPlatformIntegration(buildco, 'gitlab', 'active'); + + const codelab = await createOrg('CodeLab (Teams active small)', 'teams', { + seatCount: 3, + withStripeCustomer: true, + }); + await addMember(codelab, frank, 'owner'); + await addSubscription(codelab, 'active', { amountUsd: 297, seatCount: 3, createdDaysAgo: 30 }); + await addKiloClawInstance(codelab, frank); + await addPlatformIntegration(codelab, 'github', 'pending'); + + // --- Paying Teams: past_due (two subscription rows — active then past_due) --- + const debtco = await createOrg('DebtCo (Teams past_due)', 'teams', { + seatCount: 8, + withStripeCustomer: true, + settings: { data_collection: 'deny' }, + }); + await addMember(debtco, grace, 'owner'); + await addMember(debtco, henry); + await addSubscription(debtco, 'active', { amountUsd: 792, seatCount: 8, createdDaysAgo: 60 }); + await addSubscription(debtco, 'past_due', { amountUsd: 792, seatCount: 8, createdDaysAgo: 5 }); + + // --- Paying Teams: canceled (churned) --- + const exstartup = await createOrg('ExStartup (Teams canceled)', 'teams', { seatCount: 0 }); + await addMember(exstartup, iris, 'owner'); + await addSubscription(exstartup, 'active', { + amountUsd: 1485, + seatCount: 15, + createdDaysAgo: 240, + }); + await addSubscription(exstartup, 'canceled', { + amountUsd: 1485, + seatCount: 15, + createdDaysAgo: 45, + }); + + // --- Paying Enterprise: active --- + console.log('Creating paying Enterprise orgs...'); + + const megacorp = await createOrg('MegaCorp (Enterprise active)', 'enterprise', { + seatCount: 200, + requireSeats: false, + withStripeCustomer: true, + autoTopUp: true, + ssoDomain: 'megacorp.example.com', + settings: { + provider_allow_list: ['anthropic', 'openai'], + model_deny_list: ['openai/gpt-3.5-turbo'], + data_collection: 'deny', + }, + }); + await addMember(megacorp, jack, 'owner'); + await addMember(megacorp, kate); + await addMember(megacorp, leo); + await addSubscription(megacorp, 'active', { + amountUsd: 25_000, + seatCount: 200, + billingCycle: 'yearly', + createdDaysAgo: 365, + }); + await addKiloClawInstance(megacorp, jack); + await addKiloClawInstance(megacorp, kate); + await addPlatformIntegration(megacorp, 'github', 'active'); + await addPlatformIntegration(megacorp, 'gitlab', 'active'); + await addPlatformIntegration(megacorp, 'slack', 'active'); + + const globaltech = await createOrg('GlobalTech (Enterprise active)', 'enterprise', { + seatCount: 50, + requireSeats: false, + withStripeCustomer: true, + ssoDomain: 'globaltech.example.com', + settings: { + provider_allow_list: ['anthropic'], + }, + }); + await addMember(globaltech, alice, 'owner'); + await addMember(globaltech, david); + await addSubscription(globaltech, 'active', { + amountUsd: 8_000, + seatCount: 50, + createdDaysAgo: 120, + }); + await addPlatformIntegration(globaltech, 'github', 'active'); + // Suspended Slack should NOT show in the Integrations pills + await addPlatformIntegration(globaltech, 'slack', 'suspended'); + + // --- Paying Enterprise: ended (churned) --- + const oldenterprise = await createOrg('OldEnterprise (Enterprise ended)', 'enterprise', { + seatCount: 0, + requireSeats: false, + withStripeCustomer: true, + }); + await addMember(oldenterprise, bob, 'owner'); + await addSubscription(oldenterprise, 'active', { + amountUsd: 5_000, + seatCount: 40, + createdDaysAgo: 400, + }); + await addSubscription(oldenterprise, 'ended', { + amountUsd: 5_000, + seatCount: 40, + createdDaysAgo: 60, + }); + + // --- Trial orgs (no seats purchase) --- + console.log('Creating trial orgs...'); + + const trialA = await createOrg('TrialTeam Alpha', 'teams', { + requireSeats: true, + freeTrialEndAt: daysFromNow(14), + }); + await addMember(trialA, eve, 'owner'); + await addMember(trialA, frank); + await addMember(trialA, grace); + await addPlatformIntegration(trialA, 'github', 'pending'); + + const trialB = await createOrg('TrialTeam Beta (expiring soon)', 'teams', { + requireSeats: true, + freeTrialEndAt: daysFromNow(2), + }); + await addMember(trialB, henry, 'owner'); + + const trialE = await createOrg('TrialEnterprise Gamma', 'enterprise', { + requireSeats: false, + freeTrialEndAt: daysFromNow(21), + ssoDomain: 'trialenterprise.example.com', + }); + await addMember(trialE, iris, 'owner'); + await addMember(trialE, jack); + await addPlatformIntegration(trialE, 'github', 'active'); + + // --- Deleted org (was paying) --- + console.log('Creating deleted org...'); + + const deleted = await createOrg('DeletedCo (deleted)', 'teams', { + seatCount: 5, + deleted: true, + }); + await addSubscription(deleted, 'active', { amountUsd: 495, seatCount: 5, createdDaysAgo: 300 }); + await addSubscription(deleted, 'canceled', { amountUsd: 495, seatCount: 5, createdDaysAgo: 10 }); + + console.log(` +Seed complete. Useful filter combos to verify on /admin/organizations: + Default view (paying, no deleted) → 8 orgs + Stripe Status = active → Acme, BuildCo, CodeLab, MegaCorp, GlobalTech + Stripe Status = past_due → DebtCo + Stripe Status = canceled → ExStartup + Stripe Status = ended → OldEnterprise + Plan = enterprise → MegaCorp, GlobalTech, OldEnterprise + Include deleted ✓ → also shows DeletedCo (Stripe = canceled) + /admin/organizations/trials → TrialTeam Alpha/Beta, TrialEnterprise Gamma + Kilo Pass = Yes → Acme (alice, carol), GlobalTech (alice) + KiloClaw = Yes → Acme, CodeLab, MegaCorp + +Usage tab signals to verify: + Stripe link in Links → Acme, BuildCo, CodeLab, DebtCo, MegaCorp, GlobalTech, OldEnterprise + Auto Top-Up = On → Acme, MegaCorp + SSO pill → MegaCorp, GlobalTech, TrialEnterprise Gamma + P/M Controls pill → MegaCorp, GlobalTech + Data Privacy pill → DebtCo, MegaCorp + GitHub integration → Acme, CodeLab (pending), MegaCorp, GlobalTech, TrialTeam Alpha (pending), TrialEnterprise Gamma + GitLab integration → BuildCo, MegaCorp + Slack integration → Acme, MegaCorp (GlobalTech's Slack is suspended → hidden) +`); + + return { + orgsCreated: 11, + usersCreated: 12, + kiloPassUsers: 'alice, carol, grace', + kiloClawOrgs: 'Acme Corp, CodeLab, MegaCorp', + autoTopUpOrgs: 'Acme Corp, MegaCorp', + ssoOrgs: 'MegaCorp, GlobalTech, TrialEnterprise Gamma', + }; +}