From 09bbf1d6ff05f3be943fd08bd03127fa8856f71d Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:23:01 -0700 Subject: [PATCH] improvement(stripe) (#308): added orgs, teams * added organizations, stripe team plan, team management page * added db hook to set active organization * simplified data access patterns, added back environments --------- Co-authored-by: Waleed Latif --- sim/app/api/user/subscription/route.ts | 7 +- .../api/user/transfer-subscription/route.ts | 124 ++ sim/app/invite/[id]/page.tsx | 248 +++ .../settings-navigation.tsx | 12 +- .../components/subscription/subscription.tsx | 460 ++++- .../team-management/team-management.tsx | 1049 ++++++++++ .../settings-modal/settings-modal.tsx | 31 +- sim/components/emails/invitation-email.tsx | 103 + sim/components/emails/render-email.ts | 22 + sim/db/migrations/0031_lively_nico_minoru.sql | 37 + sim/db/migrations/meta/0031_snapshot.json | 1742 +++++++++++++++++ sim/db/migrations/meta/_journal.json | 7 + sim/db/schema.ts | 51 +- sim/lib/auth-client.ts | 7 +- sim/lib/auth.ts | 240 ++- sim/lib/subscription.ts | 280 ++- sim/lib/usage-monitor.ts | 197 +- sim/middleware.ts | 13 + 18 files changed, 4410 insertions(+), 220 deletions(-) create mode 100644 sim/app/api/user/transfer-subscription/route.ts create mode 100644 sim/app/invite/[id]/page.tsx create mode 100644 sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx create mode 100644 sim/components/emails/invitation-email.tsx create mode 100644 sim/db/migrations/0031_lively_nico_minoru.sql create mode 100644 sim/db/migrations/meta/0031_snapshot.json diff --git a/sim/app/api/user/subscription/route.ts b/sim/app/api/user/subscription/route.ts index a1a2e58cb3..1820a8881c 100644 --- a/sim/app/api/user/subscription/route.ts +++ b/sim/app/api/user/subscription/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { isProPlan } from '@/lib/subscription' +import { isProPlan, isTeamPlan } from '@/lib/subscription' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' @@ -21,7 +21,10 @@ export async function GET(request: NextRequest) { // Check if the user is on the Pro plan const isPro = await isProPlan(session.user.id) - return NextResponse.json({ isPro }) + // Check if the user is on the Team plan + const isTeam = await isTeamPlan(session.user.id) + + return NextResponse.json({ isPro, isTeam }) } catch (error) { logger.error('Error checking subscription status:', error) return NextResponse.json( diff --git a/sim/app/api/user/transfer-subscription/route.ts b/sim/app/api/user/transfer-subscription/route.ts new file mode 100644 index 0000000000..de1caf9a1d --- /dev/null +++ b/sim/app/api/user/transfer-subscription/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import * as schema from '@/db/schema' +import { eq } from 'drizzle-orm' + +const logger = createLogger('TransferSubscriptionAPI') + +export async function POST(request: NextRequest) { + try { + // Get the authenticated user + const session = await getSession() + + if (!session?.user?.id) { + logger.warn('Unauthorized subscription transfer attempt') + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ) + } + + // Parse the request body + const body = await request.json() + const { subscriptionId, organizationId } = body + + if (!subscriptionId || !organizationId) { + return NextResponse.json( + { error: 'Missing required fields: subscriptionId and organizationId' }, + { status: 400 } + ) + } + + logger.info('Transferring subscription to organization', { + userId: session.user.id, + subscriptionId, + organizationId + }) + + // Verify the user has access to both the subscription and organization + const subscription = await db.select() + .from(schema.subscription) + .where(eq(schema.subscription.id, subscriptionId)) + .then(rows => rows[0]) + + if (!subscription) { + logger.warn('Subscription not found', { subscriptionId }) + return NextResponse.json( + { error: 'Subscription not found' }, + { status: 404 } + ) + } + + // Verify the subscription belongs to the user + if (subscription.referenceId !== session.user.id) { + logger.warn('Unauthorized subscription transfer - subscription does not belong to user', { + userId: session.user.id, + subscriptionReferenceId: subscription.referenceId + }) + return NextResponse.json( + { error: 'Unauthorized - subscription does not belong to user' }, + { status: 403 } + ) + } + + // Verify the organization exists + const organization = await db.select() + .from(schema.organization) + .where(eq(schema.organization.id, organizationId)) + .then(rows => rows[0]) + + if (!organization) { + logger.warn('Organization not found', { organizationId }) + return NextResponse.json( + { error: 'Organization not found' }, + { status: 404 } + ) + } + + // Verify the user has admin access to the organization (is owner or admin) + const member = await db.select() + .from(schema.member) + .where( + eq(schema.member.userId, session.user.id) && + eq(schema.member.organizationId, organizationId) + ) + .then(rows => rows[0]) + + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + logger.warn('Unauthorized subscription transfer - user is not admin of organization', { + userId: session.user.id, + organizationId, + memberRole: member?.role + }) + return NextResponse.json( + { error: 'Unauthorized - user is not admin of organization' }, + { status: 403 } + ) + } + + // Update the subscription to point to the organization instead of the user + await db.update(schema.subscription) + .set({ referenceId: organizationId }) + .where(eq(schema.subscription.id, subscriptionId)) + + logger.info('Successfully transferred subscription to organization', { + subscriptionId, + organizationId, + userId: session.user.id + }) + + return NextResponse.json({ + success: true, + message: 'Subscription transferred successfully' + }) + + } catch (error) { + logger.error('Error transferring subscription', { error }) + return NextResponse.json( + { error: 'Failed to transfer subscription' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/sim/app/invite/[id]/page.tsx b/sim/app/invite/[id]/page.tsx new file mode 100644 index 0000000000..7d012dd272 --- /dev/null +++ b/sim/app/invite/[id]/page.tsx @@ -0,0 +1,248 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter, useSearchParams, useParams } from 'next/navigation' +import { client, useSession } from '@/lib/auth-client' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { XCircle, CheckCircle } from 'lucide-react' + +export default function InvitePage() { + const router = useRouter() + const params = useParams() + const invitationId = params.id as string + const searchParams = useSearchParams() + const { data: session, isPending, error: sessionError } = useSession() + const [invitation, setInvitation] = useState(null) + const [organization, setOrganization] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isAccepting, setIsAccepting] = useState(false) + const [accepted, setAccepted] = useState(false) + const [isNewUser, setIsNewUser] = useState(false) + + // Check if this is a new user vs. existing user + useEffect(() => { + const isNew = searchParams.get('new') === 'true' + setIsNewUser(isNew) + }, [searchParams]) + + // Fetch invitation details + useEffect(() => { + async function fetchInvitation() { + try { + setIsLoading(true) + const { data } = await client.organization.getInvitation({ + query: { id: invitationId } + }) + + if (data) { + setInvitation(data) + + // Get organization details if we have the invitation + if (data.organizationId) { + const orgResponse = await client.organization.getFullOrganization({ + query: { organizationId: data.organizationId } + }) + setOrganization(orgResponse.data) + } + } else { + setError('Invitation not found or has expired') + } + } catch (err: any) { + setError(err.message || 'Failed to load invitation') + } finally { + setIsLoading(false) + } + } + + // Only fetch if the user is logged in + if (session?.user && invitationId) { + fetchInvitation() + } + }, [invitationId, session?.user]) + + // Handle invitation acceptance + const handleAcceptInvitation = async () => { + if (!session?.user) return + + try { + setIsAccepting(true) + console.log("Accepting invitation:", invitationId, "for user:", session.user.id); + + const response = await client.organization.acceptInvitation({ + invitationId + }) + + console.log("Invitation acceptance response:", response); + + // Explicitly verify membership was created + try { + const orgResponse = await client.organization.getFullOrganization({ + query: { organizationId: invitation.organizationId } + }); + + console.log("Organization members after acceptance:", orgResponse.data?.members); + + const isMember = orgResponse.data?.members?.some( + (member: any) => member.userId === session.user.id + ); + + if (!isMember) { + console.error("User was not added as a member after invitation acceptance"); + throw new Error("Failed to add you as a member. Please contact support."); + } + + // Set the active organization to the one the user just joined + await client.organization.setActive({ + organizationId: invitation.organizationId + }); + + console.log("Successfully set active organization:", invitation.organizationId); + } catch (memberCheckErr: any) { + console.error("Error verifying membership:", memberCheckErr); + throw memberCheckErr; + } + + setAccepted(true) + + // Redirect to the workspace after a short delay + setTimeout(() => { + router.push('/w') + }, 2000) + + } catch (err: any) { + console.error("Error accepting invitation:", err); + setError(err.message || 'Failed to accept invitation') + } finally { + setIsAccepting(false) + } + } + + // Show login/signup prompt if not logged in + if (!session?.user && !isPending) { + return ( +
+ + + You've been invited to join a team + + {isNewUser ? + "Create an account to join this team on Sim Studio" : + "Sign in to your account to accept this invitation"} + + + + {isNewUser ? ( + <> + + + + ) : ( + <> + + + + )} + + +
+ ) + } + + // Show loading state + if (isLoading || isPending) { + return ( +
+ +

Loading invitation...

+
+ ) + } + + // Show error state + if (error) { + return ( +
+ + + Error + {error} + +
+ ) + } + + // Show success state + if (accepted) { + return ( +
+ + + Invitation Accepted + + You have successfully joined {organization?.name}. Redirecting to your workspace... + + +
+ ) + } + + // Show invitation details + return ( +
+ + + Team Invitation + + You've been invited to join{' '} + {organization?.name || 'a team'} + + + +

+ {invitation?.inviterId ? 'A team member has' : 'You have'} invited you to collaborate in {organization?.name || 'their workspace'}. +

+
+ + + + +
+
+ ) +} diff --git a/sim/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/sim/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx index b955c688b3..d0dde6cd3e 100644 --- a/sim/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx +++ b/sim/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx @@ -1,16 +1,16 @@ -import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard } from 'lucide-react' +import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard, Users } from 'lucide-react' import { cn } from '@/lib/utils' import { isDev } from '@/lib/environment' interface SettingsNavigationProps { activeSection: string onSectionChange: ( - section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' + section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' ) => void } type NavigationItem = { - id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' + id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' label: string icon: React.ComponentType<{ className?: string }> hideInDev?: boolean @@ -48,6 +48,12 @@ const allNavigationItems: NavigationItem[] = [ icon: CreditCard, hideInDev: true, }, + { + id: 'team', + label: 'Team', + icon: Users, + hideInDev: true, + }, ] export function SettingsNavigation({ activeSection, onSectionChange }: SettingsNavigationProps) { diff --git a/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index b38d5f83f3..3defe96082 100644 --- a/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -1,18 +1,37 @@ import { useState, useEffect } from 'react' -import { client, useSession } from '@/lib/auth-client' +import { client, useSession, useActiveOrganization } from '@/lib/auth-client' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { AlertCircle } from 'lucide-react' import { Button } from '@/components/ui/button' -import { LoadingAgent } from '@/components/ui/loading-agent' import { Progress } from '@/components/ui/progress' +import { Skeleton } from '@/components/ui/skeleton' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('Subscription') interface SubscriptionProps { onOpenChange: (open: boolean) => void } -export function Subscription({ onOpenChange }: SubscriptionProps) { - const { data: session } = useSession() +const useSubscriptionData = (userId: string | null | undefined, activeOrgId: string | null | undefined) => { const [isPro, setIsPro] = useState(false) + const [isTeam, setIsTeam] = useState(false) const [usageData, setUsageData] = useState<{ percentUsed: number; isWarning: boolean; @@ -26,76 +45,166 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { currentUsage: 0, limit: 0 }) - const [loading, setLoading] = useState(true) const [subscriptionData, setSubscriptionData] = useState(null) - const [isCanceling, setIsCanceling] = useState(false) + const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { - async function checkSubscriptionStatus() { - if (session?.user?.id) { - try { - setLoading(true) - setError(null) + async function loadSubscriptionData() { + if (!userId) return + + try { + setLoading(true) + setError(null) + + // Fetch subscription status and usage data in parallel + const [proStatusResponse, usageResponse] = await Promise.all([ + fetch('/api/user/subscription'), + fetch('/api/user/usage') + ]) + + if (!proStatusResponse.ok) { + throw new Error('Failed to fetch subscription status') + } + if (!usageResponse.ok) { + throw new Error('Failed to fetch usage data') + } + + // Process the responses + const proStatusData = await proStatusResponse.json() + setIsPro(proStatusData.isPro) + setIsTeam(proStatusData.isTeam) + + const usageDataResponse = await usageResponse.json() + setUsageData(usageDataResponse) + + logger.info('Subscription status and usage data retrieved', { + isPro: proStatusData.isPro, + isTeam: proStatusData.isTeam, + usage: usageDataResponse + }) + + // Main subscription logic - prioritize organization team subscription + let activeSubscription = null + + // First check if user has an active organization with a team subscription + if (activeOrgId) { + logger.info('Checking organization subscription first', { orgId: activeOrgId }) - // Fetch subscription status from API - const proStatusResponse = await fetch('/api/user/subscription') - if (!proStatusResponse.ok) { - throw new Error('Failed to fetch subscription status') - } - const proStatusData = await proStatusResponse.json() - setIsPro(proStatusData.isPro) + // Get the organization's subscription + const { data: orgSubscriptions, error: orgSubError } = await client.subscription.list({ + query: { referenceId: activeOrgId } + }) - // Fetch usage data from API - const usageResponse = await fetch('/api/user/usage') - if (!usageResponse.ok) { - throw new Error('Failed to fetch usage data') - } - const usageData = await usageResponse.json() - setUsageData(usageData) - - // Fetch detailed subscription data - const { data, error: subError } = await client.subscription.list() - - if (subError) { - console.error('Error fetching subscription details', subError) - // Continue with basic subscription info we already have + if (orgSubError) { + logger.error('Error fetching organization subscription details', orgSubError) } else { - // Find active subscription - const activeSubscription = data?.find( + // Find active team subscription for the organization + activeSubscription = orgSubscriptions?.find( + sub => sub.status === 'active' && sub.plan === 'team' + ) + + if (activeSubscription) { + logger.info('Using organization team subscription as primary', { + id: activeSubscription.id, + seats: activeSubscription.seats + }) + } + } + } + + // If no org team subscription was found, check for personal subscription + if (!activeSubscription) { + // Fetch detailed subscription data for the user + const { data: userSubscriptions, error: userSubError } = await client.subscription.list() + + if (userSubError) { + logger.error('Error fetching user subscription details', userSubError) + } else { + // Find active subscription for the user + activeSubscription = userSubscriptions?.find( sub => sub.status === 'active' ) - setSubscriptionData(activeSubscription) } - } catch (error) { - console.error('Error checking subscription status:', error) - } finally { - setLoading(false) } + + if (activeSubscription) { + logger.info('Using active subscription', { + id: activeSubscription.id, + plan: activeSubscription.plan, + status: activeSubscription.status + }) + + setSubscriptionData(activeSubscription) + } else { + logger.warn('No active subscription found') + } + } catch (error) { + logger.error('Error checking subscription status:', error) + setError('Failed to load subscription data') + } finally { + setLoading(false) } } - checkSubscriptionStatus() - }, [session?.user?.id]) + loadSubscriptionData() + }, [userId, activeOrgId]) - const handleUpgrade = async () => { + return { isPro, isTeam, usageData, subscriptionData, loading, error } +} + +export function Subscription({ onOpenChange }: SubscriptionProps) { + const { data: session } = useSession() + const { data: activeOrg } = useActiveOrganization() + + const { + isPro, + isTeam, + usageData, + subscriptionData, + loading, + error: subscriptionError + } = useSubscriptionData(session?.user?.id, activeOrg?.id) + + const [isCanceling, setIsCanceling] = useState(false) + const [error, setError] = useState(null) + const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false) + const [seats, setSeats] = useState(1) + const [isUpgradingTeam, setIsUpgradingTeam] = useState(false) + const [isUpgrading, setIsUpgrading] = useState(false) + + // Set error from subscription hook if there is one + useEffect(() => { + if (subscriptionError) { + setError(subscriptionError) + } + }, [subscriptionError]) + + const handleUpgrade = async (plan: string) => { if (!session?.user) { setError('You need to be logged in to upgrade your subscription') return } + setIsUpgrading(true) + setError(null) + try { const { error } = await client.subscription.upgrade({ - plan: 'pro', + plan: plan, successUrl: window.location.href, cancelUrl: window.location.href, }) if (error) { - setError(error.message || 'There was an error upgrading your subscription') + setError(error.message || `There was an error upgrading to the ${plan} plan`) + logger.error('Subscription upgrade error:', error) } } catch (error: any) { - setError(error.message || 'There was an error upgrading your subscription') + logger.error('Subscription upgrade exception:', error) + setError(error.message || `There was an unexpected error upgrading to the ${plan} plan`) + } finally { + setIsUpgrading(false) } } @@ -106,6 +215,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { } setIsCanceling(true) + setError(null) try { const { error } = await client.subscription.cancel({ @@ -114,14 +224,51 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { if (error) { setError(error.message || 'There was an error canceling your subscription') + logger.error('Subscription cancellation error:', error) } } catch (error: any) { - setError(error.message || 'There was an error canceling your subscription') + logger.error('Subscription cancellation exception:', error) + setError(error.message || 'There was an unexpected error canceling your subscription') } finally { setIsCanceling(false) } } + const handleTeamUpgrade = () => { + setIsTeamDialogOpen(true) + } + + const confirmTeamUpgrade = async () => { + if (!session?.user) { + setError('You need to be logged in to upgrade your team subscription') + return + } + + setIsUpgradingTeam(true) + setError(null) + + try { + const { error } = await client.subscription.upgrade({ + plan: 'team', + successUrl: window.location.href, + cancelUrl: window.location.href, + seats: seats + }) + + if (error) { + setError(error.message || 'There was an error upgrading to the team plan') + logger.error('Team subscription upgrade error:', error) + } else { + setIsTeamDialogOpen(false) + } + } catch (error: any) { + logger.error('Team subscription upgrade exception:', error) + setError(error.message || 'There was an unexpected error upgrading to the team plan') + } finally { + setIsUpgradingTeam(false) + } + } + return (

Subscription Plans

@@ -148,10 +295,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { )} {loading ? ( -
- - Loading subscription details... -
+ ) : ( <>
@@ -199,25 +343,26 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { onClick={handleCancel} disabled={isCanceling} > - {isCanceling && } - Downgrade + {isCanceling ? : ( + Downgrade + )} )}
{/* Pro Tier */} -
+

Pro Tier

For professional users and teams

    -
  • • ${isPro ? usageData.limit : 20} of inference credits
  • +
  • • ${isPro && !isTeam ? usageData.limit : 20} of inference credits
  • • All features included
  • • Workflow sharing capabilities
- {isPro && ( + {isPro && !isTeam && (
Usage @@ -239,17 +384,75 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { )}
- {isPro ? ( + {isPro && !isTeam ? (
Current Plan
) : ( + )} +
+
+ + {/* Team Tier */} +
+

Team Tier

+

For collaborative teams

+ +
    +
  • • $40 of inference credits per seat
  • +
  • • All Pro features included
  • +
  • • Real-time multiplayer collaboration
  • +
  • • Shared workspace for team members
  • +
+ + {isTeam && ( +
+
+ Usage + + {usageData.currentUsage.toFixed(2)}$ / {(subscriptionData?.seats || 1) * 40}$ + +
+ *]:bg-destructive' + : usageData.isWarning + ? 'bg-muted [&>*]:bg-amber-500' + : '' + }`} + /> + +
+ Team Size + {subscriptionData?.seats || 1} {subscriptionData?.seats === 1 ? 'seat' : 'seats'} +
+
+ )} + +
+ {isTeam ? ( +
+ Current Plan +
+ ) : ( + )}
@@ -306,16 +509,149 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { onClick={handleCancel} disabled={isCanceling} > - {isCanceling && } - Manage Subscription + {isCanceling ? : ( + Manage Subscription + )}
)}
)} + + + + + Team Subscription + + Set up a team workspace with collaborative features. Each seat costs $40/month and gets $40 of inference credits. + + + +
+ + + +

+ Your team will have {seats} {seats === 1 ? 'seat' : 'seats'} with a total of ${seats * 40} inference credits per month. +

+
+ + + + + +
+
)}
) -} \ No newline at end of file +} + +// Skeleton component for subscription loading state +function SubscriptionSkeleton() { + return ( +
+
+ {/* Free Tier Skeleton */} +
+ + + +
+ + + +
+ +
+ +
+
+ + {/* Pro Tier Skeleton */} +
+ + + +
+ + + +
+ +
+ +
+
+ + {/* Team Tier Skeleton */} +
+ + + +
+ + + + +
+ +
+ +
+
+ + {/* Enterprise Tier Skeleton */} +
+ + + +
+ + + + +
+ +
+ +
+
+
+
+ ) +} + +// Skeleton component for loading state in buttons +function ButtonSkeleton() { + return +} \ No newline at end of file diff --git a/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx new file mode 100644 index 0000000000..eb9f720547 --- /dev/null +++ b/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx @@ -0,0 +1,1049 @@ +import { useState, useEffect, useCallback } from 'react' +import { Copy, PlusCircle, UserX, RefreshCw, CheckCircle, XCircle, Building } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { client, useSession } from '@/lib/auth-client' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Progress } from '@/components/ui/progress' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('TeamManagement') + +export function TeamManagement() { + const { data: session } = useSession() + const { data: activeOrg } = client.useActiveOrganization() + + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [organizations, setOrganizations] = useState([]) + const [inviteEmail, setInviteEmail] = useState('') + const [isInviting, setIsInviting] = useState(false) + const [isCreatingOrg, setIsCreatingOrg] = useState(false) + const [createOrgDialogOpen, setCreateOrgDialogOpen] = useState(false) + const [removeMemberDialog, setRemoveMemberDialog] = useState<{ + open: boolean; + memberId: string; + memberName: string; + shouldReduceSeats: boolean; + }>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false }) + const [orgName, setOrgName] = useState('') + const [orgSlug, setOrgSlug] = useState('') + const [inviteSuccess, setInviteSuccess] = useState(false) + const [activeTab, setActiveTab] = useState('members') + const [activeOrganization, setActiveOrganization] = useState(null) + const [subscriptionData, setSubscriptionData] = useState(null) + const [isLoadingSubscription, setIsLoadingSubscription] = useState(false) + const [hasTeamPlan, setHasTeamPlan] = useState(false) + const [userRole, setUserRole] = useState('member') + const [isAdminOrOwner, setIsAdminOrOwner] = useState(false) + + const loadData = useCallback(async () => { + if (!session?.user) return + + try { + setIsLoading(true) + setError(null) + + // Get all organizations the user is a member of + const orgsResponse = await client.organization.list() + setOrganizations(orgsResponse.data || []) + + // Check if user has a team subscription + const response = await fetch('/api/user/subscription') + const data = await response.json() + setHasTeamPlan(data.isTeam) + + // If user has team plan but no organizations, prompt to create one + if (data.isTeam && (!orgsResponse.data || orgsResponse.data.length === 0)) { + setOrgName(`${session.user.name || 'My'}'s Team`) + setOrgSlug(generateSlug(`${session.user.name || 'My'}'s Team`)) + setCreateOrgDialogOpen(true) + } + + } catch (err: any) { + setError(err.message || 'Failed to load data') + logger.error('Failed to load data:', err) + } finally { + setIsLoading(false) + } + }, [session?.user]) + + // Update local state when the active organization changes + useEffect(() => { + if (activeOrg) { + setActiveOrganization(activeOrg) + + // Determine the user's role in this organization + if (session?.user?.email && activeOrg.members) { + const currentMember = activeOrg.members.find( + (m: any) => m.user?.email === session.user?.email + ) + + if (currentMember) { + setUserRole(currentMember.role) + setIsAdminOrOwner(currentMember.role === 'owner' || currentMember.role === 'admin') + logger.info('User role in organization', { + role: currentMember.role, + isAdminOrOwner: currentMember.role === 'owner' || currentMember.role === 'admin' + }) + } + } + + // Load subscription data for the organization + if (activeOrg.id) { + loadOrganizationSubscription(activeOrg.id) + } + } + }, [activeOrg, session?.user?.email]) + + // Load organization's subscription data + const loadOrganizationSubscription = async (orgId: string) => { + try { + setIsLoadingSubscription(true) + logger.info('Loading subscription for organization', { orgId }) + + const { data, error } = await client.subscription.list({ + query: { referenceId: orgId } + }) + + if (error) { + logger.error('Error fetching organization subscription', { error }) + setError('Failed to load subscription data') + } else { + logger.info('Organization subscription data loaded', { + subscriptions: data?.map(s => ({ + id: s.id, + plan: s.plan, + status: s.status, + seats: s.seats, + referenceId: s.referenceId + })) + }) + + // Filter to only active team subscription + const teamSubscription = data?.find( + sub => sub.status === 'active' && sub.plan === 'team' + ) + + if (teamSubscription) { + logger.info('Found active team subscription', { + id: teamSubscription.id, + seats: teamSubscription.seats + }) + setSubscriptionData([teamSubscription]) + } else { + logger.warn('No active team subscription found for organization', { + orgId + }) + setSubscriptionData([]) + } + } + } catch (err: any) { + logger.error('Error loading subscription data', { error: err }) + setError(err.message || 'Failed to load subscription data') + } finally { + setIsLoadingSubscription(false) + } + } + + // Initial data loading + useEffect(() => { + loadData() + }, [loadData]) + + // Refresh organization data + const refreshOrganization = useCallback(async () => { + if (!activeOrganization?.id) return + + try { + const fullOrgResponse = await client.organization.getFullOrganization() + setActiveOrganization(fullOrgResponse.data) + + // Also refresh subscription data when organization is refreshed + if (fullOrgResponse.data?.id) { + await loadOrganizationSubscription(fullOrgResponse.data.id) + } + } catch (err: any) { + setError(err.message || 'Failed to refresh organization data') + } + }, [activeOrganization?.id]) + + // Handle seat reduction - remove members when seats are reduced + const handleReduceSeats = async () => { + if (!session?.user || !activeOrganization || !subscriptionData || subscriptionData.length === 0) return + + const currentSeats = subscriptionData[0]?.seats || 0 + if (currentSeats <= 1) { + setError("Cannot reduce seats below 1") + return + } + + // Calculate current usage + const currentMemberCount = activeOrganization.members?.length || 0 + const pendingInvitationCount = activeOrganization.invitations?.filter( + (inv: any) => inv.status === 'pending' + ).length || 0 + const totalCount = currentMemberCount + pendingInvitationCount + + // Check if we need to remove members before reducing seats + if (totalCount >= currentSeats) { + setError(`You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.`) + return + } + + try { + setIsLoading(true) + setError(null) + + // Reduce the seats by 1 + const newSeatCount = currentSeats - 1 + + // Upgrade with reduced seat count + const { error } = await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + successUrl: window.location.href, + cancelUrl: window.location.href, + seats: newSeatCount + }) + + if (error) { + setError(error.message || 'Failed to update seat count') + } else { + await refreshOrganization() + } + } catch (err: any) { + setError(err.message || 'Failed to reduce seats') + } finally { + setIsLoading(false) + } + } + + // Generate a slug from organization name + const generateSlug = (name: string) => { + return name.toLowerCase().replace(/[^a-z0-9]/g, '-') + } + + // Handle organization name change + const handleOrgNameChange = (e: React.ChangeEvent) => { + const newName = e.target.value + setOrgName(newName) + setOrgSlug(generateSlug(newName)) + } + + // Create a new organization + const handleCreateOrganization = async () => { + if (!session?.user) return + + try { + setIsCreatingOrg(true) + setError(null) + + logger.info('Creating team organization', { name: orgName, slug: orgSlug }) + + // Create the organization using Better Auth API + const result = await client.organization.create({ + name: orgName, + slug: orgSlug, + }) + + if (!result.data?.id) { + throw new Error('Failed to create organization') + } + + const orgId = result.data.id + logger.info('Organization created', { orgId }) + + // Set the new organization as active + logger.info('Setting organization as active', { orgId }) + await client.organization.setActive({ + organizationId: orgId + }) + + // If the user has a team subscription, update the subscription reference + // directly through a custom API endpoint instead of using upgrade + if (hasTeamPlan) { + const userSubResponse = await client.subscription.list() + const teamSubscription = userSubResponse.data?.find( + sub => sub.plan === 'team' && sub.status === 'active' + ) + + if (teamSubscription) { + logger.info('Found user team subscription to transfer', { + subscriptionId: teamSubscription.id, + seats: teamSubscription.seats, + targetOrgId: orgId + }) + + // Use a custom API endpoint to transfer the subscription without going to Stripe + const transferResponse = await fetch('/api/user/transfer-subscription', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + subscriptionId: teamSubscription.id, + organizationId: orgId + }) + }) + + if (!transferResponse.ok) { + const errorData = await transferResponse.json() + throw new Error(errorData.error || 'Failed to transfer subscription to organization') + } + + logger.info('Successfully transferred subscription to organization') + } + } + + // Refresh the organization list + await loadData() + + // Close the dialog + setCreateOrgDialogOpen(false) + setOrgName('') + setOrgSlug('') + + } catch (err: any) { + logger.error('Failed to create organization', { error: err }) + setError(err.message || 'Failed to create organization') + } finally { + setIsCreatingOrg(false) + } + } + + // Upgrade to team subscription with organization as reference + const confirmTeamUpgrade = async (seats: number) => { + if (!session?.user || !activeOrganization) return + + try { + setIsLoading(true) + setError(null) + + // Use the organization's ID as the reference for the team subscription + const { error } = await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + successUrl: window.location.href, + cancelUrl: window.location.href, + seats: seats + }) + + if (error) { + setError(error.message || 'Failed to upgrade to team subscription') + } else { + await refreshOrganization() + } + } catch (err: any) { + setError(err.message || 'Failed to upgrade to team subscription') + } finally { + setIsLoading(false) + } + } + + // Set an organization as active + const handleSetActiveOrg = async (orgId: string) => { + if (!session?.user) return + + try { + setIsLoading(true) + + // Set the active organization + await client.organization.setActive({ + organizationId: orgId, + }) + + } catch (err: any) { + setError(err.message || 'Failed to set active organization') + } finally { + setIsLoading(false) + } + } + + // Invite a member to the organization + const handleInviteMember = async () => { + if (!session?.user || !activeOrganization) return + + try { + setIsInviting(true) + setError(null) + setInviteSuccess(false) + + // Check seat limit - compare current members + pending invitations against seats + const currentMemberCount = activeOrganization.members?.length || 0; + const pendingInvitationCount = activeOrganization.invitations?.filter( + (inv: any) => inv.status === 'pending' + ).length || 0; + const totalCount = currentMemberCount + pendingInvitationCount; + + // Get the number of seats from subscription data + const teamSubscription = subscriptionData?.[0]; + const seatLimit = teamSubscription?.seats || 0; + + logger.info('Checking seat availability for invitation', { + currentMembers: currentMemberCount, + pendingInvites: pendingInvitationCount, + totalUsed: totalCount, + seatLimit: seatLimit, + subscriptionId: teamSubscription?.id + }); + + if (totalCount >= seatLimit) { + const error = `You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.`; + logger.warn('Invitation failed - seat limit reached', { + totalCount, + seatLimit + }); + setError(error); + return; + } + + if (!inviteEmail || !inviteEmail.includes('@')) { + setError('Please enter a valid email address'); + return; + } + + logger.info('Sending invitation to member', { + email: inviteEmail, + organizationId: activeOrganization.id + }); + + // Invite the member + const inviteResult = await client.organization.inviteMember({ + email: inviteEmail, + role: 'member', + organizationId: activeOrganization.id, + }); + + if (inviteResult.error) { + throw new Error(inviteResult.error.message || 'Failed to send invitation'); + } + + logger.info('Invitation sent successfully'); + + // Clear the input and show success message + setInviteEmail(''); + setInviteSuccess(true); + + // Refresh the organization + await refreshOrganization(); + + } catch (err: any) { + logger.error('Error inviting member', { error: err }); + setError(err.message || 'Failed to invite member'); + } finally { + setIsInviting(false); + } + } + + // Remove a member from the organization + const handleRemoveMember = async (member: any) => { + if (!session?.user || !activeOrganization) return + + // Open confirmation dialog + setRemoveMemberDialog({ + open: true, + memberId: member.id, + memberName: member.user?.name || member.user?.email || 'this member', + shouldReduceSeats: false + }) + } + + // Actual member removal after confirmation + const confirmRemoveMember = async (shouldReduceSeats: boolean = false) => { + const { memberId } = removeMemberDialog + if (!session?.user || !activeOrganization || !memberId) return + + try { + setIsLoading(true) + + // Remove the member + await client.organization.removeMember({ + memberIdOrEmail: memberId, + organizationId: activeOrganization.id, + }) + + // If the user opted to reduce seats as well + if (shouldReduceSeats && subscriptionData && subscriptionData.length > 0) { + const currentSeats = subscriptionData[0]?.seats || 0 + + if (currentSeats > 1) { + // Reduce the seat count by 1 + await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + successUrl: window.location.href, + cancelUrl: window.location.href, + seats: currentSeats - 1 + }) + } + } + + // Refresh the organization + await refreshOrganization() + + // Close the dialog + setRemoveMemberDialog({ open: false, memberId: '', memberName: '', shouldReduceSeats: false }) + + } catch (err: any) { + setError(err.message || 'Failed to remove member') + } finally { + setIsLoading(false) + } + } + + // Cancel an invitation + const handleCancelInvitation = async (invitationId: string) => { + if (!session?.user || !activeOrganization) return + + try { + setIsLoading(true) + + // Cancel the invitation + await client.organization.cancelInvitation({ + invitationId, + }) + + // Refresh the organization + await refreshOrganization() + + } catch (err: any) { + setError(err.message || 'Failed to cancel invitation') + } finally { + setIsLoading(false) + } + } + + if (isLoading && !activeOrganization && !hasTeamPlan) { + return + } + + const getInvitationStatus = (status: string) => { + switch (status) { + case 'pending': + return ( +
+ + Pending +
+ ) + case 'accepted': + return ( +
+ + Accepted +
+ ) + case 'canceled': + return ( +
+ + Canceled +
+ ) + default: + return status + } + } + + // No organization yet - show creation UI + if (!activeOrganization) { + return ( +
+
+

+ {hasTeamPlan + ? "Create Your Team Workspace" + : "No Team Workspace"} +

+

+ {hasTeamPlan + ? "You're subscribed to a team plan. Create your workspace to start collaborating with your team." + : "You don't have a team workspace yet. Create one to start collaborating with your team."} +

+ + +
+ + + + + Create Team Workspace + + Create a workspace for your team to collaborate on projects. + + + +
+
+ + +
+ +
+ +
+
+ simstudio.ai/team/ +
+ setOrgSlug(e.target.value)} + className="rounded-l-none" + /> +
+
+
+ + {error && ( + + Error + {error} + + )} + + + + + +
+
+
+ ) + } + + return ( +
+
+

Team Management

+ + {organizations.length > 1 && ( +
+ +
+ )} +
+ + {error && ( + + Error + {error} + + )} + + + + Members + Settings + + + + {isAdminOrOwner && ( +
+

Invite Team Members

+ +
+ setInviteEmail(e.target.value)} + disabled={isInviting} + /> + +
+ + {inviteSuccess && ( +

+ Invitation sent successfully +

+ )} +
+ )} + + {/* Team Seats Usage - only show to admins/owners */} + {isAdminOrOwner && ( +
+

Team Seats

+ + {isLoadingSubscription ? ( + + ) : subscriptionData && subscriptionData.length > 0 ? ( + <> +
+ Used + + {(activeOrganization.members?.length || 0) + + (activeOrganization.invitations?.filter((inv: any) => inv.status === 'pending').length || 0)} + /{subscriptionData[0]?.seats || 0} + +
+ inv.status === 'pending').length || 0)) / + (subscriptionData[0]?.seats || 1) * 100} + className="h-2" + /> + +
+ + +
+ + ) : ( +
+

No active team subscription found for this organization.

+

This might happen if your subscription was created for your personal account + but hasn't been properly transferred to the organization.

+ +
+ )} +
+ )} + + {/* Team Members - show to all users */} +
+

Team Members

+ + {activeOrganization.members?.length === 0 ? ( +
+ No members in this organization yet. +
+ ) : ( +
+ {activeOrganization.members?.map((member: any) => ( +
+
+
{member.user?.name || 'Unknown'}
+
{member.user?.email}
+
+ {member.role.charAt(0).toUpperCase() + member.role.slice(1)} +
+
+ + {/* Only show remove button for non-owners and if current user is admin/owner */} + {isAdminOrOwner && member.role !== 'owner' && member.user?.email !== session?.user?.email && ( + + )} +
+ ))} +
+ )} +
+ + {/* Pending Invitations - only show to admins/owners */} + {isAdminOrOwner && activeOrganization.invitations?.length > 0 && ( +
+

Pending Invitations

+ +
+ {activeOrganization.invitations?.map((invitation: any) => ( +
+
+
{invitation.email}
+
+ {getInvitationStatus(invitation.status)} +
+
+ + {invitation.status === 'pending' && ( + + )} +
+ ))} +
+
+ )} +
+ + +
+
+

Team Workspace Name

+
{activeOrganization.name}
+
+ +
+

URL Slug

+
+ + {activeOrganization.slug} + + +
+
+ +
+

Created On

+
+ {new Date(activeOrganization.createdAt).toLocaleDateString()} +
+
+ + {/* Only show subscription details to admins/owners */} + {isAdminOrOwner && ( +
+

Subscription Status

+ {isLoadingSubscription ? ( + + ) : subscriptionData ? ( +
+
+
+ + {subscriptionData.status} + {subscriptionData.cancelAtPeriodEnd ? ' (Cancels at period end)' : ''} + +
+
+
Team seats: {subscriptionData.seats}
+ {subscriptionData.periodEnd && ( +
Next billing date: {new Date(subscriptionData.periodEnd).toLocaleDateString()}
+ )} + {subscriptionData.trialEnd && ( +
Trial ends: {new Date(subscriptionData.trialEnd).toLocaleDateString()}
+ )} +
+ This subscription is associated with this team workspace. +
+
+
+ ) : ( +
No active subscription found
+ )} +
+ )} + + {!isAdminOrOwner && ( +
+

Your Role

+
+ You are a {userRole} of this workspace. + {userRole === 'member' && ( +

+ Contact a workspace admin or owner for subscription changes or to invite new members. +

+ )} +
+
+ )} +
+
+
+ + {/* Member removal confirmation dialog */} + { + if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false }); + }} + > + + + Remove Team Member + + Are you sure you want to remove {removeMemberDialog.memberName} from the team? + + + +
+
+ setRemoveMemberDialog({ + ...removeMemberDialog, + shouldReduceSeats: e.target.checked + })} + /> + +
+

+ If selected, your team seat count will be reduced by 1, lowering your monthly billing. +

+
+ + + + + +
+
+
+ ) +} + +// Skeleton component for team management loading state +function TeamManagementSkeleton() { + return ( +
+
+ + +
+ +
+
+ +
+ + +
+
+ +
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+ +
+ {[1, 2, 3].map((i) => ( +
+
+ + + +
+ +
+ ))} +
+
+
+
+ ) + } + + // Skeleton component for loading state in buttons + function ButtonSkeleton() { + return + } + + // Skeleton component for loading state in team seats + function TeamSeatsSkeleton() { + return ( +
+ + +
+ ) + } \ No newline at end of file diff --git a/sim/app/w/components/sidebar/components/settings-modal/settings-modal.tsx b/sim/app/w/components/sidebar/components/settings-modal/settings-modal.tsx index d8acdebb77..38d2ff4c0a 100644 --- a/sim/app/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/sim/app/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -13,16 +13,21 @@ import { EnvironmentVariables } from './components/environment/environment' import { General } from './components/general/general' import { Subscription } from './components/subscription/subscription' import { SettingsNavigation } from './components/settings-navigation/settings-navigation' +import { TeamManagement } from './components/team-management/team-management' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('SettingsModal') interface SettingsModalProps { open: boolean onOpenChange: (open: boolean) => void } -type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' +type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const [activeSection, setActiveSection] = useState('general') + const [isTeam, setIsTeam] = useState(false) // Listen for the custom event to open the settings modal with a specific tab useEffect(() => { @@ -40,6 +45,25 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { } }, [onOpenChange]) + // Check if user is on team plan + useEffect(() => { + async function checkTeamPlan() { + try { + const response = await fetch('/api/user/subscription') + if (response.ok) { + const data = await response.json() + setIsTeam(data.isTeam) + } + } catch (error) { + logger.error('Error checking team plan:', error) + } + } + + if (open) { + checkTeamPlan() + } + }, [open]) + // Check if subscriptions are enabled const isSubscriptionEnabled = !!client.subscription @@ -89,6 +113,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { )} + {isTeam && ( +
+ +
+ )} diff --git a/sim/components/emails/invitation-email.tsx b/sim/components/emails/invitation-email.tsx new file mode 100644 index 0000000000..85bc6e31ef --- /dev/null +++ b/sim/components/emails/invitation-email.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { + Body, + Column, + Container, + Head, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, +} from '@react-email/components' +import { format } from 'date-fns' +import { baseStyles } from './base-styles' +import EmailFooter from './footer' + +interface InvitationEmailProps { + inviterName?: string + organizationName?: string + inviteLink?: string + invitedEmail?: string + updatedDate?: Date +} + +const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + +export const InvitationEmail = ({ + inviterName = 'A team member', + organizationName = 'an organization', + inviteLink = '', + invitedEmail = '', + updatedDate = new Date(), +}: InvitationEmailProps) => { + return ( + + + + You've been invited to join {organizationName} on Sim Studio + +
+ + + Sim Studio + + +
+ +
+ + + + + +
+ +
+ Hello, + + {inviterName} has invited you to join {organizationName} on Sim Studio. + Sim Studio is a powerful, user-friendly platform for building, testing, and optimizing agentic workflows. + + + Accept Invitation + + + This invitation will expire in 48 hours. If you believe this invitation was sent in error, + please ignore this email. + + + Best regards, +
+ The Sim Studio Team +
+ + This email was sent on {format(updatedDate, 'MMMM do, yyyy')} to {invitedEmail} with an invitation + to join {organizationName} on Sim Studio. + +
+
+ + + + + ) +} + +export default InvitationEmail \ No newline at end of file diff --git a/sim/components/emails/render-email.ts b/sim/components/emails/render-email.ts index b4bfebd708..faef57e3bb 100644 --- a/sim/components/emails/render-email.ts +++ b/sim/components/emails/render-email.ts @@ -3,6 +3,7 @@ import { OTPVerificationEmail } from './otp-verification-email' import { ResetPasswordEmail } from './reset-password-email' import { WaitlistApprovalEmail } from './waitlist-approval-email' import { WaitlistConfirmationEmail } from './waitlist-confirmation-email' +import { InvitationEmail } from './invitation-email' /** * Renders the OTP verification email to HTML @@ -25,6 +26,24 @@ export async function renderPasswordResetEmail( return await renderAsync(ResetPasswordEmail({ username, resetLink, updatedDate: new Date() })) } +/** + * Renders the invitation email to HTML + */ +export async function renderInvitationEmail( + inviterName: string, + organizationName: string, + inviteLink: string, + invitedEmail: string +): Promise { + return await renderAsync(InvitationEmail({ + inviterName, + organizationName, + inviteLink, + invitedEmail, + updatedDate: new Date() + })) +} + /** * Renders the waitlist confirmation email to HTML */ @@ -53,6 +72,7 @@ export function getEmailSubject( | 'reset-password' | 'waitlist-confirmation' | 'waitlist-approval' + | 'invitation' ): string { switch (type) { case 'sign-in': @@ -67,6 +87,8 @@ export function getEmailSubject( return 'Welcome to the Sim Studio Waitlist' case 'waitlist-approval': return "You've Been Approved to Join Sim Studio!" + case 'invitation': + return "You've been invited to join a team on Sim Studio" default: return 'Sim Studio' } diff --git a/sim/db/migrations/0031_lively_nico_minoru.sql b/sim/db/migrations/0031_lively_nico_minoru.sql new file mode 100644 index 0000000000..720c00903a --- /dev/null +++ b/sim/db/migrations/0031_lively_nico_minoru.sql @@ -0,0 +1,37 @@ +CREATE TABLE "invitation" ( + "id" text PRIMARY KEY NOT NULL, + "email" text NOT NULL, + "inviter_id" text NOT NULL, + "organization_id" text NOT NULL, + "role" text NOT NULL, + "status" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "member" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "organization_id" text NOT NULL, + "role" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "session" ADD COLUMN "active_organization_id" text;--> statement-breakpoint +ALTER TABLE "subscription" ADD COLUMN "trial_start" timestamp;--> statement-breakpoint +ALTER TABLE "subscription" ADD COLUMN "trial_end" timestamp;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_active_organization_id_organization_id_fk" FOREIGN KEY ("active_organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/sim/db/migrations/meta/0031_snapshot.json b/sim/db/migrations/meta/0031_snapshot.json new file mode 100644 index 0000000000..98f3e6ea4a --- /dev/null +++ b/sim/db/migrations/meta/0031_snapshot.json @@ -0,0 +1,1742 @@ +{ + "id": "5307d326-d999-4a88-9d3e-b370cfe599e1", + "prevId": "416bb8cf-4277-43d0-8b0c-a44a2d341c64", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_block_id": { + "name": "output_block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subdomain_idx": { + "name": "subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "marketplace_data": { + "name": "marketplace_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_logs": { + "name": "workflow_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_logs_workflow_id_workflow_id_fk": { + "name": "workflow_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_logs", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_workflow_id_unique": { + "name": "workflow_schedule_workflow_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workflow_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/sim/db/migrations/meta/_journal.json b/sim/db/migrations/meta/_journal.json index d1bdb07e9a..082e8c49c2 100644 --- a/sim/db/migrations/meta/_journal.json +++ b/sim/db/migrations/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1745519847269, "tag": "0030_happy_joseph", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1745638714702, + "tag": "0031_lively_nico_minoru", + "breakpoints": true } ] } \ No newline at end of file diff --git a/sim/db/schema.ts b/sim/db/schema.ts index d364a95e34..75044c3c84 100644 --- a/sim/db/schema.ts +++ b/sim/db/schema.ts @@ -3,6 +3,7 @@ import { decimal, integer, json, + jsonb, pgTable, text, timestamp, @@ -31,6 +32,7 @@ export const session = pgTable('session', { userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + activeOrganizationId: text('active_organization_id').references(() => organization.id, { onDelete: 'set null' }), }) export const account = pgTable('account', { @@ -226,14 +228,16 @@ export const customTools = pgTable('custom_tools', { export const subscription = pgTable("subscription", { id: text('id').primaryKey(), plan: text('plan').notNull(), -referenceId: text('reference_id').notNull(), -stripeCustomerId: text('stripe_customer_id'), -stripeSubscriptionId: text('stripe_subscription_id'), -status: text('status'), -periodStart: timestamp('period_start'), -periodEnd: timestamp('period_end'), -cancelAtPeriodEnd: boolean('cancel_at_period_end'), -seats: integer('seats') + referenceId: text('reference_id').notNull(), + stripeCustomerId: text('stripe_customer_id'), + stripeSubscriptionId: text('stripe_subscription_id'), + status: text('status'), + periodStart: timestamp('period_start'), + periodEnd: timestamp('period_end'), + cancelAtPeriodEnd: boolean('cancel_at_period_end'), + seats: integer('seats'), + trialStart: timestamp('trial_start'), + trialEnd: timestamp('trial_end') }); export const chat = pgTable('chat', { @@ -268,4 +272,33 @@ export const chat = pgTable('chat', { subdomainIdx: uniqueIndex('subdomain_idx').on(table.subdomain), } } -) \ No newline at end of file +) + +export const organization = pgTable("organization", { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull(), + logo: text('logo'), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const member = pgTable("member", { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), + role: text('role').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull() +}); + +export const invitation = pgTable("invitation", { + id: text('id').primaryKey(), + email: text('email').notNull(), + inviterId: text('inviter_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), + role: text('role').notNull(), + status: text('status').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull() +}); \ No newline at end of file diff --git a/sim/lib/auth-client.ts b/sim/lib/auth-client.ts index 489314d25d..9aa82d7644 100644 --- a/sim/lib/auth-client.ts +++ b/sim/lib/auth-client.ts @@ -1,5 +1,6 @@ import { emailOTPClient, genericOAuthClient } from 'better-auth/client/plugins' import { stripeClient } from '@better-auth/stripe/client' +import { organizationClient } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' import { isProd } from '@/lib/environment' @@ -22,17 +23,19 @@ export function getBaseURL() { export const client = createAuthClient({ baseURL: getBaseURL(), plugins: [ - genericOAuthClient(), emailOTPClient(), + genericOAuthClient(), // Only include Stripe client in production ...(isProd ? [ stripeClient({ subscription: true // Enable subscription management }) ] : []), + organizationClient(), ], }) -export const { useSession } = client + +export const { useSession, useActiveOrganization } = client export const useSubscription = () => { // In development, provide mock implementations diff --git a/sim/lib/auth.ts b/sim/lib/auth.ts index f8ef2feccd..ee08506d26 100644 --- a/sim/lib/auth.ts +++ b/sim/lib/auth.ts @@ -2,7 +2,7 @@ import { headers } from 'next/headers' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { nextCookies } from 'better-auth/next-js' -import { emailOTP, genericOAuth } from 'better-auth/plugins' +import { emailOTP, genericOAuth, organization } from 'better-auth/plugins' import { stripe } from '@better-auth/stripe' import Stripe from 'stripe' import { Resend } from 'resend' @@ -10,10 +10,12 @@ import { getEmailSubject, renderOTPEmail, renderPasswordResetEmail, + renderInvitationEmail, } from '@/components/emails/render-email' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import * as schema from '@/db/schema' +import { eq, and } from 'drizzle-orm' const logger = createLogger('Auth') @@ -63,6 +65,41 @@ export const auth = betterAuth({ updateAge: 24 * 60 * 60, // 24 hours (how often to refresh the expiry) freshAge: 60 * 60, // 1 hour (or set to 0 to disable completely) }, + databaseHooks: { + session: { + create: { + before: async (session) => { + try { + // Find the first organization this user is a member of + const members = await db.select() + .from(schema.member) + .where(eq(schema.member.userId, session.userId)) + .limit(1); + + if (members.length > 0) { + logger.info('Found organization for user', { + userId: session.userId, + organizationId: members[0].organizationId + }); + + return { + data: { + ...session, + activeOrganizationId: members[0].organizationId + } + }; + } else { + logger.info('No organizations found for user', { userId: session.userId }); + return { data: session }; + } + } catch (error) { + logger.error('Error setting active organization', { error, userId: session.userId }); + return { data: session }; + } + } + } + } + }, account: { accountLinking: { enabled: true, @@ -647,6 +684,8 @@ export const auth = betterAuth({ limits: { cost: process.env.FREE_TIER_COST_LIMIT ? parseInt(process.env.FREE_TIER_COST_LIMIT) : 5, sharingEnabled: 0, + multiplayerEnabled: 0, + workspaceCollaborationEnabled: 0 } }, { @@ -655,16 +694,75 @@ export const auth = betterAuth({ limits: { cost: process.env.PRO_TIER_COST_LIMIT ? parseInt(process.env.PRO_TIER_COST_LIMIT) : 20, sharingEnabled: 1, + multiplayerEnabled: 0, + workspaceCollaborationEnabled: 0 + } + }, + { + name: 'team', + priceId: process.env.STRIPE_TEAM_PRICE_ID || '', + limits: { + cost: process.env.TEAM_TIER_COST_LIMIT ? parseInt(process.env.TEAM_TIER_COST_LIMIT) : 40, // $40 per seat + sharingEnabled: 1, + multiplayerEnabled: 1, + workspaceCollaborationEnabled: 1 } } ], - onSubscriptionCreate: async ({ + authorizeReference: async ({ user, referenceId, action }) => { + // User can always manage their own subscriptions + if (referenceId === user.id) { + return true + } + + // Check if referenceId is an organizationId the user has admin rights to + const members = await db.select() + .from(schema.member) + .where( + and( + eq(schema.member.userId, user.id), + eq(schema.member.organizationId, referenceId) + ) + ) + + const member = members[0] + + // Allow if the user is an owner or admin of the organization + return member?.role === 'owner' || member?.role === 'admin' + }, + getCheckoutSessionParams: async ({ user, plan, subscription }, request) => { + if (plan.name === 'team') { + return { + params: { + allow_promotion_codes: true, + line_items: [ + { + price: plan.priceId, + quantity: subscription?.seats || 1, + adjustable_quantity: { + enabled: true, + minimum: 1, + maximum: 50 + } + } + ] + } + } + } + + return { + params: { + allow_promotion_codes: true + } + } + }, + onSubscriptionComplete: async ({ event, stripeSubscription, subscription - }: { - event: Stripe.Event - stripeSubscription: Stripe.Subscription + }: { + event: Stripe.Event + stripeSubscription: Stripe.Subscription subscription: any }) => { logger.info('Subscription created', { @@ -674,29 +772,25 @@ export const auth = betterAuth({ status: subscription.status }) }, - onSubscriptionUpdated: async ({ - subscription, - previousStatus, - user - }: { - subscription: any - previousStatus: string - user: any - }, request?: any) => { + onSubscriptionUpdate: async ({ + event, + subscription + }: { + event: Stripe.Event + subscription: any + }) => { logger.info('Subscription updated', { - subscriptionId: subscription.id, - userId: user.id, - previousStatus, - newStatus: subscription.status + subscriptionId: subscription.id, + status: subscription.status }) }, onSubscriptionDeleted: async ({ event, stripeSubscription, subscription - }: { - event: Stripe.Event - stripeSubscription: Stripe.Subscription + }: { + event: Stripe.Event + stripeSubscription: Stripe.Subscription subscription: any }) => { logger.info('Subscription deleted', { @@ -704,22 +798,96 @@ export const auth = betterAuth({ referenceId: subscription.referenceId }) }, - onEvent: async (event: any) => { - logger.info("Stripe webhook hit") - logger.info('Stripe webhook event received', { - type: event.type, - id: event.id + }, + }), + // Add organization plugin as a separate entry in the plugins array + organization({ + // Allow team plan subscribers to create organizations + allowUserToCreateOrganization: async (user) => { + // Get subscription data + const dbSubscriptions = await db.select() + .from(schema.subscription) + .where(eq(schema.subscription.referenceId, user.id)) + + // Check if user has active team subscription + const hasTeamPlan = dbSubscriptions.some( + sub => (sub.status === 'active') && sub.plan === 'team' + ) + + return hasTeamPlan + }, + // Set a fixed membership limit of 50, but the actual limit will be enforced in the invitation flow + membershipLimit: 50, + // Validate seat limits before sending invitations + beforeInvite: async ({ organization }: { organization: { id: string } }) => { + // Get subscription for this organization + const subscriptions = await db.select() + .from(schema.subscription) + .where( + and( + eq(schema.subscription.referenceId, organization.id), + eq(schema.subscription.status, 'active') + ) + ) + + const teamSubscription = subscriptions.find(sub => sub.plan === 'team') + + if (!teamSubscription) { + throw new Error('No active team subscription for this organization') + } + + // Count current members + pending invitations + const members = await db.select() + .from(schema.member) + .where(eq(schema.member.organizationId, organization.id)) + + const pendingInvites = await db.select() + .from(schema.invitation) + .where( + and( + eq(schema.invitation.organizationId, organization.id), + eq(schema.invitation.status, 'pending') + ) + ) + + const totalCount = members.length + pendingInvites.length + const seatLimit = teamSubscription.seats || 1 + + if (totalCount >= seatLimit) { + throw new Error(`Organization has reached its seat limit of ${seatLimit}`) + } + }, + sendInvitationEmail: async (data: any) => { + try { + const { invitation, organization, inviter } = data + + const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/${invitation.id}` + const inviterName = inviter.user?.name || 'A team member' + + const html = await renderInvitationEmail( + inviterName, + organization.name, + inviteUrl, + invitation.email + ) + + await resend.emails.send({ + from: 'Sim Studio ', + to: invitation.email, + subject: `${inviterName} has invited you to join ${organization.name} on Sim Studio`, + html, }) - - switch (event.type) { - case 'customer.subscription.created': - logger.info('Subscription creation event details', { - subscription: event.data.object, - customerId: event.data.object.customer - }) - break - } - }, + } catch (error) { + logger.error('Error sending invitation email', { error }) + } + }, + organizationCreation: { + afterCreate: async ({ organization, member, user }) => { + logger.info('Organization created', { + organizationId: organization.id, + creatorId: user.id + }) + } }, }) ] : []), diff --git a/sim/lib/subscription.ts b/sim/lib/subscription.ts index cfa74faa80..5fc2749f2b 100644 --- a/sim/lib/subscription.ts +++ b/sim/lib/subscription.ts @@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm' import { db } from '@/db' import * as schema from '@/db/schema' import { client } from './auth-client' -import { createLogger } from './logs/console-logger' +import { createLogger } from '@/lib/logs/console-logger' import { isProd } from '@/lib/environment' const logger = createLogger('Subscription') @@ -17,22 +17,101 @@ export async function isProPlan(userId: string): Promise { return true } - const dbSubscriptions = await db.select() + // First check organizations the user belongs to (prioritize org subscriptions) + const memberships = await db.select() + .from(schema.member) + .where(eq(schema.member.userId, userId)) + + // Check each organization for active Pro or Team subscriptions + for (const membership of memberships) { + const orgSubscriptions = await db.select() + .from(schema.subscription) + .where(eq(schema.subscription.referenceId, membership.organizationId)) + + const orgHasProPlan = orgSubscriptions.some( + sub => (sub.status === 'active') && (sub.plan === 'pro' || sub.plan === 'team') + ) + + if (orgHasProPlan) { + logger.info('User has pro plan via organization', { userId, orgId: membership.organizationId }) + return true + } + } + + // If no org subscriptions, check direct subscriptions + const directSubscriptions = await db.select() .from(schema.subscription) .where(eq(schema.subscription.referenceId, userId)) - // Find active pro subscription - const activeSubscription = dbSubscriptions.find( - sub => (sub.status === 'active') && sub.plan === 'pro' + // Find active pro subscription (either Pro or Team plan) + const hasDirectProPlan = directSubscriptions.some( + sub => (sub.status === 'active') && (sub.plan === 'pro' || sub.plan === 'team') ) - return !!activeSubscription + if (hasDirectProPlan) { + logger.info('User has direct pro plan', { userId }) + return true + } + + return false } catch (error) { logger.error('Error checking pro plan status', { error, userId }) return false } } +/** + * Check if the user is on the Team plan + */ +export async function isTeamPlan(userId: string): Promise { + try { + // In development, enable Team features for easier testing + if (!isProd) { + return true + } + + // First check organizations the user belongs to (prioritize org subscriptions) + const memberships = await db.select() + .from(schema.member) + .where(eq(schema.member.userId, userId)) + + // Check each organization for active Team subscriptions + for (const membership of memberships) { + const orgSubscriptions = await db.select() + .from(schema.subscription) + .where(eq(schema.subscription.referenceId, membership.organizationId)) + + const orgHasTeamPlan = orgSubscriptions.some( + sub => (sub.status === 'active') && sub.plan === 'team' + ) + + if (orgHasTeamPlan) { + return true + } + } + + // If no org subscriptions found, check direct subscriptions + const directSubscriptions = await db.select() + .from(schema.subscription) + .where(eq(schema.subscription.referenceId, userId)) + + // Find active team subscription + const hasDirectTeamPlan = directSubscriptions.some( + sub => (sub.status === 'active') && sub.plan === 'team' + ) + + if (hasDirectTeamPlan) { + logger.info('User has direct team plan', { userId }) + return true + } + + return false + } catch (error) { + logger.error('Error checking team plan status', { error, userId }) + return false + } +} + /** * Check if a user has exceeded their cost limit based on their subscription plan */ @@ -43,30 +122,53 @@ export async function hasExceededCostLimit(userId: string): Promise { return false } - // Get user's subscription - const { data: subscriptions } = await client.subscription.list({ + // Get user's direct subscription + const { data: directSubscriptions } = await client.subscription.list({ query: { referenceId: userId } }) - // Find active subscription - const activeSubscription = subscriptions?.find( + // Find active direct subscription + const activeDirectSubscription = directSubscriptions?.find( sub => sub.status === 'active' ) - // Get configured limits from environment variables or subscription - let costLimit: number + // Get organizations the user belongs to + const memberships = await db.select() + .from(schema.member) + .where(eq(schema.member.userId, userId)) - if (activeSubscription && typeof activeSubscription.limits?.cost === 'number') { - // Use the limit from the subscription - costLimit = activeSubscription.limits.cost - } else { - // Use default free tier limit - costLimit = process.env.FREE_TIER_COST_LIMIT + let highestCostLimit = 0 + + // Check cost limit from direct subscription + if (activeDirectSubscription && typeof activeDirectSubscription.limits?.cost === 'number') { + highestCostLimit = activeDirectSubscription.limits.cost + } + + // Check cost limits from organization subscriptions + for (const membership of memberships) { + const { data: orgSubscriptions } = await client.subscription.list({ + query: { referenceId: membership.organizationId } + }) + + const activeOrgSubscription = orgSubscriptions?.find( + sub => sub.status === 'active' + ) + + if (activeOrgSubscription && + typeof activeOrgSubscription.limits?.cost === 'number' && + activeOrgSubscription.limits.cost > highestCostLimit) { + highestCostLimit = activeOrgSubscription.limits.cost + } + } + + // If no subscription found, use default free tier limit + if (highestCostLimit === 0) { + highestCostLimit = process.env.FREE_TIER_COST_LIMIT ? parseFloat(process.env.FREE_TIER_COST_LIMIT) : 5 } - logger.info('User cost limit from subscription', { userId, costLimit }) + logger.info('User cost limit from subscription', { userId, costLimit: highestCostLimit }) // Get user's actual usage from the database const statsRecords = await db.select().from(schema.userStats).where(eq(schema.userStats.userId, userId)) @@ -79,7 +181,7 @@ export async function hasExceededCostLimit(userId: string): Promise { // Get the current cost and compare with the limit const currentCost = parseFloat(statsRecords[0].totalCost.toString()) - return currentCost >= costLimit + return currentCost >= highestCostLimit } catch (error) { logger.error('Error checking cost limit', { error, userId }) return false // Be conservative in case of error @@ -89,30 +191,152 @@ export async function hasExceededCostLimit(userId: string): Promise { /** * Check if a user is allowed to share workflows based on their subscription plan */ -export async function canShareWorkflows(userId: string): Promise { +export async function isSharingEnabled(userId: string): Promise { try { // In development, always allow sharing if (!isProd) { return true } - const { data: subscriptions } = await client.subscription.list({ + // Check direct subscription + const { data: directSubscriptions } = await client.subscription.list({ query: { referenceId: userId } }) - const activeSubscription = subscriptions?.find( + const activeDirectSubscription = directSubscriptions?.find( sub => sub.status === 'active' ) - // If no active subscription or subscription is free tier, sharing is not allowed - if (!activeSubscription || activeSubscription.plan === 'free') { - return false + // If user has direct pro/team subscription with sharing enabled + if (activeDirectSubscription && activeDirectSubscription.limits?.sharingEnabled) { + return true } - // Check if the plan's limits include sharing - return !!activeSubscription.limits?.sharingEnabled + // Check organizations the user belongs to + const memberships = await db.select() + .from(schema.member) + .where(eq(schema.member.userId, userId)) + + // Check each organization for a subscription with sharing enabled + for (const membership of memberships) { + const { data: orgSubscriptions } = await client.subscription.list({ + query: { referenceId: membership.organizationId } + }) + + const activeOrgSubscription = orgSubscriptions?.find( + sub => sub.status === 'active' + ) + + if (activeOrgSubscription && activeOrgSubscription.limits?.sharingEnabled) { + return true + } + } + + return false } catch (error) { logger.error('Error checking sharing permission', { error, userId }) return false // Be conservative in case of error } +} + +/** + * Check if multiplayer collaboration is enabled for the user + */ +export async function isMultiplayerEnabled(userId: string): Promise { + try { + // In development, always enable multiplayer + if (!isProd) { + return true + } + + // Check direct subscription + const { data: directSubscriptions } = await client.subscription.list({ + query: { referenceId: userId } + }) + + const activeDirectSubscription = directSubscriptions?.find( + sub => sub.status === 'active' + ) + + // If user has direct team subscription with multiplayer enabled + if (activeDirectSubscription && activeDirectSubscription.limits?.multiplayerEnabled) { + return true + } + + // Check organizations the user belongs to + const memberships = await db.select() + .from(schema.member) + .where(eq(schema.member.userId, userId)) + + // Check each organization for a subscription with multiplayer enabled + for (const membership of memberships) { + const { data: orgSubscriptions } = await client.subscription.list({ + query: { referenceId: membership.organizationId } + }) + + const activeOrgSubscription = orgSubscriptions?.find( + sub => sub.status === 'active' + ) + + if (activeOrgSubscription && activeOrgSubscription.limits?.multiplayerEnabled) { + return true + } + } + + return false + } catch (error) { + logger.error('Error checking multiplayer permission', { error, userId }) + return false // Be conservative in case of error + } +} + +/** + * Check if workspace collaboration is enabled for the user + */ +export async function isWorkspaceCollaborationEnabled(userId: string): Promise { + try { + // In development, always enable workspace collaboration + if (!isProd) { + return true + } + + // Check direct subscription + const { data: directSubscriptions } = await client.subscription.list({ + query: { referenceId: userId } + }) + + const activeDirectSubscription = directSubscriptions?.find( + sub => sub.status === 'active' + ) + + // If user has direct team subscription with workspace collaboration enabled + if (activeDirectSubscription && activeDirectSubscription.limits?.workspaceCollaborationEnabled) { + return true + } + + // Check organizations the user belongs to + const memberships = await db.select() + .from(schema.member) + .where(eq(schema.member.userId, userId)) + + // Check each organization for a subscription with workspace collaboration enabled + for (const membership of memberships) { + const { data: orgSubscriptions } = await client.subscription.list({ + query: { referenceId: membership.organizationId } + }) + + const activeOrgSubscription = orgSubscriptions?.find( + sub => sub.status === 'active' + ) + + if (activeOrgSubscription && activeOrgSubscription.limits?.workspaceCollaborationEnabled) { + return true + } + } + + return false + } catch (error) { + logger.error('Error checking workspace collaboration permission', { error, userId }) + return false // Be conservative in case of error + } } \ No newline at end of file diff --git a/sim/lib/usage-monitor.ts b/sim/lib/usage-monitor.ts index 14b8339f4e..d57e74a01b 100644 --- a/sim/lib/usage-monitor.ts +++ b/sim/lib/usage-monitor.ts @@ -1,9 +1,8 @@ -import { isProPlan } from './subscription' +import { isProPlan, isTeamPlan } from './subscription' import { createLogger } from './logs/console-logger' import { db } from '@/db' import { eq } from 'drizzle-orm' -import { userStats } from '@/db/schema' -import { client } from './auth-client' +import { userStats, member, organization as organizationTable, subscription } from '@/db/schema' import { isProd } from '@/lib/environment' const logger = createLogger('UsageMonitor') @@ -19,12 +18,74 @@ interface UsageData { limit: number } +/** + * Gets the number of seats for a team subscription + * Used to calculate usage limits for team plans + */ +async function getTeamSeats(userId: string): Promise { + try { + // First check if user is part of an organization with a team subscription + const memberships = await db.select() + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + + if (memberships.length > 0) { + const orgId = memberships[0].organizationId + + // Check for organization's team subscription + const orgSubscriptions = await db.select() + .from(subscription) + .where(eq(subscription.referenceId, orgId)) + + const teamSubscription = orgSubscriptions.find( + sub => (sub.status === 'active' && sub.plan === 'team') + ) + + if (teamSubscription?.seats) { + logger.info('Found organization team subscription with seats', { + userId, + orgId, + seats: teamSubscription.seats + }) + return teamSubscription.seats + } + } + + // If no organization team subscription, check for personal team subscription + const userSubscriptions = await db.select() + .from(subscription) + .where(eq(subscription.referenceId, userId)) + + const teamSubscription = userSubscriptions.find( + sub => (sub.status === 'active' && sub.plan === 'team') + ) + + if (teamSubscription?.seats) { + logger.info('Found personal team subscription with seats', { + userId, + seats: teamSubscription.seats + }) + return teamSubscription.seats + } + + // Default to 10 seats if we know they're on a team plan but couldn't get seats info + return 10 + } catch (error) { + logger.error('Error getting team seats', { error, userId }) + // Default to 10 seats on error + return 10 + } +} + /** * Checks a user's cost usage against their subscription plan limit * and returns usage information including whether they're approaching the limit */ export async function checkUsageStatus(userId: string): Promise { try { + logger.info('Starting usage status check for user', { userId }) + // In development, always return permissive limits if (!isProd) { // Get actual usage from the database for display purposes @@ -33,15 +94,12 @@ export async function checkUsageStatus(userId: string): Promise { ? parseFloat(statsRecords[0].totalCost.toString()) : 0 - // In development, set a very high limit to avoid restrictions - const devLimit = 1000 - return { - percentUsed: Math.min(Math.round((currentUsage / devLimit) * 100), 100), + percentUsed: Math.min(Math.round((currentUsage / 1000) * 100), 100), isWarning: false, isExceeded: false, currentUsage, - limit: devLimit + limit: 1000 } } @@ -49,35 +107,42 @@ export async function checkUsageStatus(userId: string): Promise { // Get user's subscription details const isPro = await isProPlan(userId) + const isTeam = await isTeamPlan(userId) - // Get the subscription limits - const { data: subscriptions } = await client.subscription.list({ - query: { referenceId: userId } - }) + logger.info('User subscription status', { userId, isPro, isTeam }) - // Find active subscription - const activeSubscription = subscriptions?.find( - sub => sub.status === 'active' || sub.status === 'trialing' - ) - - // Get configured limits from environment variables or subscription + // Determine the limit based on subscription type let limit: number - if (activeSubscription && typeof activeSubscription.limits?.cost === 'number') { - // Use the limit from the subscription if available - limit = activeSubscription.limits.cost + if (isTeam) { + // For team plans, get the number of seats and multiply by per-seat limit + const teamSeats = await getTeamSeats(userId) + const perSeatLimit = process.env.TEAM_TIER_COST_LIMIT + ? parseFloat(process.env.TEAM_TIER_COST_LIMIT) + : 40 + + limit = perSeatLimit * teamSeats + + logger.info('Using team plan limit', { + userId, + seats: teamSeats, + perSeatLimit, + totalLimit: limit + }) + } else if (isPro) { + // Pro plan has a fixed limit + limit = process.env.PRO_TIER_COST_LIMIT + ? parseFloat(process.env.PRO_TIER_COST_LIMIT) + : 20 + + logger.info('Using pro plan limit', { userId, limit }) } else { - // Fallback to environment variables - const freeLimit = process.env.FREE_TIER_COST_LIMIT + // Free tier limit + limit = process.env.FREE_TIER_COST_LIMIT ? parseFloat(process.env.FREE_TIER_COST_LIMIT) : 5 - const proLimit = process.env.PRO_TIER_COST_LIMIT - ? parseFloat(process.env.PRO_TIER_COST_LIMIT) - : 50 - - // Set the appropriate limit based on subscription - limit = isPro ? proLimit : freeLimit + logger.info('Using free tier limit', { userId, limit }) } // Get actual usage from the database @@ -85,6 +150,8 @@ export async function checkUsageStatus(userId: string): Promise { // If no stats record exists, create a default one if (statsRecords.length === 0) { + logger.info('No usage stats found for user', { userId, limit }) + return { percentUsed: 0, isWarning: false, @@ -104,6 +171,15 @@ export async function checkUsageStatus(userId: string): Promise { const isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100 const isExceeded = currentUsage >= limit + logger.info('Final usage statistics', { + userId, + currentUsage, + limit, + percentUsed, + isWarning, + isExceeded + }) + return { percentUsed, isWarning, @@ -112,7 +188,10 @@ export async function checkUsageStatus(userId: string): Promise { limit } } catch (error) { - logger.error('Error checking usage status', { error, userId }) + logger.error('Error checking usage status', { + error: error instanceof Error ? { message: error.message, stack: error.stack } : error, + userId + }) // Return default values in case of error return { @@ -178,7 +257,6 @@ export async function checkAndNotifyUsage(userId: string): Promise { } } -// Add this function to check usage limits on the server-side for API routes /** * Server-side function to check if a user has exceeded their usage limits * For use in API routes, webhooks, and scheduled executions @@ -204,57 +282,22 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{ logger.info('Server-side checking usage limits for user', { userId }) - // Get the user's subscription - const { data: subscriptions } = await client.subscription.list({ - query: { referenceId: userId } - }) - - // Find active subscription - const activeSubscription = subscriptions?.find( - sub => sub.status === 'active' || sub.status === 'trialing' - ) - - // Get configured limits from environment variables or subscription - let costLimit: number - - if (activeSubscription && typeof activeSubscription.limits?.cost === 'number') { - // Use the limit from the subscription - costLimit = activeSubscription.limits.cost - } else { - // Use default free tier limit - costLimit = process.env.FREE_TIER_COST_LIMIT - ? parseFloat(process.env.FREE_TIER_COST_LIMIT) - : 5 - } - - logger.info('Server-side user cost limit from subscription', { userId, costLimit }) - - // Get user's actual usage from the database - const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) - - if (statsRecords.length === 0) { - // No usage yet, so they haven't exceeded the limit - return { - isExceeded: false, - currentUsage: 0, - limit: costLimit - } - } - - // Get the current cost and compare with the limit - const currentUsage = parseFloat(statsRecords[0].totalCost.toString()) - const isExceeded = currentUsage >= costLimit + // Get usage data using the same function we use for client-side + const usageData = await checkUsageStatus(userId) return { - isExceeded, - currentUsage, - limit: costLimit, - message: isExceeded - ? `Usage limit exceeded: ${currentUsage.toFixed(2)}$ used of ${costLimit}$ limit. Please upgrade your plan to continue.` + isExceeded: usageData.isExceeded, + currentUsage: usageData.currentUsage, + limit: usageData.limit, + message: usageData.isExceeded + ? `Usage limit exceeded: ${usageData.currentUsage.toFixed(2)}$ used of ${usageData.limit}$ limit. Please upgrade your plan to continue.` : undefined } } catch (error) { - logger.error('Error in server-side usage limit check', { error, userId }) + logger.error('Error in server-side usage limit check', { + error: error instanceof Error ? { message: error.message, stack: error.stack } : error, + userId + }) // Be conservative in case of error - allow execution but log the issue return { diff --git a/sim/middleware.ts b/sim/middleware.ts index bce52372e8..5eb970f468 100644 --- a/sim/middleware.ts +++ b/sim/middleware.ts @@ -51,6 +51,11 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(new URL('/w/1', request.url)) } + // Allow access to invitation links + if (request.nextUrl.pathname.startsWith('/invite/')) { + return NextResponse.next() + } + // Handle protected routes that require authentication if (url.pathname.startsWith('/w/') || url.pathname === '/w') { if (!hasActiveSession) { @@ -79,6 +84,12 @@ export async function middleware(request: NextRequest) { // Check for a waitlist token in the URL const waitlistToken = url.searchParams.get('token') + // If there's a redirect to the invite page, bypass waitlist check + const redirectParam = request.nextUrl.searchParams.get('redirect') + if (redirectParam && redirectParam.startsWith('/invite/')) { + return NextResponse.next() + } + // Validate the token if present if (waitlistToken) { try { @@ -151,12 +162,14 @@ export async function middleware(request: NextRequest) { return response } +// Update matcher to include invitation routes export const config = { matcher: [ '/w', // Match exactly /w '/w/:path*', // Match protected routes '/login', '/signup', + '/invite/:path*', // Match invitation routes '/((?!_next/static|_next/image|favicon.ico).*)' ], }