mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
feat(billing): add comprehensive usage-based billing system (#625)
* feat(billing): add comprehensive usage-based billing system - Complete billing infrastructure with subscription management - Usage tracking and limits for organizations - Team management with role-based permissions - CRON jobs for automated billing and cleanup - Stripe integration for payments and invoicing - Email notifications for billing events - Organization-based workspace management - API endpoints for billing operations * fix tests, standardize datetime logic * add lazy init for stripe client, similar to s3 * cleanup * ack PR comments * fixed build * convert everything to UTC * add delete subscription functionality using better auth * fix lint * fix linter error * remove invoice emails since it is natively managed via stripe * fix build --------- Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
This commit is contained in:
@@ -15,5 +15,3 @@ ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate
|
||||
# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails
|
||||
# If left commented out, emails will be logged to console instead
|
||||
|
||||
# Freestyle API Key (Required for sandboxed code execution for functions/custom-tools)
|
||||
# FREESTYLE_API_KEY= # Uncomment and add your key from https://docs.freestyle.sh/Getting-Started/run
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const emailSchema = z.string().email('Please enter a valid email')
|
||||
|
||||
export default function WaitlistForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'exists' | 'ratelimited'>(
|
||||
'idle'
|
||||
)
|
||||
const [_errorMessage, setErrorMessage] = useState('')
|
||||
const [_retryAfter, setRetryAfter] = useState<number | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus('idle')
|
||||
setErrorMessage('')
|
||||
setRetryAfter(null)
|
||||
|
||||
try {
|
||||
// Validate email
|
||||
emailSchema.parse(email)
|
||||
|
||||
setIsSubmitting(true)
|
||||
const response = await fetch('/api/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for rate limiting (429 status)
|
||||
if (response.status === 429) {
|
||||
setStatus('ratelimited')
|
||||
setErrorMessage(data.message || 'Too many attempts. Please try again later.')
|
||||
setRetryAfter(data.retryAfter || 60)
|
||||
}
|
||||
// Check if the error is because the email already exists
|
||||
else if (response.status === 400 && data.message?.includes('already exists')) {
|
||||
setStatus('exists')
|
||||
setErrorMessage('Already on the waitlist')
|
||||
} else {
|
||||
setStatus('error')
|
||||
setErrorMessage(data.message || 'Failed to join waitlist')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('success')
|
||||
setEmail('')
|
||||
} catch (_error) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Please try again')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isSubmitting) return 'Joining...'
|
||||
if (status === 'success') return 'Joined!'
|
||||
if (status === 'error') return 'Try again'
|
||||
if (status === 'exists') return 'Already joined'
|
||||
if (status === 'ratelimited') return 'Try again later'
|
||||
return 'Join waitlist'
|
||||
}
|
||||
|
||||
const getButtonStyle = () => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'bg-green-500 hover:bg-green-600'
|
||||
case 'error':
|
||||
return 'bg-red-500 hover:bg-red-600'
|
||||
case 'exists':
|
||||
return 'bg-amber-500 hover:bg-amber-600'
|
||||
case 'ratelimited':
|
||||
return 'bg-gray-500 hover:bg-gray-600'
|
||||
default:
|
||||
return 'bg-white text-black hover:bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className='mx-auto mt-8 flex max-w-lg flex-col items-center gap-3'
|
||||
>
|
||||
<div className='flex w-full gap-3'>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
className='h-[49px] flex-1 rounded-md border-white/20 bg-[#020817] text-sm focus:border-white/30 focus:ring-white/30 md:text-md lg:text-[16px]'
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting || status === 'ratelimited'}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
className={`h-[48px] rounded-md px-8 text-sm md:text-md ${getButtonStyle()}`}
|
||||
disabled={isSubmitting || status === 'ratelimited'}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
109
apps/sim/app/api/billing/daily/route.ts
Normal file
109
apps/sim/app/api/billing/daily/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { processDailyBillingCheck } from '@/lib/billing/core/billing'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('DailyBillingCron')
|
||||
|
||||
/**
|
||||
* Daily billing CRON job endpoint that checks individual billing periods
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authError = verifyCronAuth(request, 'daily billing check')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
logger.info('Starting daily billing check cron job')
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// Process overage billing for users and organizations with periods ending today
|
||||
const result = await processDailyBillingCheck()
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Daily billing check completed successfully', {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
duration: `${duration}ms`,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
logger.error('Daily billing check completed with errors', {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
errorCount: result.errors.length,
|
||||
errors: result.errors,
|
||||
duration: `${duration}ms`,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
summary: {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
errorCount: result.errors.length,
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
errors: result.errors,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Fatal error in monthly billing cron job', { error })
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error during daily billing check',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET endpoint for manual testing and health checks
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authError = verifyCronAuth(request, 'daily billing check health check')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ready',
|
||||
message:
|
||||
'Daily billing check cron job is ready to process users and organizations with periods ending today',
|
||||
currentDate: new Date().toISOString().split('T')[0],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error in billing health check', { error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
116
apps/sim/app/api/billing/route.ts
Normal file
116
apps/sim/app/api/billing/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UnifiedBillingAPI')
|
||||
|
||||
/**
|
||||
* Unified Billing Endpoint
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const context = searchParams.get('context') || 'user'
|
||||
const contextId = searchParams.get('id')
|
||||
|
||||
// Validate context parameter
|
||||
if (!['user', 'organization'].includes(context)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid context. Must be "user" or "organization"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// For organization context, require contextId
|
||||
if (context === 'organization' && !contextId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization ID is required when context=organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let billingData
|
||||
|
||||
if (context === 'user') {
|
||||
// Get user billing (may include organization if they're part of one)
|
||||
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
|
||||
} else {
|
||||
// Get user role in organization for permission checks first
|
||||
const memberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, contextId!), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberRecord.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied - not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get organization-specific billing
|
||||
const rawBillingData = await getOrganizationBillingData(contextId!)
|
||||
|
||||
if (!rawBillingData) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Transform data to match component expectations
|
||||
billingData = {
|
||||
organizationId: rawBillingData.organizationId,
|
||||
organizationName: rawBillingData.organizationName,
|
||||
subscriptionPlan: rawBillingData.subscriptionPlan,
|
||||
subscriptionStatus: rawBillingData.subscriptionStatus,
|
||||
totalSeats: rawBillingData.totalSeats,
|
||||
usedSeats: rawBillingData.usedSeats,
|
||||
totalCurrentUsage: rawBillingData.totalCurrentUsage,
|
||||
totalUsageLimit: rawBillingData.totalUsageLimit,
|
||||
averageUsagePerMember: rawBillingData.averageUsagePerMember,
|
||||
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
|
||||
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
|
||||
members: rawBillingData.members.map((member) => ({
|
||||
...member,
|
||||
joinedAt: member.joinedAt.toISOString(),
|
||||
lastActive: member.lastActive?.toISOString() || null,
|
||||
})),
|
||||
}
|
||||
|
||||
const userRole = memberRecord[0].role
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
data: billingData,
|
||||
userRole,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
data: billingData,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get billing data', {
|
||||
userId: session?.user?.id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
116
apps/sim/app/api/billing/webhooks/stripe/route.ts
Normal file
116
apps/sim/app/api/billing/webhooks/stripe/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { handleInvoiceWebhook } from '@/lib/billing/webhooks/stripe-invoice-webhooks'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhook')
|
||||
|
||||
/**
|
||||
* Stripe billing webhook endpoint for invoice-related events
|
||||
* Endpoint: /api/billing/webhooks/stripe
|
||||
* Handles: invoice.payment_succeeded, invoice.payment_failed, invoice.finalized
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.text()
|
||||
const headersList = await headers()
|
||||
const signature = headersList.get('stripe-signature')
|
||||
|
||||
if (!signature) {
|
||||
logger.error('Missing Stripe signature header')
|
||||
return NextResponse.json({ error: 'Missing Stripe signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!env.STRIPE_WEBHOOK_SECRET) {
|
||||
logger.error('Missing Stripe webhook secret configuration')
|
||||
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Check if Stripe client is available
|
||||
let stripe
|
||||
try {
|
||||
stripe = requireStripeClient()
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe client not available for webhook processing', {
|
||||
error: stripeError,
|
||||
})
|
||||
return NextResponse.json({ error: 'Stripe client not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_WEBHOOK_SECRET)
|
||||
} catch (signatureError) {
|
||||
logger.error('Invalid Stripe webhook signature', {
|
||||
error: signatureError,
|
||||
signature,
|
||||
})
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info('Received Stripe invoice webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
})
|
||||
|
||||
// Handle specific invoice events
|
||||
const supportedEvents = [
|
||||
'invoice.payment_succeeded',
|
||||
'invoice.payment_failed',
|
||||
'invoice.finalized',
|
||||
]
|
||||
|
||||
if (supportedEvents.includes(event.type)) {
|
||||
try {
|
||||
await handleInvoiceWebhook(event)
|
||||
|
||||
logger.info('Successfully processed invoice webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
})
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (processingError) {
|
||||
logger.error('Failed to process invoice webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
error: processingError,
|
||||
})
|
||||
|
||||
// Return 500 to tell Stripe to retry the webhook
|
||||
return NextResponse.json({ error: 'Failed to process webhook' }, { status: 500 })
|
||||
}
|
||||
} else {
|
||||
// Not a supported invoice event, ignore
|
||||
logger.info('Ignoring unsupported webhook event', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
supportedEvents,
|
||||
})
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Fatal error in invoice webhook handler', {
|
||||
error,
|
||||
url: request.url,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET endpoint for webhook health checks
|
||||
*/
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
webhook: 'stripe-invoices',
|
||||
events: ['invoice.payment_succeeded', 'invoice.payment_failed', 'invoice.finalized'],
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { render } from '@react-email/render'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import OTPVerificationEmail from '@/components/emails/otp-verification-email'
|
||||
import { renderOTPEmail } from '@/components/emails/render-email'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getRedisClient, markMessageAsProcessed, releaseLock } from '@/lib/redis'
|
||||
@@ -158,7 +157,6 @@ export async function POST(
|
||||
? deployment.allowedEmails
|
||||
: []
|
||||
|
||||
// Check if the email is allowed
|
||||
const isEmailAllowed =
|
||||
allowedEmails.includes(email) ||
|
||||
allowedEmails.some((allowed: string) => {
|
||||
@@ -176,24 +174,17 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Generate OTP
|
||||
const otp = generateOTP()
|
||||
|
||||
// Store OTP in Redis - AWAIT THIS BEFORE RETURNING RESPONSE
|
||||
await storeOTP(email, deployment.id, otp)
|
||||
|
||||
// Create the email
|
||||
const emailContent = OTPVerificationEmail({
|
||||
const emailHtml = await renderOTPEmail(
|
||||
otp,
|
||||
email,
|
||||
type: 'chat-access',
|
||||
chatTitle: deployment.title || 'Chat',
|
||||
})
|
||||
'email-verification',
|
||||
deployment.title || 'Chat'
|
||||
)
|
||||
|
||||
// await the render function
|
||||
const emailHtml = await render(emailContent)
|
||||
|
||||
// MAKE SURE TO AWAIT THE EMAIL SENDING
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: `Verification code for ${deployment.title || 'Chat'}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { snapshotService } from '@/lib/logs/snapshot-service'
|
||||
@@ -18,17 +19,11 @@ const S3_CONFIG = {
|
||||
region: env.AWS_REGION || '',
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (!env.CRON_SECRET) {
|
||||
return new NextResponse('Configuration error: Cron secret is not set', { status: 500 })
|
||||
}
|
||||
|
||||
if (!authHeader || authHeader !== `Bearer ${env.CRON_SECRET}`) {
|
||||
logger.warn('Unauthorized access attempt to logs cleanup endpoint')
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
const authError = verifyCronAuth(request, 'logs cleanup')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
if (!S3_CONFIG.bucket || !S3_CONFIG.region) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { marketplace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceInfoAPI')
|
||||
|
||||
@@ -24,8 +24,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
// Fetch marketplace data for the workflow
|
||||
const marketplaceEntry = await db
|
||||
.select()
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.workflowId, id))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { marketplace, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceUnpublishAPI')
|
||||
|
||||
@@ -34,13 +34,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
// Get the marketplace entry using the marketplace ID
|
||||
const marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
workflowId: schema.marketplace.workflowId,
|
||||
authorId: schema.marketplace.authorId,
|
||||
name: schema.marketplace.name,
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
authorId: marketplace.authorId,
|
||||
name: marketplace.name,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.id, id))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
@@ -60,36 +60,33 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const workflowId = marketplaceEntry.workflowId
|
||||
|
||||
// Verify the workflow exists and belongs to the user
|
||||
const workflow = await db
|
||||
const workflowEntry = await db
|
||||
.select({
|
||||
id: schema.workflow.id,
|
||||
userId: schema.workflow.userId,
|
||||
id: workflow.id,
|
||||
userId: workflow.userId,
|
||||
})
|
||||
.from(schema.workflow)
|
||||
.where(eq(schema.workflow.id, workflowId))
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workflow) {
|
||||
if (!workflowEntry) {
|
||||
logger.warn(`[${requestId}] Associated workflow not found: ${workflowId}`)
|
||||
// We'll still delete the marketplace entry even if the workflow is missing
|
||||
} else if (workflow.userId !== userId) {
|
||||
} else if (workflowEntry.userId !== userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workflow ${workflowId} belongs to user ${workflow.userId}, not current user ${userId}`
|
||||
`[${requestId}] Workflow ${workflowId} belongs to user ${workflowEntry.userId}, not current user ${userId}`
|
||||
)
|
||||
return createErrorResponse('You do not have permission to unpublish this workflow', 403)
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the marketplace entry - this is the primary action
|
||||
await db.delete(schema.marketplace).where(eq(schema.marketplace.id, id))
|
||||
await db.delete(marketplace).where(eq(marketplace.id, id))
|
||||
|
||||
// Update the workflow to mark it as unpublished if it exists
|
||||
if (workflow) {
|
||||
await db
|
||||
.update(schema.workflow)
|
||||
.set({ isPublished: false })
|
||||
.where(eq(schema.workflow.id, workflowId))
|
||||
if (workflowEntry) {
|
||||
await db.update(workflow).set({ isPublished: false }).where(eq(workflow.id, workflowId))
|
||||
}
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { marketplace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceViewAPI')
|
||||
|
||||
@@ -22,10 +22,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
// Find the marketplace entry for this marketplace ID
|
||||
const marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
id: marketplace.id,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.id, id))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
@@ -36,11 +36,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
// Increment the view count for this workflow
|
||||
await db
|
||||
.update(schema.marketplace)
|
||||
.update(marketplace)
|
||||
.set({
|
||||
views: sql`${schema.marketplace.views} + 1`,
|
||||
views: sql`${marketplace.views} + 1`,
|
||||
})
|
||||
.where(eq(schema.marketplace.id, id))
|
||||
.where(eq(marketplace.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { CATEGORIES } from '@/app/workspace/[workspaceId]/marketplace/constants/categories'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { marketplace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('MarketplaceWorkflowsAPI')
|
||||
|
||||
@@ -50,39 +50,39 @@ export async function GET(request: NextRequest) {
|
||||
// Query with state included
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
workflowId: schema.marketplace.workflowId,
|
||||
name: schema.marketplace.name,
|
||||
description: schema.marketplace.description,
|
||||
authorId: schema.marketplace.authorId,
|
||||
authorName: schema.marketplace.authorName,
|
||||
state: schema.marketplace.state,
|
||||
views: schema.marketplace.views,
|
||||
category: schema.marketplace.category,
|
||||
createdAt: schema.marketplace.createdAt,
|
||||
updatedAt: schema.marketplace.updatedAt,
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
state: marketplace.state,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.workflowId, workflowId))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, workflowId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
} else {
|
||||
// Query without state
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
workflowId: schema.marketplace.workflowId,
|
||||
name: schema.marketplace.name,
|
||||
description: schema.marketplace.description,
|
||||
authorId: schema.marketplace.authorId,
|
||||
authorName: schema.marketplace.authorName,
|
||||
views: schema.marketplace.views,
|
||||
category: schema.marketplace.category,
|
||||
createdAt: schema.marketplace.createdAt,
|
||||
updatedAt: schema.marketplace.updatedAt,
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.workflowId, workflowId))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, workflowId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
}
|
||||
@@ -114,39 +114,39 @@ export async function GET(request: NextRequest) {
|
||||
// Query with state included
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
workflowId: schema.marketplace.workflowId,
|
||||
name: schema.marketplace.name,
|
||||
description: schema.marketplace.description,
|
||||
authorId: schema.marketplace.authorId,
|
||||
authorName: schema.marketplace.authorName,
|
||||
state: schema.marketplace.state,
|
||||
views: schema.marketplace.views,
|
||||
category: schema.marketplace.category,
|
||||
createdAt: schema.marketplace.createdAt,
|
||||
updatedAt: schema.marketplace.updatedAt,
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
state: marketplace.state,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.id, marketplaceId))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, marketplaceId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
} else {
|
||||
// Query without state
|
||||
marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
workflowId: schema.marketplace.workflowId,
|
||||
name: schema.marketplace.name,
|
||||
description: schema.marketplace.description,
|
||||
authorId: schema.marketplace.authorId,
|
||||
authorName: schema.marketplace.authorName,
|
||||
views: schema.marketplace.views,
|
||||
category: schema.marketplace.category,
|
||||
createdAt: schema.marketplace.createdAt,
|
||||
updatedAt: schema.marketplace.updatedAt,
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.id, marketplaceId))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, marketplaceId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
}
|
||||
@@ -183,21 +183,19 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Define common fields to select
|
||||
const baseFields = {
|
||||
id: schema.marketplace.id,
|
||||
workflowId: schema.marketplace.workflowId,
|
||||
name: schema.marketplace.name,
|
||||
description: schema.marketplace.description,
|
||||
authorName: schema.marketplace.authorName,
|
||||
views: schema.marketplace.views,
|
||||
category: schema.marketplace.category,
|
||||
createdAt: schema.marketplace.createdAt,
|
||||
updatedAt: schema.marketplace.updatedAt,
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorName: marketplace.authorName,
|
||||
views: marketplace.views,
|
||||
category: marketplace.category,
|
||||
createdAt: marketplace.createdAt,
|
||||
updatedAt: marketplace.updatedAt,
|
||||
}
|
||||
|
||||
// Add state if requested
|
||||
const selectFields = includeState
|
||||
? { ...baseFields, state: schema.marketplace.state }
|
||||
: baseFields
|
||||
const selectFields = includeState ? { ...baseFields, state: marketplace.state } : baseFields
|
||||
|
||||
// Determine which sections to fetch
|
||||
const sections = sectionParam ? sectionParam.split(',') : ['popular', 'recent', 'byCategory']
|
||||
@@ -206,8 +204,8 @@ export async function GET(request: NextRequest) {
|
||||
if (sections.includes('popular')) {
|
||||
result.popular = await db
|
||||
.select(selectFields)
|
||||
.from(schema.marketplace)
|
||||
.orderBy(desc(schema.marketplace.views))
|
||||
.from(marketplace)
|
||||
.orderBy(desc(marketplace.views))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
@@ -215,8 +213,8 @@ export async function GET(request: NextRequest) {
|
||||
if (sections.includes('recent')) {
|
||||
result.recent = await db
|
||||
.select(selectFields)
|
||||
.from(schema.marketplace)
|
||||
.orderBy(desc(schema.marketplace.createdAt))
|
||||
.from(marketplace)
|
||||
.orderBy(desc(marketplace.createdAt))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
@@ -255,9 +253,9 @@ export async function GET(request: NextRequest) {
|
||||
categoriesToFetch.map(async (categoryValue) => {
|
||||
const categoryItems = await db
|
||||
.select(selectFields)
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.category, categoryValue))
|
||||
.orderBy(desc(schema.marketplace.views))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.category, categoryValue))
|
||||
.orderBy(desc(marketplace.views))
|
||||
.limit(limit)
|
||||
|
||||
// Always add the category to the result, even if empty
|
||||
@@ -328,10 +326,10 @@ export async function POST(request: NextRequest) {
|
||||
// Find the marketplace entry
|
||||
const marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
id: marketplace.id,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.id, id))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.id, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
@@ -342,11 +340,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Increment the view count
|
||||
await db
|
||||
.update(schema.marketplace)
|
||||
.update(marketplace)
|
||||
.set({
|
||||
views: sql`${schema.marketplace.views} + 1`,
|
||||
views: sql`${marketplace.views} + 1`,
|
||||
})
|
||||
.where(eq(schema.marketplace.id, id))
|
||||
.where(eq(marketplace.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`)
|
||||
|
||||
|
||||
506
apps/sim/app/api/organizations/[id]/invitations/route.ts
Normal file
506
apps/sim/app/api/organizations/[id]/invitations/route.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderBatchInvitationEmail,
|
||||
renderInvitationEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
validateBulkInvitations,
|
||||
validateSeatAvailability,
|
||||
} from '@/lib/billing/validation/seat-management'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { validateAndNormalizeEmail } from '@/lib/email/utils'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, organization, user, workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationsAPI')
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/invitations
|
||||
* Get all pending invitations for an organization
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
// Verify user has access to this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = memberEntry[0].role
|
||||
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all pending invitations for the organization
|
||||
const invitations = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
createdAt: invitation.createdAt,
|
||||
inviterName: user.name,
|
||||
inviterEmail: user.email,
|
||||
})
|
||||
.from(invitation)
|
||||
.leftJoin(user, eq(invitation.inviterId, user.id))
|
||||
.where(eq(invitation.organizationId, organizationId))
|
||||
.orderBy(invitation.createdAt)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
invitations,
|
||||
userRole,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization invitations', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/organizations/[id]/invitations
|
||||
* Create organization invitations with optional validation and batch workspace invitations
|
||||
* Query parameters:
|
||||
* - ?validate=true - Only validate, don't send invitations
|
||||
* - ?batch=true - Include workspace invitations
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const url = new URL(request.url)
|
||||
const validateOnly = url.searchParams.get('validate') === 'true'
|
||||
const isBatch = url.searchParams.get('batch') === 'true'
|
||||
|
||||
const body = await request.json()
|
||||
const { email, emails, role = 'member', workspaceInvitations } = body
|
||||
|
||||
// Handle single invitation vs batch
|
||||
const invitationEmails = email ? [email] : emails
|
||||
|
||||
// Validate input
|
||||
if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!['member', 'admin'].includes(role)) {
|
||||
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Handle validation-only requests
|
||||
if (validateOnly) {
|
||||
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
|
||||
|
||||
logger.info('Invitation validation completed', {
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
emailCount: invitationEmails.length,
|
||||
result: validationResult,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: validationResult,
|
||||
validatedBy: session.user.id,
|
||||
validatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate seat availability
|
||||
const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length)
|
||||
|
||||
if (!seatValidation.canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: seatValidation.reason,
|
||||
seatInfo: {
|
||||
currentSeats: seatValidation.currentSeats,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats,
|
||||
seatsRequested: invitationEmails.length,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get organization details
|
||||
const organizationEntry = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (organizationEntry.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate and normalize emails
|
||||
const processedEmails = invitationEmails
|
||||
.map((email: string) => {
|
||||
const result = validateAndNormalizeEmail(email)
|
||||
return result.isValid ? result.normalized : null
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
if (processedEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Handle batch workspace invitations if provided
|
||||
const validWorkspaceInvitations: WorkspaceInvitation[] = []
|
||||
if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) {
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
// Check if user has admin permission on this workspace
|
||||
const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId)
|
||||
|
||||
if (!canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `You don't have permission to invite users to workspace ${wsInvitation.workspaceId}`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
validWorkspaceInvitations.push(wsInvitation)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing members
|
||||
const existingMembers = await db
|
||||
.select({ userEmail: user.email })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const existingEmails = existingMembers.map((m) => m.userEmail)
|
||||
const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email))
|
||||
|
||||
// Check for existing pending invitations
|
||||
const existingInvitations = await db
|
||||
.select({ email: invitation.email })
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
|
||||
|
||||
const pendingEmails = existingInvitations.map((i) => i.email)
|
||||
const emailsToInvite = newEmails.filter((email: string) => !pendingEmails.includes(email))
|
||||
|
||||
if (emailsToInvite.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'All emails are already members or have pending invitations',
|
||||
details: {
|
||||
existingMembers: processedEmails.filter((email: string) =>
|
||||
existingEmails.includes(email)
|
||||
),
|
||||
pendingInvitations: processedEmails.filter((email: string) =>
|
||||
pendingEmails.includes(email)
|
||||
),
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create invitations
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||
const invitationsToCreate = emailsToInvite.map((email: string) => ({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
inviterId: session.user.id,
|
||||
organizationId,
|
||||
role,
|
||||
status: 'pending' as const,
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
}))
|
||||
|
||||
await db.insert(invitation).values(invitationsToCreate)
|
||||
|
||||
// Create workspace invitations if batch mode
|
||||
const workspaceInvitationIds: string[] = []
|
||||
if (isBatch && validWorkspaceInvitations.length > 0) {
|
||||
for (const email of emailsToInvite) {
|
||||
for (const wsInvitation of validWorkspaceInvitations) {
|
||||
const wsInvitationId = randomUUID()
|
||||
const token = randomUUID()
|
||||
|
||||
await db.insert(workspaceInvitation).values({
|
||||
id: wsInvitationId,
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
email,
|
||||
inviterId: session.user.id,
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token,
|
||||
permissions: wsInvitation.permission,
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
workspaceInvitationIds.push(wsInvitationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send invitation emails
|
||||
const inviter = await db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
for (const email of emailsToInvite) {
|
||||
const orgInvitation = invitationsToCreate.find((inv) => inv.email === email)
|
||||
if (!orgInvitation) continue
|
||||
|
||||
let emailResult
|
||||
if (isBatch && validWorkspaceInvitations.length > 0) {
|
||||
// Get workspace details for batch email
|
||||
const workspaceDetails = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(
|
||||
inArray(
|
||||
workspace.id,
|
||||
validWorkspaceInvitations.map((w) => w.workspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
const workspaceInvitationsWithNames = validWorkspaceInvitations.map((wsInv) => ({
|
||||
workspaceId: wsInv.workspaceId,
|
||||
workspaceName:
|
||||
workspaceDetails.find((w) => w.id === wsInv.workspaceId)?.name || 'Unknown Workspace',
|
||||
permission: wsInv.permission,
|
||||
}))
|
||||
|
||||
const emailHtml = await renderBatchInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
role,
|
||||
workspaceInvitationsWithNames,
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject('batch-invitation'),
|
||||
html: emailHtml,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
} else {
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`,
|
||||
email
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject('invitation'),
|
||||
html: emailHtml,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
}
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error('Failed to send invitation email', {
|
||||
email,
|
||||
error: emailResult.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Organization invitations created', {
|
||||
organizationId,
|
||||
invitedBy: session.user.id,
|
||||
invitationCount: invitationsToCreate.length,
|
||||
emails: emailsToInvite,
|
||||
role,
|
||||
isBatch,
|
||||
workspaceInvitationCount: workspaceInvitationIds.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${invitationsToCreate.length} invitation(s) sent successfully`,
|
||||
data: {
|
||||
invitationsSent: invitationsToCreate.length,
|
||||
invitedEmails: emailsToInvite,
|
||||
existingMembers: processedEmails.filter((email: string) => existingEmails.includes(email)),
|
||||
pendingInvitations: processedEmails.filter((email: string) =>
|
||||
pendingEmails.includes(email)
|
||||
),
|
||||
invalidEmails: invitationEmails.filter(
|
||||
(email: string) => !validateAndNormalizeEmail(email)
|
||||
),
|
||||
workspaceInvitations: isBatch ? validWorkspaceInvitations.length : 0,
|
||||
seatInfo: {
|
||||
seatsUsed: seatValidation.currentSeats + invitationsToCreate.length,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats - invitationsToCreate.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization invitations', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/organizations/[id]/invitations?invitationId=...
|
||||
* Cancel a pending invitation
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const url = new URL(request.url)
|
||||
const invitationId = url.searchParams.get('invitationId')
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invitation ID is required as query parameter' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Cancel the invitation
|
||||
const result = await db
|
||||
.update(invitation)
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.id, invitationId),
|
||||
eq(invitation.organizationId, organizationId),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invitation not found or already processed' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Organization invitation cancelled', {
|
||||
organizationId,
|
||||
invitationId,
|
||||
cancelledBy: session.user.id,
|
||||
email: result[0].email,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Invitation cancelled successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel organization invitation', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
314
apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
Normal file
314
apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationMemberAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/members/[memberId]
|
||||
* Get individual organization member details
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, memberId } = await params
|
||||
const url = new URL(request.url)
|
||||
const includeUsage = url.searchParams.get('include') === 'usage'
|
||||
|
||||
// Verify user has access to this organization
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (userMember.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = userMember[0].role
|
||||
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
// Get target member details
|
||||
const memberQuery = db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.limit(1)
|
||||
|
||||
const memberEntry = await memberQuery
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user can view this member's details
|
||||
const canViewDetails = hasAdminAccess || session.user.id === memberId
|
||||
|
||||
if (!canViewDetails) {
|
||||
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
let memberData = memberEntry[0]
|
||||
|
||||
// Include usage data if requested and user has permission
|
||||
if (includeUsage && hasAdminAccess) {
|
||||
const usageData = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
billingPeriodStart: userStats.billingPeriodStart,
|
||||
billingPeriodEnd: userStats.billingPeriodEnd,
|
||||
usageLimitSetBy: userStats.usageLimitSetBy,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
lastPeriodCost: userStats.lastPeriodCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, memberId))
|
||||
.limit(1)
|
||||
|
||||
if (usageData.length > 0) {
|
||||
memberData = {
|
||||
...memberData,
|
||||
usage: usageData[0],
|
||||
} as typeof memberData & { usage: (typeof usageData)[0] }
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: memberData,
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization member', {
|
||||
organizationId: (await params).id,
|
||||
memberId: (await params).memberId,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]/members/[memberId]
|
||||
* Update organization member role
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, memberId } = await params
|
||||
const { role } = await request.json()
|
||||
|
||||
// Validate input
|
||||
if (!role || !['admin', 'member'].includes(role)) {
|
||||
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (userMember.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!['owner', 'admin'].includes(userMember[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if target member exists
|
||||
const targetMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.limit(1)
|
||||
|
||||
if (targetMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent changing owner role
|
||||
if (targetMember[0].role === 'owner') {
|
||||
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Prevent non-owners from promoting to admin
|
||||
if (role === 'admin' && userMember[0].role !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only owners can promote members to admin' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update member role
|
||||
const updatedMember = await db
|
||||
.update(member)
|
||||
.set({ role })
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.returning()
|
||||
|
||||
if (updatedMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Organization member role updated', {
|
||||
organizationId,
|
||||
memberId,
|
||||
newRole: role,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Member role updated successfully',
|
||||
data: {
|
||||
id: updatedMember[0].id,
|
||||
userId: updatedMember[0].userId,
|
||||
role: updatedMember[0].role,
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update organization member role', {
|
||||
organizationId: (await params).id,
|
||||
memberId: (await params).memberId,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/organizations/[id]/members/[memberId]
|
||||
* Remove member from organization
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, memberId } = await params
|
||||
|
||||
// Verify user has admin access
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (userMember.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const canRemoveMembers =
|
||||
['owner', 'admin'].includes(userMember[0].role) || session.user.id === memberId
|
||||
|
||||
if (!canRemoveMembers) {
|
||||
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if target member exists
|
||||
const targetMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.limit(1)
|
||||
|
||||
if (targetMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent removing the owner
|
||||
if (targetMember[0].role === 'owner') {
|
||||
return NextResponse.json({ error: 'Cannot remove organization owner' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Remove member
|
||||
const removedMember = await db
|
||||
.delete(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.returning()
|
||||
|
||||
if (removedMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Organization member removed', {
|
||||
organizationId,
|
||||
removedMemberId: memberId,
|
||||
removedBy: session.user.id,
|
||||
wasSelfRemoval: session.user.id === memberId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message:
|
||||
session.user.id === memberId
|
||||
? 'You have left the organization'
|
||||
: 'Member removed successfully',
|
||||
data: {
|
||||
removedMemberId: memberId,
|
||||
removedBy: session.user.id,
|
||||
removedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove organization member', {
|
||||
organizationId: (await params).id,
|
||||
memberId: (await params).memberId,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
293
apps/sim/app/api/organizations/[id]/members/route.ts
Normal file
293
apps/sim/app/api/organizations/[id]/members/route.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { validateAndNormalizeEmail } from '@/lib/email/utils'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, organization, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationMembersAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/members
|
||||
* Get organization members with optional usage data
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const url = new URL(request.url)
|
||||
const includeUsage = url.searchParams.get('include') === 'usage'
|
||||
|
||||
// Verify user has access to this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = memberEntry[0].role
|
||||
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
// Get organization members
|
||||
const query = db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
// Include usage data if requested and user has admin access
|
||||
if (includeUsage && hasAdminAccess) {
|
||||
const membersWithUsage = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
billingPeriodStart: userStats.billingPeriodStart,
|
||||
billingPeriodEnd: userStats.billingPeriodEnd,
|
||||
usageLimitSetBy: userStats.usageLimitSetBy,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(user.id, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: membersWithUsage,
|
||||
total: membersWithUsage.length,
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
}
|
||||
|
||||
const members = await query
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: members,
|
||||
total: members.length,
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization members', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/organizations/[id]/members
|
||||
* Invite new member to organization
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const { email, role = 'member' } = await request.json()
|
||||
|
||||
// Validate input
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!['admin', 'member'].includes(role)) {
|
||||
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate and normalize email
|
||||
const { isValid, normalized: normalizedEmail } = validateAndNormalizeEmail(email)
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid email format' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check seat availability
|
||||
const seatValidation = await validateSeatAvailability(organizationId, 1)
|
||||
if (!seatValidation.canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Cannot invite member. Using ${seatValidation.currentSeats} of ${seatValidation.maxSeats} seats.`,
|
||||
details: seatValidation,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingUser = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, normalizedEmail))
|
||||
.limit(1)
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.organizationId, organizationId), eq(member.userId, existingUser[0].id))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing pending invitation
|
||||
const existingInvitation = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.organizationId, organizationId),
|
||||
eq(invitation.email, normalizedEmail),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingInvitation.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Pending invitation already exists for this email' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create invitation
|
||||
const invitationId = randomUUID()
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
|
||||
|
||||
await db.insert(invitation).values({
|
||||
id: invitationId,
|
||||
email: normalizedEmail,
|
||||
inviterId: session.user.id,
|
||||
organizationId,
|
||||
role,
|
||||
status: 'pending',
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
const organizationEntry = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
const inviter = await db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/api/organizations/invitations/accept?id=${invitationId}`,
|
||||
normalizedEmail
|
||||
)
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject: getEmailSubject('invitation'),
|
||||
html: emailHtml,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Member invitation sent', {
|
||||
email: normalizedEmail,
|
||||
organizationId,
|
||||
invitationId,
|
||||
role,
|
||||
})
|
||||
} else {
|
||||
logger.error('Failed to send invitation email', {
|
||||
email: normalizedEmail,
|
||||
error: emailResult.message,
|
||||
})
|
||||
// Don't fail the request if email fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Invitation sent to ${normalizedEmail}`,
|
||||
data: {
|
||||
invitationId,
|
||||
email: normalizedEmail,
|
||||
role,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to invite organization member', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
248
apps/sim/app/api/organizations/[id]/route.ts
Normal file
248
apps/sim/app/api/organizations/[id]/route.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
getOrganizationSeatAnalytics,
|
||||
getOrganizationSeatInfo,
|
||||
updateOrganizationSeats,
|
||||
} from '@/lib/billing/validation/seat-management'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]
|
||||
* Get organization details including settings and seat information
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const url = new URL(request.url)
|
||||
const includeSeats = url.searchParams.get('include') === 'seats'
|
||||
|
||||
// Verify user has access to this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get organization data
|
||||
const organizationEntry = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (organizationEntry.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const userRole = memberEntry[0].role
|
||||
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
const response: any = {
|
||||
success: true,
|
||||
data: {
|
||||
id: organizationEntry[0].id,
|
||||
name: organizationEntry[0].name,
|
||||
slug: organizationEntry[0].slug,
|
||||
logo: organizationEntry[0].logo,
|
||||
metadata: organizationEntry[0].metadata,
|
||||
createdAt: organizationEntry[0].createdAt,
|
||||
updatedAt: organizationEntry[0].updatedAt,
|
||||
},
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
}
|
||||
|
||||
// Include seat information if requested
|
||||
if (includeSeats) {
|
||||
const seatInfo = await getOrganizationSeatInfo(organizationId)
|
||||
if (seatInfo) {
|
||||
response.data.seats = seatInfo
|
||||
}
|
||||
|
||||
// Include analytics for admins
|
||||
if (hasAdminAccess) {
|
||||
const analytics = await getOrganizationSeatAnalytics(organizationId)
|
||||
if (analytics) {
|
||||
response.data.seatAnalytics = analytics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]
|
||||
* Update organization settings or seat count
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const body = await request.json()
|
||||
const { name, slug, logo, seats } = body
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Handle seat count update
|
||||
if (seats !== undefined) {
|
||||
if (typeof seats !== 'number' || seats < 1) {
|
||||
return NextResponse.json({ error: 'Invalid seat count' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await updateOrganizationSeats(organizationId, seats, session.user.id)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info('Organization seat count updated', {
|
||||
organizationId,
|
||||
newSeatCount: seats,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Seat count updated successfully',
|
||||
data: {
|
||||
seats: seats,
|
||||
updatedBy: session.user.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle settings update
|
||||
if (name !== undefined || slug !== undefined || logo !== undefined) {
|
||||
// Validate required fields
|
||||
if (name !== undefined && (!name || typeof name !== 'string' || name.trim().length === 0)) {
|
||||
return NextResponse.json({ error: 'Organization name is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (slug !== undefined && (!slug || typeof slug !== 'string' || slug.trim().length === 0)) {
|
||||
return NextResponse.json({ error: 'Organization slug is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate slug format
|
||||
if (slug !== undefined) {
|
||||
const slugRegex = /^[a-z0-9-_]+$/
|
||||
if (!slugRegex.test(slug)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Slug can only contain lowercase letters, numbers, hyphens, and underscores',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if slug is already taken by another organization
|
||||
const existingSlug = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(and(eq(organization.slug, slug), ne(organization.id, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSlug.length > 0) {
|
||||
return NextResponse.json({ error: 'This slug is already taken' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Build update object with only provided fields
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
if (name !== undefined) updateData.name = name.trim()
|
||||
if (slug !== undefined) updateData.slug = slug.trim()
|
||||
if (logo !== undefined) updateData.logo = logo || null
|
||||
|
||||
// Update organization
|
||||
const updatedOrg = await db
|
||||
.update(organization)
|
||||
.set(updateData)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.returning()
|
||||
|
||||
if (updatedOrg.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info('Organization settings updated', {
|
||||
organizationId,
|
||||
updatedBy: session.user.id,
|
||||
changes: { name, slug, logo },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Organization updated successfully',
|
||||
data: {
|
||||
id: updatedOrg[0].id,
|
||||
name: updatedOrg[0].name,
|
||||
slug: updatedOrg[0].slug,
|
||||
logo: updatedOrg[0].logo,
|
||||
updatedAt: updatedOrg[0].updatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'No valid fields provided for update' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update organization', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE method removed - organization deletion not implemented
|
||||
// If deletion is needed in the future, it should be implemented with proper
|
||||
// cleanup of subscriptions, members, workspaces, and billing data
|
||||
209
apps/sim/app/api/organizations/[id]/workspaces/route.ts
Normal file
209
apps/sim/app/api/organizations/[id]/workspaces/route.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, permissions, user, workspace, workspaceMember } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationWorkspacesAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/workspaces
|
||||
* Get workspaces related to the organization with optional filtering
|
||||
* Query parameters:
|
||||
* - ?available=true - Only workspaces where user can invite others (admin permissions)
|
||||
* - ?member=userId - Workspaces where specific member has access
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const url = new URL(request.url)
|
||||
const availableOnly = url.searchParams.get('available') === 'true'
|
||||
const memberId = url.searchParams.get('member')
|
||||
|
||||
// Verify user is a member of this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden - Not a member of this organization',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = memberEntry[0].role
|
||||
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
if (availableOnly) {
|
||||
// Get workspaces where user has admin permissions (can invite others)
|
||||
const availableWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
createdAt: workspace.createdAt,
|
||||
isOwner: eq(workspace.ownerId, session.user.id),
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
.from(workspace)
|
||||
.leftJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspace.id),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
// User owns the workspace
|
||||
eq(workspace.ownerId, session.user.id),
|
||||
// User has admin permission on the workspace
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Filter and format the results
|
||||
const workspacesWithInvitePermission = availableWorkspaces
|
||||
.filter((workspace) => {
|
||||
// Include if user owns the workspace OR has admin permission
|
||||
return workspace.isOwner || workspace.permissionType === 'admin'
|
||||
})
|
||||
.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isOwner: workspace.isOwner,
|
||||
canInvite: true, // All returned workspaces have invite permission
|
||||
createdAt: workspace.createdAt,
|
||||
}))
|
||||
|
||||
logger.info('Retrieved available workspaces for organization member', {
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
workspaceCount: workspacesWithInvitePermission.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: workspacesWithInvitePermission,
|
||||
totalCount: workspacesWithInvitePermission.length,
|
||||
filter: 'available',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (memberId && hasAdminAccess) {
|
||||
// Get workspaces where specific member has access (admin only)
|
||||
const memberWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
createdAt: workspace.createdAt,
|
||||
isOwner: eq(workspace.ownerId, memberId),
|
||||
permissionType: permissions.permissionType,
|
||||
joinedAt: workspaceMember.joinedAt,
|
||||
})
|
||||
.from(workspace)
|
||||
.leftJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspace.id),
|
||||
eq(permissions.userId, memberId)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
workspaceMember,
|
||||
and(eq(workspaceMember.workspaceId, workspace.id), eq(workspaceMember.userId, memberId))
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
// Member owns the workspace
|
||||
eq(workspace.ownerId, memberId),
|
||||
// Member has permissions on the workspace
|
||||
and(eq(permissions.userId, memberId), eq(permissions.entityType, 'workspace'))
|
||||
)
|
||||
)
|
||||
|
||||
const formattedWorkspaces = memberWorkspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isOwner: workspace.isOwner,
|
||||
permission: workspace.permissionType,
|
||||
joinedAt: workspace.joinedAt,
|
||||
createdAt: workspace.createdAt,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: formattedWorkspaces,
|
||||
totalCount: formattedWorkspaces.length,
|
||||
filter: 'member',
|
||||
memberId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Default: Get all workspaces (basic info only for regular members)
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: [],
|
||||
totalCount: 0,
|
||||
message: 'Workspace access information is only available to organization admins',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// For admins: Get summary of all workspaces
|
||||
const allWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
createdAt: workspace.createdAt,
|
||||
ownerName: user.name,
|
||||
})
|
||||
.from(workspace)
|
||||
.leftJoin(user, eq(workspace.ownerId, user.id))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
workspaces: allWorkspaces,
|
||||
totalCount: allWorkspaces.length,
|
||||
filter: 'all',
|
||||
},
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization workspaces', { error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
378
apps/sim/app/api/organizations/invitations/accept/route.ts
Normal file
378
apps/sim/app/api/organizations/invitations/accept/route.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, permissions, workspaceInvitation, workspaceMember } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationAcceptance')
|
||||
|
||||
// Accept an organization invitation and any associated workspace invitations
|
||||
export async function GET(req: NextRequest) {
|
||||
const invitationId = req.nextUrl.searchParams.get('id')
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=missing-invitation-id',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// Redirect to login, user will be redirected back after login
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/organization?id=${invitationId}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the organization invitation
|
||||
const invitationResult = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(eq(invitation.id, invitationId))
|
||||
.limit(1)
|
||||
|
||||
if (invitationResult.length === 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=invalid-invitation',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const orgInvitation = invitationResult[0]
|
||||
|
||||
// Check if invitation has expired
|
||||
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=expired',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invitation is still pending
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-processed',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the email matches the current user
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=email-mismatch',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is already a member of the organization
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgInvitation.organizationId),
|
||||
eq(member.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-member',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Start transaction to accept both organization and workspace invitations
|
||||
await db.transaction(async (tx) => {
|
||||
// Accept organization invitation - add user as member
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark organization invitation as accepted
|
||||
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
|
||||
|
||||
// Find and accept any pending workspace invitations for the same email
|
||||
const workspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.email, orgInvitation.email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
// Check if invitation hasn't expired
|
||||
if (
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
// Check if user isn't already a member of the workspace
|
||||
const existingWorkspaceMember = await tx
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, wsInvitation.workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
// Check if user doesn't already have permissions on the workspace
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, wsInvitation.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) {
|
||||
// Add user as workspace member
|
||||
await tx.insert(workspaceMember).values({
|
||||
id: randomUUID(),
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: wsInvitation.role,
|
||||
joinedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Add workspace permissions
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
permissionType: wsInvitation.permissions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark workspace invitation as accepted
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'accepted' })
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
logger.info('Accepted workspace invitation', {
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permission: wsInvitation.permissions,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Successfully accepted batch invitation', {
|
||||
organizationId: orgInvitation.organizationId,
|
||||
userId: session.user.id,
|
||||
role: orgInvitation.role,
|
||||
})
|
||||
|
||||
// Redirect to success page or main app
|
||||
return NextResponse.redirect(
|
||||
new URL('/workspaces?invite=accepted', env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept organization invitation', {
|
||||
invitationId,
|
||||
userId: session.user.id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=server-error',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST endpoint for programmatic acceptance (for API use)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { invitationId } = await req.json()
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.json({ error: 'Missing invitationId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Similar logic to GET but return JSON response
|
||||
const invitationResult = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(eq(invitation.id, invitationId))
|
||||
.limit(1)
|
||||
|
||||
if (invitationResult.length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid invitation' }, { status: 404 })
|
||||
}
|
||||
|
||||
const orgInvitation = invitationResult[0]
|
||||
|
||||
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
|
||||
return NextResponse.json({ error: 'Invitation expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgInvitation.organizationId),
|
||||
eq(member.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.json({ error: 'Already a member' }, { status: 400 })
|
||||
}
|
||||
|
||||
let acceptedWorkspaces = 0
|
||||
|
||||
// Accept invitations in transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Accept organization invitation
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
|
||||
|
||||
// Accept workspace invitations
|
||||
const workspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.email, orgInvitation.email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
if (
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
const existingWorkspaceMember = await tx
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, wsInvitation.workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, wsInvitation.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) {
|
||||
await tx.insert(workspaceMember).values({
|
||||
id: randomUUID(),
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: wsInvitation.role,
|
||||
joinedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
permissionType: wsInvitation.permissions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'accepted' })
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
acceptedWorkspaces++
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Successfully joined organization and ${acceptedWorkspaces} workspace(s)`,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
workspacesJoined: acceptedWorkspaces,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept organization invitation via API', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { and, eq, lte, not, sql } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/trace-spans'
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
getScheduleTimeValues,
|
||||
getSubBlockValue,
|
||||
} from '@/lib/schedules/utils'
|
||||
import { checkServerSideUsageLimits } from '@/lib/usage-monitor'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
|
||||
179
apps/sim/app/api/usage-limits/route.ts
Normal file
179
apps/sim/app/api/usage-limits/route.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing'
|
||||
import { updateMemberUsageLimit } from '@/lib/billing/core/organization-billing'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils'
|
||||
|
||||
const logger = createLogger('UnifiedUsageLimitsAPI')
|
||||
|
||||
/**
|
||||
* Unified Usage Limits Endpoint
|
||||
* GET/PUT /api/usage-limits?context=user|member&userId=<id>&organizationId=<id>
|
||||
*
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const context = searchParams.get('context') || 'user'
|
||||
const userId = searchParams.get('userId') || session.user.id
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
// Validate context
|
||||
if (!['user', 'member'].includes(context)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid context. Must be "user" or "member"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// For member context, require organizationId and check permissions
|
||||
if (context === 'member') {
|
||||
if (!organizationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization ID is required when context=member' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the current user has permission to view member usage info
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasPermission) {
|
||||
logger.warn('Unauthorized attempt to view member usage info', {
|
||||
requesterId: session.user.id,
|
||||
targetUserId: userId,
|
||||
organizationId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Permission denied. Only organization owners and admins can view member usage information',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// For user context, ensure they can only view their own info
|
||||
if (context === 'user' && userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot view other users' usage information" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get usage limit info
|
||||
const usageLimitInfo = await getUserUsageLimitInfo(userId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
userId,
|
||||
organizationId,
|
||||
data: usageLimitInfo,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage limit info', {
|
||||
userId: session?.user?.id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const context = searchParams.get('context') || 'user'
|
||||
const userId = searchParams.get('userId') || session.user.id
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
const { limit } = await request.json()
|
||||
|
||||
if (typeof limit !== 'number' || limit < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid limit. Must be a positive number' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (context === 'user') {
|
||||
// Update user's own usage limit
|
||||
if (userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Cannot update other users' limits" }, { status: 403 })
|
||||
}
|
||||
|
||||
await updateUserUsageLimit(userId, limit)
|
||||
} else if (context === 'member') {
|
||||
// Update organization member's usage limit
|
||||
if (!organizationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization ID is required when context=member' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the current user has permission to update member limits
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasPermission) {
|
||||
logger.warn('Unauthorized attempt to update member usage limit', {
|
||||
adminUserId: session.user.id,
|
||||
targetUserId: userId,
|
||||
organizationId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Permission denied. Only organization owners and admins can update member usage limits',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Authorized member usage limit update', {
|
||||
adminUserId: session.user.id,
|
||||
targetUserId: userId,
|
||||
organizationId,
|
||||
newLimit: limit,
|
||||
})
|
||||
|
||||
await updateMemberUsageLimit(organizationId, userId, limit, session.user.id)
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid context. Must be "user" or "member"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Return updated limit info
|
||||
const updatedInfo = await getUserUsageLimitInfo(userId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
userId,
|
||||
organizationId,
|
||||
data: updatedInfo,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update usage limit', {
|
||||
userId: session?.user?.id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UserStatsAPI')
|
||||
|
||||
/**
|
||||
* GET endpoint to retrieve user statistics including the count of workflows
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the user session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized user stats access attempt')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Get workflow count for user
|
||||
const [workflowCountResult] = await db
|
||||
.select({ count: sql`count(*)::int` })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.userId, userId))
|
||||
|
||||
const workflowCount = workflowCountResult?.count || 0
|
||||
|
||||
// Get user stats record
|
||||
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
// If no stats record exists, create one
|
||||
if (userStatsRecords.length === 0) {
|
||||
const newStats = {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
totalManualExecutions: 0,
|
||||
totalApiCalls: 0,
|
||||
totalWebhookTriggers: 0,
|
||||
totalScheduledExecutions: 0,
|
||||
totalChatExecutions: 0,
|
||||
totalTokensUsed: 0,
|
||||
totalCost: '0.00',
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
await db.insert(userStats).values(newStats)
|
||||
|
||||
// Return the newly created stats with workflow count
|
||||
return NextResponse.json({
|
||||
...newStats,
|
||||
workflowCount,
|
||||
})
|
||||
}
|
||||
|
||||
// Return stats with workflow count
|
||||
const stats = userStatsRecords[0]
|
||||
return NextResponse.json({
|
||||
...stats,
|
||||
workflowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user stats:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch user statistics' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Tests for Subscription Seats Update API
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
mockDb,
|
||||
mockLogger,
|
||||
mockPersonalSubscription,
|
||||
mockRegularMember,
|
||||
mockSubscription,
|
||||
mockTeamSubscription,
|
||||
mockUser,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Subscription Seats Update API Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: mockUser,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/db', () => ({
|
||||
db: mockDb,
|
||||
}))
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
})
|
||||
|
||||
const mockSetFn = vi.fn().mockReturnThis()
|
||||
const mockWhereFn = vi.fn().mockResolvedValue([{ affected: 1 }])
|
||||
mockDb.update.mockReturnValue({
|
||||
set: mockSetFn,
|
||||
where: mockWhereFn,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('POST handler', () => {
|
||||
it('should encounter a permission error when trying to update subscription seats', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
mockDb.select.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
}))
|
||||
|
||||
mockDb.select.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty(
|
||||
'error',
|
||||
'Unauthorized - you do not have permission to modify this subscription'
|
||||
)
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject team plan subscription updates', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockTeamSubscription]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty(
|
||||
'error',
|
||||
'Only enterprise subscriptions can be updated through this endpoint'
|
||||
)
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should encounter permission issues with personal subscription updates', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockPersonalSubscription]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should reject updates from non-admin members', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
const mockSelectImpl = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockRegularMember]),
|
||||
})
|
||||
|
||||
mockDb.select.mockImplementation(mockSelectImpl)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should reject invalid request parameters', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
seats: -5,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'Invalid request parameters')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle subscription not found with permission error', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle authentication error', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle internal server error', async () => {
|
||||
mockDb.select.mockImplementation(() => {
|
||||
throw new Error('Database error')
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toHaveProperty('error', 'Failed to update subscription seats')
|
||||
expect(mockLogger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,151 +0,0 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { checkEnterprisePlan } from '@/lib/subscription/utils'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('SubscriptionSeatsUpdateAPI')
|
||||
|
||||
const updateSeatsSchema = z.object({
|
||||
seats: z.number().int().min(1),
|
||||
})
|
||||
|
||||
const subscriptionMetadataSchema = z
|
||||
.object({
|
||||
perSeatAllowance: z.number().positive().optional(),
|
||||
totalAllowance: z.number().positive().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
|
||||
interface SubscriptionMetadata {
|
||||
perSeatAllowance?: number
|
||||
totalAllowance?: number
|
||||
updatedAt?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const subscriptionId = (await params).id
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized seats update attempt')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch (_parseError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid JSON in request body',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const validationResult = updateSeatsSchema.safeParse(body)
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid request parameters',
|
||||
details: validationResult.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { seats } = validationResult.data
|
||||
|
||||
const sub = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sub) {
|
||||
return NextResponse.json({ error: 'Subscription not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!checkEnterprisePlan(sub)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only enterprise subscriptions can be updated through this endpoint' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const isPersonalSubscription = sub.referenceId === session.user.id
|
||||
|
||||
let hasAccess = isPersonalSubscription
|
||||
|
||||
if (!isPersonalSubscription) {
|
||||
const mem = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, sub.referenceId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
hasAccess = mem && (mem.role === 'owner' || mem.role === 'admin')
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - you do not have permission to modify this subscription' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
let validatedMetadata: SubscriptionMetadata
|
||||
try {
|
||||
validatedMetadata = subscriptionMetadataSchema.parse(sub.metadata || {})
|
||||
} catch (error) {
|
||||
logger.error('Invalid subscription metadata format', {
|
||||
error,
|
||||
subscriptionId,
|
||||
metadata: sub.metadata,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Subscription metadata has invalid format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (validatedMetadata.perSeatAllowance && validatedMetadata.perSeatAllowance > 0) {
|
||||
validatedMetadata.totalAllowance = seats * validatedMetadata.perSeatAllowance
|
||||
validatedMetadata.updatedAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({
|
||||
seats,
|
||||
metadata: validatedMetadata,
|
||||
})
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
|
||||
logger.info('Subscription seats updated', {
|
||||
subscriptionId,
|
||||
oldSeats: sub.seats,
|
||||
newSeats: seats,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Subscription seats updated successfully',
|
||||
seats,
|
||||
metadata: validatedMetadata,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription seats', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to update subscription seats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { checkEnterprisePlan } from '@/lib/subscription/utils'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('EnterpriseSubscriptionAPI')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const userSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
if (userSubscriptions.length > 0 && checkEnterprisePlan(userSubscriptions[0])) {
|
||||
const enterpriseSub = userSubscriptions[0]
|
||||
logger.info('Found direct enterprise subscription', { userId, subId: enterpriseSub.id })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
subscription: enterpriseSub,
|
||||
})
|
||||
}
|
||||
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
for (const { organizationId } of memberships) {
|
||||
const orgSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
if (orgSubscriptions.length > 0 && checkEnterprisePlan(orgSubscriptions[0])) {
|
||||
const enterpriseSub = orgSubscriptions[0]
|
||||
logger.info('Found organization enterprise subscription', {
|
||||
userId,
|
||||
orgId: organizationId,
|
||||
subId: enterpriseSub.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
subscription: enterpriseSub,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
subscription: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching enterprise subscription:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch enterprise subscription data',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getHighestPrioritySubscription } from '@/lib/subscription/subscription'
|
||||
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/subscription/utils'
|
||||
|
||||
const logger = createLogger('UserSubscriptionAPI')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const activeSub = await getHighestPrioritySubscription(session.user.id)
|
||||
|
||||
const isPaid =
|
||||
activeSub?.status === 'active' &&
|
||||
['pro', 'team', 'enterprise'].includes(activeSub?.plan ?? '')
|
||||
|
||||
const isPro = isPaid
|
||||
|
||||
const isTeam = checkTeamPlan(activeSub)
|
||||
|
||||
const isEnterprise = checkEnterprisePlan(activeSub)
|
||||
|
||||
return NextResponse.json({
|
||||
isPaid,
|
||||
isPro,
|
||||
isTeam,
|
||||
isEnterprise,
|
||||
plan: activeSub?.plan || 'free',
|
||||
status: activeSub?.status || null,
|
||||
seats: activeSub?.seats || null,
|
||||
metadata: activeSub?.metadata || null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching subscription:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch subscription data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { checkUsageStatus } from '@/lib/usage-monitor'
|
||||
|
||||
const logger = createLogger('UserUsageAPI')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get the authenticated user
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized usage data access attempt')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get usage data using our monitor utility
|
||||
const usageData = await checkUsageStatus(session.user.id)
|
||||
|
||||
// Set appropriate caching headers
|
||||
const response = NextResponse.json(usageData)
|
||||
|
||||
// Cache for 5 minutes, private (user-specific data), must revalidate
|
||||
response.headers.set('Cache-Control', 'private, max-age=300, must-revalidate')
|
||||
// Add date header for age calculation
|
||||
response.headers.set('Date', new Date().toUTCString())
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error checking usage data:', error)
|
||||
return NextResponse.json({ error: 'Failed to check usage data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { apiKey } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('ApiKeyAPI')
|
||||
|
||||
// DELETE /api/user/api-keys/[id] - Delete an API key
|
||||
// DELETE /api/users/me/api-keys/[id] - Delete an API key
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -9,7 +9,7 @@ import { apiKey } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('ApiKeysAPI')
|
||||
|
||||
// GET /api/user/api-keys - Get all API keys for the current user
|
||||
// GET /api/users/me/api-keys - Get all API keys for the current user
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/user/api-keys - Create a new API key
|
||||
// POST /api/users/me/api-keys - Create a new API key
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
@@ -1,76 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { isRateLimited } from '@/lib/waitlist/rate-limiter'
|
||||
import { addToWaitlist } from '@/lib/waitlist/service'
|
||||
|
||||
const waitlistSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const rateLimitCheck = await isRateLimited(request, 'waitlist')
|
||||
if (rateLimitCheck.limited) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: rateLimitCheck.message || 'Too many requests. Please try again later.',
|
||||
retryAfter: rateLimitCheck.remainingTime,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimitCheck.remainingTime || 60),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the request
|
||||
const validatedData = waitlistSchema.safeParse(body)
|
||||
|
||||
if (!validatedData.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid email address',
|
||||
errors: validatedData.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email } = validatedData.data
|
||||
|
||||
// Add the email to the waitlist and send confirmation email
|
||||
const result = await addToWaitlist(email)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: result.message,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully added to waitlist',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Waitlist API error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'An error occurred while processing your request',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
import { acquireLock, releaseLock } from '@/lib/redis'
|
||||
import { pollGmailWebhooks } from '@/lib/webhooks/gmail-polling-service'
|
||||
@@ -20,16 +20,9 @@ export async function GET(request: NextRequest) {
|
||||
let lockValue: string | undefined
|
||||
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const webhookSecret = env.CRON_SECRET
|
||||
|
||||
if (!webhookSecret) {
|
||||
return new NextResponse('Configuration error: Webhook secret is not set', { status: 500 })
|
||||
}
|
||||
|
||||
if (!authHeader || authHeader !== `Bearer ${webhookSecret}`) {
|
||||
logger.warn(`Unauthorized access attempt to Gmail polling endpoint (${requestId})`)
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
const authError = verifyCronAuth(request, 'Gmail webhook polling')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
lockValue = requestId // unique value to identify the holder
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { acquireLock, hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
|
||||
import { checkServerSideUsageLimits } from '@/lib/usage-monitor'
|
||||
import {
|
||||
fetchAndProcessAirtablePayloads,
|
||||
handleSlackChallenge,
|
||||
|
||||
@@ -2,10 +2,10 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/trace-spans'
|
||||
import { checkServerSideUsageLimits } from '@/lib/usage-monitor'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { marketplace, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('PublicWorkflowAPI')
|
||||
|
||||
@@ -19,25 +19,25 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
// First, check if the workflow exists and is published to the marketplace
|
||||
const marketplaceEntry = await db
|
||||
.select({
|
||||
id: schema.marketplace.id,
|
||||
workflowId: schema.marketplace.workflowId,
|
||||
state: schema.marketplace.state,
|
||||
name: schema.marketplace.name,
|
||||
description: schema.marketplace.description,
|
||||
authorId: schema.marketplace.authorId,
|
||||
authorName: schema.marketplace.authorName,
|
||||
id: marketplace.id,
|
||||
workflowId: marketplace.workflowId,
|
||||
state: marketplace.state,
|
||||
name: marketplace.name,
|
||||
description: marketplace.description,
|
||||
authorId: marketplace.authorId,
|
||||
authorName: marketplace.authorName,
|
||||
})
|
||||
.from(schema.marketplace)
|
||||
.where(eq(schema.marketplace.workflowId, id))
|
||||
.from(marketplace)
|
||||
.where(eq(marketplace.workflowId, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!marketplaceEntry) {
|
||||
// Check if workflow exists but is not in marketplace
|
||||
const workflowExists = await db
|
||||
.select({ id: schema.workflow.id })
|
||||
.from(schema.workflow)
|
||||
.where(eq(schema.workflow.id, id))
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, id))
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUsersWithPermissions } from '@/lib/permissions/utils'
|
||||
import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { permissions, type permissionTypeEnum, workspaceMember } from '@/db/schema'
|
||||
|
||||
@@ -79,21 +79,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify the current user has admin access to this workspace
|
||||
const userPermissions = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
// Verify the current user has admin access to this workspace (either direct or through organization)
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
|
||||
if (userPermissions.length === 0) {
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required to update permissions' },
|
||||
{ status: 403 }
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function UnsubscribePage() {
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/user/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
@@ -64,7 +64,7 @@ export default function UnsubscribePage() {
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/settings/unsubscribe', {
|
||||
const response = await fetch('/api/users/me/settings/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { TimerOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { useUserSubscription } from '@/hooks/use-user-subscription'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import FilterSection from './components/filter-section'
|
||||
import FolderFilter from './components/folder'
|
||||
import Level from './components/level'
|
||||
@@ -15,7 +15,9 @@ import Workflow from './components/workflow'
|
||||
* Filters component for logs page - includes timeline and other filter options
|
||||
*/
|
||||
export function Filters() {
|
||||
const { isPaid, isLoading } = useUserSubscription()
|
||||
const { getSubscriptionStatus, isLoading } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
const isPaid = subscription.isPaid
|
||||
|
||||
const handleUpgradeClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -101,7 +101,7 @@ export function DeployForm({
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const response = await fetch('/api/user/api-keys', {
|
||||
const response = await fetch('/api/users/me/api-keys', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -152,7 +152,7 @@ export function DeployModal({
|
||||
|
||||
try {
|
||||
setKeysLoaded(false)
|
||||
const response = await fetch('/api/user/api-keys')
|
||||
const response = await fetch('/api/users/me/api-keys')
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { usePanelStore } from '@/stores/panel/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -65,7 +66,11 @@ import { UserAvatarStack } from './components/user-avatar-stack/user-avatar-stac
|
||||
const logger = createLogger('ControlBar')
|
||||
|
||||
// Cache for usage data to prevent excessive API calls
|
||||
let usageDataCache = {
|
||||
let usageDataCache: {
|
||||
data: any | null
|
||||
timestamp: number
|
||||
expirationMs: number
|
||||
} = {
|
||||
data: null,
|
||||
timestamp: 0,
|
||||
// Cache expires after 1 minute
|
||||
@@ -338,16 +343,13 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
}, [session?.user?.id, completedRuns, isRegistryLoading])
|
||||
|
||||
/**
|
||||
* Check user usage data with caching to prevent excessive API calls
|
||||
* @param userId User ID to check usage for
|
||||
* @param forceRefresh Whether to force a fresh API call ignoring cache
|
||||
* @returns Usage data or null if error
|
||||
* Check user usage limits and cache results
|
||||
*/
|
||||
async function checkUserUsage(userId: string, forceRefresh = false): Promise<any | null> {
|
||||
const now = Date.now()
|
||||
const cacheAge = now - usageDataCache.timestamp
|
||||
|
||||
// Use cache if available and not expired
|
||||
// Return cached data if still valid and not forcing refresh
|
||||
if (!forceRefresh && usageDataCache.data && cacheAge < usageDataCache.expirationMs) {
|
||||
logger.info('Using cached usage data', {
|
||||
cacheAge: `${Math.round(cacheAge / 1000)}s`,
|
||||
@@ -356,12 +358,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/usage')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch usage data')
|
||||
// Use subscription store to get usage data
|
||||
const { getUsage, refresh } = useSubscriptionStore.getState()
|
||||
|
||||
// Force refresh if requested
|
||||
if (forceRefresh) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const usage = await response.json()
|
||||
const usage = getUsage()
|
||||
|
||||
// Update cache
|
||||
usageDataCache = {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/user/api-keys')
|
||||
const response = await fetch('/api/users/me/api-keys')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setApiKeys(data.keys || [])
|
||||
@@ -81,7 +81,7 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const response = await fetch('/api/user/api-keys', {
|
||||
const response = await fetch('/api/users/me/api-keys', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -113,7 +113,7 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
if (!userId || !deleteKey) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/user/api-keys/${deleteKey.id}`, {
|
||||
const response = await fetch(`/api/users/me/api-keys/${deleteKey.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
activeSection: string
|
||||
@@ -24,8 +25,7 @@ interface SettingsNavigationProps {
|
||||
| 'team'
|
||||
| 'privacy'
|
||||
) => void
|
||||
isTeam?: boolean
|
||||
isEnterprise?: boolean
|
||||
hasOrganization: boolean
|
||||
}
|
||||
|
||||
type NavigationItem = {
|
||||
@@ -93,16 +93,18 @@ const allNavigationItems: NavigationItem[] = [
|
||||
export function SettingsNavigation({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
isTeam = false,
|
||||
isEnterprise = false,
|
||||
hasOrganization,
|
||||
}: SettingsNavigationProps) {
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
const navigationItems = allNavigationItems.filter((item) => {
|
||||
if (item.hideInDev && isDev) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hide team tab if user doesn't have team or enterprisesubscription
|
||||
if (item.requiresTeam && !isTeam && !isEnterprise) {
|
||||
// Hide team tab if user doesn't have team or enterprise subscription
|
||||
if (item.requiresTeam && !subscription.isTeam && !subscription.isEnterprise) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useActiveOrganization, useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('BillingSummary')
|
||||
|
||||
interface BillingSummaryData {
|
||||
type: 'individual' | 'organization'
|
||||
plan: string
|
||||
currentUsage: number
|
||||
planMinimum: number
|
||||
projectedCharge: number
|
||||
usageLimit: number
|
||||
percentUsed: number
|
||||
isWarning: boolean
|
||||
isExceeded: boolean
|
||||
daysRemaining: number
|
||||
organizationData?: {
|
||||
seatCount: number
|
||||
averageUsagePerSeat: number
|
||||
totalMinimum: number
|
||||
}
|
||||
}
|
||||
|
||||
interface BillingSummaryProps {
|
||||
showDetails?: boolean
|
||||
className?: string
|
||||
onDataLoaded?: (data: BillingSummaryData) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function BillingSummary({
|
||||
showDetails = true,
|
||||
className = '',
|
||||
onDataLoaded,
|
||||
onError,
|
||||
}: BillingSummaryProps) {
|
||||
const { data: session } = useSession()
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
|
||||
const [billingSummary, setBillingSummary] = useState<BillingSummaryData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadBillingSummary() {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const url = new URL('/api/billing', window.location.origin)
|
||||
if (activeOrg?.id) {
|
||||
url.searchParams.set('context', 'organization')
|
||||
url.searchParams.set('id', activeOrg.id)
|
||||
} else {
|
||||
url.searchParams.set('context', 'user')
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch billing summary: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to load billing data')
|
||||
}
|
||||
|
||||
setBillingSummary(result.data)
|
||||
setError(null)
|
||||
onDataLoaded?.(result.data)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load billing data'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
logger.error('Failed to load billing summary', { error: err })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBillingSummary()
|
||||
}, [session?.user?.id, activeOrg?.id, onDataLoaded, onError])
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (!billingSummary) return null
|
||||
|
||||
if (billingSummary.isExceeded) {
|
||||
return (
|
||||
<Badge variant='destructive' className='gap-1'>
|
||||
<AlertCircle className='h-3 w-3' />
|
||||
Limit Exceeded
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (billingSummary.isWarning) {
|
||||
return (
|
||||
<Badge variant='outline' className='gap-1 border-yellow-500 text-yellow-700'>
|
||||
<AlertCircle className='h-3 w-3' />
|
||||
Approaching Limit
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
|
||||
|
||||
if (isLoading || error || !billingSummary) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Status Badge */}
|
||||
{getStatusBadge()}
|
||||
|
||||
{/* Billing Details */}
|
||||
{showDetails && (
|
||||
<div className='mt-3 space-y-1 text-muted-foreground text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span>Plan minimum:</span>
|
||||
<span>{formatCurrency(billingSummary.planMinimum)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span>Projected charge:</span>
|
||||
<span className='font-medium'>{formatCurrency(billingSummary.projectedCharge)}</span>
|
||||
</div>
|
||||
{billingSummary.organizationData && (
|
||||
<div className='flex justify-between'>
|
||||
<span>Team seats:</span>
|
||||
<span>{billingSummary.organizationData.seatCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { BillingSummaryData }
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useSubscription } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('CancelSubscription')
|
||||
|
||||
interface CancelSubscriptionProps {
|
||||
subscription: {
|
||||
plan: string
|
||||
status: string | null
|
||||
isPaid: boolean
|
||||
}
|
||||
subscriptionData?: {
|
||||
periodEnd?: Date | null
|
||||
}
|
||||
}
|
||||
|
||||
export function CancelSubscription({ subscription, subscriptionData }: CancelSubscriptionProps) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const betterAuthSubscription = useSubscription()
|
||||
|
||||
// Don't show for free plans
|
||||
if (!subscription.isPaid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Use Better Auth client-side cancel method
|
||||
// This redirects to Stripe Billing Portal where user can cancel
|
||||
const result = await betterAuthSubscription.cancel?.({
|
||||
returnUrl: window.location.href, // Return to current page after cancellation
|
||||
})
|
||||
|
||||
if (result && 'error' in result && result.error) {
|
||||
setError(result.error.message || 'Failed to cancel subscription')
|
||||
logger.error('Failed to cancel subscription via Better Auth', { error: result.error })
|
||||
} else {
|
||||
// Better Auth cancel redirects to Stripe Billing Portal
|
||||
// So if we reach here without error, the redirect should happen
|
||||
logger.info('Redirecting to Stripe Billing Portal for cancellation')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to cancel subscription'
|
||||
setError(errorMessage)
|
||||
logger.error('Failed to cancel subscription', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
const getPeriodEndDate = () => {
|
||||
return subscriptionData?.periodEnd || null
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return 'end of current billing period'
|
||||
|
||||
try {
|
||||
// Ensure we have a valid Date object
|
||||
const dateObj = date instanceof Date ? date : new Date(date)
|
||||
|
||||
// Check if the date is valid
|
||||
if (Number.isNaN(dateObj.getTime())) {
|
||||
return 'end of current billing period'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(dateObj)
|
||||
} catch (error) {
|
||||
console.warn('Invalid date in cancel subscription:', date, error)
|
||||
return 'end of current billing period'
|
||||
}
|
||||
}
|
||||
|
||||
const periodEndDate = getPeriodEndDate()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<span className='font-medium text-sm'>Cancel Subscription</span>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You'll keep access until {formatDate(periodEndDate)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel {subscription.plan} subscription?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '}
|
||||
{formatDate(periodEndDate)}, then downgrade to free plan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='rounded-lg bg-muted p-3 text-sm'>
|
||||
<ul className='space-y-1 text-muted-foreground'>
|
||||
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
||||
<li>• No more charges</li>
|
||||
<li>• Data preserved</li>
|
||||
<li>• Can reactivate anytime</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setIsDialogOpen(false)} disabled={isLoading}>
|
||||
Keep Subscription
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleCancel} disabled={isLoading}>
|
||||
{isLoading ? 'Redirecting...' : 'Continue'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, DollarSign, User } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface EditMemberLimitDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
member: {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentUsage: number
|
||||
usageLimit: number
|
||||
role: string
|
||||
} | null
|
||||
onSave: (userId: string, newLimit: number) => Promise<void>
|
||||
isLoading: boolean
|
||||
planType?: string
|
||||
}
|
||||
|
||||
export function EditMemberLimitDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
member,
|
||||
onSave,
|
||||
isLoading,
|
||||
planType = 'team',
|
||||
}: EditMemberLimitDialogProps) {
|
||||
const [limitValue, setLimitValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update limit value when member changes
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
setLimitValue(member.usageLimit.toString())
|
||||
setError(null)
|
||||
}
|
||||
}, [member])
|
||||
|
||||
// Get plan minimum based on plan type
|
||||
const getPlanMinimum = (plan: string): number => {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
return 20
|
||||
case 'team':
|
||||
return 40
|
||||
case 'enterprise':
|
||||
return 100
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
const planMinimum = getPlanMinimum(planType)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!member) return
|
||||
|
||||
const newLimit = Number.parseFloat(limitValue)
|
||||
|
||||
if (Number.isNaN(newLimit) || newLimit < 0) {
|
||||
setError('Please enter a valid positive number')
|
||||
return
|
||||
}
|
||||
|
||||
if (newLimit < planMinimum) {
|
||||
setError(
|
||||
`The limit cannot be below the ${planType} plan minimum of $${planMinimum.toFixed(2)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (newLimit < member.currentUsage) {
|
||||
setError(
|
||||
`The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage.toFixed(2)})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
await onSave(member.userId, newLimit)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update limit')
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
|
||||
|
||||
if (!member) return null
|
||||
|
||||
const newLimit = Number.parseFloat(limitValue) || 0
|
||||
const isIncrease = newLimit > member.usageLimit
|
||||
const isDecrease = newLimit < member.usageLimit
|
||||
const limitDifference = Math.abs(newLimit - member.usageLimit)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<User className='h-5 w-5' />
|
||||
Edit Usage Limit
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adjust the monthly usage limit for <strong>{member.userName}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
{/* Member Info */}
|
||||
<div className='flex items-center gap-3 rounded-lg bg-muted/50 p-3'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-medium text-primary'>
|
||||
{member.userName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium'>{member.userName}</div>
|
||||
<div className='text-muted-foreground text-sm'>{member.userEmail}</div>
|
||||
</div>
|
||||
<Badge variant={member.role === 'owner' ? 'default' : 'secondary'}>{member.role}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Current Usage Stats */}
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-sm'>Current Usage</div>
|
||||
<div className='font-semibold text-lg'>{formatCurrency(member.currentUsage)}</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-sm'>Current Limit</div>
|
||||
<div className='font-semibold text-lg'>{formatCurrency(member.usageLimit)}</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-sm'>Plan Minimum</div>
|
||||
<div className='font-semibold text-blue-600 text-lg'>
|
||||
{formatCurrency(planMinimum)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Limit Input */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-limit'>New Monthly Limit</Label>
|
||||
<div className='relative'>
|
||||
<DollarSign className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' />
|
||||
<Input
|
||||
id='new-limit'
|
||||
type='number'
|
||||
value={limitValue}
|
||||
onChange={(e) => setLimitValue(e.target.value)}
|
||||
className='pl-9'
|
||||
min={planMinimum}
|
||||
max={10000}
|
||||
step='1'
|
||||
placeholder={planMinimum.toString()}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Minimum limit for {planType} plan: ${planMinimum}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Change Indicator */}
|
||||
{limitValue && !Number.isNaN(newLimit) && limitDifference > 0 && (
|
||||
<div
|
||||
className={`rounded-lg border p-3 ${isIncrease ? 'border-green-200 bg-green-50' : 'border-orange-200 bg-orange-50'}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 font-medium text-sm ${isIncrease ? 'text-green-700' : 'text-orange-700'}`}
|
||||
>
|
||||
{isIncrease ? '↗' : '↘'}
|
||||
{isIncrease ? 'Increasing' : 'Decreasing'} limit by{' '}
|
||||
{formatCurrency(limitDifference)}
|
||||
</div>
|
||||
<div className={`mt-1 text-xs ${isIncrease ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
{isIncrease
|
||||
? 'This will give the member more usage allowance.'
|
||||
: "This will reduce the member's usage allowance."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning for below plan minimum */}
|
||||
{newLimit < planMinimum && newLimit > 0 && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
The limit cannot be below the {planType} plan minimum of{' '}
|
||||
{formatCurrency(planMinimum)}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Warning for decreasing below current usage */}
|
||||
{newLimit < member.currentUsage && newLimit >= planMinimum && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
The new limit is below the member's current usage. The limit must be at least{' '}
|
||||
{formatCurrency(member.currentUsage)}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !limitValue || Number.isNaN(newLimit) || newLimit < planMinimum}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Limit'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircle, Settings2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useActiveOrganization } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import type { MemberUsageData } from '@/stores/organization/types'
|
||||
import { EditMemberLimitDialog } from './edit-member-limit-dialog'
|
||||
|
||||
const logger = createLogger('TeamUsageOverview')
|
||||
|
||||
interface TeamUsageOverviewProps {
|
||||
hasAdminAccess: boolean
|
||||
}
|
||||
|
||||
export function TeamUsageOverview({ hasAdminAccess }: TeamUsageOverviewProps) {
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState<MemberUsageData | null>(null)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
|
||||
const {
|
||||
organizationBillingData: billingData,
|
||||
loadOrganizationBillingData,
|
||||
updateMemberUsageLimit,
|
||||
isLoadingOrgBilling,
|
||||
error,
|
||||
} = useOrganizationStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (activeOrg?.id) {
|
||||
loadOrganizationBillingData(activeOrg.id)
|
||||
}
|
||||
}, [activeOrg?.id, loadOrganizationBillingData])
|
||||
|
||||
const handleEditLimit = (member: MemberUsageData) => {
|
||||
setSelectedMember(member)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveLimit = async (userId: string, newLimit: number): Promise<void> => {
|
||||
if (!activeOrg?.id) {
|
||||
throw new Error('No active organization found')
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUpdating(true)
|
||||
const result = await updateMemberUsageLimit(userId, activeOrg.id, newLimit)
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Failed to update usage limit', { error: result.error, userId, newLimit })
|
||||
throw new Error(result.error || 'Failed to update usage limit')
|
||||
}
|
||||
|
||||
logger.info('Successfully updated member usage limit', {
|
||||
userId,
|
||||
newLimit,
|
||||
organizationId: activeOrg.id,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to update usage limit'
|
||||
logger.error('Failed to update usage limit', {
|
||||
error,
|
||||
userId,
|
||||
newLimit,
|
||||
organizationId: activeOrg.id,
|
||||
})
|
||||
throw new Error(errorMessage)
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseEditDialog = () => {
|
||||
setEditDialogOpen(false)
|
||||
setSelectedMember(null)
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
if (isLoadingOrgBilling) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Table Skeleton */}
|
||||
<Card className='border-0 shadow-sm'>
|
||||
<CardContent className='p-0'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{/* Table Header Skeleton */}
|
||||
<div className='bg-muted/30 px-6 py-4'>
|
||||
<div className='grid grid-cols-12 gap-4'>
|
||||
<div className='col-span-4'>
|
||||
<Skeleton className='h-3 w-16' />
|
||||
</div>
|
||||
<div className='col-span-2 flex justify-center'>
|
||||
<Skeleton className='h-3 w-8' />
|
||||
</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-3 w-12' />
|
||||
</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-3 w-12' />
|
||||
</div>
|
||||
<div className='col-span-1 hidden text-center lg:block'>
|
||||
<Skeleton className='mx-auto h-3 w-12' />
|
||||
</div>
|
||||
<div className='col-span-1' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body Skeleton */}
|
||||
<div className='divide-y divide-border'>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className='px-6 py-4'>
|
||||
<div className='grid grid-cols-12 items-center gap-4'>
|
||||
{/* Member Info Skeleton */}
|
||||
<div className='col-span-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Skeleton className='h-8 w-8 rounded-full' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='mt-1 h-3 w-32' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-only usage info skeleton */}
|
||||
<div className='mt-3 grid grid-cols-2 gap-4 sm:hidden'>
|
||||
<div>
|
||||
<Skeleton className='h-3 w-10' />
|
||||
<Skeleton className='mt-1 h-4 w-16' />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='h-3 w-8' />
|
||||
<Skeleton className='mt-1 h-4 w-16' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Skeleton */}
|
||||
<div className='col-span-2 flex justify-center'>
|
||||
<Skeleton className='h-4 w-12' />
|
||||
</div>
|
||||
|
||||
{/* Usage - Desktop Skeleton */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
</div>
|
||||
|
||||
{/* Limit - Desktop Skeleton */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
</div>
|
||||
|
||||
{/* Last Active - Desktop Skeleton */}
|
||||
<div className='col-span-1 hidden text-center lg:block'>
|
||||
<Skeleton className='mx-auto h-3 w-16' />
|
||||
</div>
|
||||
|
||||
{/* Actions Skeleton */}
|
||||
<div className='col-span-1 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!billingData) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>No Data</AlertTitle>
|
||||
<AlertDescription>No billing data available for this organization.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const membersOverLimit = billingData.members?.filter((m) => m.isOverLimit).length || 0
|
||||
const membersNearLimit =
|
||||
billingData.members?.filter((m) => !m.isOverLimit && m.percentUsed >= 80).length || 0
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Alerts */}
|
||||
{membersOverLimit > 0 && (
|
||||
<div className='rounded-lg border border-orange-200 bg-orange-50 p-6'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='flex h-9 w-9 items-center justify-center rounded-full bg-orange-100'>
|
||||
<AlertCircle className='h-5 w-5 text-orange-600' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<h4 className='font-medium text-orange-800 text-sm'>Usage Limits Exceeded</h4>
|
||||
<p className='mt-2 text-orange-700 text-sm'>
|
||||
{membersOverLimit} team {membersOverLimit === 1 ? 'member has' : 'members have'}{' '}
|
||||
exceeded their usage limits. Consider increasing their limits below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Usage Table */}
|
||||
<Card className='border-0 shadow-sm'>
|
||||
<CardContent className='p-0'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{/* Table Header */}
|
||||
<div className='bg-muted/30 px-6 py-4'>
|
||||
<div className='grid grid-cols-12 gap-4 font-medium text-muted-foreground text-xs'>
|
||||
<div className='col-span-4'>Member</div>
|
||||
<div className='col-span-2 text-center'>Role</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>Usage</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>Limit</div>
|
||||
<div className='col-span-1 hidden text-center lg:block'>Active</div>
|
||||
<div className='col-span-1 text-center' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className='divide-y divide-border'>
|
||||
{billingData.members && billingData.members.length > 0 ? (
|
||||
billingData.members.map((member) => (
|
||||
<div
|
||||
key={member.userId}
|
||||
className='group px-6 py-4 transition-colors hover:bg-muted/30'
|
||||
>
|
||||
<div className='grid grid-cols-12 items-center gap-4'>
|
||||
{/* Member Info */}
|
||||
<div className='col-span-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 font-semibold text-primary text-xs'>
|
||||
{member.userName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-sm'>{member.userName}</div>
|
||||
<div className='mt-0.5 truncate text-muted-foreground text-xs'>
|
||||
{member.userEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-only usage info */}
|
||||
<div className='mt-3 grid grid-cols-2 gap-4 sm:hidden'>
|
||||
<div>
|
||||
<div className='text-muted-foreground text-xs'>Usage</div>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.currentUsage)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-muted-foreground text-xs'>Limit</div>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.usageLimit)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className='col-span-2 flex justify-center'>
|
||||
<Badge variant='secondary' className='text-xs'>
|
||||
{member.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Usage - Desktop */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.currentUsage)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limit - Desktop */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.usageLimit)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Active - Desktop */}
|
||||
<div className='col-span-1 hidden text-center lg:block'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{formatDate(member.lastActive)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='col-span-1 text-center'>
|
||||
{hasAdminAccess && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
onClick={() => handleEditLimit(member)}
|
||||
disabled={isUpdating}
|
||||
className='h-8 w-8 p-0 opacity-0 transition-opacity group-hover:opacity-100 sm:opacity-100'
|
||||
title='Edit usage limit'
|
||||
>
|
||||
<Settings2 className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='px-6 py-8 text-center'>
|
||||
<div className='text-muted-foreground text-sm'>No team members found.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Member Limit Dialog */}
|
||||
<EditMemberLimitDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={handleCloseEditDialog}
|
||||
member={selectedMember}
|
||||
onSave={handleSaveLimit}
|
||||
isLoading={isUpdating}
|
||||
planType='team'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
const logger = createLogger('UsageLimitEditor')
|
||||
|
||||
interface UsageLimitEditorProps {
|
||||
currentLimit: number
|
||||
canEdit: boolean
|
||||
minimumLimit: number
|
||||
onLimitUpdated?: (newLimit: number) => void
|
||||
}
|
||||
|
||||
export function UsageLimitEditor({
|
||||
currentLimit,
|
||||
canEdit,
|
||||
minimumLimit,
|
||||
onLimitUpdated,
|
||||
}: UsageLimitEditorProps) {
|
||||
const [inputValue, setInputValue] = useState(currentLimit.toString())
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const { updateUsageLimit } = useSubscriptionStore()
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(currentLimit.toString())
|
||||
}, [currentLimit])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const newLimit = Number.parseInt(inputValue, 10)
|
||||
|
||||
if (Number.isNaN(newLimit) || newLimit < minimumLimit) {
|
||||
setInputValue(currentLimit.toString())
|
||||
return
|
||||
}
|
||||
|
||||
if (newLimit === currentLimit) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const result = await updateUsageLimit(newLimit)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update limit')
|
||||
}
|
||||
|
||||
setInputValue(newLimit.toString())
|
||||
onLimitUpdated?.(newLimit)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update usage limit', { error })
|
||||
setInputValue(currentLimit.toString())
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<span className='mr-1 text-sm'>$</span>
|
||||
{canEdit ? (
|
||||
<Input
|
||||
type='number'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSubmit}
|
||||
className='h-8 w-20 font-medium text-sm'
|
||||
min={minimumLimit}
|
||||
step='1'
|
||||
disabled={isSaving}
|
||||
/>
|
||||
) : (
|
||||
<span className='font-medium text-sm'>{currentLimit}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { CheckCircle, ChevronDown, PlusCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PermissionType = 'read' | 'write' | 'admin'
|
||||
|
||||
interface PermissionSelectorProps {
|
||||
value: PermissionType
|
||||
onChange: (value: PermissionType) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PermissionSelector = React.memo<PermissionSelectorProps>(
|
||||
({ value, onChange, disabled = false, className = '' }) => {
|
||||
const permissionOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'read' as PermissionType, label: 'Read', description: 'View only' },
|
||||
{ value: 'write' as PermissionType, label: 'Write', description: 'Edit content' },
|
||||
{ value: 'admin' as PermissionType, label: 'Admin', description: 'Full access' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex overflow-hidden rounded-md border border-input bg-background shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{permissionOptions.map((option, index) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => !disabled && onChange(option.value)}
|
||||
disabled={disabled}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'relative px-3 py-1.5 font-medium text-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
value === option.value
|
||||
? 'z-10 bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:z-20 hover:bg-muted/50 hover:text-foreground',
|
||||
index > 0 && 'border-input border-l'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PermissionSelector.displayName = 'PermissionSelector'
|
||||
|
||||
interface MemberInvitationCardProps {
|
||||
inviteEmail: string
|
||||
setInviteEmail: (email: string) => void
|
||||
isInviting: boolean
|
||||
showWorkspaceInvite: boolean
|
||||
setShowWorkspaceInvite: (show: boolean) => void
|
||||
selectedWorkspaces: Array<{ workspaceId: string; permission: string }>
|
||||
userWorkspaces: any[]
|
||||
onInviteMember: () => Promise<void>
|
||||
onLoadUserWorkspaces: () => Promise<void>
|
||||
onWorkspaceToggle: (workspaceId: string, permission: string) => void
|
||||
inviteSuccess: boolean
|
||||
}
|
||||
|
||||
function ButtonSkeleton() {
|
||||
return (
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-muted border-t-primary' />
|
||||
)
|
||||
}
|
||||
|
||||
export function MemberInvitationCard({
|
||||
inviteEmail,
|
||||
setInviteEmail,
|
||||
isInviting,
|
||||
showWorkspaceInvite,
|
||||
setShowWorkspaceInvite,
|
||||
selectedWorkspaces,
|
||||
userWorkspaces,
|
||||
onInviteMember,
|
||||
onLoadUserWorkspaces,
|
||||
onWorkspaceToggle,
|
||||
inviteSuccess,
|
||||
}: MemberInvitationCardProps) {
|
||||
const selectedCount = selectedWorkspaces.length
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-4'>
|
||||
<CardTitle className='text-base'>Invite Team Members</CardTitle>
|
||||
<CardDescription>
|
||||
Add new members to your team and optionally give them access to specific workspaces
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
disabled={isInviting}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setShowWorkspaceInvite(!showWorkspaceInvite)
|
||||
if (!showWorkspaceInvite) {
|
||||
onLoadUserWorkspaces()
|
||||
}
|
||||
}}
|
||||
disabled={isInviting}
|
||||
className='shrink-0 gap-1'
|
||||
>
|
||||
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
|
||||
{selectedCount > 0 && (
|
||||
<Badge variant='secondary' className='ml-1 h-5 px-1.5 text-xs'>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 transition-transform', showWorkspaceInvite && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={onInviteMember}
|
||||
disabled={!inviteEmail || isInviting}
|
||||
className='shrink-0 gap-2'
|
||||
>
|
||||
{isInviting ? <ButtonSkeleton /> : <PlusCircle className='h-4 w-4' />}
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showWorkspaceInvite && (
|
||||
<div className='space-y-3 pt-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h5 className='font-medium text-sm'>Workspace Access</h5>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
Optional
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||
Grant access to specific workspaces. You can modify permissions later.
|
||||
</p>
|
||||
|
||||
{userWorkspaces.length === 0 ? (
|
||||
<div className='rounded-md border border-dashed py-8 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>No workspaces available</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You need admin access to workspaces to invite members
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-48 space-y-2 overflow-y-auto rounded-md border bg-muted/20 p-3'>
|
||||
{userWorkspaces.map((workspace) => {
|
||||
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
|
||||
const selectedWorkspace = selectedWorkspaces.find(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-md border bg-background p-3 transition-all',
|
||||
isSelected
|
||||
? 'border-primary/20 bg-primary/5'
|
||||
: 'hover:border-border hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Checkbox
|
||||
id={`workspace-${workspace.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onWorkspaceToggle(workspace.id, 'read')
|
||||
} else {
|
||||
onWorkspaceToggle(workspace.id, '')
|
||||
}
|
||||
}}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`workspace-${workspace.id}`}
|
||||
className='cursor-pointer font-medium text-sm leading-none'
|
||||
>
|
||||
{workspace.name}
|
||||
</Label>
|
||||
{workspace.isOwner && (
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<PermissionSelector
|
||||
value={
|
||||
(['read', 'write', 'admin'].includes(
|
||||
selectedWorkspace?.permission ?? ''
|
||||
)
|
||||
? selectedWorkspace?.permission
|
||||
: 'read') as PermissionType
|
||||
}
|
||||
onChange={(permission) => onWorkspaceToggle(workspace.id, permission)}
|
||||
disabled={isInviting}
|
||||
className='h-8'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inviteSuccess && (
|
||||
<Alert className='border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
|
||||
<CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
<AlertDescription>
|
||||
Invitation sent successfully
|
||||
{selectedCount > 0 &&
|
||||
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { OrganizationCreationDialog } from './organization-creation-dialog'
|
||||
|
||||
interface NoOrganizationViewProps {
|
||||
hasTeamPlan: boolean
|
||||
hasEnterprisePlan: boolean
|
||||
orgName: string
|
||||
setOrgName: (name: string) => void
|
||||
orgSlug: string
|
||||
setOrgSlug: (slug: string) => void
|
||||
onOrgNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onCreateOrganization: () => Promise<void>
|
||||
isCreatingOrg: boolean
|
||||
error: string | null
|
||||
createOrgDialogOpen: boolean
|
||||
setCreateOrgDialogOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function NoOrganizationView({
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
orgName,
|
||||
setOrgName,
|
||||
orgSlug,
|
||||
setOrgSlug,
|
||||
onOrgNameChange,
|
||||
onCreateOrganization,
|
||||
isCreatingOrg,
|
||||
error,
|
||||
createOrgDialogOpen,
|
||||
setCreateOrgDialogOpen,
|
||||
}: NoOrganizationViewProps) {
|
||||
if (hasTeamPlan || hasEnterprisePlan) {
|
||||
return (
|
||||
<div className='space-y-6 p-6'>
|
||||
<div className='space-y-6'>
|
||||
<h3 className='font-medium text-lg'>Create Your Team Workspace</h3>
|
||||
|
||||
<div className='space-y-6 rounded-lg border p-6'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
|
||||
workspace to start collaborating with your team.
|
||||
</p>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgName' className='font-medium text-sm'>
|
||||
Team Name
|
||||
</label>
|
||||
<Input
|
||||
id='orgName'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
placeholder='My Team'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgSlug' className='font-medium text-sm'>
|
||||
Team URL
|
||||
</label>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
|
||||
simstudio.ai/team/
|
||||
</div>
|
||||
<Input
|
||||
id='orgSlug'
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
className='rounded-l-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button
|
||||
onClick={onCreateOrganization}
|
||||
disabled={!orgName || !orgSlug || isCreatingOrg}
|
||||
>
|
||||
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
|
||||
Create Team Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrganizationCreationDialog
|
||||
open={createOrgDialogOpen}
|
||||
onOpenChange={setCreateOrgDialogOpen}
|
||||
orgName={orgName}
|
||||
onOrgNameChange={onOrgNameChange}
|
||||
orgSlug={orgSlug}
|
||||
onOrgSlugChange={setOrgSlug}
|
||||
onCreateOrganization={onCreateOrganization}
|
||||
isCreating={isCreatingOrg}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6 p-6'>
|
||||
<div className='space-y-6'>
|
||||
<h3 className='font-medium text-lg'>No Team Workspace</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
You don't have a team workspace yet. To collaborate with others, first upgrade to a team
|
||||
or enterprise plan.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Open the subscription tab
|
||||
const event = new CustomEvent('open-settings', {
|
||||
detail: { tab: 'subscription' },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}}
|
||||
>
|
||||
Upgrade to Team Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface OrganizationCreationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
orgName: string
|
||||
onOrgNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
orgSlug: string
|
||||
onOrgSlugChange: (slug: string) => void
|
||||
onCreateOrganization: () => Promise<void>
|
||||
isCreating: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function OrganizationCreationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
orgName,
|
||||
onOrgNameChange,
|
||||
orgSlug,
|
||||
onOrgSlugChange,
|
||||
onCreateOrganization,
|
||||
isCreating,
|
||||
error,
|
||||
}: OrganizationCreationDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Team Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a workspace for your team to collaborate on projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgName' className='font-medium text-sm'>
|
||||
Team Name
|
||||
</label>
|
||||
<Input id='orgName' value={orgName} onChange={onOrgNameChange} placeholder='My Team' />
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgSlug' className='font-medium text-sm'>
|
||||
Team URL
|
||||
</label>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
|
||||
simstudio.ai/team/
|
||||
</div>
|
||||
<Input
|
||||
value={orgSlug}
|
||||
onChange={(e) => onOrgSlugChange(e.target.value)}
|
||||
className='rounded-l-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onCreateOrganization} disabled={!orgName || !orgSlug || isCreating}>
|
||||
{isCreating && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
|
||||
Create Team Workspace
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Organization, OrganizationFormData } from '@/stores/organization'
|
||||
|
||||
interface OrganizationSettingsTabProps {
|
||||
organization: Organization
|
||||
isAdminOrOwner: boolean
|
||||
userRole: string
|
||||
orgFormData: OrganizationFormData
|
||||
onOrgInputChange: (field: string, value: string) => void
|
||||
onSaveOrgSettings: () => Promise<void>
|
||||
isSavingOrgSettings: boolean
|
||||
orgSettingsError: string | null
|
||||
orgSettingsSuccess: string | null
|
||||
}
|
||||
|
||||
export function OrganizationSettingsTab({
|
||||
organization,
|
||||
isAdminOrOwner,
|
||||
userRole,
|
||||
orgFormData,
|
||||
onOrgInputChange,
|
||||
onSaveOrgSettings,
|
||||
isSavingOrgSettings,
|
||||
orgSettingsError,
|
||||
orgSettingsSuccess,
|
||||
}: OrganizationSettingsTabProps) {
|
||||
return (
|
||||
<div className='mt-4 space-y-6'>
|
||||
{orgSettingsError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{orgSettingsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{orgSettingsSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{orgSettingsSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isAdminOrOwner && (
|
||||
<Alert>
|
||||
<AlertTitle>Read Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
You need owner or admin permissions to modify team settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>Basic Information</CardTitle>
|
||||
<CardDescription>Update your team's basic information and branding</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='team-name'>Team Name</Label>
|
||||
<Input
|
||||
id='team-name'
|
||||
value={orgFormData.name}
|
||||
onChange={(e) => onOrgInputChange('name', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) {
|
||||
onSaveOrgSettings()
|
||||
}
|
||||
}}
|
||||
placeholder='Enter team name'
|
||||
disabled={!isAdminOrOwner || isSavingOrgSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='team-slug'>Team Slug</Label>
|
||||
<Input
|
||||
id='team-slug'
|
||||
value={orgFormData.slug}
|
||||
onChange={(e) => onOrgInputChange('slug', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) {
|
||||
onSaveOrgSettings()
|
||||
}
|
||||
}}
|
||||
placeholder='team-slug'
|
||||
disabled={!isAdminOrOwner || isSavingOrgSettings}
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Used in URLs and API references. Can only contain lowercase letters, numbers, hyphens,
|
||||
and underscores.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='team-logo'>Logo URL (Optional)</Label>
|
||||
<Input
|
||||
id='team-logo'
|
||||
value={orgFormData.logo}
|
||||
onChange={(e) => onOrgInputChange('logo', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) {
|
||||
onSaveOrgSettings()
|
||||
}
|
||||
}}
|
||||
placeholder='https://example.com/logo.png'
|
||||
disabled={!isAdminOrOwner || isSavingOrgSettings}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>Team Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Team ID:</span>
|
||||
<span className='font-mono'>{organization.id}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Created:</span>
|
||||
<span>{new Date(organization.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Your Role:</span>
|
||||
<span className='font-medium capitalize'>{userRole}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Invitation, Organization } from '@/stores/organization'
|
||||
|
||||
interface PendingInvitationsListProps {
|
||||
organization: Organization
|
||||
onCancelInvitation: (invitationId: string) => void
|
||||
}
|
||||
|
||||
export function PendingInvitationsList({
|
||||
organization,
|
||||
onCancelInvitation,
|
||||
}: PendingInvitationsListProps) {
|
||||
const pendingInvitations = organization.invitations?.filter(
|
||||
(invitation) => invitation.status === 'pending'
|
||||
)
|
||||
|
||||
if (!pendingInvitations || pendingInvitations.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-md border'>
|
||||
<h4 className='border-b p-4 font-medium text-sm'>Pending Invitations</h4>
|
||||
<div className='divide-y'>
|
||||
{pendingInvitations.map((invitation: Invitation) => (
|
||||
<div key={invitation.id} className='flex items-center justify-between p-4'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-sm'>
|
||||
{invitation.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium'>{invitation.email}</div>
|
||||
<div className='text-muted-foreground text-sm'>Invitation pending</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant='outline' size='sm' onClick={() => onCancelInvitation(invitation.id)}>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface RemoveMemberDialogProps {
|
||||
open: boolean
|
||||
memberName: string
|
||||
shouldReduceSeats: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
|
||||
onConfirmRemove: (shouldReduceSeats: boolean) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function RemoveMemberDialog({
|
||||
open,
|
||||
memberName,
|
||||
shouldReduceSeats,
|
||||
onOpenChange,
|
||||
onShouldReduceSeatsChange,
|
||||
onConfirmRemove,
|
||||
onCancel,
|
||||
}: RemoveMemberDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Team Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove {memberName} from the team?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='py-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='reduce-seats'
|
||||
className='rounded'
|
||||
checked={shouldReduceSeats}
|
||||
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor='reduce-seats' className='text-sm'>
|
||||
Also reduce seat count in my subscription
|
||||
</label>
|
||||
</div>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
If selected, your team seat count will be reduced by 1, lowering your monthly billing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { UserX } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Member, Organization } from '@/stores/organization'
|
||||
|
||||
interface TeamMembersListProps {
|
||||
organization: Organization
|
||||
currentUserEmail: string
|
||||
isAdminOrOwner: boolean
|
||||
onRemoveMember: (member: Member) => void
|
||||
}
|
||||
|
||||
export function TeamMembersList({
|
||||
organization,
|
||||
currentUserEmail,
|
||||
isAdminOrOwner,
|
||||
onRemoveMember,
|
||||
}: TeamMembersListProps) {
|
||||
if (!organization.members || organization.members.length === 0) {
|
||||
return (
|
||||
<div className='rounded-md border'>
|
||||
<h4 className='border-b p-4 font-medium text-sm'>Team Members</h4>
|
||||
<div className='p-4 text-muted-foreground text-sm'>
|
||||
No members in this organization yet.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-md border'>
|
||||
<h4 className='border-b p-4 font-medium text-sm'>Team Members</h4>
|
||||
<div className='divide-y'>
|
||||
{organization.members.map((member: Member) => (
|
||||
<div key={member.id} className='flex items-center justify-between p-4'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 font-medium text-primary text-sm'>
|
||||
{(member.user?.name || member.user?.email || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium'>{member.user?.name || 'Unknown'}</div>
|
||||
<div className='text-muted-foreground text-sm'>{member.user?.email}</div>
|
||||
</div>
|
||||
<div className='rounded-full bg-primary/10 px-3 py-1 font-medium text-primary text-xs'>
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only show remove button for non-owners and if current user is admin/owner */}
|
||||
{isAdminOrOwner &&
|
||||
member.role !== 'owner' &&
|
||||
member.user?.email !== currentUserEmail && (
|
||||
<Button variant='outline' size='sm' onClick={() => onRemoveMember(member)}>
|
||||
<UserX className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
type Subscription = {
|
||||
id: string
|
||||
plan: string
|
||||
status: string
|
||||
seats?: number
|
||||
referenceId: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
periodEnd?: number | Date
|
||||
trialEnd?: number | Date
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
interface TeamSeatsOverviewProps {
|
||||
subscriptionData: Subscription | null
|
||||
isLoadingSubscription: boolean
|
||||
usedSeats: number
|
||||
isLoading: boolean
|
||||
onConfirmTeamUpgrade: (seats: number) => Promise<void>
|
||||
onReduceSeats: () => Promise<void>
|
||||
onAddSeatDialog: () => void
|
||||
}
|
||||
|
||||
function TeamSeatsSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Skeleton className='h-4 w-4' />
|
||||
<Skeleton className='h-4 w-32' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TeamSeatsOverview({
|
||||
subscriptionData,
|
||||
isLoadingSubscription,
|
||||
usedSeats,
|
||||
isLoading,
|
||||
onConfirmTeamUpgrade,
|
||||
onReduceSeats,
|
||||
onAddSeatDialog,
|
||||
}: TeamSeatsOverviewProps) {
|
||||
if (isLoadingSubscription) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
|
||||
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamSeatsSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!subscriptionData) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
|
||||
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4 p-6 text-center'>
|
||||
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-amber-100'>
|
||||
<Building2 className='h-6 w-6 text-amber-600' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium'>No Team Subscription Found</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Your subscription may need to be transferred to this organization.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onConfirmTeamUpgrade(2) // Start with 2 seats as default
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Set Up Team Subscription
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
|
||||
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-3 gap-4 text-center'>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-2xl'>{subscriptionData.seats || 0}</p>
|
||||
<p className='text-muted-foreground text-xs'>Licensed Seats</p>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-2xl'>{usedSeats}</p>
|
||||
<p className='text-muted-foreground text-xs'>Used Seats</p>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-2xl'>{(subscriptionData.seats || 0) - usedSeats}</p>
|
||||
<p className='text-muted-foreground text-xs'>Available</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span>Seat Usage</span>
|
||||
<span>
|
||||
{usedSeats} of {subscriptionData.seats || 0} seats
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-3' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-t pt-2 text-sm'>
|
||||
<span>Seat Cost:</span>
|
||||
<span className='font-semibold'>
|
||||
${((subscriptionData.seats || 0) * 40).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-2 text-muted-foreground text-xs'>
|
||||
Individual usage limits may vary. See Subscription tab for team totals.
|
||||
</div>
|
||||
|
||||
{checkEnterprisePlan(subscriptionData) ? (
|
||||
<div className='rounded-lg bg-purple-50 p-4 text-center'>
|
||||
<p className='font-medium text-purple-700 text-sm'>Enterprise Plan</p>
|
||||
<p className='mt-1 text-purple-600 text-xs'>Contact support to modify seats</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onReduceSeats}
|
||||
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
|
||||
className='flex-1'
|
||||
>
|
||||
Remove Seat
|
||||
</Button>
|
||||
<Button size='sm' onClick={onAddSeatDialog} disabled={isLoading} className='flex-1'>
|
||||
Add Seat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { client, useSubscription } from '@/lib/auth-client'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { Account } from './components/account/account'
|
||||
import { ApiKeys } from './components/api-keys/api-keys'
|
||||
@@ -37,14 +38,9 @@ type SettingsSection =
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const [isPro, setIsPro] = useState(false)
|
||||
const [isTeam, setIsTeam] = useState(false)
|
||||
const [isEnterprise, setIsEnterprise] = useState(false)
|
||||
const [subscriptionData, setSubscriptionData] = useState<any>(null)
|
||||
const [usageData, setUsageData] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const subscription = useMemo(() => useSubscription(), [])
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,55 +53,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
try {
|
||||
await loadSettings()
|
||||
|
||||
const proStatusResponse = await fetch('/api/user/subscription')
|
||||
|
||||
if (proStatusResponse.ok) {
|
||||
const subData = await proStatusResponse.json()
|
||||
setIsPro(subData.isPro)
|
||||
setIsTeam(subData.isTeam)
|
||||
setIsEnterprise(subData.isEnterprise)
|
||||
}
|
||||
|
||||
const usageResponse = await fetch('/api/user/usage')
|
||||
if (usageResponse.ok) {
|
||||
const usageData = await usageResponse.json()
|
||||
setUsageData(usageData)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscription.list()
|
||||
|
||||
if (isEnterprise) {
|
||||
try {
|
||||
const enterpriseResponse = await fetch('/api/user/subscription/enterprise')
|
||||
if (enterpriseResponse.ok) {
|
||||
const enterpriseData = await enterpriseResponse.json()
|
||||
if (enterpriseData.subscription) {
|
||||
setSubscriptionData(enterpriseData.subscription)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching enterprise subscription', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
const activeSubscription = result.data.find(
|
||||
(sub) =>
|
||||
sub.status === 'active' &&
|
||||
(sub.plan === 'team' || sub.plan === 'pro' || sub.plan === 'enterprise')
|
||||
)
|
||||
|
||||
if (activeSubscription) {
|
||||
setSubscriptionData(activeSubscription)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching subscription information', error)
|
||||
}
|
||||
|
||||
hasLoadedInitialData.current = true
|
||||
} catch (error) {
|
||||
logger.error('Error loading settings data:', error)
|
||||
@@ -119,7 +66,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
} else {
|
||||
hasLoadedInitialData.current = false
|
||||
}
|
||||
}, [open, loadSettings, subscription, activeSection, isEnterprise])
|
||||
}, [open, loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||
@@ -136,8 +83,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
const isSubscriptionEnabled = !!client.subscription
|
||||
|
||||
const showTeamManagement = isTeam || isEnterprise
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='flex h-[70vh] flex-col gap-0 p-0 sm:max-w-[800px]' hideCloseButton>
|
||||
@@ -162,8 +107,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<SettingsNavigation
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
isTeam={isTeam}
|
||||
isEnterprise={isEnterprise}
|
||||
hasOrganization={!!activeOrganization?.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -186,22 +130,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
</div>
|
||||
{isSubscriptionEnabled && (
|
||||
<div className={cn('h-full', activeSection === 'subscription' ? 'block' : 'hidden')}>
|
||||
<Subscription
|
||||
onOpenChange={onOpenChange}
|
||||
cachedIsPro={isPro}
|
||||
cachedIsTeam={isTeam}
|
||||
cachedIsEnterprise={isEnterprise}
|
||||
cachedUsageData={usageData}
|
||||
cachedSubscriptionData={subscriptionData}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showTeamManagement && (
|
||||
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
|
||||
<TeamManagement />
|
||||
<Subscription onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
|
||||
<TeamManagement />
|
||||
</div>
|
||||
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
|
||||
<Privacy />
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('WorkspaceHeader')
|
||||
@@ -243,7 +244,19 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
useSidebarStore()
|
||||
|
||||
const { data: sessionData, isPending } = useSession()
|
||||
const [plan, setPlan] = useState('Free Plan')
|
||||
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
const getPlanName = (subscription: ReturnType<typeof getSubscriptionStatus>) => {
|
||||
if (subscription.isEnterprise) return 'Enterprise Plan'
|
||||
if (subscription.isTeam) return 'Team Plan'
|
||||
if (subscription.isPro) return 'Pro Plan'
|
||||
return 'Free Plan'
|
||||
}
|
||||
|
||||
const plan = getPlanName(subscription)
|
||||
|
||||
// Use client-side loading instead of isPending to avoid hydration mismatch
|
||||
const [isClientLoading, setIsClientLoading] = useState(true)
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
@@ -273,16 +286,6 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
setIsClientLoading(false)
|
||||
}, [])
|
||||
|
||||
const fetchSubscriptionStatus = useCallback(async (userId: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/user/subscription')
|
||||
const data = await response.json()
|
||||
setPlan(data.isPro ? 'Pro Plan' : 'Free Plan')
|
||||
} catch (err) {
|
||||
logger.error('Error fetching subscription status:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchWorkspaces = useCallback(async () => {
|
||||
setIsWorkspacesLoading(true)
|
||||
try {
|
||||
@@ -327,10 +330,9 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
useEffect(() => {
|
||||
// Fetch subscription status if user is logged in
|
||||
if (sessionData?.user?.id) {
|
||||
fetchSubscriptionStatus(sessionData.user.id)
|
||||
fetchWorkspaces()
|
||||
}
|
||||
}, [sessionData?.user?.id, fetchSubscriptionStatus, fetchWorkspaces])
|
||||
}, [sessionData?.user?.id, fetchWorkspaces])
|
||||
|
||||
const switchWorkspace = useCallback(
|
||||
async (workspace: Workspace) => {
|
||||
|
||||
278
apps/sim/components/emails/batch-invitation-email.tsx
Normal file
278
apps/sim/components/emails/batch-invitation-email.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
interface BatchInvitationEmailProps {
|
||||
inviterName: string
|
||||
organizationName: string
|
||||
organizationRole: 'admin' | 'member'
|
||||
workspaceInvitations: WorkspaceInvitation[]
|
||||
acceptUrl: string
|
||||
}
|
||||
|
||||
const getPermissionLabel = (permission: string) => {
|
||||
switch (permission) {
|
||||
case 'admin':
|
||||
return 'Admin (full access)'
|
||||
case 'write':
|
||||
return 'Editor (can edit workflows)'
|
||||
case 'read':
|
||||
return 'Viewer (read-only access)'
|
||||
default:
|
||||
return permission
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'Team Admin (can manage team and billing)'
|
||||
case 'member':
|
||||
return 'Team Member (billing access only)'
|
||||
default:
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
export const BatchInvitationEmail = ({
|
||||
inviterName = 'Someone',
|
||||
organizationName = 'the team',
|
||||
organizationRole = 'member',
|
||||
workspaceInvitations = [],
|
||||
acceptUrl,
|
||||
}: BatchInvitationEmailProps) => {
|
||||
const hasWorkspaces = workspaceInvitations.length > 0
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
You've been invited to join {organizationName}
|
||||
{hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}
|
||||
</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={logoContainer}>
|
||||
<Img
|
||||
src='https://simstudio.ai/logo.png'
|
||||
width='120'
|
||||
height='36'
|
||||
alt='SimStudio'
|
||||
style={logo}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Heading style={h1}>You're invited to join {organizationName}!</Heading>
|
||||
|
||||
<Text style={text}>
|
||||
<strong>{inviterName}</strong> has invited you to join{' '}
|
||||
<strong>{organizationName}</strong> on SimStudio.
|
||||
</Text>
|
||||
|
||||
{/* Organization Invitation Details */}
|
||||
<Section style={invitationSection}>
|
||||
<Heading style={h2}>Team Access</Heading>
|
||||
<div style={roleCard}>
|
||||
<Text style={roleTitle}>Team Role: {getRoleLabel(organizationRole)}</Text>
|
||||
<Text style={roleDescription}>
|
||||
{organizationRole === 'admin'
|
||||
? "You'll be able to manage team members, billing, and workspace access."
|
||||
: "You'll have access to shared team billing and can be invited to workspaces."}
|
||||
</Text>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Workspace Invitations */}
|
||||
{hasWorkspaces && (
|
||||
<Section style={invitationSection}>
|
||||
<Heading style={h2}>
|
||||
Workspace Access ({workspaceInvitations.length} workspace
|
||||
{workspaceInvitations.length !== 1 ? 's' : ''})
|
||||
</Heading>
|
||||
<Text style={text}>You're also being invited to the following workspaces:</Text>
|
||||
|
||||
{workspaceInvitations.map((ws, index) => (
|
||||
<div key={ws.workspaceId} style={workspaceCard}>
|
||||
<Text style={workspaceName}>{ws.workspaceName}</Text>
|
||||
<Text style={workspacePermission}>{getPermissionLabel(ws.permission)}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={acceptUrl}>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={text}>
|
||||
By accepting this invitation, you'll join {organizationName}
|
||||
{hasWorkspaces ? ` and gain access to ${workspaceInvitations.length} workspace(s)` : ''}
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Hr style={hr} />
|
||||
|
||||
<Text style={footer}>
|
||||
If you have any questions, you can reach out to {inviterName} directly or contact our
|
||||
support team.
|
||||
</Text>
|
||||
|
||||
<Text style={footer}>
|
||||
This invitation will expire in 7 days. If you didn't expect this invitation, you can
|
||||
safely ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchInvitationEmail
|
||||
|
||||
// Styles
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
}
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '20px 0 48px',
|
||||
marginBottom: '64px',
|
||||
}
|
||||
|
||||
const logoContainer = {
|
||||
margin: '32px 0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const logo = {
|
||||
margin: '0 auto',
|
||||
}
|
||||
|
||||
const h1 = {
|
||||
color: '#333',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '40px 0',
|
||||
padding: '0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const h2 = {
|
||||
color: '#333',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
margin: '24px 0 16px 0',
|
||||
padding: '0',
|
||||
}
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '26px',
|
||||
margin: '16px 0',
|
||||
padding: '0 40px',
|
||||
}
|
||||
|
||||
const invitationSection = {
|
||||
margin: '32px 0',
|
||||
padding: '0 40px',
|
||||
}
|
||||
|
||||
const roleCard = {
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
margin: '16px 0',
|
||||
}
|
||||
|
||||
const roleTitle = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0 0 8px 0',
|
||||
}
|
||||
|
||||
const roleDescription = {
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
margin: '0',
|
||||
}
|
||||
|
||||
const workspaceCard = {
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 16px',
|
||||
margin: '8px 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const workspaceName = {
|
||||
color: '#333',
|
||||
fontSize: '15px',
|
||||
fontWeight: '500',
|
||||
margin: '0',
|
||||
}
|
||||
|
||||
const workspacePermission = {
|
||||
color: '#6c757d',
|
||||
fontSize: '13px',
|
||||
margin: '0',
|
||||
}
|
||||
|
||||
const buttonContainer = {
|
||||
margin: '32px 0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#007bff',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '12px 24px',
|
||||
margin: '0 auto',
|
||||
}
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e9ecef',
|
||||
margin: '32px 0',
|
||||
}
|
||||
|
||||
const footer = {
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
margin: '8px 0',
|
||||
padding: '0 40px',
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { render } from '@react-email/components'
|
||||
import { generateUnsubscribeToken } from '@/lib/email/unsubscribe'
|
||||
import { BatchInvitationEmail } from './batch-invitation-email'
|
||||
import { InvitationEmail } from './invitation-email'
|
||||
import { OTPVerificationEmail } from './otp-verification-email'
|
||||
import { ResetPasswordEmail } from './reset-password-email'
|
||||
import { WaitlistApprovalEmail } from './waitlist-approval-email'
|
||||
import { WaitlistConfirmationEmail } from './waitlist-confirmation-email'
|
||||
|
||||
export async function renderOTPEmail(
|
||||
otp: string,
|
||||
@@ -41,17 +39,28 @@ export async function renderInvitationEmail(
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderWaitlistConfirmationEmail(email: string): Promise<string> {
|
||||
const unsubscribeToken = generateUnsubscribeToken(email, 'marketing')
|
||||
return await render(WaitlistConfirmationEmail({ email, unsubscribeToken }))
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export async function renderWaitlistApprovalEmail(
|
||||
email: string,
|
||||
signupUrl: string
|
||||
export async function renderBatchInvitationEmail(
|
||||
inviterName: string,
|
||||
organizationName: string,
|
||||
organizationRole: 'admin' | 'member',
|
||||
workspaceInvitations: WorkspaceInvitation[],
|
||||
acceptUrl: string
|
||||
): Promise<string> {
|
||||
const unsubscribeToken = generateUnsubscribeToken(email, 'updates')
|
||||
return await render(WaitlistApprovalEmail({ email, signupUrl, unsubscribeToken }))
|
||||
return await render(
|
||||
BatchInvitationEmail({
|
||||
inviterName,
|
||||
organizationName,
|
||||
organizationRole,
|
||||
workspaceInvitations,
|
||||
acceptUrl,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function getEmailSubject(
|
||||
@@ -60,9 +69,8 @@ export function getEmailSubject(
|
||||
| 'email-verification'
|
||||
| 'forget-password'
|
||||
| 'reset-password'
|
||||
| 'waitlist-confirmation'
|
||||
| 'waitlist-approval'
|
||||
| 'invitation'
|
||||
| 'batch-invitation'
|
||||
): string {
|
||||
switch (type) {
|
||||
case 'sign-in':
|
||||
@@ -73,12 +81,10 @@ export function getEmailSubject(
|
||||
return 'Reset your Sim Studio password'
|
||||
case 'reset-password':
|
||||
return 'Reset your Sim Studio password'
|
||||
case 'waitlist-confirmation':
|
||||
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"
|
||||
case 'batch-invitation':
|
||||
return "You've been invited to join a team and workspaces on Sim Studio"
|
||||
default:
|
||||
return 'Sim Studio'
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { env } from '@/lib/env'
|
||||
import { baseStyles } from './base-styles'
|
||||
import { EmailFooter } from './footer'
|
||||
|
||||
interface WaitlistApprovalEmailProps {
|
||||
email: string
|
||||
signupUrl: string
|
||||
unsubscribeToken?: string
|
||||
}
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
|
||||
export const WaitlistApprovalEmail = ({
|
||||
email,
|
||||
signupUrl,
|
||||
unsubscribeToken,
|
||||
}: WaitlistApprovalEmailProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>You've Been Approved to Join Sim Studio!</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/sim.png`}
|
||||
width='114'
|
||||
alt='Sim Studio'
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Great news!</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've been approved to join Sim Studio! We're excited to have you as part of our
|
||||
community of developers building, testing, and optimizing AI workflows.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your email ({email}) has been approved. Click the button below to create your account
|
||||
and start using Sim Studio today:
|
||||
</Text>
|
||||
<Link href={signupUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Create Your Account</Text>
|
||||
</Link>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
This approval link will expire in 7 days. If you have any questions or need
|
||||
assistance, feel free to reach out to our support team.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Studio Team
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} unsubscribe={{ unsubscribeToken, email }} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default WaitlistApprovalEmail
|
||||
@@ -1,89 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { env } from '@/lib/env'
|
||||
import { baseStyles } from './base-styles'
|
||||
import { EmailFooter } from './footer'
|
||||
|
||||
interface WaitlistConfirmationEmailProps {
|
||||
email: string
|
||||
unsubscribeToken?: string
|
||||
}
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
const typeformLink = 'https://form.typeform.com/to/jqCO12pF'
|
||||
|
||||
export const WaitlistConfirmationEmail = ({
|
||||
email,
|
||||
unsubscribeToken,
|
||||
}: WaitlistConfirmationEmailProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Welcome to the Sim Studio Waitlist!</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/sim.png`}
|
||||
width='114'
|
||||
alt='Sim Studio'
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Welcome to the Sim Studio Waitlist!</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Thank you for your interest in Sim Studio. We've added your email ({email}) to our
|
||||
waitlist and will notify you as soon as you're granted access.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Want to get access sooner?</strong> Tell us about your use case! Schedule a
|
||||
15-minute call with our team to discuss how you plan to use Sim Studio.
|
||||
</Text>
|
||||
<Link href={typeformLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Schedule a Call</Text>
|
||||
</Link>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We're excited to help you build, test, and optimize your agentic workflows.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Studio Team
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} unsubscribe={{ unsubscribeToken, email }} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default WaitlistConfirmationEmail
|
||||
228
apps/sim/hooks/use-subscription-state.ts
Normal file
228
apps/sim/hooks/use-subscription-state.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { SubscriptionFeatures } from '@/lib/billing/types'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('useSubscriptionState')
|
||||
|
||||
interface UsageData {
|
||||
current: number
|
||||
limit: number
|
||||
percentUsed: number
|
||||
isWarning: boolean
|
||||
isExceeded: boolean
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
lastPeriodCost: number
|
||||
}
|
||||
|
||||
interface SubscriptionState {
|
||||
isPaid: boolean
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
plan: string
|
||||
status: string | null
|
||||
seats: number | null
|
||||
metadata: any | null
|
||||
features: SubscriptionFeatures
|
||||
usage: UsageData
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated hook for subscription state management
|
||||
* Combines subscription status, features, and usage data
|
||||
*/
|
||||
export function useSubscriptionState() {
|
||||
const [data, setData] = useState<SubscriptionState | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const fetchSubscriptionState = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('/api/billing?context=user')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const subscriptionData = result.data
|
||||
setData(subscriptionData)
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to fetch subscription state')
|
||||
logger.error('Failed to fetch subscription state', { error })
|
||||
setError(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscriptionState()
|
||||
}, [fetchSubscriptionState])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
return fetchSubscriptionState()
|
||||
}, [fetchSubscriptionState])
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
isPaid: data?.isPaid ?? false,
|
||||
isPro: data?.isPro ?? false,
|
||||
isTeam: data?.isTeam ?? false,
|
||||
isEnterprise: data?.isEnterprise ?? false,
|
||||
isFree: !(data?.isPaid ?? false),
|
||||
plan: data?.plan ?? 'free',
|
||||
status: data?.status,
|
||||
seats: data?.seats,
|
||||
metadata: data?.metadata,
|
||||
},
|
||||
|
||||
features: {
|
||||
sharingEnabled: data?.features?.sharingEnabled ?? false,
|
||||
multiplayerEnabled: data?.features?.multiplayerEnabled ?? false,
|
||||
workspaceCollaborationEnabled: data?.features?.workspaceCollaborationEnabled ?? false,
|
||||
},
|
||||
|
||||
usage: {
|
||||
current: data?.usage?.current ?? 0,
|
||||
limit: data?.usage?.limit ?? 5,
|
||||
percentUsed: data?.usage?.percentUsed ?? 0,
|
||||
isWarning: data?.usage?.isWarning ?? false,
|
||||
isExceeded: data?.usage?.isExceeded ?? false,
|
||||
billingPeriodStart: data?.usage?.billingPeriodStart
|
||||
? new Date(data.usage.billingPeriodStart)
|
||||
: null,
|
||||
billingPeriodEnd: data?.usage?.billingPeriodEnd
|
||||
? new Date(data.usage.billingPeriodEnd)
|
||||
: null,
|
||||
lastPeriodCost: data?.usage?.lastPeriodCost ?? 0,
|
||||
},
|
||||
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
|
||||
hasFeature: (feature: keyof SubscriptionFeatures) => {
|
||||
return data?.features?.[feature] ?? false
|
||||
},
|
||||
|
||||
isAtLeastPro: () => {
|
||||
return data?.isPro || data?.isTeam || data?.isEnterprise || false
|
||||
},
|
||||
|
||||
isAtLeastTeam: () => {
|
||||
return data?.isTeam || data?.isEnterprise || false
|
||||
},
|
||||
|
||||
canUpgrade: () => {
|
||||
return data?.plan === 'free' || data?.plan === 'pro'
|
||||
},
|
||||
|
||||
getBillingStatus: () => {
|
||||
const usage = data?.usage
|
||||
if (!usage) return 'unknown'
|
||||
|
||||
if (usage.isExceeded) return 'exceeded'
|
||||
if (usage.isWarning) return 'warning'
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
getRemainingBudget: () => {
|
||||
const usage = data?.usage
|
||||
if (!usage) return 0
|
||||
return Math.max(0, usage.limit - usage.current)
|
||||
},
|
||||
|
||||
getDaysRemainingInPeriod: () => {
|
||||
const usage = data?.usage
|
||||
if (!usage?.billingPeriodEnd) return null
|
||||
|
||||
const now = new Date()
|
||||
const endDate = new Date(usage.billingPeriodEnd)
|
||||
const diffTime = endDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return Math.max(0, diffDays)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for usage limit information with editing capabilities
|
||||
*/
|
||||
export function useUsageLimit() {
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const fetchUsageLimit = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('/api/usage-limits?context=user')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const limitData = await response.json()
|
||||
setData(limitData)
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Failed to fetch usage limit')
|
||||
logger.error('Failed to fetch usage limit', { error })
|
||||
setError(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsageLimit()
|
||||
}, [fetchUsageLimit])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
return fetchUsageLimit()
|
||||
}, [fetchUsageLimit])
|
||||
|
||||
const updateLimit = async (newLimit: number) => {
|
||||
try {
|
||||
const response = await fetch('/api/usage-limits?context=user', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ limit: newLimit }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update usage limit')
|
||||
}
|
||||
|
||||
await refetch()
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to update usage limit', { error, newLimit })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentLimit: data?.currentLimit ?? 5,
|
||||
canEdit: data?.canEdit ?? false,
|
||||
minimumLimit: data?.minimumLimit ?? 5,
|
||||
plan: data?.plan ?? 'free',
|
||||
setBy: data?.setBy,
|
||||
updatedAt: data?.updatedAt ? new Date(data.updatedAt) : null,
|
||||
updateLimit,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface UserSubscription {
|
||||
isPaid: boolean
|
||||
isLoading: boolean
|
||||
plan: string | null
|
||||
error: Error | null
|
||||
isEnterprise: boolean
|
||||
}
|
||||
|
||||
export function useUserSubscription(): UserSubscription {
|
||||
const [subscription, setSubscription] = useState<UserSubscription>({
|
||||
isPaid: false,
|
||||
isLoading: true,
|
||||
plan: null,
|
||||
error: null,
|
||||
isEnterprise: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const fetchSubscription = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/subscription')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscription data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (mounted) {
|
||||
setSubscription({
|
||||
isPaid: data.isPaid,
|
||||
isLoading: false,
|
||||
plan: data.plan,
|
||||
error: null,
|
||||
isEnterprise: !!data.isEnterprise,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setSubscription({
|
||||
isPaid: false,
|
||||
isLoading: false,
|
||||
plan: null,
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
isEnterprise: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSubscription()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return subscription
|
||||
}
|
||||
@@ -46,7 +46,6 @@ if (validStripeKey) {
|
||||
|
||||
// If there is no resend key, it might be a local dev environment
|
||||
// In that case, we don't want to send emails and just log them
|
||||
|
||||
const validResendAPIKEY =
|
||||
env.RESEND_API_KEY && env.RESEND_API_KEY.trim() !== '' && env.RESEND_API_KEY !== 'placeholder'
|
||||
|
||||
@@ -1027,6 +1026,18 @@ export const auth = betterAuth({
|
||||
customerId: customer.id,
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
// Initialize usage limits for new user
|
||||
try {
|
||||
const { initializeUserUsageLimit } = await import('./billing')
|
||||
await initializeUserUsageLimit(user.id)
|
||||
logger.info('Usage limits initialized for new user', { userId: user.id })
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize usage limits for new user', {
|
||||
userId: user.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
},
|
||||
subscription: {
|
||||
enabled: true,
|
||||
@@ -1125,6 +1136,134 @@ export const auth = betterAuth({
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
})
|
||||
|
||||
// Auto-create organization for team plan purchases
|
||||
if (subscription.plan === 'team') {
|
||||
try {
|
||||
// Get the user who purchased the subscription
|
||||
const user = await db
|
||||
.select()
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (user.length > 0) {
|
||||
const currentUser = user[0]
|
||||
|
||||
// Create organization for the team
|
||||
const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}`
|
||||
|
||||
// Create a separate Stripe customer for the organization
|
||||
let orgStripeCustomerId: string | null = null
|
||||
if (stripeClient) {
|
||||
try {
|
||||
const orgStripeCustomer = await stripeClient.customers.create({
|
||||
name: `${currentUser.name || 'User'}'s Team`,
|
||||
email: currentUser.email,
|
||||
metadata: {
|
||||
organizationId: orgId,
|
||||
type: 'organization',
|
||||
},
|
||||
})
|
||||
orgStripeCustomerId = orgStripeCustomer.id
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Stripe customer for organization', {
|
||||
organizationId: orgId,
|
||||
error,
|
||||
})
|
||||
// Continue without Stripe customer - can be created later
|
||||
}
|
||||
}
|
||||
|
||||
const newOrg = await db
|
||||
.insert(schema.organization)
|
||||
.values({
|
||||
id: orgId,
|
||||
name: `${currentUser.name || 'User'}'s Team`,
|
||||
slug: orgSlug,
|
||||
metadata: orgStripeCustomerId
|
||||
? { stripeCustomerId: orgStripeCustomerId }
|
||||
: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Add the user as owner of the organization
|
||||
await db.insert(schema.member).values({
|
||||
id: `member_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`,
|
||||
userId: currentUser.id,
|
||||
organizationId: orgId,
|
||||
role: 'owner',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Update the subscription to reference the organization instead of the user
|
||||
await db
|
||||
.update(schema.subscription)
|
||||
.set({ referenceId: orgId })
|
||||
.where(eq(schema.subscription.id, subscription.id))
|
||||
|
||||
// Update the session to set the new organization as active
|
||||
await db
|
||||
.update(schema.session)
|
||||
.set({ activeOrganizationId: orgId })
|
||||
.where(eq(schema.session.userId, currentUser.id))
|
||||
|
||||
logger.info('Auto-created organization for team subscription', {
|
||||
organizationId: orgId,
|
||||
userId: currentUser.id,
|
||||
subscriptionId: subscription.id,
|
||||
orgName: `${currentUser.name || 'User'}'s Team`,
|
||||
})
|
||||
|
||||
// Update referenceId for usage limit sync
|
||||
subscription.referenceId = orgId
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to auto-create organization for team subscription', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sync usage limits and initialize billing period for the user/organization
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('./billing')
|
||||
const { initializeBillingPeriod } = await import('./billing/core/billing-periods')
|
||||
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
logger.info('Usage limits synced after subscription creation', {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
// Initialize billing period for new subscription using Stripe dates
|
||||
if (subscription.plan !== 'free') {
|
||||
const stripeStart = new Date(stripeSubscription.current_period_start * 1000)
|
||||
const stripeEnd = new Date(stripeSubscription.current_period_end * 1000)
|
||||
|
||||
await initializeBillingPeriod(subscription.referenceId, stripeStart, stripeEnd)
|
||||
logger.info(
|
||||
'Billing period initialized for new subscription with Stripe dates',
|
||||
{
|
||||
referenceId: subscription.referenceId,
|
||||
billingStart: stripeStart,
|
||||
billingEnd: stripeEnd,
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to sync usage limits or initialize billing period after subscription creation',
|
||||
{
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onSubscriptionUpdate: async ({
|
||||
event,
|
||||
@@ -1137,6 +1276,20 @@ export const auth = betterAuth({
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
})
|
||||
|
||||
// Sync usage limits for the user/organization
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('./billing')
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
logger.info('Usage limits synced after subscription update', {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync usage limits after subscription update', {
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSubscriptionDeleted: async ({
|
||||
event,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { jwtVerify, SignJWT } from 'jose'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('CronAuth')
|
||||
|
||||
// Create a secret key for JWT signing
|
||||
const getJwtSecret = () => {
|
||||
@@ -45,3 +49,26 @@ export async function verifyInternalToken(token: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CRON authentication for scheduled API endpoints
|
||||
* Returns null if authorized, or a NextResponse with error if unauthorized
|
||||
*/
|
||||
export function verifyCronAuth(request: NextRequest, context?: string): NextResponse | null {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedAuth = `Bearer ${env.CRON_SECRET}`
|
||||
|
||||
if (authHeader !== expectedAuth) {
|
||||
const contextInfo = context ? ` for ${context}` : ''
|
||||
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
|
||||
providedAuth: authHeader,
|
||||
ip: request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? 'unknown',
|
||||
userAgent: request.headers.get('user-agent') ?? 'unknown',
|
||||
context,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats } from '@/db/schema'
|
||||
import { env } from './env'
|
||||
import { createLogger } from './logs/console-logger'
|
||||
import { getHighestPrioritySubscription } from './subscription/subscription'
|
||||
import { calculateUsageLimit } from './subscription/utils'
|
||||
import { getUserUsageLimit } from '../core/usage'
|
||||
|
||||
const logger = createLogger('UsageMonitor')
|
||||
|
||||
@@ -31,7 +29,11 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
|
||||
// Get actual usage from the database for display purposes
|
||||
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
const currentUsage =
|
||||
statsRecords.length > 0 ? Number.parseFloat(statsRecords[0].totalCost.toString()) : 0
|
||||
statsRecords.length > 0
|
||||
? Number.parseFloat(
|
||||
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
|
||||
)
|
||||
: 0
|
||||
|
||||
return {
|
||||
percentUsed: Math.min(Math.round((currentUsage / 1000) * 100), 100),
|
||||
@@ -42,23 +44,9 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine subscription (single source of truth)
|
||||
const activeSubscription = await getHighestPrioritySubscription(userId)
|
||||
let limit = 0
|
||||
|
||||
if (activeSubscription) {
|
||||
limit = calculateUsageLimit(activeSubscription)
|
||||
logger.info('Using calculated subscription limit', {
|
||||
userId,
|
||||
plan: activeSubscription.plan,
|
||||
seats: activeSubscription.seats || 1,
|
||||
limit,
|
||||
})
|
||||
} else {
|
||||
// Free tier limit
|
||||
limit = env.FREE_TIER_COST_LIMIT ?? 5
|
||||
logger.info('Using free tier limit', { userId, limit })
|
||||
}
|
||||
// Get usage limit from user_stats (new method)
|
||||
const limit = await getUserUsageLimit(userId)
|
||||
logger.info('Using stored usage limit', { userId, limit })
|
||||
|
||||
// Get actual usage from the database
|
||||
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
@@ -76,8 +64,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current cost from the user stats
|
||||
const currentUsage = Number.parseFloat(statsRecords[0].totalCost.toString())
|
||||
// Get the current period cost from the user stats (use currentPeriodCost if available, fallback to totalCost)
|
||||
const currentUsage = Number.parseFloat(
|
||||
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
|
||||
)
|
||||
|
||||
// Calculate percentage used
|
||||
const percentUsed = Math.min(Math.round((currentUsage / limit) * 100), 100)
|
||||
172
apps/sim/lib/billing/core/billing-periods.test.ts
Normal file
172
apps/sim/lib/billing/core/billing-periods.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { calculateBillingPeriod, calculateNextBillingPeriod } from './billing-periods'
|
||||
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Billing Period Calculations', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Set consistent date for testing
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('calculateBillingPeriod', () => {
|
||||
it.concurrent('calculates current period from subscription dates when within period', () => {
|
||||
vi.setSystemTime(new Date('2024-01-20T00:00:00Z')) // Within the subscription period
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-02-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
expect(period.start).toEqual(subscriptionStart)
|
||||
expect(period.end).toEqual(subscriptionEnd)
|
||||
})
|
||||
|
||||
it.concurrent('calculates next period when current period has ended', () => {
|
||||
vi.setSystemTime(new Date('2024-03-01T00:00:00Z'))
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-02-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
expect(period.start).toEqual(subscriptionEnd)
|
||||
// Expect month-based calculation: Feb 15 + 1 month = Mar 15
|
||||
expect(period.end.getUTCFullYear()).toBe(2024)
|
||||
expect(period.end.getUTCMonth()).toBe(2) // March (0-indexed)
|
||||
expect(period.end.getUTCDate()).toBe(15)
|
||||
})
|
||||
|
||||
it.concurrent('calculates monthly periods from subscription start date', () => {
|
||||
vi.setSystemTime(new Date('2024-01-20T00:00:00Z'))
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart)
|
||||
|
||||
expect(period.start).toEqual(subscriptionStart)
|
||||
expect(period.end).toEqual(new Date('2024-02-15T00:00:00Z'))
|
||||
})
|
||||
|
||||
it.concurrent('advances periods when past end date', () => {
|
||||
vi.setSystemTime(new Date('2024-03-20T00:00:00Z'))
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart)
|
||||
|
||||
expect(period.start).toEqual(new Date('2024-03-15T00:00:00Z'))
|
||||
expect(period.end).toEqual(new Date('2024-04-15T00:00:00Z'))
|
||||
})
|
||||
|
||||
it.concurrent('falls back to calendar month when no subscription data', () => {
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
|
||||
const period = calculateBillingPeriod()
|
||||
|
||||
expect(period.start.getUTCFullYear()).toBe(2024)
|
||||
expect(period.start.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(period.start.getUTCDate()).toBe(1)
|
||||
expect(period.end.getUTCFullYear()).toBe(2024)
|
||||
expect(period.end.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(period.end.getUTCDate()).toBe(31)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateNextBillingPeriod', () => {
|
||||
it.concurrent('calculates next period from given end date', () => {
|
||||
const periodEnd = new Date('2024-02-15T00:00:00Z')
|
||||
|
||||
const nextPeriod = calculateNextBillingPeriod(periodEnd)
|
||||
|
||||
expect(nextPeriod.start).toEqual(periodEnd)
|
||||
expect(nextPeriod.end.getUTCFullYear()).toBe(2024)
|
||||
expect(nextPeriod.end.getUTCMonth()).toBe(2) // March (0-indexed)
|
||||
expect(nextPeriod.end.getUTCDate()).toBe(15)
|
||||
})
|
||||
|
||||
it.concurrent('handles month transitions correctly', () => {
|
||||
const periodEnd = new Date('2024-01-31T00:00:00Z')
|
||||
|
||||
const nextPeriod = calculateNextBillingPeriod(periodEnd)
|
||||
|
||||
expect(nextPeriod.start).toEqual(periodEnd)
|
||||
// JavaScript's setUTCMonth handles overflow: Jan 31 + 1 month = Mar 2 (Feb 29 + 2 days in 2024)
|
||||
expect(nextPeriod.end.getUTCMonth()).toBe(2) // March (0-indexed) due to overflow
|
||||
})
|
||||
})
|
||||
|
||||
describe('Period Alignment Scenarios', () => {
|
||||
it.concurrent('aligns with mid-month subscription perfectly', () => {
|
||||
vi.setSystemTime(new Date('2024-03-20T00:00:00Z')) // Within the subscription period
|
||||
|
||||
const midMonthStart = new Date('2024-03-15T10:30:00Z')
|
||||
const midMonthEnd = new Date('2024-04-15T10:30:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(midMonthStart, midMonthEnd)
|
||||
|
||||
expect(period.start.getTime()).toBe(midMonthStart.getTime())
|
||||
expect(period.end.getTime()).toBe(midMonthEnd.getTime())
|
||||
})
|
||||
|
||||
it.concurrent('handles annual subscriptions correctly', () => {
|
||||
vi.setSystemTime(new Date('2024-06-15T00:00:00Z')) // Within the annual subscription period
|
||||
|
||||
const annualStart = new Date('2024-01-01T00:00:00Z')
|
||||
const annualEnd = new Date('2025-01-01T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(annualStart, annualEnd)
|
||||
|
||||
expect(period.start.getTime()).toBe(annualStart.getTime())
|
||||
expect(period.end.getTime()).toBe(annualEnd.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Billing Check Scenarios', () => {
|
||||
it.concurrent('identifies subscriptions ending today', () => {
|
||||
const today = new Date('2024-07-06T00:00:00Z')
|
||||
vi.setSystemTime(today)
|
||||
|
||||
const endingToday = new Date(today)
|
||||
const shouldBill = endingToday.toDateString() === today.toDateString()
|
||||
|
||||
expect(shouldBill).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('excludes subscriptions ending tomorrow', () => {
|
||||
const today = new Date('2024-07-06T00:00:00Z')
|
||||
vi.setSystemTime(today)
|
||||
|
||||
const endingTomorrow = new Date(today)
|
||||
endingTomorrow.setUTCDate(endingTomorrow.getUTCDate() + 1)
|
||||
|
||||
const shouldBill = endingTomorrow.toDateString() === today.toDateString()
|
||||
|
||||
expect(shouldBill).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('excludes subscriptions that ended yesterday', () => {
|
||||
const today = new Date('2024-07-06T00:00:00Z')
|
||||
vi.setSystemTime(today)
|
||||
|
||||
const endedYesterday = new Date(today)
|
||||
endedYesterday.setUTCDate(endedYesterday.getUTCDate() - 1)
|
||||
|
||||
const shouldBill = endedYesterday.toDateString() === today.toDateString()
|
||||
|
||||
expect(shouldBill).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
281
apps/sim/lib/billing/core/billing-periods.ts
Normal file
281
apps/sim/lib/billing/core/billing-periods.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('BillingPeriodManager')
|
||||
|
||||
/**
|
||||
* Calculate billing period dates based on subscription for proper Stripe alignment
|
||||
* Supports both subscription start date and full period alignment
|
||||
*/
|
||||
export function calculateBillingPeriod(
|
||||
subscriptionPeriodStart?: Date,
|
||||
subscriptionPeriodEnd?: Date
|
||||
): {
|
||||
start: Date
|
||||
end: Date
|
||||
} {
|
||||
const now = new Date()
|
||||
|
||||
// If we have both subscription dates, use them for perfect alignment
|
||||
if (subscriptionPeriodStart && subscriptionPeriodEnd) {
|
||||
const start = new Date(subscriptionPeriodStart)
|
||||
const end = new Date(subscriptionPeriodEnd)
|
||||
|
||||
// If we're past the current period, calculate the next period using calendar months
|
||||
if (now >= end) {
|
||||
const newStart = new Date(end)
|
||||
const newEnd = new Date(end)
|
||||
|
||||
// Use UTC methods to avoid timezone issues
|
||||
newEnd.setUTCMonth(newEnd.getUTCMonth() + 1)
|
||||
|
||||
logger.info('Calculated next billing period from subscription dates', {
|
||||
originalStart: subscriptionPeriodStart,
|
||||
originalEnd: subscriptionPeriodEnd,
|
||||
newStart,
|
||||
newEnd,
|
||||
})
|
||||
|
||||
return { start: newStart, end: newEnd }
|
||||
}
|
||||
|
||||
logger.info('Using current subscription billing period', {
|
||||
start,
|
||||
end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// If we only have subscription start date, calculate monthly periods from that date
|
||||
if (subscriptionPeriodStart) {
|
||||
const start = new Date(subscriptionPeriodStart)
|
||||
const end = new Date(start)
|
||||
|
||||
// Add one month to start date using UTC to avoid timezone issues
|
||||
end.setUTCMonth(end.getUTCMonth() + 1)
|
||||
|
||||
// If we're past the end date, calculate the current period
|
||||
while (end <= now) {
|
||||
start.setUTCMonth(start.getUTCMonth() + 1)
|
||||
end.setUTCMonth(end.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
logger.info('Calculated billing period from subscription start date', {
|
||||
subscriptionStart: subscriptionPeriodStart,
|
||||
currentPeriodStart: start,
|
||||
currentPeriodEnd: end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// Fallback: Default monthly billing period (1st to last day of month)
|
||||
// This should only be used for users without proper subscription data
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59, 999))
|
||||
|
||||
logger.warn('Using fallback calendar month billing period', {
|
||||
start,
|
||||
end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next billing period starting from a given period end date
|
||||
*/
|
||||
export function calculateNextBillingPeriod(periodEnd: Date): {
|
||||
start: Date
|
||||
end: Date
|
||||
} {
|
||||
const start = new Date(periodEnd)
|
||||
const end = new Date(start)
|
||||
|
||||
// Add one month for the next period using UTC to avoid timezone issues
|
||||
end.setUTCMonth(end.getUTCMonth() + 1)
|
||||
|
||||
logger.info('Calculated next billing period', {
|
||||
previousPeriodEnd: periodEnd,
|
||||
nextPeriodStart: start,
|
||||
nextPeriodEnd: end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize billing period for a user based on their subscription
|
||||
* Can optionally accept Stripe subscription dates to ensure proper alignment
|
||||
*/
|
||||
export async function initializeBillingPeriod(
|
||||
userId: string,
|
||||
stripeSubscriptionStart?: Date,
|
||||
stripeSubscriptionEnd?: Date
|
||||
): Promise<void> {
|
||||
try {
|
||||
let start: Date
|
||||
let end: Date
|
||||
|
||||
if (stripeSubscriptionStart && stripeSubscriptionEnd) {
|
||||
// Use Stripe subscription dates for perfect alignment
|
||||
start = stripeSubscriptionStart
|
||||
end = stripeSubscriptionEnd
|
||||
logger.info('Using Stripe subscription dates for billing period', {
|
||||
userId,
|
||||
stripeStart: stripeSubscriptionStart,
|
||||
stripeEnd: stripeSubscriptionEnd,
|
||||
})
|
||||
} else {
|
||||
// Fallback: Get user's subscription to determine billing period
|
||||
const subscriptionData = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
const billingPeriod = calculateBillingPeriod(
|
||||
subscriptionData[0]?.periodStart || undefined,
|
||||
subscriptionData[0]?.periodEnd || undefined
|
||||
)
|
||||
start = billingPeriod.start
|
||||
end = billingPeriod.end
|
||||
}
|
||||
|
||||
// Update user stats with billing period info
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
billingPeriodStart: start,
|
||||
billingPeriodEnd: end,
|
||||
currentPeriodCost: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Billing period initialized for user', {
|
||||
userId,
|
||||
billingPeriodStart: start,
|
||||
billingPeriodEnd: end,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize billing period', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset billing period for a user (archive current usage and start new period)
|
||||
* Now properly calculates next period based on subscription billing cycle
|
||||
*/
|
||||
export async function resetUserBillingPeriod(userId: string): Promise<void> {
|
||||
try {
|
||||
// Get current period data and subscription info before reset
|
||||
const [currentStats, userSubscription] = await Promise.all([
|
||||
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
if (currentStats.length === 0) {
|
||||
logger.warn('No user stats found for billing period reset', { userId })
|
||||
return
|
||||
}
|
||||
|
||||
const stats = currentStats[0]
|
||||
const currentPeriodCost = stats.currentPeriodCost || '0'
|
||||
|
||||
// Calculate next billing period based on subscription or current period end
|
||||
let newPeriodStart: Date
|
||||
let newPeriodEnd: Date
|
||||
|
||||
if (userSubscription.length > 0 && userSubscription[0].periodEnd) {
|
||||
// Use subscription-based period calculation
|
||||
const nextPeriod = calculateNextBillingPeriod(userSubscription[0].periodEnd)
|
||||
newPeriodStart = nextPeriod.start
|
||||
newPeriodEnd = nextPeriod.end
|
||||
} else if (stats.billingPeriodEnd) {
|
||||
// Use current billing period end to calculate next period
|
||||
const nextPeriod = calculateNextBillingPeriod(stats.billingPeriodEnd)
|
||||
newPeriodStart = nextPeriod.start
|
||||
newPeriodEnd = nextPeriod.end
|
||||
} else {
|
||||
// Fallback to subscription start date or default calculation
|
||||
const subscriptionStart = userSubscription[0]?.periodStart
|
||||
const billingPeriod = calculateBillingPeriod(subscriptionStart || undefined)
|
||||
newPeriodStart = billingPeriod.start
|
||||
newPeriodEnd = billingPeriod.end
|
||||
}
|
||||
|
||||
// Archive current period cost and reset for new period
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: currentPeriodCost, // Archive previous period
|
||||
currentPeriodCost: '0', // Reset to zero for new period
|
||||
billingPeriodStart: newPeriodStart,
|
||||
billingPeriodEnd: newPeriodEnd,
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Reset billing period for user', {
|
||||
userId,
|
||||
archivedAmount: currentPeriodCost,
|
||||
newPeriodStart,
|
||||
newPeriodEnd,
|
||||
basedOnSubscription: !!userSubscription[0]?.periodEnd,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset user billing period', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset billing period for all members of an organization
|
||||
*/
|
||||
export async function resetOrganizationBillingPeriod(organizationId: string): Promise<void> {
|
||||
try {
|
||||
// Get all organization members
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
if (members.length === 0) {
|
||||
logger.info('No members found for organization billing reset', { organizationId })
|
||||
return
|
||||
}
|
||||
|
||||
// Reset billing period for each member in parallel
|
||||
const memberUserIds = members.map((m) => m.userId)
|
||||
|
||||
await Promise.all(
|
||||
memberUserIds.map(async (userId) => {
|
||||
try {
|
||||
await resetUserBillingPeriod(userId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset billing period for organization member', {
|
||||
organizationId,
|
||||
userId,
|
||||
error,
|
||||
})
|
||||
// Don't throw - continue processing other members
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('Reset billing period for organization', {
|
||||
organizationId,
|
||||
memberCount: members.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset organization billing period', { organizationId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
268
apps/sim/lib/billing/core/billing.test.ts
Normal file
268
apps/sim/lib/billing/core/billing.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getPlanPricing, getUsersAndOrganizationsForOverageBilling } from './billing'
|
||||
import { calculateBillingPeriod, calculateNextBillingPeriod } from './billing-periods'
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage', () => ({
|
||||
getUserUsageData: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../stripe-client', () => ({
|
||||
getStripeClient: vi.fn().mockReturnValue(null),
|
||||
requireStripeClient: vi.fn().mockImplementation(() => {
|
||||
throw new Error(
|
||||
'Stripe client is not available. Set STRIPE_SECRET_KEY in your environment variables.'
|
||||
)
|
||||
}),
|
||||
hasValidStripeCredentials: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
describe('Billing Core Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('calculateBillingPeriod', () => {
|
||||
it.concurrent('calculates billing period from subscription dates correctly', () => {
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-08-15T00:00:00Z')
|
||||
const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
// Should return the current subscription period since we're within it
|
||||
expect(result.start).toEqual(subscriptionStart)
|
||||
expect(result.end).toEqual(subscriptionEnd)
|
||||
expect(result.start.getUTCDate()).toBe(15) // Should preserve day from subscription
|
||||
expect(result.end.getUTCDate()).toBe(15)
|
||||
})
|
||||
|
||||
it.concurrent('calculates next period when current subscription period has ended', () => {
|
||||
vi.setSystemTime(new Date('2024-08-20T10:00:00Z')) // After subscription end
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-08-15T00:00:00Z') // Already ended
|
||||
const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
// Should calculate next period starting from subscription end
|
||||
expect(result.start).toEqual(subscriptionEnd)
|
||||
expect(result.end.getUTCFullYear()).toBe(2024)
|
||||
expect(result.end.getUTCMonth()).toBe(8) // September (0-indexed)
|
||||
expect(result.end.getUTCDate()).toBe(15) // Should preserve day
|
||||
})
|
||||
|
||||
it.concurrent('returns current month when no subscription date provided', () => {
|
||||
vi.setSystemTime(new Date('2024-07-15T10:00:00Z'))
|
||||
const result = calculateBillingPeriod()
|
||||
|
||||
// Should return current calendar month (1st to last day of current month)
|
||||
expect(result.start.getUTCFullYear()).toBe(2024)
|
||||
expect(result.start.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(result.start.getUTCDate()).toBe(1) // Should start on 1st of month
|
||||
expect(result.end.getUTCFullYear()).toBe(2024)
|
||||
expect(result.end.getUTCMonth()).toBe(6) // July (0-indexed) - ends on last day of current month
|
||||
expect(result.end.getUTCDate()).toBe(31) // Should end on last day of July
|
||||
expect(result.end.getUTCHours()).toBe(23) // Should end at 23:59:59.999
|
||||
expect(result.end.getUTCMinutes()).toBe(59)
|
||||
expect(result.end.getUTCSeconds()).toBe(59)
|
||||
})
|
||||
|
||||
it.concurrent('handles subscription anniversary date correctly', () => {
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-07-15T00:00:00Z')
|
||||
const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
// Should maintain the 15th as billing day
|
||||
expect(result.start.getUTCDate()).toBe(15)
|
||||
expect(result.end.getUTCDate()).toBe(15)
|
||||
|
||||
// Current period should contain the current date (July 6)
|
||||
const currentDate = new Date('2024-07-06T10:00:00Z')
|
||||
expect(currentDate.getTime()).toBeGreaterThanOrEqual(result.start.getTime())
|
||||
expect(currentDate.getTime()).toBeLessThan(result.end.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateNextBillingPeriod', () => {
|
||||
it.concurrent('calculates next period correctly', () => {
|
||||
const currentPeriodEnd = new Date('2024-07-15T23:59:59Z')
|
||||
const result = calculateNextBillingPeriod(currentPeriodEnd)
|
||||
|
||||
expect(result.start.getUTCDate()).toBe(15)
|
||||
expect(result.start.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(result.end.getUTCDate()).toBe(15)
|
||||
expect(result.end.getUTCMonth()).toBe(7) // August (0-indexed)
|
||||
})
|
||||
|
||||
it.concurrent('handles month boundary correctly', () => {
|
||||
const currentPeriodEnd = new Date('2024-01-31T23:59:59Z')
|
||||
const result = calculateNextBillingPeriod(currentPeriodEnd)
|
||||
|
||||
expect(result.start.getUTCMonth()).toBe(0) // January
|
||||
expect(result.end.getUTCMonth()).toBeGreaterThanOrEqual(1) // February or later due to month overflow
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPlanPricing', () => {
|
||||
it.concurrent('returns correct pricing for free plan', () => {
|
||||
const result = getPlanPricing('free')
|
||||
expect(result).toEqual({ basePrice: 0, minimum: 0 })
|
||||
})
|
||||
|
||||
it.concurrent('returns correct pricing for pro plan', () => {
|
||||
const result = getPlanPricing('pro')
|
||||
expect(result).toEqual({ basePrice: 20, minimum: 20 })
|
||||
})
|
||||
|
||||
it.concurrent('returns correct pricing for team plan', () => {
|
||||
const result = getPlanPricing('team')
|
||||
expect(result).toEqual({ basePrice: 40, minimum: 40 })
|
||||
})
|
||||
|
||||
it.concurrent('returns correct pricing for enterprise plan with metadata', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: 150 },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
expect(result).toEqual({ basePrice: 150, minimum: 150 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - negative number', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: -50 },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - zero', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: 0 },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - non-numeric string', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: 'invalid' },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - null', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: null },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('returns default enterprise pricing when metadata missing', () => {
|
||||
const result = getPlanPricing('enterprise')
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsersAndOrganizationsForOverageBilling', () => {
|
||||
it.concurrent('returns empty arrays when no subscriptions due', async () => {
|
||||
const result = await getUsersAndOrganizationsForOverageBilling()
|
||||
|
||||
expect(result).toHaveProperty('users')
|
||||
expect(result).toHaveProperty('organizations')
|
||||
expect(Array.isArray(result.users)).toBe(true)
|
||||
expect(Array.isArray(result.organizations)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('filters by current date correctly', async () => {
|
||||
vi.setSystemTime(new Date('2024-07-15T10:00:00Z'))
|
||||
|
||||
const result = await getUsersAndOrganizationsForOverageBilling()
|
||||
|
||||
// Should only return entities whose billing period ends on July 15th
|
||||
expect(result.users).toEqual([])
|
||||
expect(result.organizations).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stripe client integration', () => {
|
||||
it.concurrent('does not fail when Stripe credentials are not available', async () => {
|
||||
const result = await getUsersAndOrganizationsForOverageBilling()
|
||||
|
||||
expect(result).toHaveProperty('users')
|
||||
expect(result).toHaveProperty('organizations')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Date handling edge cases', () => {
|
||||
it.concurrent('handles month boundaries correctly', () => {
|
||||
// Test end of January (28/29 days) to February
|
||||
const janEnd = new Date('2024-01-31T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(janEnd)
|
||||
|
||||
expect(result.start.getUTCMonth()).toBe(0) // January
|
||||
expect(result.end.getUTCMonth()).toBeGreaterThanOrEqual(1) // February or later due to month overflow
|
||||
})
|
||||
|
||||
it.concurrent('handles leap year correctly', () => {
|
||||
const febEnd = new Date('2024-02-29T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(febEnd)
|
||||
|
||||
expect(result.start.getUTCFullYear()).toBe(2024)
|
||||
expect(result.start.getUTCMonth()).toBe(1)
|
||||
expect(result.start.getUTCDate()).toBe(29)
|
||||
expect(result.end.getUTCFullYear()).toBe(2024)
|
||||
expect(result.end.getUTCMonth()).toBe(2)
|
||||
expect(result.end.getUTCDate()).toBe(29)
|
||||
})
|
||||
|
||||
it.concurrent('handles year boundary correctly', () => {
|
||||
const decEnd = new Date('2024-12-15T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(decEnd)
|
||||
|
||||
expect(result.start.getUTCFullYear()).toBe(2024)
|
||||
expect(result.start.getUTCMonth()).toBe(11) // December
|
||||
expect(result.end.getUTCFullYear()).toBe(2025)
|
||||
expect(result.end.getUTCMonth()).toBe(0) // January
|
||||
})
|
||||
|
||||
it.concurrent('basic date calculations work', () => {
|
||||
const testDate = new Date('2024-07-15T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(testDate)
|
||||
|
||||
expect(result.start).toBeInstanceOf(Date)
|
||||
expect(result.end).toBeInstanceOf(Date)
|
||||
expect(result.end.getTime()).toBeGreaterThan(result.start.getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
1053
apps/sim/lib/billing/core/billing.ts
Normal file
1053
apps/sim/lib/billing/core/billing.ts
Normal file
File diff suppressed because it is too large
Load Diff
314
apps/sim/lib/billing/core/organization-billing.ts
Normal file
314
apps/sim/lib/billing/core/organization-billing.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, user, userStats } from '@/db/schema'
|
||||
import { getPlanPricing } from './billing'
|
||||
import { getHighestPrioritySubscription } from './subscription'
|
||||
|
||||
const logger = createLogger('OrganizationBilling')
|
||||
|
||||
interface OrganizationUsageData {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
subscriptionPlan: string
|
||||
subscriptionStatus: string
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
averageUsagePerMember: number
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
members: MemberUsageData[]
|
||||
}
|
||||
|
||||
interface MemberUsageData {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentUsage: number
|
||||
usageLimit: number
|
||||
percentUsed: number
|
||||
isOverLimit: boolean
|
||||
role: string
|
||||
joinedAt: Date
|
||||
lastActive: Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive organization billing and usage data
|
||||
*/
|
||||
export async function getOrganizationBillingData(
|
||||
organizationId: string
|
||||
): Promise<OrganizationUsageData | null> {
|
||||
try {
|
||||
// Get organization info
|
||||
const orgRecord = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (orgRecord.length === 0) {
|
||||
logger.warn('Organization not found', { organizationId })
|
||||
return null
|
||||
}
|
||||
|
||||
const organizationData = orgRecord[0]
|
||||
|
||||
// Get organization subscription
|
||||
const subscription = await getHighestPrioritySubscription(organizationId)
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('No subscription found for organization', { organizationId })
|
||||
return null
|
||||
}
|
||||
|
||||
// Get all organization members with their usage data
|
||||
const membersWithUsage = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
// User stats fields
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
billingPeriodStart: userStats.billingPeriodStart,
|
||||
billingPeriodEnd: userStats.billingPeriodEnd,
|
||||
lastActive: userStats.lastActive,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
// Process member data
|
||||
const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => {
|
||||
const currentUsage = Number(memberRecord.currentPeriodCost || 0)
|
||||
const usageLimit = Number(memberRecord.currentUsageLimit || 5)
|
||||
const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0
|
||||
|
||||
return {
|
||||
userId: memberRecord.userId,
|
||||
userName: memberRecord.userName,
|
||||
userEmail: memberRecord.userEmail,
|
||||
currentUsage,
|
||||
usageLimit,
|
||||
percentUsed: Math.round(percentUsed * 100) / 100,
|
||||
isOverLimit: currentUsage > usageLimit,
|
||||
role: memberRecord.role,
|
||||
joinedAt: memberRecord.joinedAt,
|
||||
lastActive: memberRecord.lastActive,
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate aggregated statistics
|
||||
const totalCurrentUsage = members.reduce((sum, member) => sum + member.currentUsage, 0)
|
||||
|
||||
// Get per-seat pricing for the plan
|
||||
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan, subscription)
|
||||
const licensedSeats = subscription.seats || members.length
|
||||
|
||||
// Validate seat capacity - warn if members exceed licensed seats
|
||||
if (subscription.seats && members.length > subscription.seats) {
|
||||
logger.warn('Organization has more members than licensed seats', {
|
||||
organizationId,
|
||||
licensedSeats: subscription.seats,
|
||||
actualMembers: members.length,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
}
|
||||
|
||||
// Billing is based on licensed seats, not actual member count
|
||||
// This ensures organizations pay for their seat capacity regardless of utilization
|
||||
const seatsCount = licensedSeats
|
||||
const minimumBillingAmount = seatsCount * pricePerSeat
|
||||
|
||||
// Total usage limit represents the minimum amount the team will be billed
|
||||
// This is based on licensed seats, not individual member limits (which are personal controls)
|
||||
const totalUsageLimit = minimumBillingAmount
|
||||
|
||||
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
|
||||
|
||||
// Get billing period from first member (should be consistent across org)
|
||||
const firstMember = membersWithUsage[0]
|
||||
const billingPeriodStart = firstMember?.billingPeriodStart || null
|
||||
const billingPeriodEnd = firstMember?.billingPeriodEnd || null
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
organizationName: organizationData.name,
|
||||
subscriptionPlan: subscription.plan,
|
||||
subscriptionStatus: subscription.status || 'active',
|
||||
totalSeats: subscription.seats || 1,
|
||||
usedSeats: members.length,
|
||||
totalCurrentUsage: Math.round(totalCurrentUsage * 100) / 100,
|
||||
totalUsageLimit: Math.round(totalUsageLimit * 100) / 100,
|
||||
averageUsagePerMember: Math.round(averageUsagePerMember * 100) / 100,
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
members: members.sort((a, b) => b.currentUsage - a.currentUsage), // Sort by usage desc
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization billing data', { organizationId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage limit for a specific organization member
|
||||
*/
|
||||
export async function updateMemberUsageLimit(
|
||||
organizationId: string,
|
||||
memberId: string,
|
||||
newLimit: number,
|
||||
adminUserId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Verify admin has permission to modify limits
|
||||
const adminMemberRecord = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, adminUserId)))
|
||||
.limit(1)
|
||||
|
||||
if (adminMemberRecord.length === 0 || !['owner', 'admin'].includes(adminMemberRecord[0].role)) {
|
||||
throw new Error('Insufficient permissions to modify usage limits')
|
||||
}
|
||||
|
||||
// Verify member exists in organization
|
||||
const targetMemberRecord = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.limit(1)
|
||||
|
||||
if (targetMemberRecord.length === 0) {
|
||||
throw new Error('Member not found in organization')
|
||||
}
|
||||
|
||||
// Get organization subscription to validate limit
|
||||
const subscription = await getHighestPrioritySubscription(organizationId)
|
||||
if (!subscription) {
|
||||
throw new Error('No active subscription found')
|
||||
}
|
||||
|
||||
// Validate minimum limit based on plan
|
||||
const planLimits = {
|
||||
free: 5,
|
||||
pro: 20,
|
||||
team: 40,
|
||||
enterprise: 100, // Default, can be overridden by metadata
|
||||
}
|
||||
|
||||
let minimumLimit = planLimits[subscription.plan as keyof typeof planLimits] || 5
|
||||
|
||||
// For enterprise, check metadata for custom limits
|
||||
if (subscription.plan === 'enterprise' && subscription.metadata) {
|
||||
try {
|
||||
const metadata =
|
||||
typeof subscription.metadata === 'string'
|
||||
? JSON.parse(subscription.metadata)
|
||||
: subscription.metadata
|
||||
if (metadata.perSeatAllowance) {
|
||||
minimumLimit = metadata.perSeatAllowance
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse subscription metadata', { error: e })
|
||||
}
|
||||
}
|
||||
|
||||
if (newLimit < minimumLimit) {
|
||||
throw new Error(`Usage limit cannot be below $${minimumLimit} for ${subscription.plan} plan`)
|
||||
}
|
||||
|
||||
// Update the member's usage limit
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: newLimit.toString(),
|
||||
usageLimitSetBy: adminUserId,
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, memberId))
|
||||
|
||||
logger.info('Updated member usage limit', {
|
||||
organizationId,
|
||||
memberId,
|
||||
newLimit,
|
||||
adminUserId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update member usage limit', {
|
||||
organizationId,
|
||||
memberId,
|
||||
newLimit,
|
||||
adminUserId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization billing summary for admin dashboard
|
||||
*/
|
||||
export async function getOrganizationBillingSummary(organizationId: string) {
|
||||
try {
|
||||
const billingData = await getOrganizationBillingData(organizationId)
|
||||
|
||||
if (!billingData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate additional metrics
|
||||
const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length
|
||||
const membersNearLimit = billingData.members.filter(
|
||||
(m) => !m.isOverLimit && m.percentUsed >= 80
|
||||
).length
|
||||
|
||||
const topUsers = billingData.members.slice(0, 5).map((m) => ({
|
||||
name: m.userName,
|
||||
usage: m.currentUsage,
|
||||
limit: m.usageLimit,
|
||||
percentUsed: m.percentUsed,
|
||||
}))
|
||||
|
||||
return {
|
||||
organization: {
|
||||
id: billingData.organizationId,
|
||||
name: billingData.organizationName,
|
||||
plan: billingData.subscriptionPlan,
|
||||
status: billingData.subscriptionStatus,
|
||||
},
|
||||
usage: {
|
||||
total: billingData.totalCurrentUsage,
|
||||
limit: billingData.totalUsageLimit,
|
||||
average: billingData.averageUsagePerMember,
|
||||
percentUsed:
|
||||
billingData.totalUsageLimit > 0
|
||||
? (billingData.totalCurrentUsage / billingData.totalUsageLimit) * 100
|
||||
: 0,
|
||||
},
|
||||
seats: {
|
||||
total: billingData.totalSeats,
|
||||
used: billingData.usedSeats,
|
||||
available: billingData.totalSeats - billingData.usedSeats,
|
||||
},
|
||||
alerts: {
|
||||
membersOverLimit,
|
||||
membersNearLimit,
|
||||
},
|
||||
billingPeriod: {
|
||||
start: billingData.billingPeriodStart,
|
||||
end: billingData.billingPeriodEnd,
|
||||
},
|
||||
topUsers,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization billing summary', { organizationId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
385
apps/sim/lib/billing/core/subscription.ts
Normal file
385
apps/sim/lib/billing/core/subscription.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription, userStats } from '@/db/schema'
|
||||
import { client } from '../../auth-client'
|
||||
import {
|
||||
calculateDefaultUsageLimit,
|
||||
checkEnterprisePlan,
|
||||
checkProPlan,
|
||||
checkTeamPlan,
|
||||
} from '../subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '../types'
|
||||
|
||||
const logger = createLogger('SubscriptionCore')
|
||||
|
||||
/**
|
||||
* Core subscription management - single source of truth
|
||||
* Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the highest priority active subscription for a user
|
||||
* Priority: Enterprise > Team > Pro > Free
|
||||
*/
|
||||
export async function getHighestPrioritySubscription(userId: string) {
|
||||
try {
|
||||
// Get direct subscriptions
|
||||
const personalSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
|
||||
// Get organization memberships
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
|
||||
|
||||
// Get organization subscriptions
|
||||
let orgSubs: any[] = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
if (allSubs.length === 0) return null
|
||||
|
||||
// Return highest priority subscription
|
||||
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
|
||||
if (enterpriseSub) return enterpriseSub
|
||||
|
||||
const teamSub = allSubs.find((s) => checkTeamPlan(s))
|
||||
if (teamSub) return teamSub
|
||||
|
||||
const proSub = allSubs.find((s) => checkProPlan(s))
|
||||
if (proSub) return proSub
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error getting highest priority subscription', { error, userId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is on Pro plan (direct or via organization)
|
||||
*/
|
||||
export async function isProPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// In development, enable Pro features for easier testing
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const isPro =
|
||||
subscription &&
|
||||
(checkProPlan(subscription) ||
|
||||
checkTeamPlan(subscription) ||
|
||||
checkEnterprisePlan(subscription))
|
||||
|
||||
if (isPro) {
|
||||
logger.info('User has pro-level plan', { userId, plan: subscription.plan })
|
||||
}
|
||||
|
||||
return !!isPro
|
||||
} catch (error) {
|
||||
logger.error('Error checking pro plan status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is on Team plan (direct or via organization)
|
||||
*/
|
||||
export async function isTeamPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const isTeam =
|
||||
subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription))
|
||||
|
||||
if (isTeam) {
|
||||
logger.info('User has team-level plan', { userId, plan: subscription.plan })
|
||||
}
|
||||
|
||||
return !!isTeam
|
||||
} catch (error) {
|
||||
logger.error('Error checking team plan status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is on Enterprise plan (direct or via organization)
|
||||
*/
|
||||
export async function isEnterprisePlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const isEnterprise = subscription && checkEnterprisePlan(subscription)
|
||||
|
||||
if (isEnterprise) {
|
||||
logger.info('User has enterprise plan', { userId, plan: subscription.plan })
|
||||
}
|
||||
|
||||
return !!isEnterprise
|
||||
} catch (error) {
|
||||
logger.error('Error checking enterprise plan status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded their cost limit based on current period usage
|
||||
*/
|
||||
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return false
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
// Calculate usage limit
|
||||
let limit = 5 // Default free tier limit
|
||||
if (subscription) {
|
||||
limit = calculateDefaultUsageLimit(subscription)
|
||||
logger.info('Using subscription-based limit', {
|
||||
userId,
|
||||
plan: subscription.plan,
|
||||
seats: subscription.seats || 1,
|
||||
limit,
|
||||
})
|
||||
} else {
|
||||
logger.info('Using free tier limit', { userId, limit })
|
||||
}
|
||||
|
||||
// Get user stats to check current period usage
|
||||
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
if (statsRecords.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use current period cost instead of total cost for accurate billing period tracking
|
||||
const currentCost = Number.parseFloat(
|
||||
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
|
||||
)
|
||||
|
||||
logger.info('Checking cost limit', { userId, currentCost, limit })
|
||||
|
||||
return currentCost >= limit
|
||||
} catch (error) {
|
||||
logger.error('Error checking cost limit', { error, userId })
|
||||
return false // Be conservative in case of error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sharing features are enabled for user
|
||||
*/
|
||||
export async function isSharingEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (!subscription) {
|
||||
return false // Free users don't have sharing
|
||||
}
|
||||
|
||||
// Use Better-Auth client to check feature flags
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
return !!activeSubscription?.limits?.sharingEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking sharing permission', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if multiplayer features are enabled for user
|
||||
*/
|
||||
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (!subscription) {
|
||||
return false // Free users don't have multiplayer
|
||||
}
|
||||
|
||||
// Use Better-Auth client to check feature flags
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
return !!activeSubscription?.limits?.multiplayerEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking multiplayer permission', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workspace collaboration features are enabled for user
|
||||
*/
|
||||
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (!subscription) {
|
||||
return false // Free users don't have workspace collaboration
|
||||
}
|
||||
|
||||
// Use Better-Auth client to check feature flags
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
return !!activeSubscription?.limits?.workspaceCollaborationEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking workspace collaboration permission', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive subscription state for a user
|
||||
* Single function to get all subscription information
|
||||
*/
|
||||
export async function getUserSubscriptionState(userId: string): Promise<UserSubscriptionState> {
|
||||
try {
|
||||
// Get subscription and user stats in parallel to minimize DB calls
|
||||
const [subscription, statsRecords] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
|
||||
])
|
||||
|
||||
// Determine plan types based on subscription (avoid redundant DB calls)
|
||||
const isPro =
|
||||
!isProd ||
|
||||
(subscription &&
|
||||
(checkProPlan(subscription) ||
|
||||
checkTeamPlan(subscription) ||
|
||||
checkEnterprisePlan(subscription)))
|
||||
const isTeam =
|
||||
!isProd ||
|
||||
(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
|
||||
const isEnterprise = !isProd || (subscription && checkEnterprisePlan(subscription))
|
||||
const isFree = !isPro && !isTeam && !isEnterprise
|
||||
|
||||
// Determine plan name
|
||||
let planName = 'free'
|
||||
if (isEnterprise) planName = 'enterprise'
|
||||
else if (isTeam) planName = 'team'
|
||||
else if (isPro) planName = 'pro'
|
||||
|
||||
// Check features based on subscription (avoid redundant better-auth calls)
|
||||
let sharingEnabled = false
|
||||
let multiplayerEnabled = false
|
||||
let workspaceCollaborationEnabled = false
|
||||
|
||||
if (!isProd || subscription) {
|
||||
if (!isProd) {
|
||||
// Development mode - enable all features
|
||||
sharingEnabled = true
|
||||
multiplayerEnabled = true
|
||||
workspaceCollaborationEnabled = true
|
||||
} else {
|
||||
// Production mode - check subscription features
|
||||
try {
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
|
||||
sharingEnabled = !!activeSubscription?.limits?.sharingEnabled
|
||||
multiplayerEnabled = !!activeSubscription?.limits?.multiplayerEnabled
|
||||
workspaceCollaborationEnabled =
|
||||
!!activeSubscription?.limits?.workspaceCollaborationEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking subscription features', { error, userId })
|
||||
// Default to false on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check cost limit using already-fetched user stats
|
||||
let hasExceededLimit = false
|
||||
if (isProd && statsRecords.length > 0) {
|
||||
let limit = 5 // Default free tier limit
|
||||
if (subscription) {
|
||||
limit = calculateDefaultUsageLimit(subscription)
|
||||
}
|
||||
|
||||
const currentCost = Number.parseFloat(
|
||||
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
|
||||
)
|
||||
hasExceededLimit = currentCost >= limit
|
||||
}
|
||||
|
||||
return {
|
||||
isPro,
|
||||
isTeam,
|
||||
isEnterprise,
|
||||
isFree,
|
||||
highestPrioritySubscription: subscription,
|
||||
features: {
|
||||
sharingEnabled,
|
||||
multiplayerEnabled,
|
||||
workspaceCollaborationEnabled,
|
||||
},
|
||||
hasExceededLimit,
|
||||
planName,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error getting user subscription state', { error, userId })
|
||||
|
||||
// Return safe defaults in case of error
|
||||
return {
|
||||
isPro: false,
|
||||
isTeam: false,
|
||||
isEnterprise: false,
|
||||
isFree: true,
|
||||
highestPrioritySubscription: null,
|
||||
features: {
|
||||
sharingEnabled: false,
|
||||
multiplayerEnabled: false,
|
||||
workspaceCollaborationEnabled: false,
|
||||
},
|
||||
hasExceededLimit: false,
|
||||
planName: 'free',
|
||||
}
|
||||
}
|
||||
}
|
||||
511
apps/sim/lib/billing/core/usage.ts
Normal file
511
apps/sim/lib/billing/core/usage.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, user, userStats } from '@/db/schema'
|
||||
import { calculateDefaultUsageLimit, canEditUsageLimit } from '../subscriptions/utils'
|
||||
import type { BillingData, UsageData, UsageLimitInfo } from '../types'
|
||||
import { getHighestPrioritySubscription } from './subscription'
|
||||
|
||||
const logger = createLogger('UsageManagement')
|
||||
|
||||
/**
|
||||
* Consolidated usage management module
|
||||
* Handles user usage tracking, limits, and monitoring
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get comprehensive usage data for a user
|
||||
*/
|
||||
export async function getUserUsageData(userId: string): Promise<UsageData> {
|
||||
try {
|
||||
const userStatsData = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsData.length === 0) {
|
||||
// Initialize user stats if they don't exist
|
||||
await initializeUserUsageLimit(userId)
|
||||
return {
|
||||
currentUsage: 0,
|
||||
limit: 5,
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
lastPeriodCost: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const stats = userStatsData[0]
|
||||
const currentUsage = Number.parseFloat(
|
||||
stats.currentPeriodCost?.toString() ?? stats.totalCost.toString()
|
||||
)
|
||||
const limit = Number.parseFloat(stats.currentUsageLimit)
|
||||
const percentUsed = limit > 0 ? Math.round((currentUsage / limit) * 100) : 0
|
||||
const isWarning = percentUsed >= 80
|
||||
const isExceeded = currentUsage >= limit
|
||||
|
||||
return {
|
||||
currentUsage,
|
||||
limit,
|
||||
percentUsed,
|
||||
isWarning,
|
||||
isExceeded,
|
||||
billingPeriodStart: stats.billingPeriodStart,
|
||||
billingPeriodEnd: stats.billingPeriodEnd,
|
||||
lastPeriodCost: Number.parseFloat(stats.lastPeriodCost?.toString() || '0'),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user usage data', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage limit information for a user
|
||||
*/
|
||||
export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitInfo> {
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
// For team plans, check if user is owner/admin to determine if they can edit their own limit
|
||||
let canEdit = canEditUsageLimit(subscription)
|
||||
|
||||
if (subscription?.plan === 'team') {
|
||||
// For team plans, the subscription referenceId should be the organization ID
|
||||
// Check user's role in that organization
|
||||
const orgMemberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, subscription.referenceId)))
|
||||
.limit(1)
|
||||
|
||||
if (orgMemberRecord.length > 0) {
|
||||
const userRole = orgMemberRecord[0].role
|
||||
// Team owners and admins can edit their own usage limits
|
||||
// Regular team members cannot edit their own limits
|
||||
canEdit = canEdit && ['owner', 'admin'].includes(userRole)
|
||||
} else {
|
||||
// User is not a member of the organization, should not be able to edit
|
||||
canEdit = false
|
||||
}
|
||||
}
|
||||
|
||||
// Use plan-based minimums instead of role-based minimums
|
||||
let minimumLimit: number
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
// Free plan users
|
||||
minimumLimit = 5
|
||||
} else if (subscription.plan === 'pro') {
|
||||
// Pro plan users: $20 minimum
|
||||
minimumLimit = 20
|
||||
} else if (subscription.plan === 'team') {
|
||||
// Team plan users: $40 minimum (per-seat allocation, regardless of role)
|
||||
minimumLimit = 40
|
||||
} else if (subscription.plan === 'enterprise') {
|
||||
// Enterprise plan users: per-seat allocation from their plan
|
||||
const metadata = subscription.metadata || {}
|
||||
if (metadata.perSeatAllowance) {
|
||||
minimumLimit = Number.parseFloat(metadata.perSeatAllowance)
|
||||
} else if (metadata.totalAllowance) {
|
||||
// For total allowance, use per-seat calculation
|
||||
const seats = subscription.seats || 1
|
||||
minimumLimit = Number.parseFloat(metadata.totalAllowance) / seats
|
||||
} else {
|
||||
minimumLimit = 200 // Default enterprise per-seat limit
|
||||
}
|
||||
} else {
|
||||
// Fallback to plan-based calculation
|
||||
minimumLimit = calculateDefaultUsageLimit(subscription)
|
||||
}
|
||||
|
||||
const userStatsRecord = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRecord.length === 0) {
|
||||
await initializeUserUsageLimit(userId)
|
||||
return {
|
||||
currentLimit: 5,
|
||||
canEdit: false,
|
||||
minimumLimit: 5,
|
||||
plan: 'free',
|
||||
setBy: null,
|
||||
updatedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
const stats = userStatsRecord[0]
|
||||
return {
|
||||
currentLimit: Number.parseFloat(stats.currentUsageLimit),
|
||||
canEdit,
|
||||
minimumLimit,
|
||||
plan: subscription?.plan || 'free',
|
||||
setBy: stats.usageLimitSetBy,
|
||||
updatedAt: stats.usageLimitUpdatedAt,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage limit info', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize usage limits for a new user
|
||||
*/
|
||||
export async function initializeUserUsageLimit(userId: string): Promise<void> {
|
||||
try {
|
||||
// Check if user already has usage stats
|
||||
const existingStats = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existingStats.length > 0) {
|
||||
return // User already has usage stats, don't override
|
||||
}
|
||||
|
||||
// Create initial usage stats with default $5 limit
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
currentUsageLimit: '5', // Default $5 for new users
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
billingPeriodStart: new Date(), // Start billing period immediately
|
||||
})
|
||||
|
||||
logger.info('Initialized usage limit for new user', { userId, limit: 5 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize usage limit', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's custom usage limit
|
||||
*/
|
||||
export async function updateUserUsageLimit(
|
||||
userId: string,
|
||||
newLimit: number,
|
||||
setBy?: string // For team admin tracking
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
// Check if user can edit limits
|
||||
let canEdit = canEditUsageLimit(subscription)
|
||||
|
||||
if (subscription?.plan === 'team') {
|
||||
// For team plans, the subscription referenceId should be the organization ID
|
||||
// Check user's role in that organization
|
||||
const orgMemberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, subscription.referenceId)))
|
||||
.limit(1)
|
||||
|
||||
if (orgMemberRecord.length > 0) {
|
||||
const userRole = orgMemberRecord[0].role
|
||||
// Team owners and admins can edit their own usage limits
|
||||
// Regular team members cannot edit their own limits
|
||||
canEdit = canEdit && ['owner', 'admin'].includes(userRole)
|
||||
} else {
|
||||
// User is not a member of the organization, should not be able to edit
|
||||
canEdit = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
if (subscription?.plan === 'team') {
|
||||
return { success: false, error: 'Only team owners and admins can edit usage limits' }
|
||||
}
|
||||
return { success: false, error: 'Free plan users cannot edit usage limits' }
|
||||
}
|
||||
|
||||
// Use plan-based minimums instead of role-based minimums
|
||||
let minimumLimit: number
|
||||
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
// Free plan users (shouldn't reach here due to canEditUsageLimit check above)
|
||||
minimumLimit = 5
|
||||
} else if (subscription.plan === 'pro') {
|
||||
// Pro plan users: $20 minimum
|
||||
minimumLimit = 20
|
||||
} else if (subscription.plan === 'team') {
|
||||
// Team plan users: $40 minimum (per-seat allocation, regardless of role)
|
||||
minimumLimit = 40
|
||||
} else if (subscription.plan === 'enterprise') {
|
||||
// Enterprise plan users: per-seat allocation from their plan
|
||||
const metadata = subscription.metadata || {}
|
||||
if (metadata.perSeatAllowance) {
|
||||
minimumLimit = Number.parseFloat(metadata.perSeatAllowance)
|
||||
} else if (metadata.totalAllowance) {
|
||||
// For total allowance, use per-seat calculation
|
||||
const seats = subscription.seats || 1
|
||||
minimumLimit = Number.parseFloat(metadata.totalAllowance) / seats
|
||||
} else {
|
||||
minimumLimit = 200 // Default enterprise per-seat limit
|
||||
}
|
||||
} else {
|
||||
// Fallback to plan-based calculation
|
||||
minimumLimit = calculateDefaultUsageLimit(subscription)
|
||||
}
|
||||
|
||||
logger.info('Applying plan-based validation', {
|
||||
userId,
|
||||
newLimit,
|
||||
minimumLimit,
|
||||
plan: subscription?.plan,
|
||||
})
|
||||
|
||||
// Validate new limit is not below minimum
|
||||
if (newLimit < minimumLimit) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Usage limit cannot be below plan minimum of $${minimumLimit}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Update the usage limit
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: newLimit.toString(),
|
||||
usageLimitSetBy: setBy || userId,
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Updated user usage limit', {
|
||||
userId,
|
||||
newLimit,
|
||||
setBy: setBy || userId,
|
||||
planMinimum: minimumLimit,
|
||||
plan: subscription?.plan,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to update usage limit', { userId, newLimit, error })
|
||||
return { success: false, error: 'Failed to update usage limit' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage limit for a user (simple version)
|
||||
*/
|
||||
export async function getUserUsageLimit(userId: string): Promise<number> {
|
||||
try {
|
||||
const userStatsQuery = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsQuery.length === 0) {
|
||||
// User doesn't have stats yet, initialize and return default
|
||||
await initializeUserUsageLimit(userId)
|
||||
return 5 // Default free plan limit
|
||||
}
|
||||
|
||||
return Number.parseFloat(userStatsQuery[0].currentUsageLimit)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user usage limit', { userId, error })
|
||||
return 5 // Fallback to safe default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check usage status with warning thresholds
|
||||
*/
|
||||
export async function checkUsageStatus(userId: string): Promise<{
|
||||
status: 'ok' | 'warning' | 'exceeded'
|
||||
usageData: UsageData
|
||||
}> {
|
||||
try {
|
||||
const usageData = await getUserUsageData(userId)
|
||||
|
||||
let status: 'ok' | 'warning' | 'exceeded' = 'ok'
|
||||
if (usageData.isExceeded) {
|
||||
status = 'exceeded'
|
||||
} else if (usageData.isWarning) {
|
||||
status = 'warning'
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
usageData,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check usage status', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync usage limits based on subscription changes
|
||||
*/
|
||||
export async function syncUsageLimitsFromSubscription(userId: string): Promise<void> {
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const defaultLimit = calculateDefaultUsageLimit(subscription)
|
||||
|
||||
// Get current user stats
|
||||
const currentUserStats = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (currentUserStats.length === 0) {
|
||||
// Create new user stats with default limit
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
currentUsageLimit: defaultLimit.toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
logger.info('Created usage stats with synced limit', { userId, limit: defaultLimit })
|
||||
return
|
||||
}
|
||||
|
||||
const currentStats = currentUserStats[0]
|
||||
const currentLimit = Number.parseFloat(currentStats.currentUsageLimit)
|
||||
|
||||
// Only update if subscription is free plan or if current limit is below new minimum
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
// User downgraded to free plan - cap at $5
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: '5',
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Synced usage limit to free plan', { userId, limit: 5 })
|
||||
} else if (currentLimit < defaultLimit) {
|
||||
// User upgraded and current limit is below new minimum - raise to minimum
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: defaultLimit.toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Synced usage limit to new minimum', {
|
||||
userId,
|
||||
oldLimit: currentLimit,
|
||||
newLimit: defaultLimit,
|
||||
})
|
||||
}
|
||||
// If user has higher custom limit, keep it unchanged
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync usage limits', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage limit information for team members (for admin dashboard)
|
||||
*/
|
||||
export async function getTeamUsageLimits(organizationId: string): Promise<
|
||||
Array<{
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentLimit: number
|
||||
currentUsage: number
|
||||
totalCost: number
|
||||
lastActive: Date | null
|
||||
limitSetBy: string | null
|
||||
limitUpdatedAt: Date | null
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const teamMembers = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentLimit: userStats.currentUsageLimit,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
totalCost: userStats.totalCost,
|
||||
lastActive: userStats.lastActive,
|
||||
limitSetBy: userStats.usageLimitSetBy,
|
||||
limitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
return teamMembers.map((memberData) => ({
|
||||
userId: memberData.userId,
|
||||
userName: memberData.userName,
|
||||
userEmail: memberData.userEmail,
|
||||
currentLimit: Number.parseFloat(memberData.currentLimit || '5'),
|
||||
currentUsage: Number.parseFloat(memberData.currentPeriodCost || '0'),
|
||||
totalCost: Number.parseFloat(memberData.totalCost || '0'),
|
||||
lastActive: memberData.lastActive,
|
||||
limitSetBy: memberData.limitSetBy,
|
||||
limitUpdatedAt: memberData.limitUpdatedAt,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Failed to get team usage limits', { organizationId, error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate billing projection based on current usage
|
||||
*/
|
||||
export async function calculateBillingProjection(userId: string): Promise<BillingData> {
|
||||
try {
|
||||
const usageData = await getUserUsageData(userId)
|
||||
|
||||
if (!usageData.billingPeriodStart || !usageData.billingPeriodEnd) {
|
||||
return {
|
||||
currentPeriodCost: usageData.currentUsage,
|
||||
projectedCost: usageData.currentUsage,
|
||||
limit: usageData.limit,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
daysRemaining: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const periodStart = new Date(usageData.billingPeriodStart)
|
||||
const periodEnd = new Date(usageData.billingPeriodEnd)
|
||||
|
||||
const totalDays = Math.ceil(
|
||||
(periodEnd.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
const daysElapsed = Math.ceil((now.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const daysRemaining = Math.max(0, totalDays - daysElapsed)
|
||||
|
||||
// Project cost based on daily usage rate
|
||||
const dailyRate = daysElapsed > 0 ? usageData.currentUsage / daysElapsed : 0
|
||||
const projectedCost = dailyRate * totalDays
|
||||
|
||||
return {
|
||||
currentPeriodCost: usageData.currentUsage,
|
||||
projectedCost: Math.min(projectedCost, usageData.limit), // Cap at limit
|
||||
limit: usageData.limit,
|
||||
billingPeriodStart: usageData.billingPeriodStart,
|
||||
billingPeriodEnd: usageData.billingPeriodEnd,
|
||||
daysRemaining,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to calculate billing projection', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
33
apps/sim/lib/billing/index.ts
Normal file
33
apps/sim/lib/billing/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Billing System - Main Entry Point
|
||||
* Provides clean, organized exports for the billing system
|
||||
*/
|
||||
|
||||
export * from './calculations/usage-monitor'
|
||||
export * from './core/billing'
|
||||
export * from './core/billing-periods'
|
||||
export * from './core/organization-billing'
|
||||
export * from './core/subscription'
|
||||
export {
|
||||
getHighestPrioritySubscription as getActiveSubscription,
|
||||
getUserSubscriptionState as getSubscriptionState,
|
||||
isEnterprisePlan as hasEnterprisePlan,
|
||||
isProPlan as hasProPlan,
|
||||
isTeamPlan as hasTeamPlan,
|
||||
} from './core/subscription'
|
||||
export * from './core/usage'
|
||||
export {
|
||||
checkUsageStatus,
|
||||
getTeamUsageLimits,
|
||||
getUserUsageData as getUsageData,
|
||||
getUserUsageLimit as getUsageLimit,
|
||||
updateUserUsageLimit as updateUsageLimit,
|
||||
} from './core/usage'
|
||||
export * from './subscriptions/utils'
|
||||
export {
|
||||
calculateDefaultUsageLimit as getDefaultLimit,
|
||||
canEditUsageLimit as canEditLimit,
|
||||
getMinimumUsageLimit as getMinimumLimit,
|
||||
} from './subscriptions/utils'
|
||||
export * from './types'
|
||||
export * from './validation/seat-management'
|
||||
91
apps/sim/lib/billing/stripe-client.ts
Normal file
91
apps/sim/lib/billing/stripe-client.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import Stripe from 'stripe'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('StripeClient')
|
||||
|
||||
/**
|
||||
* Check if Stripe credentials are valid
|
||||
*/
|
||||
export function hasValidStripeCredentials(): boolean {
|
||||
return !!(
|
||||
env.STRIPE_SECRET_KEY &&
|
||||
env.STRIPE_SECRET_KEY.trim() !== '' &&
|
||||
env.STRIPE_SECRET_KEY !== 'placeholder'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure Stripe client singleton with initialization guard
|
||||
*/
|
||||
const createStripeClientSingleton = () => {
|
||||
let stripeClient: Stripe | null = null
|
||||
let isInitializing = false
|
||||
|
||||
return {
|
||||
getInstance(): Stripe | null {
|
||||
// If already initialized, return immediately
|
||||
if (stripeClient) return stripeClient
|
||||
|
||||
// Prevent concurrent initialization attempts
|
||||
if (isInitializing) {
|
||||
logger.debug('Stripe client initialization already in progress')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasValidStripeCredentials()) {
|
||||
logger.warn('Stripe credentials not available - Stripe operations will be disabled')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
isInitializing = true
|
||||
|
||||
stripeClient = new Stripe(env.STRIPE_SECRET_KEY || '', {
|
||||
apiVersion: '2025-02-24.acacia',
|
||||
})
|
||||
|
||||
logger.info('Stripe client initialized successfully')
|
||||
return stripeClient
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize Stripe client', { error })
|
||||
stripeClient = null // Ensure cleanup on failure
|
||||
return null
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
},
|
||||
|
||||
// For testing purposes only - allows resetting the singleton
|
||||
reset(): void {
|
||||
stripeClient = null
|
||||
isInitializing = false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const stripeClientSingleton = createStripeClientSingleton()
|
||||
|
||||
/**
|
||||
* Get the Stripe client instance
|
||||
* @returns Stripe client or null if credentials are not available
|
||||
*/
|
||||
export function getStripeClient(): Stripe | null {
|
||||
return stripeClientSingleton.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Stripe client instance, throwing an error if not available
|
||||
* Use this when Stripe operations are required
|
||||
*/
|
||||
export function requireStripeClient(): Stripe {
|
||||
const client = getStripeClient()
|
||||
|
||||
if (!client) {
|
||||
throw new Error(
|
||||
'Stripe client is not available. Set STRIPE_SECRET_KEY in your environment variables.'
|
||||
)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { calculateUsageLimit, checkEnterprisePlan } from './utils'
|
||||
import { calculateDefaultUsageLimit, checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
vi.mock('../env', () => ({
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
FREE_TIER_COST_LIMIT: 5,
|
||||
PRO_TIER_COST_LIMIT: 20,
|
||||
@@ -25,25 +25,25 @@ describe('Subscription Utilities', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateUsageLimit', () => {
|
||||
describe('calculateDefaultUsageLimit', () => {
|
||||
it.concurrent('returns free-tier limit when subscription is null', () => {
|
||||
expect(calculateUsageLimit(null)).toBe(5)
|
||||
expect(calculateDefaultUsageLimit(null)).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('returns free-tier limit when subscription is undefined', () => {
|
||||
expect(calculateUsageLimit(undefined)).toBe(5)
|
||||
expect(calculateDefaultUsageLimit(undefined)).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('returns free-tier limit when subscription is not active', () => {
|
||||
expect(calculateUsageLimit({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(5)
|
||||
expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('returns pro limit for active pro plan', () => {
|
||||
expect(calculateUsageLimit({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
|
||||
expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
|
||||
})
|
||||
|
||||
it.concurrent('returns team limit multiplied by seats', () => {
|
||||
expect(calculateUsageLimit({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
|
||||
expect(calculateDefaultUsageLimit({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
|
||||
})
|
||||
|
||||
it.concurrent('returns enterprise limit using perSeatAllowance metadata', () => {
|
||||
@@ -53,7 +53,7 @@ describe('Subscription Utilities', () => {
|
||||
seats: 10,
|
||||
metadata: { perSeatAllowance: '150' },
|
||||
}
|
||||
expect(calculateUsageLimit(sub)).toBe(10 * 150)
|
||||
expect(calculateDefaultUsageLimit(sub)).toBe(10 * 150)
|
||||
})
|
||||
|
||||
it.concurrent('returns enterprise limit using totalAllowance metadata', () => {
|
||||
@@ -63,12 +63,12 @@ describe('Subscription Utilities', () => {
|
||||
seats: 8,
|
||||
metadata: { totalAllowance: '5000' },
|
||||
}
|
||||
expect(calculateUsageLimit(sub)).toBe(5000)
|
||||
expect(calculateDefaultUsageLimit(sub)).toBe(5000)
|
||||
})
|
||||
|
||||
it.concurrent('falls back to default enterprise tier when metadata missing', () => {
|
||||
const sub = { plan: 'enterprise', status: 'active', seats: 2, metadata: {} }
|
||||
expect(calculateUsageLimit(sub)).toBe(2 * 200)
|
||||
expect(calculateDefaultUsageLimit(sub)).toBe(2 * 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
77
apps/sim/lib/billing/subscriptions/utils.ts
Normal file
77
apps/sim/lib/billing/subscriptions/utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
export function checkEnterprisePlan(subscription: any): boolean {
|
||||
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
|
||||
}
|
||||
|
||||
export function checkProPlan(subscription: any): boolean {
|
||||
return subscription?.plan === 'pro' && subscription?.status === 'active'
|
||||
}
|
||||
|
||||
export function checkTeamPlan(subscription: any): boolean {
|
||||
return subscription?.plan === 'team' && subscription?.status === 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate default usage limit for a subscription based on its type and metadata
|
||||
* This is now used as the minimum limit for paid plans
|
||||
* @param subscription The subscription object
|
||||
* @returns The calculated default usage limit in dollars
|
||||
*/
|
||||
export function calculateDefaultUsageLimit(subscription: any): number {
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
return env.FREE_TIER_COST_LIMIT || 0
|
||||
}
|
||||
|
||||
const seats = subscription.seats || 1
|
||||
|
||||
if (subscription.plan === 'pro') {
|
||||
return env.PRO_TIER_COST_LIMIT || 0
|
||||
}
|
||||
if (subscription.plan === 'team') {
|
||||
return seats * (env.TEAM_TIER_COST_LIMIT || 0)
|
||||
}
|
||||
if (subscription.plan === 'enterprise') {
|
||||
const metadata = subscription.metadata || {}
|
||||
|
||||
if (metadata.perSeatAllowance) {
|
||||
return seats * Number.parseFloat(metadata.perSeatAllowance)
|
||||
}
|
||||
|
||||
if (metadata.totalAllowance) {
|
||||
return Number.parseFloat(metadata.totalAllowance)
|
||||
}
|
||||
|
||||
return seats * (env.ENTERPRISE_TIER_COST_LIMIT || 0)
|
||||
}
|
||||
|
||||
return env.FREE_TIER_COST_LIMIT || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can edit their usage limits based on their subscription
|
||||
* Free plan users cannot edit limits, paid plan users can
|
||||
* @param subscription The subscription object
|
||||
* @returns Whether the user can edit their usage limits
|
||||
*/
|
||||
export function canEditUsageLimit(subscription: any): boolean {
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
return false // Free plan users cannot edit limits
|
||||
}
|
||||
|
||||
return (
|
||||
subscription.plan === 'pro' ||
|
||||
subscription.plan === 'team' ||
|
||||
subscription.plan === 'enterprise'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum allowed usage limit for a subscription
|
||||
* This prevents users from setting limits below their plan's base amount
|
||||
* @param subscription The subscription object
|
||||
* @returns The minimum allowed usage limit in dollars
|
||||
*/
|
||||
export function getMinimumUsageLimit(subscription: any): number {
|
||||
return calculateDefaultUsageLimit(subscription)
|
||||
}
|
||||
219
apps/sim/lib/billing/types/index.ts
Normal file
219
apps/sim/lib/billing/types/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Billing System Types
|
||||
* Centralized type definitions for the billing system
|
||||
*/
|
||||
|
||||
export interface SubscriptionFeatures {
|
||||
sharingEnabled: boolean
|
||||
multiplayerEnabled: boolean
|
||||
workspaceCollaborationEnabled: boolean
|
||||
}
|
||||
|
||||
export interface UsageData {
|
||||
currentUsage: number
|
||||
limit: number
|
||||
percentUsed: number
|
||||
isWarning: boolean
|
||||
isExceeded: boolean
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
lastPeriodCost: number
|
||||
}
|
||||
|
||||
export interface UsageLimitInfo {
|
||||
currentLimit: number
|
||||
canEdit: boolean
|
||||
minimumLimit: number
|
||||
plan: string
|
||||
setBy: string | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
|
||||
export interface BillingData {
|
||||
currentPeriodCost: number
|
||||
projectedCost: number
|
||||
limit: number
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
daysRemaining: number
|
||||
}
|
||||
|
||||
export interface UserSubscriptionState {
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
isFree: boolean
|
||||
highestPrioritySubscription: any | null
|
||||
features: SubscriptionFeatures
|
||||
hasExceededLimit: boolean
|
||||
planName: string
|
||||
}
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
name: string
|
||||
priceId: string
|
||||
limits: {
|
||||
cost: number
|
||||
sharingEnabled: number
|
||||
multiplayerEnabled: number
|
||||
workspaceCollaborationEnabled: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface BillingEntity {
|
||||
id: string
|
||||
type: 'user' | 'organization'
|
||||
referenceId: string
|
||||
metadata?: { stripeCustomerId?: string; [key: string]: any } | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface BillingConfig {
|
||||
id: string
|
||||
entityType: 'user' | 'organization'
|
||||
entityId: string
|
||||
usageLimit: number
|
||||
limitSetBy?: string
|
||||
limitUpdatedAt?: Date
|
||||
billingPeriodType: 'monthly' | 'annual'
|
||||
autoResetEnabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface UsagePeriod {
|
||||
id: string
|
||||
entityType: 'user' | 'organization'
|
||||
entityId: string
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
totalCost: number
|
||||
finalCost?: number
|
||||
isCurrent: boolean
|
||||
status: 'active' | 'finalized' | 'billed'
|
||||
createdAt: Date
|
||||
finalizedAt?: Date
|
||||
}
|
||||
|
||||
export interface BillingStatus {
|
||||
status: 'ok' | 'warning' | 'exceeded'
|
||||
usageData: UsageData
|
||||
}
|
||||
|
||||
export interface TeamUsageLimit {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentLimit: number
|
||||
currentUsage: number
|
||||
totalCost: number
|
||||
lastActive: Date | null
|
||||
limitSetBy: string | null
|
||||
limitUpdatedAt: Date | null
|
||||
}
|
||||
|
||||
export interface BillingSummary {
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
currentPeriodCost: number
|
||||
currentUsageLimit: number
|
||||
currentUsagePercentage: number
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
plan: string
|
||||
subscriptionStatus: string | null
|
||||
seats: number | null
|
||||
billingStatus: 'ok' | 'warning' | 'exceeded'
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface SubscriptionAPIResponse {
|
||||
isPaid: boolean
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
plan: string
|
||||
status: string | null
|
||||
seats: number | null
|
||||
metadata: any | null
|
||||
features: SubscriptionFeatures
|
||||
usage: UsageData
|
||||
}
|
||||
|
||||
export interface UsageLimitAPIResponse {
|
||||
currentLimit: number
|
||||
canEdit: boolean
|
||||
minimumLimit: number
|
||||
plan: string
|
||||
setBy?: string
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
// Utility Types
|
||||
export type PlanType = 'free' | 'pro' | 'team' | 'enterprise'
|
||||
export type SubscriptionStatus =
|
||||
| 'active'
|
||||
| 'canceled'
|
||||
| 'past_due'
|
||||
| 'unpaid'
|
||||
| 'trialing'
|
||||
| 'incomplete'
|
||||
| 'incomplete_expired'
|
||||
export type BillingEntityType = 'user' | 'organization'
|
||||
export type BillingPeriodType = 'monthly' | 'annual'
|
||||
export type UsagePeriodStatus = 'active' | 'finalized' | 'billed'
|
||||
export type BillingStatusType = 'ok' | 'warning' | 'exceeded'
|
||||
|
||||
// Error Types
|
||||
export interface BillingError {
|
||||
code: string
|
||||
message: string
|
||||
details?: any
|
||||
}
|
||||
|
||||
export interface UpdateUsageLimitResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Hook Types for React
|
||||
export interface UseSubscriptionStateReturn {
|
||||
subscription: {
|
||||
isPaid: boolean
|
||||
isPro: boolean
|
||||
isTeam: boolean
|
||||
isEnterprise: boolean
|
||||
isFree: boolean
|
||||
plan: string
|
||||
status?: string
|
||||
seats?: number
|
||||
metadata?: any
|
||||
}
|
||||
features: SubscriptionFeatures
|
||||
usage: UsageData
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
refetch: () => Promise<any>
|
||||
hasFeature: (feature: keyof SubscriptionFeatures) => boolean
|
||||
isAtLeastPro: () => boolean
|
||||
isAtLeastTeam: () => boolean
|
||||
canUpgrade: () => boolean
|
||||
getBillingStatus: () => BillingStatusType
|
||||
getRemainingBudget: () => number
|
||||
getDaysRemainingInPeriod: () => number | null
|
||||
}
|
||||
|
||||
export interface UseUsageLimitReturn {
|
||||
currentLimit: number
|
||||
canEdit: boolean
|
||||
minimumLimit: number
|
||||
plan: string
|
||||
setBy?: string
|
||||
updatedAt?: Date
|
||||
updateLimit: (newLimit: number) => Promise<{ success: boolean }>
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
refetch: () => Promise<any>
|
||||
}
|
||||
451
apps/sim/lib/billing/validation/seat-management.ts
Normal file
451
apps/sim/lib/billing/validation/seat-management.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, organization, subscription, user, userStats } from '@/db/schema'
|
||||
import { getHighestPrioritySubscription } from '../core/subscription'
|
||||
|
||||
const logger = createLogger('SeatManagement')
|
||||
|
||||
interface SeatValidationResult {
|
||||
canInvite: boolean
|
||||
reason?: string
|
||||
currentSeats: number
|
||||
maxSeats: number
|
||||
availableSeats: number
|
||||
}
|
||||
|
||||
interface OrganizationSeatInfo {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
currentSeats: number
|
||||
maxSeats: number
|
||||
availableSeats: number
|
||||
subscriptionPlan: string
|
||||
canAddSeats: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if an organization can invite new members based on seat limits
|
||||
*/
|
||||
export async function validateSeatAvailability(
|
||||
organizationId: string,
|
||||
additionalSeats = 1
|
||||
): Promise<SeatValidationResult> {
|
||||
try {
|
||||
// Get organization subscription
|
||||
const subscription = await getHighestPrioritySubscription(organizationId)
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
canInvite: false,
|
||||
reason: 'No active subscription found',
|
||||
currentSeats: 0,
|
||||
maxSeats: 0,
|
||||
availableSeats: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Free and Pro plans don't support organizations
|
||||
if (['free', 'pro'].includes(subscription.plan)) {
|
||||
return {
|
||||
canInvite: false,
|
||||
reason: 'Organization features require Team or Enterprise plan',
|
||||
currentSeats: 0,
|
||||
maxSeats: 0,
|
||||
availableSeats: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Get current member count
|
||||
const memberCount = await db
|
||||
.select({ count: count() })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const currentSeats = memberCount[0]?.count || 0
|
||||
|
||||
// Determine seat limits based on subscription
|
||||
let maxSeats = subscription.seats || 1
|
||||
|
||||
// For enterprise plans, check metadata for custom seat allowances
|
||||
if (subscription.plan === 'enterprise' && subscription.metadata) {
|
||||
try {
|
||||
const metadata = JSON.parse(subscription.metadata)
|
||||
if (metadata.maxSeats) {
|
||||
maxSeats = metadata.maxSeats
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse enterprise subscription metadata', {
|
||||
organizationId,
|
||||
metadata: subscription.metadata,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const availableSeats = Math.max(0, maxSeats - currentSeats)
|
||||
const canInvite = availableSeats >= additionalSeats
|
||||
|
||||
const result: SeatValidationResult = {
|
||||
canInvite,
|
||||
currentSeats,
|
||||
maxSeats,
|
||||
availableSeats,
|
||||
}
|
||||
|
||||
if (!canInvite) {
|
||||
if (additionalSeats === 1) {
|
||||
result.reason = `No available seats. Currently using ${currentSeats} of ${maxSeats} seats.`
|
||||
} else {
|
||||
result.reason = `Not enough available seats. Need ${additionalSeats} seats, but only ${availableSeats} available.`
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Seat validation result', {
|
||||
organizationId,
|
||||
additionalSeats,
|
||||
result,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate seat availability', { organizationId, additionalSeats, error })
|
||||
return {
|
||||
canInvite: false,
|
||||
reason: 'Failed to check seat availability',
|
||||
currentSeats: 0,
|
||||
maxSeats: 0,
|
||||
availableSeats: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive seat information for an organization
|
||||
*/
|
||||
export async function getOrganizationSeatInfo(
|
||||
organizationId: string
|
||||
): Promise<OrganizationSeatInfo | null> {
|
||||
try {
|
||||
// Get organization details
|
||||
const organizationData = await db
|
||||
.select({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
})
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (organizationData.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get subscription
|
||||
const subscription = await getHighestPrioritySubscription(organizationId)
|
||||
|
||||
if (!subscription) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get current member count
|
||||
const memberCount = await db
|
||||
.select({ count: count() })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const currentSeats = memberCount[0]?.count || 0
|
||||
|
||||
// Determine seat limits
|
||||
let maxSeats = subscription.seats || 1
|
||||
let canAddSeats = true
|
||||
|
||||
if (subscription.plan === 'enterprise' && subscription.metadata) {
|
||||
try {
|
||||
const metadata = JSON.parse(subscription.metadata)
|
||||
if (metadata.maxSeats) {
|
||||
maxSeats = metadata.maxSeats
|
||||
}
|
||||
// Enterprise plans might have fixed seat counts
|
||||
canAddSeats = !metadata.fixedSeats
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse enterprise subscription metadata', { organizationId, error })
|
||||
}
|
||||
}
|
||||
|
||||
const availableSeats = Math.max(0, maxSeats - currentSeats)
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
organizationName: organizationData[0].name,
|
||||
currentSeats,
|
||||
maxSeats,
|
||||
availableSeats,
|
||||
subscriptionPlan: subscription.plan,
|
||||
canAddSeats,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization seat info', { organizationId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and reserve seats for bulk invitations
|
||||
*/
|
||||
export async function validateBulkInvitations(
|
||||
organizationId: string,
|
||||
emailList: string[]
|
||||
): Promise<{
|
||||
canInviteAll: boolean
|
||||
validEmails: string[]
|
||||
duplicateEmails: string[]
|
||||
existingMembers: string[]
|
||||
seatsNeeded: number
|
||||
seatsAvailable: number
|
||||
validationResult: SeatValidationResult
|
||||
}> {
|
||||
try {
|
||||
// Remove duplicates and validate email format
|
||||
const uniqueEmails = [...new Set(emailList)]
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const validEmails = uniqueEmails.filter((email) => emailRegex.test(email))
|
||||
const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index)
|
||||
|
||||
// Check for existing members
|
||||
const existingMembers = await db
|
||||
.select({ userEmail: user.email })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const existingEmails = existingMembers.map((m) => m.userEmail)
|
||||
const newEmails = validEmails.filter((email) => !existingEmails.includes(email))
|
||||
|
||||
// Check for pending invitations
|
||||
const pendingInvitations = await db
|
||||
.select({ email: invitation.email })
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
|
||||
|
||||
const pendingEmails = pendingInvitations.map((i) => i.email)
|
||||
const finalEmailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
|
||||
|
||||
// Validate seat availability
|
||||
const seatsNeeded = finalEmailsToInvite.length
|
||||
const validationResult = await validateSeatAvailability(organizationId, seatsNeeded)
|
||||
|
||||
return {
|
||||
canInviteAll: validationResult.canInvite && finalEmailsToInvite.length > 0,
|
||||
validEmails: finalEmailsToInvite,
|
||||
duplicateEmails,
|
||||
existingMembers: validEmails.filter((email) => existingEmails.includes(email)),
|
||||
seatsNeeded,
|
||||
seatsAvailable: validationResult.availableSeats,
|
||||
validationResult,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate bulk invitations', {
|
||||
organizationId,
|
||||
emailCount: emailList.length,
|
||||
error,
|
||||
})
|
||||
|
||||
const validationResult: SeatValidationResult = {
|
||||
canInvite: false,
|
||||
reason: 'Validation failed',
|
||||
currentSeats: 0,
|
||||
maxSeats: 0,
|
||||
availableSeats: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
canInviteAll: false,
|
||||
validEmails: [],
|
||||
duplicateEmails: [],
|
||||
existingMembers: [],
|
||||
seatsNeeded: 0,
|
||||
seatsAvailable: 0,
|
||||
validationResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization seat count in subscription
|
||||
*/
|
||||
export async function updateOrganizationSeats(
|
||||
organizationId: string,
|
||||
newSeatCount: number,
|
||||
updatedBy: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Get current subscription
|
||||
const subscriptionRecord = await getHighestPrioritySubscription(organizationId)
|
||||
|
||||
if (!subscriptionRecord) {
|
||||
return { success: false, error: 'No active subscription found' }
|
||||
}
|
||||
|
||||
// Validate minimum seat requirements
|
||||
const memberCount = await db
|
||||
.select({ count: count() })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const currentMembers = memberCount[0]?.count || 0
|
||||
|
||||
if (newSeatCount < currentMembers) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot reduce seats below current member count (${currentMembers})`,
|
||||
}
|
||||
}
|
||||
|
||||
// Update subscription seat count
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({
|
||||
seats: newSeatCount,
|
||||
})
|
||||
.where(eq(subscription.id, subscriptionRecord.id))
|
||||
|
||||
logger.info('Organization seat count updated', {
|
||||
organizationId,
|
||||
oldSeatCount: subscriptionRecord.seats,
|
||||
newSeatCount,
|
||||
updatedBy,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to update organization seats', {
|
||||
organizationId,
|
||||
newSeatCount,
|
||||
updatedBy,
|
||||
error,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can be removed from an organization
|
||||
*/
|
||||
export async function validateMemberRemoval(
|
||||
organizationId: string,
|
||||
userIdToRemove: string,
|
||||
removedBy: string
|
||||
): Promise<{ canRemove: boolean; reason?: string }> {
|
||||
try {
|
||||
// Get member details
|
||||
const memberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userIdToRemove)))
|
||||
.limit(1)
|
||||
|
||||
if (memberRecord.length === 0) {
|
||||
return { canRemove: false, reason: 'Member not found in organization' }
|
||||
}
|
||||
|
||||
// Check if trying to remove the organization owner
|
||||
if (memberRecord[0].role === 'owner') {
|
||||
return { canRemove: false, reason: 'Cannot remove organization owner' }
|
||||
}
|
||||
|
||||
// Check if the person removing has sufficient permissions
|
||||
const removerMemberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, removedBy)))
|
||||
.limit(1)
|
||||
|
||||
if (removerMemberRecord.length === 0) {
|
||||
return { canRemove: false, reason: 'You are not a member of this organization' }
|
||||
}
|
||||
|
||||
const removerRole = removerMemberRecord[0].role
|
||||
const targetRole = memberRecord[0].role
|
||||
|
||||
// Permission hierarchy: owner > admin > member
|
||||
if (removerRole === 'owner') {
|
||||
// Owners can remove anyone except themselves
|
||||
return userIdToRemove === removedBy
|
||||
? { canRemove: false, reason: 'Cannot remove yourself as owner' }
|
||||
: { canRemove: true }
|
||||
}
|
||||
|
||||
if (removerRole === 'admin') {
|
||||
// Admins can remove members but not other admins or owners
|
||||
return targetRole === 'member'
|
||||
? { canRemove: true }
|
||||
: { canRemove: false, reason: 'Insufficient permissions to remove this member' }
|
||||
}
|
||||
|
||||
// Members cannot remove other members
|
||||
return { canRemove: false, reason: 'Insufficient permissions' }
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate member removal', {
|
||||
organizationId,
|
||||
userIdToRemove,
|
||||
removedBy,
|
||||
error,
|
||||
})
|
||||
|
||||
return { canRemove: false, reason: 'Validation failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get seat usage analytics for an organization
|
||||
*/
|
||||
export async function getOrganizationSeatAnalytics(organizationId: string) {
|
||||
try {
|
||||
const seatInfo = await getOrganizationSeatInfo(organizationId)
|
||||
|
||||
if (!seatInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get member activity data
|
||||
const memberActivity = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
lastActive: userStats.lastActive,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
// Calculate utilization metrics
|
||||
const utilizationRate =
|
||||
seatInfo.maxSeats > 0 ? (seatInfo.currentSeats / seatInfo.maxSeats) * 100 : 0
|
||||
|
||||
const recentlyActive = memberActivity.filter((memberData) => {
|
||||
if (!memberData.lastActive) return false
|
||||
const daysSinceActive = (Date.now() - memberData.lastActive.getTime()) / (1000 * 60 * 60 * 24)
|
||||
return daysSinceActive <= 30 // Active in last 30 days
|
||||
}).length
|
||||
|
||||
return {
|
||||
...seatInfo,
|
||||
utilizationRate: Math.round(utilizationRate * 100) / 100,
|
||||
activeMembers: recentlyActive,
|
||||
inactiveMembers: seatInfo.currentSeats - recentlyActive,
|
||||
memberActivity,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization seat analytics', { organizationId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
152
apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts
Normal file
152
apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type Stripe from 'stripe'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhooks')
|
||||
|
||||
/**
|
||||
* Handle invoice payment succeeded webhook
|
||||
* This is triggered when a user successfully pays a usage billing invoice
|
||||
*/
|
||||
export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
// Check if this is an overage billing invoice
|
||||
if (invoice.metadata?.type !== 'overage_billing') {
|
||||
logger.info('Ignoring non-overage billing invoice', { invoiceId: invoice.id })
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = invoice.customer as string
|
||||
const chargedAmount = invoice.amount_paid / 100 // Convert from cents to dollars
|
||||
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
||||
|
||||
logger.info('Overage billing invoice payment succeeded', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
chargedAmount,
|
||||
billingPeriod,
|
||||
customerEmail: invoice.customer_email,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})
|
||||
|
||||
// Additional payment success logic can be added here
|
||||
// For example: update internal billing status, trigger analytics events, etc.
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice payment succeeded', {
|
||||
eventId: event.id,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice payment failed webhook
|
||||
* This is triggered when a user's payment fails for a usage billing invoice
|
||||
*/
|
||||
export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
// Check if this is an overage billing invoice
|
||||
if (invoice.metadata?.type !== 'overage_billing') {
|
||||
logger.info('Ignoring non-overage billing invoice payment failure', { invoiceId: invoice.id })
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = invoice.customer as string
|
||||
const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars
|
||||
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
||||
const attemptCount = invoice.attempt_count || 1
|
||||
|
||||
logger.warn('Overage billing invoice payment failed', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
failedAmount,
|
||||
billingPeriod,
|
||||
attemptCount,
|
||||
customerEmail: invoice.customer_email,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})
|
||||
|
||||
// Implement dunning management logic here
|
||||
// For example: suspend service after multiple failures, notify admins, etc.
|
||||
if (attemptCount >= 3) {
|
||||
logger.error('Multiple payment failures for overage billing', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
attemptCount,
|
||||
})
|
||||
|
||||
// Could implement service suspension here
|
||||
// await suspendUserService(customerId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice payment failed', {
|
||||
eventId: event.id,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice finalized webhook
|
||||
* This is triggered when a usage billing invoice is finalized and ready for payment
|
||||
*/
|
||||
export async function handleInvoiceFinalized(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
// Check if this is an overage billing invoice
|
||||
if (invoice.metadata?.type !== 'overage_billing') {
|
||||
logger.info('Ignoring non-overage billing invoice finalization', { invoiceId: invoice.id })
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = invoice.customer as string
|
||||
const invoiceAmount = invoice.amount_due / 100 // Convert from cents to dollars
|
||||
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
||||
|
||||
logger.info('Overage billing invoice finalized', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
invoiceAmount,
|
||||
billingPeriod,
|
||||
customerEmail: invoice.customer_email,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})
|
||||
|
||||
// Additional invoice finalization logic can be added here
|
||||
// For example: update internal records, trigger notifications, etc.
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice finalized', {
|
||||
eventId: event.id,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main webhook handler for all invoice-related events
|
||||
*/
|
||||
export async function handleInvoiceWebhook(event: Stripe.Event) {
|
||||
switch (event.type) {
|
||||
case 'invoice.payment_succeeded':
|
||||
await handleInvoicePaymentSucceeded(event)
|
||||
break
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
await handleInvoicePaymentFailed(event)
|
||||
break
|
||||
|
||||
case 'invoice.finalized':
|
||||
await handleInvoiceFinalized(event)
|
||||
break
|
||||
|
||||
default:
|
||||
logger.info('Unhandled invoice webhook event', { eventType: event.type })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getCostMultiplier } from '@/lib/environment'
|
||||
import { db } from '@/db'
|
||||
import { workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema'
|
||||
import { userStats, workflow, workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema'
|
||||
import { createLogger } from './console-logger'
|
||||
import { snapshotService } from './snapshot-service'
|
||||
import type {
|
||||
@@ -18,6 +19,17 @@ import type {
|
||||
WorkflowState,
|
||||
} from './types'
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
duration: number // in milliseconds
|
||||
startTime: string // ISO timestamp
|
||||
endTime: string // ISO timestamp
|
||||
status: 'success' | 'error'
|
||||
input?: Record<string, any>
|
||||
output?: Record<string, any>
|
||||
error?: string
|
||||
}
|
||||
|
||||
const logger = createLogger('EnhancedExecutionLogger')
|
||||
|
||||
export class EnhancedExecutionLogger implements IExecutionLoggerService {
|
||||
@@ -119,6 +131,7 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
cost?: CostBreakdown
|
||||
metadata?: BlockExecutionLog['metadata']
|
||||
toolCalls?: ToolCall[]
|
||||
}): Promise<BlockExecutionLog> {
|
||||
const {
|
||||
executionId,
|
||||
@@ -133,6 +146,7 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
|
||||
error,
|
||||
cost,
|
||||
metadata,
|
||||
toolCalls,
|
||||
} = params
|
||||
|
||||
logger.debug(`Logging block execution ${blockId} for execution ${executionId}`)
|
||||
@@ -163,7 +177,10 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
|
||||
tokensCompletion: cost?.tokens?.completion || null,
|
||||
tokensTotal: cost?.tokens?.total || null,
|
||||
modelUsed: cost?.model || null,
|
||||
metadata: metadata || {},
|
||||
metadata: {
|
||||
...(metadata || {}),
|
||||
...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
@@ -266,6 +283,13 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
|
||||
throw new Error(`Workflow log not found for execution ${executionId}`)
|
||||
}
|
||||
|
||||
// Update user stats with cost information (same logic as original execution logger)
|
||||
await this.updateUserStats(
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type']
|
||||
)
|
||||
|
||||
logger.debug(`Completed workflow execution ${executionId}`)
|
||||
|
||||
return {
|
||||
@@ -370,6 +394,149 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user stats with cost and token information
|
||||
* Maintains same logic as original execution logger for billing consistency
|
||||
*/
|
||||
private async updateUserStats(
|
||||
workflowId: string,
|
||||
costSummary: {
|
||||
totalCost: number
|
||||
totalInputCost: number
|
||||
totalOutputCost: number
|
||||
totalTokens: number
|
||||
totalPromptTokens: number
|
||||
totalCompletionTokens: number
|
||||
},
|
||||
trigger: ExecutionTrigger['type']
|
||||
): Promise<void> {
|
||||
if (costSummary.totalCost <= 0) {
|
||||
logger.debug('No cost to update in user stats')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the workflow record to get the userId
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
logger.error(`Workflow ${workflowId} not found for user stats update`)
|
||||
return
|
||||
}
|
||||
|
||||
const userId = workflowRecord.userId
|
||||
const costMultiplier = getCostMultiplier()
|
||||
const costToStore = costSummary.totalCost * costMultiplier
|
||||
|
||||
// Check if user stats record exists
|
||||
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
if (userStatsRecords.length === 0) {
|
||||
// Create new user stats record with trigger-specific counts
|
||||
const triggerCounts = this.getTriggerCounts(trigger)
|
||||
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
totalManualExecutions: triggerCounts.manual,
|
||||
totalApiCalls: triggerCounts.api,
|
||||
totalWebhookTriggers: triggerCounts.webhook,
|
||||
totalScheduledExecutions: triggerCounts.schedule,
|
||||
totalChatExecutions: triggerCounts.chat,
|
||||
totalTokensUsed: costSummary.totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
currentPeriodCost: costToStore.toString(), // Initialize current period usage
|
||||
lastActive: new Date(),
|
||||
})
|
||||
|
||||
logger.debug('Created new user stats record with cost data', {
|
||||
userId,
|
||||
trigger,
|
||||
totalCost: costToStore,
|
||||
totalTokens: costSummary.totalTokens,
|
||||
})
|
||||
} else {
|
||||
// Update existing user stats record with trigger-specific increments
|
||||
const updateFields: any = {
|
||||
totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`, // Track current billing period usage
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
// Add trigger-specific increment
|
||||
switch (trigger) {
|
||||
case 'manual':
|
||||
updateFields.totalManualExecutions = sql`total_manual_executions + 1`
|
||||
break
|
||||
case 'api':
|
||||
updateFields.totalApiCalls = sql`total_api_calls + 1`
|
||||
break
|
||||
case 'webhook':
|
||||
updateFields.totalWebhookTriggers = sql`total_webhook_triggers + 1`
|
||||
break
|
||||
case 'schedule':
|
||||
updateFields.totalScheduledExecutions = sql`total_scheduled_executions + 1`
|
||||
break
|
||||
case 'chat':
|
||||
updateFields.totalChatExecutions = sql`total_chat_executions + 1`
|
||||
break
|
||||
}
|
||||
|
||||
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
|
||||
|
||||
logger.debug('Updated existing user stats record with cost data', {
|
||||
userId,
|
||||
trigger,
|
||||
addedCost: costToStore,
|
||||
addedTokens: costSummary.totalTokens,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating user stats with cost information', {
|
||||
workflowId,
|
||||
error,
|
||||
costSummary,
|
||||
})
|
||||
// Don't throw - we want execution to continue even if user stats update fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trigger counts for new user stats records
|
||||
*/
|
||||
private getTriggerCounts(trigger: ExecutionTrigger['type']): {
|
||||
manual: number
|
||||
api: number
|
||||
webhook: number
|
||||
schedule: number
|
||||
chat: number
|
||||
} {
|
||||
const counts = { manual: 0, api: 0, webhook: 0, schedule: 0, chat: 0 }
|
||||
switch (trigger) {
|
||||
case 'manual':
|
||||
counts.manual = 1
|
||||
break
|
||||
case 'api':
|
||||
counts.api = 1
|
||||
break
|
||||
case 'webhook':
|
||||
counts.webhook = 1
|
||||
break
|
||||
case 'schedule':
|
||||
counts.schedule = 1
|
||||
break
|
||||
case 'chat':
|
||||
counts.chat = 1
|
||||
break
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
private getTriggerPrefix(triggerType: ExecutionTrigger['type']): string {
|
||||
switch (triggerType) {
|
||||
case 'api':
|
||||
|
||||
@@ -632,6 +632,7 @@ export async function persistExecutionLogs(
|
||||
totalChatExecutions: 0,
|
||||
totalTokensUsed: totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
currentPeriodCost: costToStore.toString(), // Initialize current period usage
|
||||
lastActive: new Date(),
|
||||
})
|
||||
} else {
|
||||
@@ -640,6 +641,7 @@ export async function persistExecutionLogs(
|
||||
.set({
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`, // Track current billing period usage
|
||||
lastActive: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import { permissions, type permissionTypeEnum, user } from '@/db/schema'
|
||||
import { member, permissions, type permissionTypeEnum, user, workspace } from '@/db/schema'
|
||||
|
||||
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
@@ -96,3 +96,227 @@ export async function getUsersWithPermissions(workspaceId: string) {
|
||||
permissionType: row.permissionType,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is an admin or owner of any organization that has access to a workspace
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param workspaceId - The ID of the workspace
|
||||
* @returns Promise<boolean> - True if the user is an organization admin with access to the workspace
|
||||
*/
|
||||
export async function isOrganizationAdminForWorkspace(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Get the workspace owner
|
||||
const workspaceRecord = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceRecord.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const workspaceOwnerId = workspaceRecord[0].ownerId
|
||||
|
||||
// Check if the user is an admin/owner of any organization that the workspace owner belongs to
|
||||
const orgMemberships = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, userId),
|
||||
// Only admin and owner roles can manage workspace permissions
|
||||
eq(member.role, 'admin') // We'll also check for 'owner' separately
|
||||
)
|
||||
)
|
||||
|
||||
// Also check for owner role
|
||||
const ownerMemberships = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.role, 'owner')))
|
||||
|
||||
const allOrgMemberships = [...orgMemberships, ...ownerMemberships]
|
||||
|
||||
if (allOrgMemberships.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the workspace owner is a member of any of these organizations
|
||||
for (const membership of allOrgMemberships) {
|
||||
const workspaceOwnerInOrg = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, workspaceOwnerId),
|
||||
eq(member.organizationId, membership.organizationId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (workspaceOwnerInOrg.length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Error checking organization admin status for workspace:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has admin permissions (either direct workspace admin or organization admin)
|
||||
*
|
||||
* @param userId - The ID of the user to check permissions for
|
||||
* @param workspaceId - The ID of the workspace to check admin permission for
|
||||
* @returns Promise<boolean> - True if the user has admin permission for the workspace, false otherwise
|
||||
*/
|
||||
export async function hasWorkspaceAdminAccess(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
// Check direct workspace admin permission
|
||||
const directAdmin = await hasAdminPermission(userId, workspaceId)
|
||||
if (directAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check organization admin permission
|
||||
const orgAdmin = await isOrganizationAdminForWorkspace(userId, workspaceId)
|
||||
return orgAdmin
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all workspaces that a user can manage (either as direct admin or organization admin)
|
||||
*
|
||||
* @param userId - The ID of the user
|
||||
* @returns Promise<Array<{id: string, name: string, ownerId: string}>> - Array of workspaces the user can manage
|
||||
*/
|
||||
export async function getManageableWorkspaces(userId: string): Promise<
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
accessType: 'direct' | 'organization'
|
||||
}>
|
||||
> {
|
||||
const manageableWorkspaces: Array<{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
accessType: 'direct' | 'organization'
|
||||
}> = []
|
||||
|
||||
// Get workspaces where user has direct admin permissions
|
||||
const directWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.innerJoin(permissions, eq(permissions.entityId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
|
||||
directWorkspaces.forEach((ws) => {
|
||||
manageableWorkspaces.push({
|
||||
...ws,
|
||||
accessType: 'direct',
|
||||
})
|
||||
})
|
||||
|
||||
// Get workspaces where user has organization admin access
|
||||
// First, get organizations where the user is admin/owner
|
||||
const adminOrgs = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, userId)
|
||||
// Check for both admin and owner roles
|
||||
)
|
||||
)
|
||||
|
||||
// Get all organization workspaces for these orgs
|
||||
for (const org of adminOrgs) {
|
||||
// Get all members of this organization
|
||||
const orgMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, org.organizationId))
|
||||
|
||||
// Get workspaces owned by org members
|
||||
const orgWorkspaces = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(
|
||||
// Find workspaces owned by any org member
|
||||
eq(workspace.ownerId, orgMembers.length > 0 ? orgMembers[0].userId : 'none')
|
||||
)
|
||||
|
||||
// Add these workspaces if not already included
|
||||
orgWorkspaces.forEach((ws) => {
|
||||
if (!manageableWorkspaces.find((existing) => existing.id === ws.id)) {
|
||||
manageableWorkspaces.push({
|
||||
...ws,
|
||||
accessType: 'organization',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return manageableWorkspaces
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is an owner or admin of a specific organization
|
||||
*
|
||||
* @param userId - The ID of the user to check
|
||||
* @param organizationId - The ID of the organization
|
||||
* @returns Promise<boolean> - True if the user is an owner or admin of the organization
|
||||
*/
|
||||
export async function isOrganizationOwnerOrAdmin(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const memberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (memberRecord.length === 0) {
|
||||
return false // User is not a member of the organization
|
||||
}
|
||||
|
||||
const userRole = memberRecord[0].role
|
||||
return ['owner', 'admin'].includes(userRole)
|
||||
} catch (error) {
|
||||
console.error('Error checking organization ownership/admin status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { client } from './auth-client'
|
||||
import { env } from './env'
|
||||
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
/**
|
||||
* Check if the user is on the Pro plan
|
||||
*/
|
||||
export async function isProPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// In development, enable Pro 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 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 (either Pro or Team plan)
|
||||
const hasDirectProPlan = directSubscriptions.some(
|
||||
(sub) => sub.status === 'active' && (sub.plan === 'pro' || sub.plan === 'team')
|
||||
)
|
||||
|
||||
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<boolean> {
|
||||
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
|
||||
*/
|
||||
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// In development, users never exceed their limit
|
||||
if (!isProd) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get user's direct subscription
|
||||
const { data: directSubscriptions } = await client.subscription.list({
|
||||
query: { referenceId: userId },
|
||||
})
|
||||
|
||||
// Find active direct subscription
|
||||
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
|
||||
|
||||
// Get organizations the user belongs to
|
||||
const memberships = await db
|
||||
.select()
|
||||
.from(schema.member)
|
||||
.where(eq(schema.member.userId, userId))
|
||||
|
||||
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 = env.FREE_TIER_COST_LIMIT ?? 5
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
if (statsRecords.length === 0) {
|
||||
// No usage yet, so they haven't exceeded the limit
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the current cost and compare with the limit
|
||||
const currentCost = Number.parseFloat(statsRecords[0].totalCost.toString())
|
||||
|
||||
return currentCost >= highestCostLimit
|
||||
} catch (error) {
|
||||
logger.error('Error checking cost limit', { error, userId })
|
||||
return false // Be conservative in case of error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is allowed to share workflows based on their subscription plan
|
||||
*/
|
||||
export async function isSharingEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// In development, always allow sharing
|
||||
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 pro/team subscription with sharing enabled
|
||||
if (activeDirectSubscription?.limits?.sharingEnabled) {
|
||||
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 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?.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<boolean> {
|
||||
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?.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?.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<boolean> {
|
||||
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?.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?.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
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription, userStats } from '@/db/schema'
|
||||
import { client } from '../auth-client'
|
||||
import { env } from '../env'
|
||||
import { calculateUsageLimit, checkEnterprisePlan, checkProPlan, checkTeamPlan } from './utils'
|
||||
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
export async function isProPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const directSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, userId))
|
||||
|
||||
const hasDirectProPlan = directSubscriptions.some(checkProPlan)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function isTeamPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const memberships = await db.select().from(member).where(eq(member.userId, userId))
|
||||
|
||||
for (const membership of memberships) {
|
||||
const orgSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, membership.organizationId))
|
||||
|
||||
const orgHasTeamPlan = orgSubscriptions.some(
|
||||
(sub) => sub.status === 'active' && sub.plan === 'team'
|
||||
)
|
||||
|
||||
if (orgHasTeamPlan) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const directSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, userId))
|
||||
|
||||
const hasDirectTeamPlan = directSubscriptions.some(checkTeamPlan)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function isEnterprisePlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const memberships = await db.select().from(member).where(eq(member.userId, userId))
|
||||
|
||||
for (const membership of memberships) {
|
||||
const orgSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, membership.organizationId))
|
||||
|
||||
const orgHasEnterprisePlan = orgSubscriptions.some((sub) => checkEnterprisePlan(sub))
|
||||
|
||||
if (orgHasEnterprisePlan) {
|
||||
logger.info('User has enterprise plan via organization', {
|
||||
userId,
|
||||
orgId: membership.organizationId,
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const directSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, userId))
|
||||
|
||||
const hasDirectEnterprisePlan = directSubscriptions.some(checkEnterprisePlan)
|
||||
|
||||
if (hasDirectEnterprisePlan) {
|
||||
logger.info('User has direct enterprise plan', { userId })
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error checking enterprise plan status', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return false
|
||||
}
|
||||
|
||||
let activeSubscription = null
|
||||
|
||||
const userSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
|
||||
if (userSubscriptions.length > 0) {
|
||||
const enterpriseSub = userSubscriptions.find(checkEnterprisePlan)
|
||||
const teamSub = userSubscriptions.find(checkTeamPlan)
|
||||
const proSub = userSubscriptions.find(checkProPlan)
|
||||
|
||||
activeSubscription = enterpriseSub || teamSub || proSub || null
|
||||
}
|
||||
|
||||
if (!activeSubscription) {
|
||||
const memberships = await db.select().from(member).where(eq(member.userId, userId))
|
||||
|
||||
for (const membership of memberships) {
|
||||
const orgId = membership.organizationId
|
||||
|
||||
const orgSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, orgId), eq(subscription.status, 'active')))
|
||||
|
||||
if (orgSubscriptions.length > 0) {
|
||||
const orgEnterpriseSub = orgSubscriptions.find(checkEnterprisePlan)
|
||||
const orgTeamSub = orgSubscriptions.find(checkTeamPlan)
|
||||
const orgProSub = orgSubscriptions.find(checkProPlan)
|
||||
|
||||
activeSubscription = orgEnterpriseSub || orgTeamSub || orgProSub || null
|
||||
if (activeSubscription) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let limit = 0
|
||||
if (activeSubscription) {
|
||||
limit = calculateUsageLimit(activeSubscription)
|
||||
logger.info('Using calculated subscription limit', {
|
||||
userId,
|
||||
plan: activeSubscription.plan,
|
||||
seats: activeSubscription.seats || 1,
|
||||
limit,
|
||||
})
|
||||
} else {
|
||||
limit = env.FREE_TIER_COST_LIMIT || 5
|
||||
logger.info('Using free tier limit', { userId, limit })
|
||||
}
|
||||
|
||||
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
if (statsRecords.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentCost = Number.parseFloat(statsRecords[0].totalCost.toString())
|
||||
|
||||
logger.info('Checking cost limit', { userId, currentCost, limit })
|
||||
|
||||
return currentCost >= limit
|
||||
} catch (error) {
|
||||
logger.error('Error checking cost limit', { error, userId })
|
||||
return false // Be conservative in case of error
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSharingEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { data: directSubscriptions } = await client.subscription.list({
|
||||
query: { referenceId: userId },
|
||||
})
|
||||
|
||||
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
|
||||
|
||||
if (activeDirectSubscription?.limits?.sharingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
const memberships = await db.select().from(member).where(eq(member.userId, userId))
|
||||
|
||||
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?.limits?.sharingEnabled) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error checking sharing permission', { error, userId })
|
||||
return false // Be conservative in case of error
|
||||
}
|
||||
}
|
||||
|
||||
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { data: directSubscriptions } = await client.subscription.list({
|
||||
query: { referenceId: userId },
|
||||
})
|
||||
|
||||
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
|
||||
|
||||
if (activeDirectSubscription?.limits?.multiplayerEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
const memberships = await db.select().from(member).where(eq(member.userId, userId))
|
||||
|
||||
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?.limits?.multiplayerEnabled) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error checking multiplayer permission', { error, userId })
|
||||
return false // Be conservative in case of error
|
||||
}
|
||||
}
|
||||
|
||||
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { data: directSubscriptions } = await client.subscription.list({
|
||||
query: { referenceId: userId },
|
||||
})
|
||||
|
||||
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
|
||||
|
||||
if (activeDirectSubscription?.limits?.workspaceCollaborationEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
const memberships = await db.select().from(member).where(eq(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?.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
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHighestPrioritySubscription(userId: string) {
|
||||
const personalSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
|
||||
|
||||
let orgSubs: any[] = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
if (allSubs.length === 0) return null
|
||||
|
||||
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
|
||||
if (enterpriseSub) return enterpriseSub
|
||||
|
||||
const teamSub = allSubs.find((s) => checkTeamPlan(s))
|
||||
if (teamSub) return teamSub
|
||||
|
||||
const proSub = allSubs.find((s) => checkProPlan(s))
|
||||
if (proSub) return proSub
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { env } from '../env'
|
||||
|
||||
export function checkEnterprisePlan(subscription: any): boolean {
|
||||
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
|
||||
}
|
||||
|
||||
export function checkProPlan(subscription: any): boolean {
|
||||
return subscription?.plan === 'pro' && subscription?.status === 'active'
|
||||
}
|
||||
|
||||
export function checkTeamPlan(subscription: any): boolean {
|
||||
return subscription?.plan === 'team' && subscription?.status === 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate usage limit for a subscription based on its type and metadata
|
||||
* @param subscription The subscription object
|
||||
* @returns The calculated usage limit in dollars
|
||||
*/
|
||||
export function calculateUsageLimit(subscription: any): number {
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
return env.FREE_TIER_COST_LIMIT || 0
|
||||
}
|
||||
|
||||
const seats = subscription.seats || 1
|
||||
|
||||
if (subscription.plan === 'pro') {
|
||||
return env.PRO_TIER_COST_LIMIT || 0
|
||||
}
|
||||
if (subscription.plan === 'team') {
|
||||
return seats * (env.TEAM_TIER_COST_LIMIT || 0)
|
||||
}
|
||||
if (subscription.plan === 'enterprise') {
|
||||
const metadata = subscription.metadata || {}
|
||||
|
||||
if (metadata.perSeatAllowance) {
|
||||
return seats * Number.parseFloat(metadata.perSeatAllowance)
|
||||
}
|
||||
|
||||
if (metadata.totalAllowance) {
|
||||
return Number.parseFloat(metadata.totalAllowance)
|
||||
}
|
||||
|
||||
return seats * (env.ENTERPRISE_TIER_COST_LIMIT || 0)
|
||||
}
|
||||
|
||||
return env.FREE_TIER_COST_LIMIT || 0
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { getRedisClient } from '../redis'
|
||||
|
||||
// Configuration
|
||||
const RATE_LIMIT_WINDOW = 60 // 1 minute window (in seconds)
|
||||
const WAITLIST_MAX_REQUESTS = 5 // 5 requests per minute per IP
|
||||
const WAITLIST_BLOCK_DURATION = 15 * 60 // 15 minutes block (in seconds)
|
||||
|
||||
// Fallback in-memory store for development or if Redis fails
|
||||
const inMemoryStore = new Map<
|
||||
string,
|
||||
{ count: number; timestamp: number; blocked: boolean; blockedUntil?: number }
|
||||
>()
|
||||
|
||||
// Clean up in-memory store periodically (only used in development)
|
||||
if (!isProd && typeof setInterval !== 'undefined') {
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
for (const [key, data] of inMemoryStore.entries()) {
|
||||
if (data.blocked && data.blockedUntil && data.blockedUntil < now) {
|
||||
inMemoryStore.delete(key)
|
||||
} else if (!data.blocked && now - data.timestamp > RATE_LIMIT_WINDOW) {
|
||||
inMemoryStore.delete(key)
|
||||
}
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// Get client IP from request
|
||||
export function getClientIp(request: NextRequest): string {
|
||||
const xff = request.headers.get('x-forwarded-for')
|
||||
const realIp = request.headers.get('x-real-ip')
|
||||
|
||||
if (xff) {
|
||||
const ips = xff.split(',')
|
||||
return ips[0].trim()
|
||||
}
|
||||
|
||||
return realIp || '0.0.0.0'
|
||||
}
|
||||
|
||||
// Check if a request is rate limited
|
||||
export async function isRateLimited(
|
||||
request: NextRequest,
|
||||
type: 'waitlist' = 'waitlist'
|
||||
): Promise<{
|
||||
limited: boolean
|
||||
message?: string
|
||||
remainingTime?: number
|
||||
}> {
|
||||
const clientIp = getClientIp(request)
|
||||
const key = `ratelimit:${type}:${clientIp}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Get the shared Redis client
|
||||
const redisClient = getRedisClient()
|
||||
|
||||
// Use Redis if available
|
||||
if (redisClient) {
|
||||
try {
|
||||
// Check if IP is blocked
|
||||
const isBlocked = await redisClient.get(`${key}:blocked`)
|
||||
|
||||
if (isBlocked) {
|
||||
const ttl = await redisClient.ttl(`${key}:blocked`)
|
||||
if (ttl > 0) {
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime: ttl,
|
||||
}
|
||||
}
|
||||
// Block expired, remove it
|
||||
await redisClient.del(`${key}:blocked`)
|
||||
}
|
||||
|
||||
// Increment counter with expiry
|
||||
const count = await redisClient.incr(key)
|
||||
|
||||
// Set expiry on first request
|
||||
if (count === 1) {
|
||||
await redisClient.expire(key, RATE_LIMIT_WINDOW)
|
||||
}
|
||||
|
||||
// If limit exceeded, block the IP
|
||||
if (count > WAITLIST_MAX_REQUESTS) {
|
||||
await redisClient.set(`${key}:blocked`, '1', 'EX', WAITLIST_BLOCK_DURATION)
|
||||
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime: WAITLIST_BLOCK_DURATION,
|
||||
}
|
||||
}
|
||||
|
||||
return { limited: false }
|
||||
} catch (error) {
|
||||
console.error('Redis rate limit error:', error)
|
||||
// Fall back to in-memory if Redis fails
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory fallback implementation
|
||||
let record = inMemoryStore.get(key)
|
||||
|
||||
// Check if IP is blocked
|
||||
if (record?.blocked) {
|
||||
if (record.blockedUntil && record.blockedUntil < now) {
|
||||
record = { count: 1, timestamp: now, blocked: false }
|
||||
inMemoryStore.set(key, record)
|
||||
return { limited: false }
|
||||
}
|
||||
|
||||
const remainingTime = record.blockedUntil ? record.blockedUntil - now : WAITLIST_BLOCK_DURATION
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime,
|
||||
}
|
||||
}
|
||||
|
||||
// If no record exists or window expired, create/reset it
|
||||
if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) {
|
||||
record = { count: 1, timestamp: now, blocked: false }
|
||||
inMemoryStore.set(key, record)
|
||||
return { limited: false }
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
record.count++
|
||||
|
||||
// If limit exceeded, block the IP
|
||||
if (record.count > WAITLIST_MAX_REQUESTS) {
|
||||
record.blocked = true
|
||||
record.blockedUntil = now + WAITLIST_BLOCK_DURATION
|
||||
inMemoryStore.set(key, record)
|
||||
|
||||
return {
|
||||
limited: true,
|
||||
message: 'Too many requests. Please try again later.',
|
||||
remainingTime: WAITLIST_BLOCK_DURATION,
|
||||
}
|
||||
}
|
||||
|
||||
inMemoryStore.set(key, record)
|
||||
return { limited: false }
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
import { and, count, desc, eq, inArray, like, or } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderWaitlistApprovalEmail,
|
||||
renderWaitlistConfirmationEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
import { type EmailType, sendBatchEmails, sendEmail } from '@/lib/email/mailer'
|
||||
import { createToken, verifyToken } from '@/lib/waitlist/token'
|
||||
import { db } from '@/db'
|
||||
import { waitlist } from '@/db/schema'
|
||||
import { env } from '../env'
|
||||
|
||||
// Define types for better type safety
|
||||
export type WaitlistStatus = 'pending' | 'approved' | 'rejected' | 'signed_up'
|
||||
|
||||
export interface WaitlistEntry {
|
||||
id: string
|
||||
email: string
|
||||
status: WaitlistStatus
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Helper function to find a user by email
|
||||
async function findUserByEmail(email: string) {
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
const users = await db.select().from(waitlist).where(eq(waitlist.email, normalizedEmail)).limit(1)
|
||||
|
||||
return {
|
||||
users,
|
||||
user: users.length > 0 ? users[0] : null,
|
||||
normalizedEmail,
|
||||
}
|
||||
}
|
||||
|
||||
// Add a user to the waitlist
|
||||
export async function addToWaitlist(email: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { users, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (users.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Email already exists in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
// Add to waitlist
|
||||
await db.insert(waitlist).values({
|
||||
id: nanoid(),
|
||||
email: normalizedEmail,
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Send confirmation email
|
||||
try {
|
||||
const emailHtml = await renderWaitlistConfirmationEmail(normalizedEmail)
|
||||
const subject = getEmailSubject('waitlist-confirmation')
|
||||
|
||||
await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('Error sending confirmation email:', emailError)
|
||||
// Continue even if email fails - user is still on waitlist
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully added to waitlist',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding to waitlist:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while adding to waitlist',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all waitlist entries with pagination and search
|
||||
export async function getWaitlistEntries(
|
||||
page = 1,
|
||||
limit = 20,
|
||||
status?: WaitlistStatus | 'all',
|
||||
search?: string
|
||||
) {
|
||||
try {
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
// Build query conditions
|
||||
let whereCondition
|
||||
|
||||
// First, determine if we need to apply status filter
|
||||
const shouldFilterByStatus = status && status !== 'all'
|
||||
|
||||
// Now build the conditions
|
||||
if (shouldFilterByStatus && search && search.trim()) {
|
||||
// Both status and search
|
||||
whereCondition = and(
|
||||
eq(waitlist.status, status as string),
|
||||
like(waitlist.email, `%${search.trim()}%`)
|
||||
)
|
||||
} else if (shouldFilterByStatus) {
|
||||
// Only status
|
||||
whereCondition = eq(waitlist.status, status as string)
|
||||
} else if (search?.trim()) {
|
||||
// Only search
|
||||
whereCondition = like(waitlist.email, `%${search.trim()}%`)
|
||||
} else {
|
||||
whereCondition = null
|
||||
}
|
||||
|
||||
// Get entries with conditions
|
||||
let entries = []
|
||||
if (whereCondition) {
|
||||
entries = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.where(whereCondition)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(desc(waitlist.createdAt))
|
||||
} else {
|
||||
// Get all entries
|
||||
entries = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(desc(waitlist.createdAt))
|
||||
}
|
||||
|
||||
// Get total count for pagination with same conditions
|
||||
let countResult = []
|
||||
if (whereCondition) {
|
||||
countResult = await db.select({ value: count() }).from(waitlist).where(whereCondition)
|
||||
} else {
|
||||
countResult = await db.select({ value: count() }).from(waitlist)
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
total: countResult[0]?.value || 0,
|
||||
page,
|
||||
limit,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting waitlist entries:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Approve a user from the waitlist and send approval email
|
||||
export async function approveWaitlistUser(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
if (user.status === 'approved') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User already approved',
|
||||
}
|
||||
}
|
||||
|
||||
// Create a special signup token
|
||||
const token = await createToken({
|
||||
email: normalizedEmail,
|
||||
type: 'waitlist-approval',
|
||||
expiresIn: '7d',
|
||||
})
|
||||
|
||||
// Generate signup link with token
|
||||
const signupLink = `${env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
|
||||
|
||||
// IMPORTANT: Send approval email BEFORE updating the status
|
||||
// This ensures we don't mark users as approved if email fails
|
||||
try {
|
||||
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
|
||||
const subject = getEmailSubject('waitlist-approval')
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
emailType: 'updates',
|
||||
})
|
||||
|
||||
// If email sending failed, don't update the user status
|
||||
if (!emailResult.success) {
|
||||
console.error('Error sending approval email:', emailResult.message)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (
|
||||
emailResult.message?.toLowerCase().includes('rate') ||
|
||||
emailResult.message?.toLowerCase().includes('too many') ||
|
||||
emailResult.message?.toLowerCase().includes('limit')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: emailResult.message || 'Failed to send approval email',
|
||||
emailError: emailResult,
|
||||
}
|
||||
}
|
||||
|
||||
// Email sent successfully, now update status to approved
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(waitlist.email, normalizedEmail))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'User approved and email sent',
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error('Error sending approval email:', emailError)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (
|
||||
emailError instanceof Error &&
|
||||
(emailError.message.toLowerCase().includes('rate') ||
|
||||
emailError.message.toLowerCase().includes('too many') ||
|
||||
emailError.message.toLowerCase().includes('limit'))
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send approval email',
|
||||
emailError,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving waitlist user:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while approving user',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reject a user from the waitlist
|
||||
export async function rejectWaitlistUser(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to rejected
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'rejected',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(waitlist.email, normalizedEmail))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'User rejected',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting waitlist user:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while rejecting user',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a user is approved
|
||||
export async function isUserApproved(email: string): Promise<boolean> {
|
||||
try {
|
||||
const { user } = await findUserByEmail(email)
|
||||
return !!user && user.status === 'approved'
|
||||
} catch (error) {
|
||||
console.error('Error checking if user is approved:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Verify waitlist token
|
||||
export async function verifyWaitlistToken(
|
||||
token: string
|
||||
): Promise<{ valid: boolean; email?: string }> {
|
||||
try {
|
||||
// Verify token
|
||||
const decoded = await verifyToken(token)
|
||||
|
||||
if (!decoded || decoded.type !== 'waitlist-approval') {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
// Check if user is in the approved waitlist
|
||||
const isApproved = await isUserApproved(decoded.email)
|
||||
|
||||
if (!isApproved) {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
email: decoded.email,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying waitlist token:', error)
|
||||
return { valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a user as signed up after they create an account
|
||||
export async function markWaitlistUserAsSignedUp(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
if (user.status !== 'approved') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User is not in approved status',
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to signed_up
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'signed_up',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(waitlist.email, normalizedEmail))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'User marked as signed up',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking waitlist user as signed up:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while updating user status',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resend approval email to an already approved user
|
||||
export async function resendApprovalEmail(
|
||||
email: string
|
||||
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
|
||||
try {
|
||||
const { user, normalizedEmail } = await findUserByEmail(email)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found in waitlist',
|
||||
}
|
||||
}
|
||||
|
||||
if (user.status !== 'approved') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'User is not approved',
|
||||
}
|
||||
}
|
||||
|
||||
// Create a special signup token
|
||||
const token = await createToken({
|
||||
email: normalizedEmail,
|
||||
type: 'waitlist-approval',
|
||||
expiresIn: '7d',
|
||||
})
|
||||
|
||||
// Generate signup link with token
|
||||
const signupLink = `${env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
|
||||
|
||||
// Send approval email
|
||||
try {
|
||||
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
|
||||
const subject = getEmailSubject('waitlist-approval')
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
emailType: 'updates',
|
||||
})
|
||||
|
||||
// Check for email sending failures
|
||||
if (!emailResult.success) {
|
||||
console.error('Error sending approval email:', emailResult.message)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (
|
||||
emailResult.message?.toLowerCase().includes('rate') ||
|
||||
emailResult.message?.toLowerCase().includes('too many') ||
|
||||
emailResult.message?.toLowerCase().includes('limit')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: emailResult.message || 'Failed to send approval email',
|
||||
emailError: emailResult,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Approval email resent successfully',
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error('Error sending approval email:', emailError)
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (
|
||||
emailError instanceof Error &&
|
||||
(emailError.message.toLowerCase().includes('rate') ||
|
||||
emailError.message.toLowerCase().includes('too many') ||
|
||||
emailError.message.toLowerCase().includes('limit'))
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Rate limit exceeded for email sending',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to send approval email',
|
||||
emailError,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resending approval email:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while resending approval email',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approve multiple users from the waitlist and send approval emails in batches
|
||||
export async function approveBatchWaitlistUsers(emails: string[]): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
results: Array<{ email: string; success: boolean; message: string }>
|
||||
emailErrors?: any
|
||||
rateLimited?: boolean
|
||||
}> {
|
||||
try {
|
||||
if (!emails || emails.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No emails provided for batch approval',
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all users from the waitlist that match the emails
|
||||
const normalizedEmails = emails.map((email) => email.trim().toLowerCase())
|
||||
|
||||
const users = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.where(
|
||||
and(
|
||||
inArray(waitlist.email, normalizedEmails),
|
||||
// Only select users who aren't already approved
|
||||
or(eq(waitlist.status, 'pending'), eq(waitlist.status, 'rejected'))
|
||||
)
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid users found for approval',
|
||||
results: emails.map((email) => ({
|
||||
email,
|
||||
success: false,
|
||||
message: 'User not found or already approved',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// Create email options for each user
|
||||
const emailOptions = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
// Create a special signup token
|
||||
const token = await createToken({
|
||||
email: user.email,
|
||||
type: 'waitlist-approval',
|
||||
expiresIn: '7d',
|
||||
})
|
||||
|
||||
// Generate signup link with token
|
||||
const signupLink = `${env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
|
||||
|
||||
// Generate email HTML
|
||||
const emailHtml = await renderWaitlistApprovalEmail(user.email, signupLink)
|
||||
const subject = getEmailSubject('waitlist-approval')
|
||||
|
||||
return {
|
||||
to: user.email,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
emailType: 'updates' as EmailType,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Send batch emails
|
||||
const emailResults = await sendBatchEmails({ emails: emailOptions })
|
||||
|
||||
// Process results and update database
|
||||
const results = users.map((user, index) => {
|
||||
const emailResult = emailResults.results[index]
|
||||
|
||||
if (emailResult?.success) {
|
||||
// Update user status to approved in database
|
||||
return {
|
||||
email: user.email,
|
||||
success: true,
|
||||
message: 'User approved and email sent successfully',
|
||||
data: emailResult.data,
|
||||
}
|
||||
}
|
||||
return {
|
||||
email: user.email,
|
||||
success: false,
|
||||
message: emailResult?.message || 'Failed to send approval email',
|
||||
error: emailResult,
|
||||
}
|
||||
})
|
||||
|
||||
// Update approved users in the database
|
||||
const successfulEmails = results
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.email)
|
||||
|
||||
if (successfulEmails.length > 0) {
|
||||
await db
|
||||
.update(waitlist)
|
||||
.set({
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(waitlist.email, successfulEmails),
|
||||
// Only update users who aren't already approved
|
||||
or(eq(waitlist.status, 'pending'), eq(waitlist.status, 'rejected'))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any rate limit errors occurred
|
||||
const rateLimitError = emailResults.results.some(
|
||||
(result: { message?: string }) =>
|
||||
result.message?.toLowerCase().includes('rate') ||
|
||||
result.message?.toLowerCase().includes('too many') ||
|
||||
result.message?.toLowerCase().includes('limit')
|
||||
)
|
||||
|
||||
return {
|
||||
success: successfulEmails.length > 0,
|
||||
message:
|
||||
successfulEmails.length === users.length
|
||||
? 'All users approved successfully'
|
||||
: successfulEmails.length > 0
|
||||
? 'Some users approved successfully'
|
||||
: 'Failed to approve any users',
|
||||
results: results.map(
|
||||
({ email, success, message }: { email: string; success: boolean; message: string }) => ({
|
||||
email,
|
||||
success,
|
||||
message,
|
||||
})
|
||||
),
|
||||
emailErrors: emailResults.results.some((r: { success: boolean }) => !r.success),
|
||||
rateLimited: rateLimitError,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving batch waitlist users:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred while approving users',
|
||||
results: emails.map((email) => ({
|
||||
email,
|
||||
success: false,
|
||||
message: 'Operation failed due to server error',
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { jwtVerify, SignJWT } from 'jose'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { env } from '../env'
|
||||
|
||||
interface TokenPayload {
|
||||
email: string
|
||||
type: 'waitlist-approval' | 'password-reset'
|
||||
expiresIn: string
|
||||
}
|
||||
|
||||
interface DecodedToken {
|
||||
email: string
|
||||
type: string
|
||||
jti: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
// Get JWT secret from environment variables
|
||||
const getJwtSecret = () => {
|
||||
const secret = env.JWT_SECRET
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JWT token
|
||||
*/
|
||||
export async function createToken({ email, type, expiresIn }: TokenPayload): Promise<string> {
|
||||
const jwt = await new SignJWT({ email, type })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiresIn)
|
||||
.setJti(nanoid())
|
||||
.sign(getJwtSecret())
|
||||
|
||||
return jwt
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export async function verifyToken(token: string): Promise<DecodedToken | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwtSecret())
|
||||
|
||||
return payload as unknown as DecodedToken
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"email:dev": "email dev --dir components/emails",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test:billing:suite": "bun run scripts/test-billing-suite.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
|
||||
462
apps/sim/scripts/test-billing-suite.ts
Normal file
462
apps/sim/scripts/test-billing-suite.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { config } from 'dotenv'
|
||||
import { eq, like } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { processDailyBillingCheck } from '@/lib/billing/core/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, subscription, user, userStats } from '@/db/schema'
|
||||
|
||||
// Load environment variables
|
||||
config()
|
||||
|
||||
const logger = createLogger('BillingTestSuite')
|
||||
|
||||
interface TestUser {
|
||||
id: string
|
||||
email: string
|
||||
stripeCustomerId: string
|
||||
plan: string
|
||||
usage: number
|
||||
overage: number
|
||||
}
|
||||
|
||||
interface TestOrg {
|
||||
id: string
|
||||
name: string
|
||||
stripeCustomerId: string
|
||||
plan: string
|
||||
seats: number
|
||||
memberCount: number
|
||||
totalUsage: number
|
||||
overage: number
|
||||
}
|
||||
|
||||
interface TestResults {
|
||||
users: TestUser[]
|
||||
organizations: TestOrg[]
|
||||
billingResults: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive billing test suite
|
||||
* Run with: bun run test:billing:suite
|
||||
*/
|
||||
async function runBillingTestSuite(): Promise<TestResults> {
|
||||
logger.info('🚀 Starting comprehensive billing test suite...')
|
||||
|
||||
const results: TestResults = {
|
||||
users: [],
|
||||
organizations: [],
|
||||
billingResults: null,
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Create test users for each scenario
|
||||
logger.info('\n📋 Creating test users...')
|
||||
|
||||
// Free user (no overage billing)
|
||||
const freeUser = await createTestUser('free', 5) // $5 usage on free plan
|
||||
results.users.push(freeUser)
|
||||
|
||||
// Pro user with no overage
|
||||
const proUserNoOverage = await createTestUser('pro', 15) // $15 usage < $20 base
|
||||
results.users.push(proUserNoOverage)
|
||||
|
||||
// Pro user with overage
|
||||
const proUserWithOverage = await createTestUser('pro', 35) // $35 usage > $20 base = $15 overage
|
||||
results.users.push(proUserWithOverage)
|
||||
|
||||
// Pro user with high overage
|
||||
const proUserHighOverage = await createTestUser('pro', 100) // $100 usage = $80 overage
|
||||
results.users.push(proUserHighOverage)
|
||||
|
||||
// 2. Create test organizations
|
||||
logger.info('\n🏢 Creating test organizations...')
|
||||
|
||||
// Team with no overage (2 seats, 3 members, low usage)
|
||||
const teamNoOverage = await createTestOrganization('team', 2, 3, 150) // 3 members, $150 total < $200 base (2 seats × $100)
|
||||
results.organizations.push(teamNoOverage)
|
||||
|
||||
// Team with overage (2 seats, 3 members, high usage)
|
||||
const teamWithOverage = await createTestOrganization('team', 2, 3, 350) // 3 members, $350 total > $200 base = $150 overage
|
||||
results.organizations.push(teamWithOverage)
|
||||
|
||||
// Enterprise with overage (5 seats, 8 members, high usage)
|
||||
const enterpriseWithOverage = await createTestOrganization('enterprise', 5, 8, 2000) // 8 members, $2000 total > $1500 base (5 seats × $300) = $500 overage
|
||||
results.organizations.push(enterpriseWithOverage)
|
||||
|
||||
// 3. Display test data summary
|
||||
logger.info('\n📊 Test Data Summary:')
|
||||
logger.info('===================')
|
||||
|
||||
logger.info('\n👤 Individual Users:')
|
||||
for (const user of results.users) {
|
||||
logger.info(` ${user.plan.toUpperCase()} - ${user.email}`)
|
||||
logger.info(` Usage: $${user.usage} | Overage: $${user.overage}`)
|
||||
logger.info(` Customer: ${user.stripeCustomerId}`)
|
||||
}
|
||||
|
||||
logger.info('\n🏢 Organizations:')
|
||||
for (const org of results.organizations) {
|
||||
logger.info(` ${org.plan.toUpperCase()} - ${org.name}`)
|
||||
logger.info(
|
||||
` Seats: ${org.seats} | Members: ${org.memberCount} | Usage: $${org.totalUsage} | Overage: $${org.overage}`
|
||||
)
|
||||
logger.info(` Customer: ${org.stripeCustomerId}`)
|
||||
}
|
||||
|
||||
// 4. Wait for user confirmation
|
||||
logger.info('\n⏸️ Test data created. Ready to run billing CRON?')
|
||||
logger.info(' Press Ctrl+C to cancel, or wait 5 seconds to continue...')
|
||||
await sleep(5000)
|
||||
|
||||
// 5. Run the daily billing CRON
|
||||
logger.info('\n🔄 Running daily billing CRON...')
|
||||
const billingResult = await processDailyBillingCheck()
|
||||
results.billingResults = billingResult
|
||||
|
||||
// 6. Display billing results
|
||||
logger.info('\n💰 Billing Results:')
|
||||
logger.info('==================')
|
||||
logger.info(`✅ Success: ${billingResult.success}`)
|
||||
logger.info(`👤 Users processed: ${billingResult.processedUsers}`)
|
||||
logger.info(`🏢 Organizations processed: ${billingResult.processedOrganizations}`)
|
||||
logger.info(`💵 Total charged: $${billingResult.totalChargedAmount}`)
|
||||
|
||||
if (billingResult.errors.length > 0) {
|
||||
logger.error('❌ Errors:', billingResult.errors)
|
||||
}
|
||||
|
||||
// 7. Verify results in Stripe
|
||||
logger.info('\n🔍 Verifying in Stripe...')
|
||||
await verifyStripeResults(results)
|
||||
|
||||
logger.info('\n✅ Test suite completed successfully!')
|
||||
logger.info('\n📝 Next steps:')
|
||||
logger.info('1. Check your Stripe Dashboard for invoices')
|
||||
logger.info('2. Monitor webhook events in your listener')
|
||||
logger.info('3. Check for email notifications (if in live mode)')
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
logger.error('Test suite failed', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestUser(plan: 'free' | 'pro', usageAmount: number): Promise<TestUser> {
|
||||
const stripe = requireStripeClient()
|
||||
const userId = nanoid()
|
||||
const email = `test-${plan}-${Date.now()}@example.com`
|
||||
|
||||
// Create Stripe customer
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: {
|
||||
userId,
|
||||
testUser: 'true',
|
||||
plan,
|
||||
},
|
||||
})
|
||||
|
||||
// Add payment method
|
||||
const paymentMethod = await stripe.paymentMethods.create({
|
||||
type: 'card',
|
||||
card: { token: 'tok_visa' },
|
||||
})
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethod.id, {
|
||||
customer: stripeCustomer.id,
|
||||
})
|
||||
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethod.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Create user in database
|
||||
await db.insert(user).values({
|
||||
id: userId,
|
||||
email,
|
||||
name: `Test ${plan.toUpperCase()} User`,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Create subscription
|
||||
const periodEnd = new Date()
|
||||
periodEnd.setUTCHours(23, 59, 59, 999) // End of today
|
||||
|
||||
await db.insert(subscription).values({
|
||||
id: nanoid(),
|
||||
plan,
|
||||
referenceId: userId,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
stripeSubscriptionId: `sub_test_${nanoid()}`,
|
||||
status: 'active',
|
||||
periodStart: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
|
||||
periodEnd,
|
||||
seats: 1,
|
||||
})
|
||||
|
||||
// Create user stats
|
||||
await db.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
currentPeriodCost: usageAmount.toString(),
|
||||
billingPeriodEnd: periodEnd,
|
||||
currentUsageLimit: (usageAmount + 10).toString(), // Some headroom
|
||||
})
|
||||
|
||||
const basePrice = plan === 'pro' ? 20 : 0
|
||||
const overage = Math.max(0, usageAmount - basePrice)
|
||||
|
||||
logger.info(`✅ Created ${plan} user`, {
|
||||
email,
|
||||
usage: `$${usageAmount}`,
|
||||
overage: `$${overage}`,
|
||||
})
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
email,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
plan,
|
||||
usage: usageAmount,
|
||||
overage,
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestOrganization(
|
||||
plan: 'team' | 'enterprise',
|
||||
seats: number,
|
||||
memberCount: number,
|
||||
totalUsage: number
|
||||
): Promise<TestOrg> {
|
||||
const stripe = requireStripeClient()
|
||||
const orgId = nanoid()
|
||||
const orgName = `Test ${plan.toUpperCase()} Org ${Date.now()}`
|
||||
|
||||
// Create Stripe customer for org FIRST
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email: `billing-${orgId}@example.com`,
|
||||
name: orgName,
|
||||
metadata: {
|
||||
organizationId: orgId,
|
||||
testOrg: 'true',
|
||||
plan,
|
||||
},
|
||||
})
|
||||
|
||||
// Add payment method
|
||||
const paymentMethod = await stripe.paymentMethods.create({
|
||||
type: 'card',
|
||||
card: { token: 'tok_visa' },
|
||||
})
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethod.id, {
|
||||
customer: stripeCustomer.id,
|
||||
})
|
||||
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethod.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Create organization in DB with Stripe customer ID in metadata
|
||||
await db.insert(organization).values({
|
||||
id: orgId,
|
||||
name: orgName,
|
||||
slug: `test-${plan}-org-${Date.now()}`,
|
||||
metadata: { stripeCustomerId: stripeCustomer.id }, // Store Stripe customer ID in metadata
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Create subscription
|
||||
const periodEnd = new Date()
|
||||
periodEnd.setUTCHours(23, 59, 59, 999) // End of today
|
||||
|
||||
// Add metadata for enterprise plans
|
||||
const metadata =
|
||||
plan === 'enterprise'
|
||||
? { perSeatAllowance: 500, totalAllowance: 5000 } // Enterprise gets $500 per seat or $5000 total
|
||||
: {}
|
||||
|
||||
await db.insert(subscription).values({
|
||||
id: nanoid(),
|
||||
plan,
|
||||
referenceId: orgId,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
stripeSubscriptionId: `sub_test_${nanoid()}`,
|
||||
status: 'active',
|
||||
periodStart: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
|
||||
periodEnd,
|
||||
seats,
|
||||
metadata,
|
||||
})
|
||||
|
||||
// Create members with usage
|
||||
const usagePerMember = Math.floor(totalUsage / memberCount)
|
||||
for (let i = 0; i < memberCount; i++) {
|
||||
const memberId = nanoid()
|
||||
const isOwner = i === 0
|
||||
|
||||
// Create user
|
||||
await db.insert(user).values({
|
||||
id: memberId,
|
||||
email: `member-${i + 1}-${orgId}@example.com`,
|
||||
name: `Member ${i + 1}`,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Add to organization
|
||||
await db.insert(member).values({
|
||||
id: nanoid(),
|
||||
userId: memberId,
|
||||
organizationId: orgId,
|
||||
role: isOwner ? 'owner' : 'member',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Create user stats
|
||||
await db.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: memberId,
|
||||
currentPeriodCost: usagePerMember.toString(),
|
||||
billingPeriodEnd: periodEnd,
|
||||
currentUsageLimit: (usagePerMember + 50).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
const basePricePerSeat = plan === 'team' ? 100 : 300
|
||||
const baseTotal = seats * basePricePerSeat
|
||||
const overage = Math.max(0, totalUsage - baseTotal)
|
||||
|
||||
logger.info(`✅ Created ${plan} organization`, {
|
||||
name: orgName,
|
||||
seats,
|
||||
members: memberCount,
|
||||
usage: `$${totalUsage}`,
|
||||
overage: `$${overage}`,
|
||||
})
|
||||
|
||||
return {
|
||||
id: orgId,
|
||||
name: orgName,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
plan,
|
||||
seats,
|
||||
memberCount,
|
||||
totalUsage,
|
||||
overage,
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyStripeResults(results: TestResults) {
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
logger.info('\n📋 Stripe Verification:')
|
||||
|
||||
// Check for recent invoices
|
||||
const recentInvoices = await stripe.invoices.list({
|
||||
limit: 20,
|
||||
created: {
|
||||
gte: Math.floor(Date.now() / 1000) - 300, // Last 5 minutes
|
||||
},
|
||||
})
|
||||
|
||||
const testInvoices = recentInvoices.data.filter((inv) => inv.metadata?.type === 'overage_billing')
|
||||
|
||||
logger.info(`Found ${testInvoices.length} overage invoices created`)
|
||||
|
||||
for (const invoice of testInvoices) {
|
||||
const customerType = invoice.metadata?.organizationId ? 'Organization' : 'User'
|
||||
logger.info(` ${customerType} Invoice: ${invoice.number || invoice.id}`)
|
||||
logger.info(` Amount: $${invoice.amount_due / 100}`)
|
||||
logger.info(` Status: ${invoice.status}`)
|
||||
logger.info(` Customer: ${invoice.customer}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
async function cleanupTestData() {
|
||||
logger.info('\n🧹 Cleaning up test data...')
|
||||
|
||||
try {
|
||||
// Find all test users
|
||||
const testUsers = await db.select().from(user).where(like(user.email, 'test-%'))
|
||||
|
||||
// Find all test organizations
|
||||
const testOrgs = await db.select().from(organization).where(like(organization.name, 'Test %'))
|
||||
|
||||
logger.info(
|
||||
`Found ${testUsers.length} test users and ${testOrgs.length} test organizations to clean up`
|
||||
)
|
||||
|
||||
// Clean up users
|
||||
for (const testUser of testUsers) {
|
||||
await db.delete(userStats).where(eq(userStats.userId, testUser.id))
|
||||
await db.delete(member).where(eq(member.userId, testUser.id))
|
||||
await db.delete(subscription).where(eq(subscription.referenceId, testUser.id))
|
||||
await db.delete(user).where(eq(user.id, testUser.id))
|
||||
}
|
||||
|
||||
// Clean up organizations
|
||||
for (const org of testOrgs) {
|
||||
await db.delete(member).where(eq(member.organizationId, org.id))
|
||||
await db.delete(subscription).where(eq(subscription.referenceId, org.id))
|
||||
await db.delete(organization).where(eq(organization.id, org.id))
|
||||
}
|
||||
|
||||
logger.info('✅ Cleanup completed')
|
||||
} catch (error) {
|
||||
logger.error('Cleanup failed', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
if (args.includes('--cleanup')) {
|
||||
await cleanupTestData()
|
||||
return
|
||||
}
|
||||
|
||||
if (args.includes('--help')) {
|
||||
logger.info('Billing Test Suite')
|
||||
logger.info('==================')
|
||||
logger.info('Usage: bun run test:billing:suite [options]')
|
||||
logger.info('')
|
||||
logger.info('Options:')
|
||||
logger.info(' --cleanup Clean up all test data')
|
||||
logger.info(' --help Show this help message')
|
||||
logger.info('')
|
||||
logger.info('This script will:')
|
||||
logger.info('1. Create test users (free, pro with/without overage)')
|
||||
logger.info('2. Create test organizations (team, enterprise)')
|
||||
logger.info('3. Run the daily billing CRON')
|
||||
logger.info('4. Verify results in Stripe')
|
||||
return
|
||||
}
|
||||
|
||||
await runBillingTestSuite()
|
||||
}
|
||||
|
||||
// Run the suite
|
||||
main().catch((error) => {
|
||||
logger.error('Test suite failed', { error })
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { useNotificationStore } from './notifications/store'
|
||||
import { useConsoleStore } from './panel/console/store'
|
||||
import { useVariablesStore } from './panel/variables/store'
|
||||
import { useEnvironmentStore } from './settings/environment/store'
|
||||
import { useSubscriptionStore } from './subscription/store'
|
||||
import { useWorkflowRegistry } from './workflows/registry/store'
|
||||
import { useSubBlockStore } from './workflows/subblock/store'
|
||||
import { useWorkflowStore } from './workflows/workflow/store'
|
||||
@@ -206,6 +207,7 @@ export {
|
||||
useCustomToolsStore,
|
||||
useVariablesStore,
|
||||
useSubBlockStore,
|
||||
useSubscriptionStore,
|
||||
}
|
||||
|
||||
// Helper function to reset all stores
|
||||
@@ -231,6 +233,7 @@ export const resetAllStores = () => {
|
||||
useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null })
|
||||
useCustomToolsStore.setState({ tools: {} })
|
||||
useVariablesStore.getState().resetLoaded() // Reset variables store tracking
|
||||
useSubscriptionStore.getState().reset() // Reset subscription store
|
||||
}
|
||||
|
||||
// Helper function to log all store states
|
||||
@@ -246,6 +249,7 @@ export const logAllStores = () => {
|
||||
customTools: useCustomToolsStore.getState(),
|
||||
subBlock: useSubBlockStore.getState(),
|
||||
variables: useVariablesStore.getState(),
|
||||
subscription: useSubscriptionStore.getState(),
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
21
apps/sim/stores/organization/index.ts
Normal file
21
apps/sim/stores/organization/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { useOrganizationStore } from './store'
|
||||
export type {
|
||||
Invitation,
|
||||
Member,
|
||||
MemberUsageData,
|
||||
Organization,
|
||||
OrganizationBillingData,
|
||||
OrganizationFormData,
|
||||
OrganizationState,
|
||||
OrganizationStore,
|
||||
Subscription,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from './types'
|
||||
export {
|
||||
calculateSeatUsage,
|
||||
generateSlug,
|
||||
validateEmail,
|
||||
validateSlug,
|
||||
} from './utils'
|
||||
832
apps/sim/stores/organization/store.ts
Normal file
832
apps/sim/stores/organization/store.ts
Normal file
@@ -0,0 +1,832 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { OrganizationStore, Subscription, WorkspaceInvitation } from './types'
|
||||
import { calculateSeatUsage, generateSlug, validateEmail, validateSlug } from './utils'
|
||||
|
||||
const logger = createLogger('OrganizationStore')
|
||||
|
||||
const CACHE_DURATION = 30 * 1000
|
||||
|
||||
export const useOrganizationStore = create<OrganizationStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
subscriptionData: null,
|
||||
userWorkspaces: [],
|
||||
organizationBillingData: null,
|
||||
orgFormData: {
|
||||
name: '',
|
||||
slug: '',
|
||||
logo: '',
|
||||
},
|
||||
isLoading: false,
|
||||
isLoadingSubscription: false,
|
||||
isLoadingOrgBilling: false,
|
||||
isCreatingOrg: false,
|
||||
isInviting: false,
|
||||
isSavingOrgSettings: false,
|
||||
error: null,
|
||||
orgSettingsError: null,
|
||||
inviteSuccess: false,
|
||||
orgSettingsSuccess: null,
|
||||
lastFetched: null,
|
||||
lastSubscriptionFetched: null,
|
||||
lastOrgBillingFetched: null,
|
||||
hasTeamPlan: false,
|
||||
hasEnterprisePlan: false,
|
||||
|
||||
loadData: async () => {
|
||||
const state = get()
|
||||
|
||||
if (state.lastFetched && Date.now() - state.lastFetched < CACHE_DURATION) {
|
||||
logger.debug('Using cached data')
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
logger.debug('Data already loading, skipping duplicate request')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
// Load organizations, active organization, and user subscription info in parallel
|
||||
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([
|
||||
client.organization.list(),
|
||||
client.organization.getFullOrganization().catch(() => ({ data: null })),
|
||||
fetch('/api/billing?context=user'),
|
||||
])
|
||||
|
||||
const organizations = orgsResponse.data || []
|
||||
const activeOrganization = activeOrgResponse.data || null
|
||||
|
||||
let hasTeamPlan = false
|
||||
let hasEnterprisePlan = false
|
||||
|
||||
if (billingResponse.ok) {
|
||||
const billingResult = await billingResponse.json()
|
||||
const billingData = billingResult.data
|
||||
hasTeamPlan = billingData.isTeam
|
||||
hasEnterprisePlan = billingData.isEnterprise
|
||||
}
|
||||
|
||||
set({
|
||||
organizations,
|
||||
activeOrganization,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: Date.now(),
|
||||
})
|
||||
|
||||
logger.debug('Organization data loaded successfully', {
|
||||
organizationCount: organizations.length,
|
||||
activeOrganizationId: activeOrganization?.id,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
})
|
||||
|
||||
// Load subscription data for the active organization
|
||||
if (activeOrganization?.id) {
|
||||
await get().loadOrganizationSubscription(activeOrganization.id)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to load organization data'
|
||||
logger.error('Failed to load organization data', { error })
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadOrganizationSubscription: async (orgId: string) => {
|
||||
const state = get()
|
||||
|
||||
if (
|
||||
state.subscriptionData &&
|
||||
state.lastSubscriptionFetched &&
|
||||
Date.now() - state.lastSubscriptionFetched < CACHE_DURATION
|
||||
) {
|
||||
logger.debug('Using cached subscription data')
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isLoadingSubscription) {
|
||||
logger.debug('Subscription data already loading, skipping duplicate request')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoadingSubscription: true })
|
||||
|
||||
try {
|
||||
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 })
|
||||
set({ error: 'Failed to load subscription data' })
|
||||
return
|
||||
}
|
||||
|
||||
// Find active team or enterprise subscription
|
||||
const teamSubscription = data?.find(
|
||||
(sub) => sub.status === 'active' && sub.plan === 'team'
|
||||
)
|
||||
const enterpriseSubscription = data?.find((sub) => checkEnterprisePlan(sub))
|
||||
const activeSubscription = enterpriseSubscription || teamSubscription
|
||||
|
||||
if (activeSubscription) {
|
||||
logger.info('Found active subscription', {
|
||||
id: activeSubscription.id,
|
||||
plan: activeSubscription.plan,
|
||||
seats: activeSubscription.seats,
|
||||
})
|
||||
set({
|
||||
subscriptionData: activeSubscription,
|
||||
isLoadingSubscription: false,
|
||||
lastSubscriptionFetched: Date.now(),
|
||||
})
|
||||
} else {
|
||||
// Check billing endpoint for enterprise subscriptions
|
||||
const { hasEnterprisePlan } = get()
|
||||
if (hasEnterprisePlan) {
|
||||
try {
|
||||
const billingResponse = await fetch('/api/billing?context=user')
|
||||
if (billingResponse.ok) {
|
||||
const billingData = await billingResponse.json()
|
||||
if (
|
||||
billingData.success &&
|
||||
billingData.data.isEnterprise &&
|
||||
billingData.data.status
|
||||
) {
|
||||
const enterpriseSubscription = {
|
||||
id: `subscription_${Date.now()}`,
|
||||
plan: billingData.data.plan,
|
||||
status: billingData.data.status,
|
||||
seats: billingData.data.seats,
|
||||
referenceId: billingData.data.organizationId || 'unknown',
|
||||
}
|
||||
logger.info('Found enterprise subscription from billing data', {
|
||||
plan: enterpriseSubscription.plan,
|
||||
seats: enterpriseSubscription.seats,
|
||||
})
|
||||
set({
|
||||
subscriptionData: enterpriseSubscription,
|
||||
isLoadingSubscription: false,
|
||||
lastSubscriptionFetched: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching enterprise subscription from billing endpoint', {
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('No active subscription found for organization', { orgId })
|
||||
set({
|
||||
subscriptionData: null,
|
||||
isLoadingSubscription: false,
|
||||
lastSubscriptionFetched: Date.now(),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading subscription data', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load subscription data',
|
||||
isLoadingSubscription: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadOrganizationBillingData: async (organizationId: string) => {
|
||||
const state = get()
|
||||
|
||||
if (
|
||||
state.organizationBillingData &&
|
||||
state.lastOrgBillingFetched &&
|
||||
Date.now() - state.lastOrgBillingFetched < CACHE_DURATION
|
||||
) {
|
||||
logger.debug('Using cached organization billing data')
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isLoadingOrgBilling) {
|
||||
logger.debug('Organization billing data already loading, skipping duplicate request')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoadingOrgBilling: true })
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/billing?context=organization&id=${organizationId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const data = result.data
|
||||
|
||||
set({
|
||||
organizationBillingData: { ...data, userRole: result.userRole },
|
||||
isLoadingOrgBilling: false,
|
||||
lastOrgBillingFetched: Date.now(),
|
||||
})
|
||||
|
||||
logger.debug('Organization billing data loaded successfully')
|
||||
} catch (error) {
|
||||
logger.error('Failed to load organization billing data', { error })
|
||||
set({ isLoadingOrgBilling: false })
|
||||
}
|
||||
},
|
||||
|
||||
loadUserWorkspaces: async (userId?: string) => {
|
||||
try {
|
||||
// Get all workspaces the user is a member of
|
||||
const workspacesResponse = await fetch('/api/workspaces')
|
||||
if (!workspacesResponse.ok) {
|
||||
logger.error('Failed to fetch workspaces')
|
||||
return
|
||||
}
|
||||
|
||||
const workspacesData = await workspacesResponse.json()
|
||||
const allUserWorkspaces = workspacesData.workspaces || []
|
||||
|
||||
// Filter to only show workspaces where user has admin permissions
|
||||
const adminWorkspaces = []
|
||||
|
||||
for (const workspace of allUserWorkspaces) {
|
||||
try {
|
||||
const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
|
||||
if (permissionResponse.ok) {
|
||||
const permissionData = await permissionResponse.json()
|
||||
|
||||
// Check if current user has admin permission
|
||||
// Use userId if provided, otherwise fall back to checking isOwner from workspace data
|
||||
let hasAdminAccess = false
|
||||
|
||||
if (userId && permissionData.users) {
|
||||
const currentUserPermission = permissionData.users.find(
|
||||
(user: any) => user.id === userId || user.userId === userId
|
||||
)
|
||||
hasAdminAccess = currentUserPermission?.permissionType === 'admin'
|
||||
}
|
||||
|
||||
// Also check if user is the workspace owner
|
||||
const isOwner = workspace.isOwner || workspace.ownerId === userId
|
||||
|
||||
if (hasAdminAccess || isOwner) {
|
||||
adminWorkspaces.push({
|
||||
...workspace,
|
||||
isOwner: isOwner,
|
||||
canInvite: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check permissions for workspace ${workspace.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
set({ userWorkspaces: adminWorkspaces })
|
||||
|
||||
logger.info('Loaded admin workspaces for invitation', {
|
||||
total: allUserWorkspaces.length,
|
||||
adminWorkspaces: adminWorkspaces.length,
|
||||
userId: userId || 'not provided',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to load workspaces:', error)
|
||||
}
|
||||
},
|
||||
|
||||
refreshOrganization: async () => {
|
||||
const { activeOrganization } = get()
|
||||
if (!activeOrganization?.id) return
|
||||
|
||||
try {
|
||||
const fullOrgResponse = await client.organization.getFullOrganization()
|
||||
const updatedOrg = fullOrgResponse.data
|
||||
|
||||
set({ activeOrganization: updatedOrg })
|
||||
|
||||
// Also refresh subscription data
|
||||
if (updatedOrg?.id) {
|
||||
await get().loadOrganizationSubscription(updatedOrg.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh organization data', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh organization data',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Organization management
|
||||
createOrganization: async (name: string, slug: string) => {
|
||||
set({ isCreatingOrg: true, error: null })
|
||||
|
||||
try {
|
||||
logger.info('Creating team organization', { name, slug })
|
||||
|
||||
const result = await client.organization.create({ name, slug })
|
||||
if (!result.data?.id) {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
const orgId = result.data.id
|
||||
logger.info('Organization created', { orgId })
|
||||
|
||||
// Set as active organization
|
||||
await client.organization.setActive({ organizationId: orgId })
|
||||
|
||||
// Handle subscription transfer if needed
|
||||
const { hasTeamPlan, hasEnterprisePlan } = get()
|
||||
if (hasTeamPlan || hasEnterprisePlan) {
|
||||
await get().transferSubscriptionToOrganization(orgId)
|
||||
}
|
||||
|
||||
// Refresh data
|
||||
await get().loadData()
|
||||
|
||||
set({ isCreatingOrg: false })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create organization',
|
||||
isCreatingOrg: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
setActiveOrganization: async (orgId: string) => {
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
await client.organization.setActive({ organizationId: orgId })
|
||||
|
||||
const activeOrgResponse = await client.organization.getFullOrganization()
|
||||
const activeOrganization = activeOrgResponse.data
|
||||
|
||||
set({ activeOrganization })
|
||||
|
||||
if (activeOrganization?.id) {
|
||||
await get().loadOrganizationSubscription(activeOrganization.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to set active organization', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to set active organization',
|
||||
})
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
updateOrganizationSettings: async () => {
|
||||
const { activeOrganization, orgFormData } = get()
|
||||
if (!activeOrganization?.id) return
|
||||
|
||||
// Validate form
|
||||
if (!orgFormData.name.trim()) {
|
||||
set({ orgSettingsError: 'Organization name is required' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!orgFormData.slug.trim()) {
|
||||
set({ orgSettingsError: 'Organization slug is required' })
|
||||
return
|
||||
}
|
||||
|
||||
// Validate slug format
|
||||
if (!validateSlug(orgFormData.slug)) {
|
||||
set({
|
||||
orgSettingsError:
|
||||
'Slug can only contain lowercase letters, numbers, hyphens, and underscores',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
set({ isSavingOrgSettings: true, orgSettingsError: null, orgSettingsSuccess: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/organizations/${activeOrganization.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: orgFormData.name.trim(),
|
||||
slug: orgFormData.slug.trim(),
|
||||
logo: orgFormData.logo.trim() || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update organization settings')
|
||||
}
|
||||
|
||||
set({ orgSettingsSuccess: 'Organization settings updated successfully' })
|
||||
|
||||
// Refresh organization data
|
||||
await get().refreshOrganization()
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
set({ orgSettingsSuccess: null })
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update organization settings', { error })
|
||||
set({
|
||||
orgSettingsError: error instanceof Error ? error.message : 'Failed to update settings',
|
||||
})
|
||||
} finally {
|
||||
set({ isSavingOrgSettings: false })
|
||||
}
|
||||
},
|
||||
|
||||
// Team management
|
||||
inviteMember: async (email: string, workspaceInvitations?: WorkspaceInvitation[]) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization) return
|
||||
|
||||
set({ isInviting: true, error: null, inviteSuccess: false })
|
||||
|
||||
try {
|
||||
const { used: totalCount } = calculateSeatUsage(activeOrganization)
|
||||
const seatLimit = subscriptionData?.seats || 0
|
||||
|
||||
if (totalCount >= seatLimit) {
|
||||
throw new Error(
|
||||
`You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
throw new Error('Please enter a valid email address')
|
||||
}
|
||||
|
||||
logger.info('Sending invitation to member', {
|
||||
email,
|
||||
organizationId: activeOrganization.id,
|
||||
workspaceInvitations,
|
||||
})
|
||||
|
||||
// Use direct API call with workspace invitations if selected
|
||||
if (workspaceInvitations && workspaceInvitations.length > 0) {
|
||||
const response = await fetch(
|
||||
`/api/organizations/${activeOrganization.id}/invitations?batch=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
role: 'member',
|
||||
workspaceInvitations,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to send invitation')
|
||||
}
|
||||
} else {
|
||||
// Use existing client method for organization-only invitations
|
||||
const inviteResult = await client.organization.inviteMember({
|
||||
email,
|
||||
role: 'member',
|
||||
organizationId: activeOrganization.id,
|
||||
})
|
||||
|
||||
if (inviteResult.error) {
|
||||
throw new Error(inviteResult.error.message || 'Failed to send invitation')
|
||||
}
|
||||
}
|
||||
|
||||
set({ inviteSuccess: true })
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Error inviting member', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to invite member' })
|
||||
} finally {
|
||||
set({ isInviting: false })
|
||||
}
|
||||
},
|
||||
|
||||
removeMember: async (memberId: string, shouldReduceSeats = false) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization) return
|
||||
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
await client.organization.removeMember({
|
||||
memberIdOrEmail: memberId,
|
||||
organizationId: activeOrganization.id,
|
||||
})
|
||||
|
||||
// If the user opted to reduce seats as well
|
||||
if (shouldReduceSeats && subscriptionData) {
|
||||
const currentSeats = subscriptionData.seats || 0
|
||||
if (currentSeats > 1) {
|
||||
await get().reduceSeats(currentSeats - 1)
|
||||
}
|
||||
}
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove member', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to remove member' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
cancelInvitation: async (invitationId: string) => {
|
||||
const { activeOrganization } = get()
|
||||
if (!activeOrganization) return
|
||||
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
await client.organization.cancelInvitation({ invitationId })
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel invitation', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to cancel invitation' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
updateMemberUsageLimit: async (userId: string, organizationId: string, newLimit: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/usage-limits?context=member&userId=${userId}&organizationId=${organizationId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ limit: newLimit }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update member usage limit')
|
||||
}
|
||||
|
||||
// Refresh organization billing data
|
||||
await get().loadOrganizationBillingData(organizationId)
|
||||
|
||||
logger.debug('Member usage limit updated successfully', { userId, newLimit })
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update member usage limit'
|
||||
logger.error('Failed to update member usage limit', { error, userId, newLimit })
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
},
|
||||
|
||||
// Seat management
|
||||
addSeats: async (newSeatCount: number) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization || !subscriptionData) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const { error } = await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: activeOrganization.id,
|
||||
subscriptionId: subscriptionData.id,
|
||||
seats: newSeatCount,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to update seats')
|
||||
}
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to add seats', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to update seats' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
reduceSeats: async (newSeatCount: number) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization || !subscriptionData) return
|
||||
|
||||
// Don't allow enterprise users to modify seats
|
||||
if (checkEnterprisePlan(subscriptionData)) {
|
||||
set({ error: 'Enterprise plan seats can only be modified by contacting support' })
|
||||
return
|
||||
}
|
||||
|
||||
if (newSeatCount <= 0) {
|
||||
set({ error: 'Cannot reduce seats below 1' })
|
||||
return
|
||||
}
|
||||
|
||||
const { used: totalCount } = calculateSeatUsage(activeOrganization)
|
||||
if (totalCount >= newSeatCount) {
|
||||
set({
|
||||
error: `You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const { error } = await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: activeOrganization.id,
|
||||
subscriptionId: subscriptionData.id,
|
||||
seats: newSeatCount,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to reduce seats')
|
||||
}
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to reduce seats', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to reduce seats' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// Private helper method for subscription transfer
|
||||
transferSubscriptionToOrganization: async (orgId: string) => {
|
||||
const { hasTeamPlan, hasEnterprisePlan } = get()
|
||||
|
||||
try {
|
||||
const userSubResponse = await client.subscription.list()
|
||||
let teamSubscription: Subscription | null =
|
||||
(userSubResponse.data?.find(
|
||||
(sub) => (sub.plan === 'team' || sub.plan === 'enterprise') && sub.status === 'active'
|
||||
) as Subscription | undefined) || null
|
||||
|
||||
// If no subscription found through client API but user has enterprise plan
|
||||
if (!teamSubscription && hasEnterprisePlan) {
|
||||
const billingResponse = await fetch('/api/billing?context=user')
|
||||
if (billingResponse.ok) {
|
||||
const billingData = await billingResponse.json()
|
||||
if (billingData.success && billingData.data.isEnterprise && billingData.data.status) {
|
||||
teamSubscription = {
|
||||
id: `subscription_${Date.now()}`,
|
||||
plan: billingData.data.plan,
|
||||
status: billingData.data.status,
|
||||
seats: billingData.data.seats,
|
||||
referenceId: billingData.data.organizationId || 'unknown',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (teamSubscription) {
|
||||
const transferResponse = await fetch(
|
||||
`/api/users/me/subscription/${teamSubscription.id}/transfer`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
organizationId: orgId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!transferResponse.ok) {
|
||||
const errorText = await transferResponse.text()
|
||||
let errorMessage = 'Failed to transfer subscription'
|
||||
|
||||
try {
|
||||
if (errorText?.trim().startsWith('{')) {
|
||||
const errorData = JSON.parse(errorText)
|
||||
errorMessage = errorData.error || errorMessage
|
||||
}
|
||||
} catch (_e) {
|
||||
errorMessage = errorText || errorMessage
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Subscription transfer failed', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Computed getters (keep only those that are used)
|
||||
getUserRole: (userEmail?: string) => {
|
||||
const { activeOrganization } = get()
|
||||
if (!userEmail || !activeOrganization?.members) {
|
||||
return 'member'
|
||||
}
|
||||
const currentMember = activeOrganization.members.find((m) => m.user?.email === userEmail)
|
||||
return currentMember?.role ?? 'member'
|
||||
},
|
||||
|
||||
isAdminOrOwner: (userEmail?: string) => {
|
||||
const role = get().getUserRole(userEmail)
|
||||
return role === 'owner' || role === 'admin'
|
||||
},
|
||||
|
||||
getUsedSeats: () => {
|
||||
const { activeOrganization } = get()
|
||||
return calculateSeatUsage(activeOrganization)
|
||||
},
|
||||
|
||||
// Form handlers
|
||||
setOrgFormData: (data) => {
|
||||
set((state) => ({
|
||||
orgFormData: { ...state.orgFormData, ...data },
|
||||
}))
|
||||
|
||||
// Auto-generate slug from name if name is being set
|
||||
if (data.name) {
|
||||
const autoSlug = generateSlug(data.name)
|
||||
set((state) => ({
|
||||
orgFormData: { ...state.orgFormData, slug: autoSlug },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
// Utility methods
|
||||
clearError: () => {
|
||||
set({ error: null })
|
||||
},
|
||||
|
||||
clearSuccessMessages: () => {
|
||||
set({ inviteSuccess: false, orgSettingsSuccess: null })
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
subscriptionData: null,
|
||||
userWorkspaces: [],
|
||||
organizationBillingData: null,
|
||||
orgFormData: {
|
||||
name: '',
|
||||
slug: '',
|
||||
logo: '',
|
||||
},
|
||||
isLoading: false,
|
||||
isLoadingSubscription: false,
|
||||
isLoadingOrgBilling: false,
|
||||
isCreatingOrg: false,
|
||||
isInviting: false,
|
||||
isSavingOrgSettings: false,
|
||||
error: null,
|
||||
orgSettingsError: null,
|
||||
inviteSuccess: false,
|
||||
orgSettingsSuccess: null,
|
||||
lastFetched: null,
|
||||
lastSubscriptionFetched: null,
|
||||
lastOrgBillingFetched: null,
|
||||
hasTeamPlan: false,
|
||||
hasEnterprisePlan: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
{ name: 'organization-store' }
|
||||
)
|
||||
)
|
||||
|
||||
// Auto-load organization data when store is first accessed
|
||||
if (typeof window !== 'undefined') {
|
||||
useOrganizationStore.getState().loadData()
|
||||
}
|
||||
169
apps/sim/stores/organization/types.ts
Normal file
169
apps/sim/stores/organization/types.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
export interface User {
|
||||
name?: string
|
||||
email?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
role: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo?: string | null
|
||||
members?: Member[]
|
||||
invitations?: Invitation[]
|
||||
createdAt: string | Date
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string
|
||||
plan: string
|
||||
status: string
|
||||
seats?: number
|
||||
referenceId: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
periodEnd?: number | Date
|
||||
trialEnd?: number | Date
|
||||
metadata?: any
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
permission: string
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
isOwner: boolean
|
||||
canInvite: boolean
|
||||
}
|
||||
|
||||
export interface OrganizationFormData {
|
||||
name: string
|
||||
slug: string
|
||||
logo: string
|
||||
}
|
||||
|
||||
// Organization billing and usage types
|
||||
export interface MemberUsageData {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentUsage: number
|
||||
usageLimit: number
|
||||
percentUsed: number
|
||||
isOverLimit: boolean
|
||||
role: string
|
||||
joinedAt: string
|
||||
lastActive: string | null
|
||||
}
|
||||
|
||||
export interface OrganizationBillingData {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
subscriptionPlan: string
|
||||
subscriptionStatus: string
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
averageUsagePerMember: number
|
||||
billingPeriodStart: string | null
|
||||
billingPeriodEnd: string | null
|
||||
members?: MemberUsageData[]
|
||||
userRole?: string
|
||||
}
|
||||
|
||||
export interface OrganizationState {
|
||||
// Core organization data
|
||||
organizations: Organization[]
|
||||
activeOrganization: Organization | null
|
||||
|
||||
// Team management
|
||||
subscriptionData: Subscription | null
|
||||
userWorkspaces: Workspace[]
|
||||
|
||||
// Organization billing and usage
|
||||
organizationBillingData: OrganizationBillingData | null
|
||||
|
||||
// Organization settings
|
||||
orgFormData: OrganizationFormData
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean
|
||||
isLoadingSubscription: boolean
|
||||
isLoadingOrgBilling: boolean
|
||||
isCreatingOrg: boolean
|
||||
isInviting: boolean
|
||||
isSavingOrgSettings: boolean
|
||||
|
||||
// Error states
|
||||
error: string | null
|
||||
orgSettingsError: string | null
|
||||
|
||||
// Success states
|
||||
inviteSuccess: boolean
|
||||
orgSettingsSuccess: string | null
|
||||
|
||||
// Cache timestamps
|
||||
lastFetched: number | null
|
||||
lastSubscriptionFetched: number | null
|
||||
lastOrgBillingFetched: number | null
|
||||
|
||||
// User permissions
|
||||
hasTeamPlan: boolean
|
||||
hasEnterprisePlan: boolean
|
||||
}
|
||||
|
||||
export interface OrganizationStore extends OrganizationState {
|
||||
loadData: () => Promise<void>
|
||||
loadOrganizationSubscription: (orgId: string) => Promise<void>
|
||||
loadOrganizationBillingData: (organizationId: string) => Promise<void>
|
||||
loadUserWorkspaces: (userId?: string) => Promise<void>
|
||||
refreshOrganization: () => Promise<void>
|
||||
|
||||
// Organization management
|
||||
createOrganization: (name: string, slug: string) => Promise<void>
|
||||
setActiveOrganization: (orgId: string) => Promise<void>
|
||||
updateOrganizationSettings: () => Promise<void>
|
||||
|
||||
// Team management
|
||||
inviteMember: (email: string, workspaceInvitations?: WorkspaceInvitation[]) => Promise<void>
|
||||
removeMember: (memberId: string, shouldReduceSeats?: boolean) => Promise<void>
|
||||
cancelInvitation: (invitationId: string) => Promise<void>
|
||||
updateMemberUsageLimit: (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
newLimit: number
|
||||
) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// Seat management
|
||||
addSeats: (newSeatCount: number) => Promise<void>
|
||||
reduceSeats: (newSeatCount: number) => Promise<void>
|
||||
|
||||
transferSubscriptionToOrganization: (orgId: string) => Promise<void>
|
||||
|
||||
getUserRole: (userEmail?: string) => string
|
||||
isAdminOrOwner: (userEmail?: string) => boolean
|
||||
getUsedSeats: () => { used: number; members: number; pending: number }
|
||||
|
||||
setOrgFormData: (data: Partial<OrganizationFormData>) => void
|
||||
|
||||
clearError: () => void
|
||||
clearSuccessMessages: () => void
|
||||
}
|
||||
36
apps/sim/stores/organization/utils.ts
Normal file
36
apps/sim/stores/organization/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Organization } from './types'
|
||||
|
||||
/**
|
||||
* Calculate seat usage for an organization
|
||||
*/
|
||||
export function calculateSeatUsage(org?: Organization | null) {
|
||||
const members = org?.members?.length ?? 0
|
||||
const pending = org?.invitations?.filter((inv) => inv.status === 'pending').length ?? 0
|
||||
return { used: members + pending, members, pending }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-friendly slug from a name
|
||||
*/
|
||||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with hyphens
|
||||
.replace(/-+/g, '-') // Replace consecutive hyphens with single hyphen
|
||||
.replace(/^-|-$/g, '') // Remove leading and trailing hyphens
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate organization slug format
|
||||
*/
|
||||
export function validateSlug(slug: string): boolean {
|
||||
const slugRegex = /^[a-z0-9-_]+$/
|
||||
return slugRegex.test(slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function validateEmail(email: string): boolean {
|
||||
return email.includes('@') && email.trim().length > 0
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user