diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 3fbae3c1df1..2df0d6f2f65 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -7,8 +7,6 @@ import { getSession } from '@/lib/auth' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' import { getOrganizationBillingData } from '@/lib/billing/core/organization' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { getPlanTierCredits } from '@/lib/billing/plan-helpers' const logger = createLogger('UnifiedBillingAPI') @@ -47,7 +45,20 @@ export async function GET(request: NextRequest) { let billingData if (context === 'user') { - // Get user billing and billing blocked status in parallel + if (contextId) { + const membership = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, contextId), eq(member.userId, session.user.id))) + .limit(1) + if (membership.length === 0) { + return NextResponse.json( + { error: 'Access denied - not a member of this organization' }, + { status: 403 } + ) + } + } + const [billingResult, billingStatus] = await Promise.all([ getSimplifiedBillingSummary(session.user.id, contextId || undefined), getEffectiveBillingStatus(session.user.id), @@ -107,7 +118,6 @@ export async function GET(request: NextRequest) { ) } - // Transform data to match component expectations billingData = { organizationId: rawBillingData.organizationId, organizationName: rawBillingData.organizationName, @@ -122,17 +132,10 @@ export async function GET(request: NextRequest) { averageUsagePerMember: rawBillingData.averageUsagePerMember, billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null, billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null, - tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan), - totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage), - totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit), - minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount), - averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember), members: rawBillingData.members.map((m) => ({ ...m, joinedAt: m.joinedAt.toISOString(), lastActive: m.lastActive?.toISOString() || null, - currentUsageCredits: dollarsToCredits(m.currentUsage), - usageLimitCredits: dollarsToCredits(m.usageLimit), })), } diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index cdc1ca5e65c..4c763caa8c9 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -9,12 +9,13 @@ import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { writeBillingInterval } from '@/lib/billing/core/subscription' -import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers' +import { getPlanType, isEnterprise } from '@/lib/billing/plan-helpers' import { getPlanByName } from '@/lib/billing/plans' import { requireStripeClient } from '@/lib/billing/stripe-client' import { hasUsableSubscriptionAccess, hasUsableSubscriptionStatus, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { toError } from '@/lib/core/utils/helpers' @@ -93,7 +94,7 @@ export async function POST(request: NextRequest) { ) } - if (isOrgPlan(sub.plan)) { + if (isOrgScopedSubscription(sub, userId)) { const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId) if (!hasPermission) { return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index 7f4f7d8004c..269efb6a32c 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -14,7 +14,7 @@ import { workspaceInvitation, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' @@ -22,9 +22,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers' -import { requireStripeClient } from '@/lib/billing/stripe-client' +import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' @@ -328,8 +329,6 @@ export async function PUT( } } - let personalProToCancel: any = null - await db.transaction(async (tx) => { await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId)) @@ -342,8 +341,7 @@ export async function PUT( createdAt: new Date(), }) - // Snapshot Pro usage and cancel Pro subscription when joining a paid team - try { + { const orgSubs = await tx .select() .from(subscriptionTable) @@ -356,7 +354,7 @@ export async function PUT( .limit(1) const orgSub = orgSubs[0] - const orgIsPaid = orgSub && isOrgPlan(orgSub.plan) + const orgIsPaid = orgSub && isPaid(orgSub.plan) if (orgIsPaid) { const userId = session.user.id @@ -393,8 +391,9 @@ export async function PUT( .update(userStats) .set({ proPeriodCostSnapshot: currentProUsage, - currentPeriodCost: '0', // Reset so new usage is attributed to team - currentPeriodCopilotCost: '0', // Reset copilot cost for new period + proPeriodCostSnapshotAt: new Date(), + currentPeriodCost: '0', + currentPeriodCopilotCost: '0', }) .where(eq(userStats.userId, userId)) @@ -405,19 +404,48 @@ export async function PUT( }) } - // Mark for cancellation after transaction - if (personalPro.cancelAtPeriodEnd !== true) { - personalProToCancel = personalPro + if (personalPro.cancelAtPeriodEnd !== true && personalPro.stripeSubscriptionId) { + await tx + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscriptionTable.id, personalPro.id)) + + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: personalPro.stripeSubscriptionId, + subscriptionId: personalPro.id, + reason: 'member-joined-paid-org', + }) } } + + const storageRows = await tx + .select({ storageUsedBytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, userId)) + .for('update') + .limit(1) + + const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0 + if (bytesToTransfer > 0) { + await tx + .update(organization) + .set({ + storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`, + }) + .where(eq(organization.id, organizationId)) + + await tx + .update(userStats) + .set({ storageUsedBytes: 0 }) + .where(eq(userStats.userId, userId)) + + logger.info('Transferred personal storage bytes to org pool on join', { + userId, + organizationId, + bytes: bytesToTransfer, + }) + } } - } catch (error) { - logger.error('Failed to handle Pro user joining team', { - userId: session.user.id, - organizationId, - error, - }) - // Don't fail the whole invitation acceptance due to this } // Auto-assign to permission group if one has autoAddNewMembers enabled @@ -557,44 +585,6 @@ export async function PUT( } } - // Handle Pro subscription cancellation after transaction commits - if (personalProToCancel) { - try { - const stripe = requireStripeClient() - if (personalProToCancel.stripeSubscriptionId) { - try { - await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, { - cancel_at_period_end: true, - }) - } catch (stripeError) { - logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', { - userId: session.user.id, - subscriptionId: personalProToCancel.id, - stripeSubscriptionId: personalProToCancel.stripeSubscriptionId, - error: stripeError, - }) - } - } - - await db - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: true }) - .where(eq(subscriptionTable.id, personalProToCancel.id)) - - logger.info('Auto-cancelled personal Pro at period end after joining paid team', { - userId: session.user.id, - personalSubscriptionId: personalProToCancel.id, - organizationId, - }) - } catch (dbError) { - logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', { - userId: session.user.id, - subscriptionId: personalProToCancel.id, - error: dbError, - }) - } - } - if (status === 'accepted') { try { await syncUsageLimitsFromSubscription(session.user.id) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 989d792b6fd..ad4e4001c82 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -1,12 +1,19 @@ import { db } from '@sim/db' -import { invitation, member, organization, user, userStats } from '@sim/db/schema' +import { + invitation, + member, + organization, + subscription as subscriptionTable, + user, + userStats, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' -import { getUserUsageData } from '@/lib/billing/core/usage' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' @@ -83,16 +90,32 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .leftJoin(userStats, eq(user.id, userStats.userId)) .where(eq(member.organizationId, organizationId)) - const membersWithUsage = await Promise.all( - base.map(async (row) => { - const usage = await getUserUsageData(row.userId) - return { - ...row, - billingPeriodStart: usage.billingPeriodStart, - billingPeriodEnd: usage.billingPeriodEnd, - } + // The billing period is the same for every member — it comes from + // whichever subscription covers them. Fetch once and attach to + // every row instead of calling `getUserUsageData` per-member, + // which would run an O(N) pooled query for each of N rows. + const [orgSub] = await db + .select({ + periodStart: subscriptionTable.periodStart, + periodEnd: subscriptionTable.periodEnd, }) - ) + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, organizationId), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + + const billingPeriodStart = orgSub?.periodStart ?? null + const billingPeriodEnd = orgSub?.periodEnd ?? null + + const membersWithUsage = base.map((row) => ({ + ...row, + billingPeriodStart, + billingPeriodEnd, + })) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 6a2be6238c0..3ea2b3f06ed 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -13,6 +13,8 @@ import { hasUsableSubscriptionStatus, USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' +import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management' import { isBillingEnabled } from '@/lib/core/config/feature-flags' const logger = createLogger('OrganizationSeatsAPI') @@ -164,8 +166,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ userId: session.user.id, }) - // Update the subscription item quantity using Stripe's recommended approach - // This will automatically prorate the billing const updatedSubscription = await stripe.subscriptions.update( orgSubscription.stripeSubscriptionId, { @@ -176,19 +176,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }, ], proration_behavior: 'always_invoice', - } + }, + { idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` } ) - // Update our local database to reflect the change - // Note: This will also be updated via webhook, but we update immediately for UX - await db - .update(subscription) - .set({ - seats: newSeatCount, - }) - .where(eq(subscription.id, orgSubscription.id)) + await syncSeatsFromStripeQuantity( + orgSubscription.id, + orgSubscription.seats, + updatedSubscription.items.data[0]?.quantity ?? newSeatCount + ) - // Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum) const { basePrice } = getPlanPricing(orgSubscription.plan) const newMinimumLimit = newSeatCount * basePrice @@ -200,7 +197,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const currentOrgLimit = orgData.length > 0 && orgData[0].orgUsageLimit - ? Number.parseFloat(orgData[0].orgUsageLimit) + ? toNumber(toDecimal(orgData[0].orgUsageLimit)) : 0 // Update if new minimum is higher than current limit diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index feaec3b95d9..41bcdefd063 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -30,10 +30,11 @@ import { and, eq, inArray } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { addCredits } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' -import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers' +import { isPaid } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, getEffectiveSeats, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import { generateShortId } from '@/lib/core/utils/uuid' import { withAdminAuth } from '@/app/api/v1/admin/middleware' @@ -110,7 +111,8 @@ export const POST = withAdminAuth(async (request) => { const plan = userSubscription.plan let seats: number | null = null - if (isOrgPlan(plan)) { + // Route admin credits to the subscription's entity (org if org-scoped). + if (isOrgScopedSubscription(userSubscription, resolvedUserId)) { entityType = 'organization' entityId = userSubscription.referenceId diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index cc9cee63206..1da561752ad 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -33,7 +33,6 @@ import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { addUserToOrganization } from '@/lib/billing/organizations/membership' -import { requireStripeClient } from '@/lib/billing/stripe-client' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -229,28 +228,6 @@ export const POST = withAdminAuthParams(async (request, context) => return badRequestResponse(result.error || 'Failed to add member') } - if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { - try { - const stripe = requireStripeClient() - await stripe.subscriptions.update( - result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - { cancel_at_period_end: true } - ) - logger.info('Admin API: Synced Pro cancellation with Stripe', { - userId: body.userId, - subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, - stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - }) - } catch (stripeError) { - logger.error('Admin API: Failed to sync Pro cancellation with Stripe', { - userId: body.userId, - subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, - stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - error: stripeError, - }) - } - } - const data: AdminMember = { id: result.memberId!, userId: body.userId, diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts new file mode 100644 index 00000000000..9de5c4696be --- /dev/null +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -0,0 +1,71 @@ +import { db } from '@sim/db' +import { outboxEvent } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' + +const logger = createLogger('AdminOutboxRequeueAPI') + +export const dynamic = 'force-dynamic' + +/** + * POST /api/v1/admin/outbox/[id]/requeue + * + * Move a dead-lettered outbox event back to `pending` so the worker + * will retry it. Resets `attempts`, `lastError`, and `availableAt` so + * the next poll picks it up. Only dead-lettered events can be + * requeued — completed/pending/processing rows are rejected to avoid + * operator errors. + */ +export const POST = withAdminAuthParams<{ id: string }>(async (_request, { params }) => { + const { id } = await params + + try { + const result = await db + .update(outboxEvent) + .set({ + status: 'pending', + attempts: 0, + lastError: null, + availableAt: new Date(), + lockedAt: null, + processedAt: null, + }) + .where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter'))) + .returning({ id: outboxEvent.id, eventType: outboxEvent.eventType }) + + if (result.length === 0) { + return NextResponse.json( + { + success: false, + error: + 'Event not found or not in dead_letter status. Only dead-lettered events can be requeued.', + }, + { status: 404 } + ) + } + + logger.info('Requeued dead-lettered outbox event', { + eventId: result[0].id, + eventType: result[0].eventType, + }) + + return NextResponse.json({ + success: true, + requeued: result[0], + }) + } catch (error) { + logger.error('Failed to requeue outbox event', { + eventId: id, + error: error instanceof Error ? error.message : error, + }) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts new file mode 100644 index 00000000000..addcb5bbe67 --- /dev/null +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -0,0 +1,91 @@ +import { db } from '@sim/db' +import { outboxEvent } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, desc, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' + +const logger = createLogger('AdminOutboxAPI') + +export const dynamic = 'force-dynamic' + +/** + * GET /api/v1/admin/outbox?status=dead_letter&eventType=...&limit=100 + * + * Inspect outbox events for operator triage. Primary use: list + * dead-lettered rows to reconcile Stripe state manually after a + * permanent handler failure (e.g. Stripe account frozen, subscription + * already canceled by another path, etc.). + * + * Filters: + * - `status`: 'pending' | 'processing' | 'completed' | 'dead_letter' (default 'dead_letter') + * - `eventType`: exact match on event_type + * - `limit`: cap rows returned (default 100, max 500) + * + * Response includes aggregate counts by status for quick health read. + */ +export const GET = withAdminAuth(async (request: NextRequest) => { + try { + const { searchParams } = new URL(request.url) + const validStatuses = ['pending', 'processing', 'completed', 'dead_letter'] as const + const status = (searchParams.get('status') ?? 'dead_letter') as (typeof validStatuses)[number] + if (!validStatuses.includes(status)) { + return NextResponse.json( + { + success: false, + error: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, + }, + { status: 400 } + ) + } + + const eventType = searchParams.get('eventType') + + const rawLimit = searchParams.get('limit') + const parsedLimit = rawLimit === null ? 100 : Number.parseInt(rawLimit, 10) + const limit = + Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.min(500, Math.max(1, parsedLimit)) + : 100 + + const whereConditions = [eq(outboxEvent.status, status)] + if (eventType) { + whereConditions.push(eq(outboxEvent.eventType, eventType)) + } + + const rows = await db + .select() + .from(outboxEvent) + .where(and(...whereConditions)) + .orderBy(desc(outboxEvent.createdAt)) + .limit(limit) + + // Aggregate counts per (status, eventType) for at-a-glance health. + const counts = await db + .select({ + status: outboxEvent.status, + eventType: outboxEvent.eventType, + count: sql`count(*)::int`, + }) + .from(outboxEvent) + .groupBy(outboxEvent.status, outboxEvent.eventType) + + return NextResponse.json({ + success: true, + filter: { status, eventType, limit }, + rows, + counts, + }) + } catch (error) { + logger.error('Failed to list outbox events', { + error: error instanceof Error ? error.message : error, + }) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts index 50ba40f3338..58d977c7707 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -28,6 +28,8 @@ import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -91,28 +93,31 @@ export const DELETE = withAdminAuthParams(async (request, context) return badRequestResponse('Subscription has no Stripe subscription ID') } - const stripe = requireStripeClient() - if (atPeriodEnd) { - // Schedule cancellation at period end - await stripe.subscriptions.update(existing.stripeSubscriptionId, { - cancel_at_period_end: true, + await db.transaction(async (tx) => { + await tx + .update(subscription) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscription.id, subscriptionId)) + + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: existing.stripeSubscriptionId, + subscriptionId: existing.id, + reason: reason ?? 'admin-cancel-at-period-end', + }) }) - // Update DB (webhooks don't sync cancelAtPeriodEnd) - await db - .update(subscription) - .set({ cancelAtPeriodEnd: true }) - .where(eq(subscription.id, subscriptionId)) - - logger.info('Admin API: Scheduled subscription cancellation at period end', { - subscriptionId, - stripeSubscriptionId: existing.stripeSubscriptionId, - plan: existing.plan, - referenceId: existing.referenceId, - periodEnd: existing.periodEnd, - reason, - }) + logger.info( + 'Admin API: Scheduled subscription cancellation at period end (DB committed, Stripe queued)', + { + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + plan: existing.plan, + referenceId: existing.referenceId, + periodEnd: existing.periodEnd, + reason, + } + ) return singleResponse({ success: true, @@ -124,11 +129,16 @@ export const DELETE = withAdminAuthParams(async (request, context) }) } - // Immediate cancellation - await stripe.subscriptions.cancel(existing.stripeSubscriptionId, { - prorate: true, - invoice_now: true, - }) + // Immediate cancellation — stays synchronous. Stripe's + // `customer.subscription.deleted` webhook triggers full cleanup + // (overage bill, usage reset, Pro restore, org delete) via + // `handleSubscriptionDeleted`, so no outbox needed here. + const stripe = requireStripeClient() + await stripe.subscriptions.cancel( + existing.stripeSubscriptionId, + { prorate: true, invoice_now: true }, + { idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` } + ) logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', { subscriptionId, diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 1639db0baea..ecc10aab242 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -23,7 +23,7 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch import { createLogger } from '@sim/logger' import { eq, or } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { generateShortId } from '@/lib/core/utils/uuid' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -155,7 +155,7 @@ export const PATCH = withAdminAuthParams(async (request, context) = .limit(1) const userSubscription = await getHighestPrioritySubscription(userId) - const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan) + const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId) const [orgMembership] = await db .select({ organizationId: member.organizationId }) @@ -168,9 +168,9 @@ export const PATCH = withAdminAuthParams(async (request, context) = const warnings: string[] = [] if (body.currentUsageLimit !== undefined) { - if (isTeamOrEnterpriseMember && orgMembership) { + if (isOrgScopedMember && orgMembership) { warnings.push( - 'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.' + 'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.' ) } diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts new file mode 100644 index 00000000000..99b3f02baf6 --- /dev/null +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' +import { processOutboxEvents } from '@/lib/core/outbox/service' +import { generateRequestId } from '@/lib/core/utils/request' + +const logger = createLogger('OutboxProcessorAPI') + +export const dynamic = 'force-dynamic' +export const maxDuration = 120 + +const handlers = { + ...billingOutboxHandlers, +} as const + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authError = verifyCronAuth(request, 'Outbox processor') + if (authError) { + return authError + } + + const result = await processOutboxEvents(handlers, { batchSize: 20 }) + + logger.info('Outbox processing completed', { requestId, ...result }) + + return NextResponse.json({ + success: true, + requestId, + result, + }) + } catch (error) { + logger.error('Outbox processing failed', { + requestId, + error: error instanceof Error ? error.message : error, + }) + return NextResponse.json( + { + success: false, + requestId, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 10658d8f7f0..ddf458a7dc6 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -2,9 +2,12 @@ import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { client, useSession } from '@/lib/auth/auth-client' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' +import { organizationKeys } from '@/hooks/queries/organization' +import { subscriptionKeys } from '@/hooks/queries/subscription' const logger = createLogger('InviteById') @@ -166,6 +169,7 @@ export default function Invite() { const inviteId = params.id as string const searchParams = useSearchParams() const { data: session, isPending } = useSession() + const queryClient = useQueryClient() const [invitationDetails, setInvitationDetails] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -345,6 +349,16 @@ export default function Invite() { organizationId: orgId, }) + // Invalidate billing / org caches so `/workspace` doesn't flash the + // user's pre-join personal subscription while the new team-scoped + // data is being refetched. Accept-flow side effects (snapshot, + // storage transfer, plan sync, member insert) have already + // committed by the time we reach here. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }), + queryClient.invalidateQueries({ queryKey: organizationKeys.all }), + ]) + setAccepted(true) setTimeout(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts index 9d901c8e7b9..bf07d457132 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts @@ -17,6 +17,12 @@ export interface SubscriptionState { isTeam: boolean isEnterprise: boolean isPaid: boolean + /** + * True when the subscription's `referenceId` is an organization. Source + * of truth for scope-based decisions — `pro_*` plans that have been + * transferred to an org are org-scoped even though `isTeam` is false. + */ + isOrgScoped: boolean plan: string status: string } @@ -30,21 +36,27 @@ export function getSubscriptionPermissions( subscription: SubscriptionState, userRole: UserRole ): SubscriptionPermissions { - const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription + const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription const { isTeamAdmin } = userRole + // Non-admin org members see the "team member" view: no edit / no cancel + // / no upgrade, pooled usage display. + const orgMemberOnly = isOrgScoped && !isTeamAdmin + const orgAdminOrSolo = !isOrgScoped || isTeamAdmin + const isEnterpriseMember = isEnterprise && !isTeamAdmin const canViewUsageInfo = !isEnterpriseMember return { canUpgradeToPro: isFree, - canUpgradeToTeam: isFree || (isPro && !isTeam), - canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members - canManageTeam: isTeam && isTeamAdmin, - canEditUsageLimit: (isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users and team admins see pencil - canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel - showTeamMemberView: isTeam && !isTeamAdmin, - showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans + canUpgradeToTeam: isFree || (isPro && !isOrgScoped), + canViewEnterprise: !isEnterprise && !orgMemberOnly, + canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise, + canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo, + canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo, + showTeamMemberView: orgMemberOnly, + showUpgradePlans: + (isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise, isEnterpriseMember, canViewUsageInfo, } @@ -55,22 +67,16 @@ export function getVisiblePlans( userRole: UserRole ): ('pro' | 'team' | 'enterprise')[] { const plans: ('pro' | 'team' | 'enterprise')[] = [] - const { isFree, isPro, isTeam } = subscription + const { isFree, isPro, isEnterprise, isOrgScoped } = subscription const { isTeamAdmin } = userRole - // Free users see all plans if (isFree) { plans.push('pro', 'team', 'enterprise') - } - // Pro users see team and enterprise - else if (isPro && !isTeam) { + } else if (isPro && !isOrgScoped) { plans.push('team', 'enterprise') - } - // Team owners see only enterprise (no team plan since they already have it) - else if (isTeam && isTeamAdmin) { + } else if (isOrgScoped && isTeamAdmin && !isEnterprise) { plans.push('enterprise') } - // Team members, Enterprise users see no plans return plans } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx index d2c6322be92..42581c86ff2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx @@ -34,7 +34,6 @@ import { getPlanTierDollars, isEnterprise, isFree, - isOrgPlan, isPaid, isPro, isTeam, @@ -294,12 +293,12 @@ export function Subscription() { const usageLimitRef = useRef(null) const hasInitializedInterval = useRef(false) - const hasOrgPlan = isOrgPlan(subscriptionData?.data?.plan) + const hasOrgScopedSubscription = Boolean(subscriptionData?.data?.isOrgScoped) const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading || - (hasOrgPlan && isOrgBillingLoading) + (hasOrgScopedSubscription && isOrgBillingLoading) const isCancelledAtPeriodEnd = subscriptionData?.data?.cancelAtPeriodEnd === true @@ -311,6 +310,12 @@ export function Subscription() { isPaid: isPaid(subscriptionData?.data?.plan) && hasPaidSubscriptionStatus(subscriptionData?.data?.status), + /** + * True when the subscription is attached to an org (regardless of plan + * name). Drives routing of usage-limit edits and whether we show pooled + * or personal usage. + */ + isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped), plan: subscriptionData?.data?.plan || 'free', status: subscriptionData?.data?.status || 'inactive', seats: getEffectiveSeats(subscriptionData?.data), @@ -364,16 +369,12 @@ export function Subscription() { const isTeamAdmin = ['owner', 'admin'].includes(userRole) const planIncludedAmount = - (subscription.isTeam || subscription.isEnterprise) && - isTeamAdmin && - organizationBillingData?.data + subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data ? organizationBillingData.data.minimumBillingAmount : getPlanTierCredits(subscription.plan) / CREDIT_MULTIPLIER const effectiveUsageLimit = - (subscription.isTeam || subscription.isEnterprise) && - isTeamAdmin && - organizationBillingData?.data + subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data ? organizationBillingData.data.totalUsageLimit : usageLimitData.currentLimit || usage.limit @@ -381,8 +382,7 @@ export function Subscription() { subscription.isPaid && planIncludedAmount > 0 && effectiveUsageLimit > planIncludedAmount const effectiveCurrentUsage = - (subscription.isTeam || subscription.isEnterprise) && - organizationBillingData?.data?.totalCurrentUsage != null + subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null ? organizationBillingData.data.totalCurrentUsage : usage.current @@ -390,8 +390,7 @@ export function Subscription() { const handleToggleOnDemand = useCallback(async () => { try { - const isOrgContext = - (subscription.isTeam || subscription.isEnterprise) && isTeamAdmin && activeOrgId + const isOrgContext = subscription.isOrgScoped && isTeamAdmin && activeOrgId if (isOnDemandActive) { if (!canDisableOnDemand) return @@ -420,8 +419,7 @@ export function Subscription() { }, [ isOnDemandActive, canDisableOnDemand, - subscription.isTeam, - subscription.isEnterprise, + subscription.isOrgScoped, isTeamAdmin, activeOrgId, planIncludedAmount, @@ -435,6 +433,7 @@ export function Subscription() { isTeam: subscription.isTeam, isEnterprise: subscription.isEnterprise, isPaid: subscription.isPaid, + isOrgScoped: subscription.isOrgScoped, plan: subscription.plan || 'free', status: subscription.status || 'inactive', }, @@ -448,6 +447,7 @@ export function Subscription() { isTeam: subscription.isTeam, isEnterprise: subscription.isEnterprise, isPaid: subscription.isPaid, + isOrgScoped: subscription.isOrgScoped, plan: subscription.plan || 'free', status: subscription.status || 'inactive', }, @@ -502,7 +502,7 @@ export function Subscription() { return } if (isBlocked) { - const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user' + const context = subscription.isOrgScoped ? 'organization' : 'user' openBillingPortal.mutate( { context, @@ -529,8 +529,7 @@ export function Subscription() { isDispute, isBlocked, subscription.isFree, - subscription.isTeam, - subscription.isEnterprise, + subscription.isOrgScoped, activeOrgId, doUpgrade, logger, @@ -591,13 +590,12 @@ export function Subscription() { : undefined } current={ - (subscription.isTeam || subscription.isEnterprise) && - organizationBillingData?.data?.totalCurrentUsage != null + subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null ? organizationBillingData.data.totalCurrentUsage : usage.current } limit={ - subscription.isEnterprise || subscription.isTeam + subscription.isOrgScoped ? organizationBillingData?.data?.totalUsageLimit : !subscription.isFree && (permissions.canEditUsageLimit || permissions.showTeamMemberView) @@ -612,31 +610,19 @@ export function Subscription() { logger.info('Usage limit updated')} /> ) : undefined @@ -905,7 +891,7 @@ export function Subscription() { setManagePlanModalOpen(false) if (!betterAuthSubscription.cancel) return try { - const isOrgSub = (subscription.isTeam || subscription.isEnterprise) && activeOrgId + const isOrgSub = subscription.isOrgScoped && activeOrgId const referenceId = isOrgSub ? activeOrgId : session?.user?.id || '' const returnUrl = getBaseUrl() + window.location.pathname await betterAuthSubscription.cancel({ returnUrl, referenceId }) @@ -917,7 +903,7 @@ export function Subscription() { onRestore={async () => { if (!betterAuthSubscription.restore) return try { - const isOrgSub = (subscription.isTeam || subscription.isEnterprise) && activeOrgId + const isOrgSub = subscription.isOrgScoped && activeOrgId const referenceId = isOrgSub ? activeOrgId : session?.user?.id || '' await betterAuthSubscription.restore({ referenceId }) await refetchSubscription() @@ -937,9 +923,7 @@ export function Subscription() { refetchSubscription()} /> @@ -974,8 +958,7 @@ export function Subscription() { disabled={openBillingPortal.isPending} onClick={() => { const portalWindow = window.open('', '_blank') - const context = - subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user' + const context = subscription.isOrgScoped ? 'organization' : 'user' openBillingPortal.mutate( { context, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index 8f65e9b0283..ae72152a8ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -86,17 +86,28 @@ interface StatusTextConfig { } /** - * Determines if user can manage billing based on plan type and org role. + * Determines if user can manage billing based on plan type, subscription + * scope, and org role. * - * @param planType - The user's current plan type + * When the subscription is org-scoped (any subscription whose referenceId + * points at an organization — includes `pro_*` plans transferred to an + * org, not just team/enterprise), only owners/admins can manage billing. + * Otherwise any free/pro user can manage their own. + * + * @param planType - The user's current plan type (for display category) * @param orgRole - The user's role in the organization, if applicable + * @param isOrgScoped - Whether the subscription is attached to an org * @returns True if the user has billing management permissions */ -function canManageBilling(planType: PlanType, orgRole: OrgRole | null): boolean { - if (planType === 'free' || planType === 'pro') return true - if (planType === 'team' || planType === 'enterprise') { +function canManageBilling( + planType: PlanType, + orgRole: OrgRole | null, + isOrgScoped: boolean +): boolean { + if (isOrgScoped || planType === 'team' || planType === 'enterprise') { return orgRole === 'owner' || orgRole === 'admin' } + if (planType === 'free' || planType === 'pro') return true return false } @@ -247,7 +258,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const isCritical = isBlocked || progressPercentage >= USAGE_THRESHOLDS.CRITICAL const isWarning = !isCritical && progressPercentage >= USAGE_THRESHOLDS.WARNING - const userCanManageBilling = canManageBilling(planType, orgRole) + const isOrgScoped = Boolean(subscriptionData?.data?.isOrgScoped) + const userCanManageBilling = canManageBilling(planType, orgRole, isOrgScoped) const displayState: DisplayState = { planType, @@ -323,17 +335,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const contextMenuItems = useMemo( () => ({ - // Set limit: Only for Pro and Team admins (not free, not enterprise) - showSetLimit: (isPro || (isTeam && userCanManageBilling)) && !isEnterprise, - // Upgrade to Pro: Only for free users + showSetLimit: userCanManageBilling && !isFree && !isEnterprise, showUpgradeToPro: isFree, - // Upgrade to Team: Free users and Pro users with billing permission showUpgradeToTeam: isFree || (isPro && userCanManageBilling), - // Manage seats: Only for Team admins showManageSeats: isTeam && userCanManageBilling, - // Upgrade to Enterprise: Only for Team admins (not free, not pro, not enterprise) showUpgradeToEnterprise: isTeam && userCanManageBilling, - // Contact support: Only for Enterprise admins showContactSupport: isEnterprise && userCanManageBilling, onSetLimit: handleSetLimit, onUpgradeToPro: handleUpgradeToPro, @@ -435,7 +441,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { if (isBlocked && userCanManageBilling) { try { - const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user' + const context = isOrgScoped ? 'organization' : 'user' const organizationId = subscriptionData?.data?.organization?.id const response = await fetch('/api/billing/portal', { diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index b5f634cf06a..3d3a65b8df6 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth/auth-client' -import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' +import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { subscriptionKeys } from '@/hooks/queries/subscription' const logger = createLogger('OrganizationQueries') @@ -87,13 +88,17 @@ async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSigna return null } - const teamSubscription = response.data?.find( - (sub: any) => hasPaidSubscriptionStatus(sub.status) && isTeam(sub.plan) + // Any paid subscription attached to the org counts as its active sub. + // Priority: Enterprise > Team > Pro (matches `getHighestPrioritySubscription`). + // This intentionally includes `pro_*` plans that have been transferred + // to the org — they are pooled org-scoped subscriptions. + const entitled = (response.data || []).filter( + (sub: any) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan) ) - const enterpriseSubscription = response.data?.find( - (sub: any) => hasPaidSubscriptionStatus(sub.status) && isEnterprise(sub.plan) - ) - const activeSubscription = enterpriseSubscription || teamSubscription + const enterpriseSubscription = entitled.find((sub: any) => isEnterprise(sub.plan)) + const teamSubscription = entitled.find((sub: any) => isTeam(sub.plan)) + const proSubscription = entitled.find((sub: any) => !isEnterprise(sub.plan) && !isTeam(sub.plan)) + const activeSubscription = enterpriseSubscription || teamSubscription || proSubscription return activeSubscription || null } @@ -326,6 +331,7 @@ export function useRemoveMember() { queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) }, }) } diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 3900bb9d383..a7f98d033ba 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -17,11 +17,6 @@ export interface BillingUsageData { lastPeriodCopilotCost: number daysRemaining: number copilotCost: number - currentCredits: number - limitCredits: number - lastPeriodCostCredits: number - lastPeriodCopilotCostCredits: number - copilotCostCredits: number } /** @@ -30,10 +25,7 @@ export interface BillingUsageData { export interface SubscriptionBillingData { type: 'individual' | 'organization' plan: string - basePrice: number currentUsage: number - overageAmount: number - totalProjected: number usageLimit: number percentUsed: number isWarning: boolean @@ -41,18 +33,22 @@ export interface SubscriptionBillingData { daysRemaining: number creditBalance: number billingInterval: 'month' | 'year' - tierCredits: number - basePriceCredits: number - currentUsageCredits: number - overageAmountCredits: number - totalProjectedCredits: number - usageLimitCredits: number isPaid: boolean isPro: boolean isTeam: boolean isEnterprise: boolean + /** + * Whether the subscription is attached to an organization. Includes + * `pro_*` plans that have been transferred to an org; use this for + * scope-based decisions instead of `isTeam` / `isEnterprise`. + */ + isOrgScoped: boolean + /** Present when `isOrgScoped` is true. */ + organizationId: string | null status: string | null seats: number | null + /** Raw subscription metadata JSON from Stripe (e.g. billingInterval). */ + metadata: unknown stripeSubscriptionId: string | null periodEnd: string | null cancelAtPeriodEnd?: boolean @@ -61,16 +57,6 @@ export interface SubscriptionBillingData { billingBlockedReason?: 'payment_failed' | 'dispute' | null blockedByOrgOwner?: boolean organization?: { id: string; role: 'owner' | 'admin' | 'member' } - organizationData?: { - seatCount: number - memberCount: number - totalBasePrice: number - totalCurrentUsage: number - totalOverage: number - totalBasePriceCredits: number - totalCurrentUsageCredits: number - totalOverageCredits: number - } } /** diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 461c02f8460..32237b4119a 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -37,7 +37,7 @@ import { } from '@/lib/auth/cimd' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' -import { writeBillingInterval } from '@/lib/billing/core/subscription' +import { syncSubscriptionPlan, writeBillingInterval } from '@/lib/billing/core/subscription' import { handleNewUser } from '@/lib/billing/core/usage' import { ensureOrganizationForTeamSubscription, @@ -2904,6 +2904,9 @@ export const auth = betterAuth({ { subscriptionId: subscription.id, dbPlan: subscription.plan, priceId } ) } + + await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) + const subscriptionForOrg = { ...subscription, plan: planFromStripe ?? subscription.plan, @@ -2981,6 +2984,9 @@ export const auth = betterAuth({ { subscriptionId: subscription.id, dbPlan: subscription.plan } ) } + + await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) + const subscriptionForOrg = { ...subscription, plan: planFromStripe ?? subscription.plan, @@ -3058,6 +3064,7 @@ export const auth = betterAuth({ await writeBillingInterval(resolvedSubscription.id, isAnnual ? 'year' : 'month') }, onSubscriptionDeleted: async ({ + event, subscription, }: { event: Stripe.Event @@ -3065,18 +3072,24 @@ export const auth = betterAuth({ subscription: any }) => { logger.info('[onSubscriptionDeleted] Subscription deleted', { + eventId: event.id, subscriptionId: subscription.id, referenceId: subscription.referenceId, }) try { - await handleSubscriptionDeleted(subscription) + await handleSubscriptionDeleted(subscription, event.id) } catch (error) { logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', { + eventId: event.id, subscriptionId: subscription.id, referenceId: subscription.referenceId, error, }) + // Rethrow so the Stripe webhook retries — otherwise + // the final overage invoice, usage reset, org cleanup, + // and personal Pro restore can be permanently skipped. + throw error } }, }, diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index c84353cc76a..ccabddaa0ec 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -1,29 +1,31 @@ -import { db } from '@sim/db' -import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { hasPaidSubscription } from '@/lib/billing' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' const logger = createLogger('BillingAuthorization') /** - * Check if a user is authorized to manage billing for a given reference ID - * Reference ID can be either a user ID (individual subscription) or organization ID (team subscription) + * Check if a user is authorized to manage billing for a given reference ID. + * Reference ID can be either a user ID (personal subscription) or an + * organization ID (org-scoped subscription — team, enterprise, or a + * `pro_*` plan transferred to an org). * - * This function also performs duplicate subscription validation for organizations: - * - Rejects if an organization already has an active subscription (prevents duplicates) - * - Personal subscriptions (referenceId === userId) skip this check to allow upgrades + * This function also performs duplicate subscription validation for + * organizations: + * - Rejects if an organization already has an active subscription (prevents + * duplicates). + * - Personal subscriptions skip this check to allow upgrades. */ export async function authorizeSubscriptionReference( userId: string, referenceId: string, action?: string ): Promise { - if (referenceId === userId) { + if (!isOrgScopedSubscription({ referenceId }, userId)) { return true } - // Only block duplicate subscriptions during upgrade/checkout, not cancel/restore/list if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) { logger.warn('Blocking checkout - active subscription already exists for organization', { userId, @@ -32,12 +34,5 @@ export async function authorizeSubscriptionReference( return false } - const members = await db - .select() - .from(schema.member) - .where(and(eq(schema.member.userId, userId), eq(schema.member.organizationId, referenceId))) - - const member = members[0] - - return member?.role === 'owner' || member?.role === 'admin' + return isOrganizationOwnerOrAdmin(userId, referenceId) } diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 71a294ba1de..c57fdcc7cd6 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -1,11 +1,19 @@ import { db } from '@sim/db' -import { member, organization, userStats } from '@sim/db/schema' +import { member, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' -import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' -import { getUserUsageLimit } from '@/lib/billing/core/usage' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers' +import { and, eq } from 'drizzle-orm' +import { + getHighestPrioritySubscription, + type HighestPrioritySubscription, +} from '@/lib/billing/core/plan' +import { getPooledOrgCurrentPeriodCost, getUserUsageLimit } from '@/lib/billing/core/usage' +import { + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' +import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { toError } from '@/lib/core/utils/helpers' @@ -19,6 +27,52 @@ interface UsageData { isExceeded: boolean currentUsage: number limit: number + /** + * Whether the returned values are this user's individual slice or the + * organization's pooled total/cap. When an org pool is the blocker, + * the pooled values are surfaced here so error messages reflect it. + */ + scope: 'user' | 'organization' + /** Present only when `scope === 'organization'`. */ + organizationId: string | null +} + +/** + * Sum `currentPeriodCost` across all members of an org, then subtract + * daily-refresh credits (with per-user window bounds for mid-cycle + * joiners). + */ +async function computePooledOrgUsage( + organizationId: string, + sub: { + plan: string | null + seats: number | null + periodStart: Date | null + periodEnd: Date | null + } +): Promise { + const { memberIds, currentPeriodCost } = await getPooledOrgCurrentPeriodCost(organizationId) + if (memberIds.length === 0) return 0 + + let pooled = currentPeriodCost + + if (isPaid(sub.plan) && sub.periodStart) { + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0) { + const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart) + const refresh = await computeDailyRefreshConsumed({ + userIds: memberIds, + periodStart: sub.periodStart, + periodEnd: sub.periodEnd ?? null, + planDollars, + seats: sub.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, + }) + pooled = Math.max(0, pooled - refresh) + } + } + + return pooled } /** @@ -30,14 +84,10 @@ export async function checkUsageStatus( preloadedSubscription?: HighestPrioritySubscription ): Promise { try { - // If billing is disabled, always return permissive limits if (!isBillingEnabled) { - // 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].currentPeriodCost?.toString()) - : 0 + statsRecords.length > 0 ? toNumber(toDecimal(statsRecords[0].currentPeriodCost)) : 0 return { percentUsed: Math.min((currentUsage / 1000) * 100, 100), @@ -45,127 +95,68 @@ export async function checkUsageStatus( isExceeded: false, currentUsage, limit: 1000, + scope: 'user', + organizationId: null, } } - // Get usage limit from user_stats (per-user cap) - const limit = await getUserUsageLimit(userId, preloadedSubscription) + const sub = + preloadedSubscription !== undefined + ? preloadedSubscription + : await getHighestPrioritySubscription(userId) + + const limit = await getUserUsageLimit(userId, sub) 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)) + const subIsOrgScoped = isOrgScopedSubscription(sub, userId) + const scope: 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user' + const organizationId: string | null = subIsOrgScoped && sub ? sub.referenceId : null - // If no stats record exists, create a default one - if (statsRecords.length === 0) { - logger.info('No usage stats found for user', { userId, limit }) + let currentUsage = 0 - return { - percentUsed: 0, - isWarning: false, - isExceeded: false, - currentUsage: 0, - limit, - } - } + if (subIsOrgScoped && sub) { + currentUsage = await computePooledOrgUsage(sub.referenceId, sub) + } else { + const statsRecords = await db + .select() + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) - const rawUsage = Number.parseFloat( - statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString() - ) - - // Deduct daily refresh credits for individual paid plans only. - // Org plans apply refresh at the pooled level in the org usage check below. - let dailyRefreshDeduction = 0 - if ( - preloadedSubscription && - isPaid(preloadedSubscription.plan) && - !isOrgPlan(preloadedSubscription.plan) && - preloadedSubscription.periodStart - ) { - const planDollars = getPlanTierDollars(preloadedSubscription.plan) - if (planDollars > 0) { - dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: [userId], - periodStart: preloadedSubscription.periodStart, - periodEnd: preloadedSubscription.periodEnd ?? null, - planDollars, - }) + if (statsRecords.length === 0) { + logger.info('No usage stats found for user', { userId, limit }) + return { + percentUsed: 0, + isWarning: false, + isExceeded: false, + currentUsage: 0, + limit, + scope: 'user', + organizationId: null, + } } - } - - const currentUsage = Math.max(0, rawUsage - dailyRefreshDeduction) - const percentUsed = Math.min((currentUsage / limit) * 100, 100) - - let isExceeded = currentUsage >= limit - let isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100 - try { - const memberships = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, userId)) - if (memberships.length > 0) { - for (const m of memberships) { - const orgRows = await db - .select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit }) - .from(organization) - .where(eq(organization.id, m.organizationId)) - .limit(1) - if (orgRows.length) { - const org = orgRows[0] - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, org.id)) - - let pooledUsage = 0 - if (teamMembers.length > 0) { - const memberIds = teamMembers.map((tm) => tm.userId) - const allMemberStats = await db - .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - for (const stats of allMemberStats) { - pooledUsage += Number.parseFloat( - stats.current?.toString() || stats.total.toString() - ) - } - } - if ( - preloadedSubscription && - isPaid(preloadedSubscription.plan) && - preloadedSubscription.periodStart - ) { - const planDollars = getPlanTierDollars(preloadedSubscription.plan) - if (planDollars > 0) { - const memberIds = teamMembers.map((tm) => tm.userId) - const orgRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: memberIds, - periodStart: preloadedSubscription.periodStart, - periodEnd: preloadedSubscription.periodEnd ?? null, - planDollars, - seats: preloadedSubscription.seats ?? 1, - }) - pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction) - } - } - - const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0 - if (!orgCap || Number.isNaN(orgCap)) { - logger.warn('Organization missing usage limit', { orgId: org.id }) - } - if (pooledUsage >= orgCap) { - isExceeded = true - isWarning = false - break - } - } + const rawUsage = toNumber(toDecimal(statsRecords[0].currentPeriodCost)) + + let refresh = 0 + if (sub && isPaid(sub.plan) && sub.periodStart) { + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0) { + refresh = await computeDailyRefreshConsumed({ + userIds: [userId], + periodStart: sub.periodStart, + periodEnd: sub.periodEnd ?? null, + planDollars, + }) } } - } catch (error) { - logger.warn('Error checking organization usage limits', { error, userId }) + currentUsage = Math.max(0, rawUsage - refresh) } + const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 100 + const isExceeded = currentUsage >= limit + const isWarning = !isExceeded && percentUsed >= WARNING_THRESHOLD + logger.info('Final usage statistics', { userId, currentUsage, @@ -173,6 +164,8 @@ export async function checkUsageStatus( percentUsed, isWarning, isExceeded, + scope, + organizationId, }) return { @@ -181,6 +174,8 @@ export async function checkUsageStatus( isExceeded, currentUsage, limit, + scope, + organizationId, } } catch (error) { logger.error('Error checking usage status', { @@ -197,9 +192,11 @@ export async function checkUsageStatus( return { percentUsed: 100, isWarning: false, - isExceeded: true, // Block execution when we can't determine status + isExceeded: true, currentUsage: 0, - limit: 0, // Zero limit forces blocking + limit: 0, + scope: 'user', + organizationId: null, } } } @@ -210,7 +207,6 @@ export async function checkUsageStatus( */ export async function checkAndNotifyUsage(userId: string): Promise { try { - // Skip usage notifications if billing is disabled if (!isBillingEnabled) { return } @@ -218,14 +214,12 @@ export async function checkAndNotifyUsage(userId: string): Promise { const usageData = await checkUsageStatus(userId) if (usageData.isExceeded) { - // User has exceeded their limit logger.warn('User has exceeded usage limits', { userId, usage: usageData.currentUsage, limit: usageData.limit, }) - // Dispatch event to show a UI notification if (typeof window !== 'undefined') { window.dispatchEvent( new CustomEvent('usage-exceeded', { @@ -234,7 +228,6 @@ export async function checkAndNotifyUsage(userId: string): Promise { ) } } else if (usageData.isWarning) { - // User is approaching their limit logger.info('User approaching usage limits', { userId, usage: usageData.currentUsage, @@ -242,7 +235,6 @@ export async function checkAndNotifyUsage(userId: string): Promise { percent: usageData.percentUsed, }) - // Dispatch event to show a UI notification if (typeof window !== 'undefined') { window.dispatchEvent( new CustomEvent('usage-warning', { @@ -283,22 +275,17 @@ export async function checkServerSideUsageLimits( logger.info('Server-side checking usage limits for user', { userId }) - // Check user's own blocked status const stats = await db .select({ blocked: userStats.billingBlocked, blockedReason: userStats.billingBlockedReason, current: userStats.currentPeriodCost, - total: userStats.totalCost, }) .from(userStats) .where(eq(userStats.userId, userId)) .limit(1) - const currentUsage = - stats.length > 0 - ? Number.parseFloat(stats[0].current?.toString() || stats[0].total.toString()) - : 0 + const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0 if (stats.length > 0 && stats[0].blocked) { const message = @@ -313,14 +300,12 @@ export async function checkServerSideUsageLimits( } } - // Check if user is in an org where the owner is blocked const memberships = await db .select({ organizationId: member.organizationId }) .from(member) .where(eq(member.userId, userId)) for (const m of memberships) { - // Find the owner of this org const owners = await db .select({ userId: member.userId }) .from(member) @@ -354,13 +339,18 @@ export async function checkServerSideUsageLimits( const usageData = await checkUsageStatus(userId, preloadedSubscription) + const formattedUsage = (usageData.currentUsage ?? 0).toFixed(2) + const formattedLimit = (usageData.limit ?? 0).toFixed(2) + const exceededMessage = + usageData.scope === 'organization' + ? `Organization usage limit exceeded: $${formattedUsage} pooled of $${formattedLimit} organization limit. Ask a team admin to raise the organization usage limit to continue.` + : `Usage limit exceeded: $${formattedUsage} used of $${formattedLimit} limit. Please upgrade your plan or raise your usage limit to continue.` + return { isExceeded: usageData.isExceeded, currentUsage: usageData.currentUsage, limit: usageData.limit, - message: usageData.isExceeded - ? `Usage limit exceeded: ${usageData.currentUsage?.toFixed(2) || 0}$ used of ${usageData.limit?.toFixed(2) || 0}$ limit. Please upgrade your plan to continue.` - : undefined, + message: usageData.isExceeded ? exceededMessage : undefined, } } catch (error) { logger.error('Error in server-side usage limit check', { @@ -374,9 +364,9 @@ export async function checkServerSideUsageLimits( }) return { - isExceeded: true, // Block execution when we can't determine limits + isExceeded: true, currentUsage: 0, - limit: 0, // Zero limit forces blocking + limit: 0, message: error instanceof Error && error.message.includes('No user stats record found') ? 'User account not properly initialized. Please contact support.' diff --git a/apps/sim/lib/billing/client/types.ts b/apps/sim/lib/billing/client/types.ts index 6122b190026..c59c287a072 100644 --- a/apps/sim/lib/billing/client/types.ts +++ b/apps/sim/lib/billing/client/types.ts @@ -25,6 +25,9 @@ export interface SubscriptionData { isPro: boolean isTeam: boolean isEnterprise: boolean + /** True when the subscription's `referenceId` is an organization. */ + isOrgScoped: boolean + organizationId: string | null plan: string status: string | null seats: number | null @@ -59,6 +62,8 @@ export interface SubscriptionStore { isPro: boolean isTeam: boolean isEnterprise: boolean + isOrgScoped: boolean + organizationId: string | null isFree: boolean plan: string status: string | null diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index 08d58f52772..8baa555b0f5 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { client, useSession, useSubscription } from '@/lib/auth/auth-client' -import { buildPlanName, isOrgPlan } from '@/lib/billing/plan-helpers' +import { buildPlanName, getDisplayPlanName, isPaid } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { organizationKeys } from '@/hooks/queries/organization' @@ -65,22 +65,23 @@ export function useSubscriptionUpgrade() { ) if (existingOrg) { - // Check if this org already has an active team subscription - const existingTeamSub = allSubscriptions.find( + const existingOrgSub = allSubscriptions.find( (sub: any) => hasPaidSubscriptionStatus(sub.status) && sub.referenceId === existingOrg.id && - isOrgPlan(sub.plan) + isPaid(sub.plan) ) - if (existingTeamSub) { - logger.warn('Organization already has an active team subscription', { + if (existingOrgSub) { + logger.warn('Organization already has an active subscription', { userId, organizationId: existingOrg.id, - existingSubscriptionId: existingTeamSub.id, + existingSubscriptionId: existingOrgSub.id, + plan: existingOrgSub.plan, }) + const existingPlanName = getDisplayPlanName(existingOrgSub.plan) throw new Error( - 'This organization already has an active team subscription. Please manage it from the billing settings.' + `This organization is already on the ${existingPlanName} plan. Manage it from the billing settings.` ) } diff --git a/apps/sim/lib/billing/client/utils.ts b/apps/sim/lib/billing/client/utils.ts index 206af4d5bdf..91211902e71 100644 --- a/apps/sim/lib/billing/client/utils.ts +++ b/apps/sim/lib/billing/client/utils.ts @@ -31,6 +31,8 @@ export function getSubscriptionStatus( isPro: subscriptionData?.isPro ?? false, isTeam: subscriptionData?.isTeam ?? false, isEnterprise: subscriptionData?.isEnterprise ?? false, + isOrgScoped: subscriptionData?.isOrgScoped ?? false, + organizationId: subscriptionData?.organizationId ?? null, isFree: !(subscriptionData?.isPaid ?? false), plan: subscriptionData?.plan ?? 'free', status: subscriptionData?.status ?? null, @@ -45,7 +47,12 @@ export function getSubscriptionAccessState( const status = getSubscriptionStatus(subscriptionData) const billingBlocked = Boolean(subscriptionData?.billingBlocked) const hasUsablePaidAccess = hasUsableSubscriptionAccess(status.status, billingBlocked) - const hasUsableTeamAccess = hasUsablePaidAccess && (status.isTeam || status.isEnterprise) + // Team-management features (invitations, seats, roles) are available on + // any paid subscription attached to an organization — including `pro_*` + // plans that have been transferred to an org. Plan-name gating would + // miss those. + const hasUsableTeamAccess = + hasUsablePaidAccess && (status.isOrgScoped || status.isTeam || status.isEnterprise) const hasUsableEnterpriseAccess = hasUsablePaidAccess && status.isEnterprise const hasUsableMaxAccess = hasUsablePaidAccess && (getPlanTierCredits(status.plan) >= 25000 || isEnterprise(status.plan)) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 2edb18f4af9..df9a31eb7c9 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -1,29 +1,24 @@ import { db } from '@sim/db' -import { member, organization, subscription, user, userStats } from '@sim/db/schema' +import { member, organization, subscription, userStats } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import { getBillingInterval, getHighestPrioritySubscription, type SubscriptionMetadata, } from '@/lib/billing/core/subscription' -import { getUserUsageData } from '@/lib/billing/core/usage' +import { getOrgUsageLimit, getUserUsageData } from '@/lib/billing/core/usage' import { getCreditBalance } from '@/lib/billing/credits/balance' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' import { - getPlanTierCredits, - getPlanTierDollars, - isEnterprise, - isOrgPlan, - isPaid, - isPro, - isTeam, -} from '@/lib/billing/plan-helpers' + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' +import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, getFreeTierLimit, getPlanPricing, hasPaidSubscriptionStatus, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' @@ -34,7 +29,13 @@ import { createLogger } from '@sim/logger' const logger = createLogger('Billing') /** - * Get organization subscription directly by organization ID + * Get the organization's subscription row when its status is one of + * `ENTITLED_SUBSCRIPTION_STATUSES` (includes `past_due`). Use this + * when making billing-side decisions (overage math, limit reads, + * webhooks) where `past_due` still counts as an active paid tenant. + * For product-access gating use `getOrganizationSubscriptionUsable` + * (from `core/subscription.ts`), which excludes `past_due`. + * Returns `null` when there is no entitled sub. */ export async function getOrganizationSubscription(organizationId: string) { try { @@ -65,45 +66,122 @@ export async function getOrganizationSubscription(organizationId: string) { */ /** - * Calculate overage billing for a user - * Returns only the amount that exceeds their subscription base price + * Check if a subscription is scoped to an organization by looking up its + * `referenceId` in the organization table. This is the authoritative + * answer — the plan name alone is unreliable because `pro_*` plans can be + * attached to organizations (and we should treat them as org-scoped). + * + * Use this in server contexts (webhooks, jobs) where we only have the + * subscription row, not a user perspective. If you do have a user id, + * `isOrgScopedSubscription(sub, userId)` is cheaper and equally correct. */ -export async function calculateUserOverage(userId: string): Promise<{ - basePrice: number - actualUsage: number - overageAmount: number - plan: string -} | null> { - try { - // Get user's subscription and usage data - const [subscription, usageData, userRecord] = await Promise.all([ - getHighestPrioritySubscription(userId), - getUserUsageData(userId), - db.select().from(user).where(eq(user.id, userId)).limit(1), - ]) - - if (userRecord.length === 0) { - logger.warn('User not found for overage calculation', { userId }) - return null - } +export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Promise { + const rows = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, sub.referenceId)) + .limit(1) + return rows.length > 0 +} - const plan = subscription?.plan || 'free' - const { basePrice } = getPlanPricing(plan) - const actualUsage = usageData.currentUsage +/** + * Aggregate raw pooled stats for all members of an organization in a single + * query. Used by org-scoped summary and overage calculations so we don't + * call `getUserUsageData` per-member — that helper now returns the entire + * pool for org-scoped subs, which would N-times-count the usage. + * + * The `currentPeriodCost` sum here is semantically identical to + * `getPooledOrgCurrentPeriodCost` (same `LEFT JOIN` + `toDecimal` + * null handling); this helper bundles the copilot fields in the same + * round-trip. Never fall back to lifetime `totalCost` on nulls — the + * column is `NOT NULL DEFAULT '0'` and mixing scopes would break + * current-period billing math. + */ +async function aggregateOrgMemberStats(organizationId: string): Promise<{ + memberIds: string[] + currentPeriodCost: number + currentPeriodCopilotCost: number + lastPeriodCopilotCost: number +}> { + const rows = await db + .select({ + userId: member.userId, + currentPeriodCost: userStats.currentPeriodCost, + currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, + lastPeriodCopilotCost: userStats.lastPeriodCopilotCost, + }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + + let currentPeriodCost = new Decimal(0) + let currentPeriodCopilotCost = new Decimal(0) + let lastPeriodCopilotCost = new Decimal(0) + const memberIds: string[] = [] + + for (const row of rows) { + memberIds.push(row.userId) + currentPeriodCost = currentPeriodCost.plus(toDecimal(row.currentPeriodCost)) + currentPeriodCopilotCost = currentPeriodCopilotCost.plus( + toDecimal(row.currentPeriodCopilotCost) + ) + lastPeriodCopilotCost = lastPeriodCopilotCost.plus(toDecimal(row.lastPeriodCopilotCost)) + } - // Calculate overage: any usage beyond what they already paid for - const overageAmount = Math.max(0, actualUsage - basePrice) + return { + memberIds, + currentPeriodCost: toNumber(currentPeriodCost), + currentPeriodCopilotCost: toNumber(currentPeriodCopilotCost), + lastPeriodCopilotCost: toNumber(lastPeriodCopilotCost), + } +} - return { - basePrice, - actualUsage, - overageAmount, - plan, - } - } catch (error) { - logger.error('Failed to calculate user overage', { userId, error }) - return null +/** + * Compute an org's overage amount from already-fetched pool/departed + * inputs. Internally performs one daily-refresh DB read to subtract + * refresh credits; callers are expected to have already loaded the + * pooled `currentPeriodCost` and `departedMemberUsage` (threshold + * billing passes lock-held values; `calculateSubscriptionOverage` + * passes lockless values from `aggregateOrgMemberStats`). Both + * callers route through this to keep the overage math in one place. + */ +export async function computeOrgOverageAmount(params: { + plan: string | null + seats: number | null + periodStart: Date | null + periodEnd: Date | null + organizationId: string + pooledCurrentPeriodCost: number + departedMemberUsage: number + memberIds: string[] +}): Promise<{ + effectiveUsage: number + baseSubscriptionAmount: number + dailyRefreshDeduction: number + totalOverage: number +}> { + const totalUsage = params.pooledCurrentPeriodCost + params.departedMemberUsage + + let dailyRefreshDeduction = 0 + const planDollars = getPlanTierDollars(params.plan) + if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) { + const userBounds = await getOrgMemberRefreshBounds(params.organizationId, params.periodStart) + dailyRefreshDeduction = await computeDailyRefreshConsumed({ + userIds: params.memberIds, + periodStart: params.periodStart, + periodEnd: params.periodEnd ?? null, + planDollars, + seats: params.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, + }) } + + const effectiveUsage = Math.max(0, totalUsage - dailyRefreshDeduction) + const { basePrice } = getPlanPricing(params.plan ?? '') + const baseSubscriptionAmount = (params.seats || 1) * basePrice + const totalOverage = Math.max(0, effectiveUsage - baseSubscriptionAmount) + + return { effectiveUsage, baseSubscriptionAmount, dailyRefreshDeduction, totalOverage } } /** @@ -129,17 +207,10 @@ export async function calculateSubscriptionOverage(sub: { let totalOverageDecimal = new Decimal(0) - if (isTeam(sub.plan)) { - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) + const isOrgScoped = await isSubscriptionOrgScoped(sub) - let totalTeamUsageDecimal = new Decimal(0) - for (const m of members) { - const usage = await getUserUsageData(m.userId) - totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(usage.currentUsage)) - } + if (isOrgScoped) { + const pooled = await aggregateOrgMemberStats(sub.referenceId) const orgData = await db .select({ departedMemberUsage: organization.departedMemberUsage }) @@ -147,82 +218,115 @@ export async function calculateSubscriptionOverage(sub: { .where(eq(organization.id, sub.referenceId)) .limit(1) - const departedUsageDecimal = - orgData.length > 0 ? toDecimal(orgData[0].departedMemberUsage) : new Decimal(0) + const departedMemberUsage = + orgData.length > 0 ? toNumber(toDecimal(orgData[0].departedMemberUsage)) : 0 - const totalUsageWithDepartedDecimal = totalTeamUsageDecimal.plus(departedUsageDecimal) - - let dailyRefreshDeduction = 0 - const planDollars = getPlanTierDollars(sub.plan) - if (planDollars > 0 && sub.periodStart) { - const memberIds = members.map((m) => m.userId) - dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: memberIds, - periodStart: sub.periodStart, - periodEnd: sub.periodEnd ?? null, - planDollars, - seats: sub.seats ?? 1, - }) - } + const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({ + plan: sub.plan, + seats: sub.seats ?? null, + periodStart: sub.periodStart ?? null, + periodEnd: sub.periodEnd ?? null, + organizationId: sub.referenceId, + pooledCurrentPeriodCost: pooled.currentPeriodCost, + departedMemberUsage, + memberIds: pooled.memberIds, + }) - const effectiveUsageDecimal = Decimal.max( - 0, - totalUsageWithDepartedDecimal.minus(toDecimal(dailyRefreshDeduction)) - ) - const { basePrice } = getPlanPricing(sub.plan ?? '') - const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice - totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(baseSubscriptionAmount)) + totalOverageDecimal = toDecimal(totalOverage) - logger.info('Calculated team overage', { + logger.info('Calculated org-scoped overage', { subscriptionId: sub.id, - currentMemberUsage: toNumber(totalTeamUsageDecimal), - departedMemberUsage: toNumber(departedUsageDecimal), - totalUsage: toNumber(totalUsageWithDepartedDecimal), + plan: sub.plan, + currentMemberUsage: pooled.currentPeriodCost, + departedMemberUsage, + totalUsage: pooled.currentPeriodCost + departedMemberUsage, + effectiveUsage, baseSubscriptionAmount, - totalOverage: toNumber(totalOverageDecimal), + totalOverage, }) } else if (isPro(sub.plan)) { - // Pro plan: include snapshot if user joined a team - const usage = await getUserUsageData(sub.referenceId) - let totalProUsageDecimal = toDecimal(usage.currentUsage) - - // Add any snapshotted Pro usage (from when they joined a team) - const userStatsRows = await db - .select({ proPeriodCostSnapshot: userStats.proPeriodCostSnapshot }) + // Read user_stats directly (not via `getUserUsageData`). Priority + // lookup prefers org over personal within tier, so during a + // cancel-at-period-end grace window it would return pooled org usage + // instead of this user's personal period — overbilling the final + // personal Pro invoice. + const [statsRow] = await db + .select({ + currentPeriodCost: userStats.currentPeriodCost, + proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, + proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt, + }) .from(userStats) .where(eq(userStats.userId, sub.referenceId)) .limit(1) - if (userStatsRows.length > 0 && userStatsRows[0].proPeriodCostSnapshot) { - const snapshotUsageDecimal = toDecimal(userStatsRows[0].proPeriodCostSnapshot) - totalProUsageDecimal = totalProUsageDecimal.plus(snapshotUsageDecimal) - logger.info('Including snapshotted Pro usage in overage calculation', { + const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0 + const snapshotUsage = statsRow ? toNumber(toDecimal(statsRow.proPeriodCostSnapshot)) : 0 + const snapshotAt = statsRow?.proPeriodCostSnapshotAt ?? null + + const joinedOrgMidCycle = snapshotAt !== null || snapshotUsage > 0 + const totalProUsageDecimal = joinedOrgMidCycle + ? toDecimal(snapshotUsage) + : toDecimal(personalCurrentUsage) + + if (joinedOrgMidCycle) { + logger.info('Billing personal Pro only for pre-join usage (user joined org mid-cycle)', { userId: sub.referenceId, - currentUsage: usage.currentUsage, - snapshotUsage: toNumber(snapshotUsageDecimal), - totalProUsage: toNumber(totalProUsageDecimal), + preJoinUsage: snapshotUsage, + postJoinUsageOnMemberRow: personalCurrentUsage, + snapshotAt: snapshotAt?.toISOString() ?? null, + subscriptionId: sub.id, }) } + let dailyRefreshDeduction = 0 + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0 && sub.periodStart) { + // If the user joined an org mid-cycle, their usageLog rows after + // `snapshotAt` belong to the org's pooled refresh. Cap refresh + // to [periodStart, snapshotAt) so post-join refresh isn't + // deducted from pre-join personal Pro usage. + const refreshCap = joinedOrgMidCycle && snapshotAt ? snapshotAt : (sub.periodEnd ?? null) + dailyRefreshDeduction = await computeDailyRefreshConsumed({ + userIds: [sub.referenceId], + periodStart: sub.periodStart, + periodEnd: refreshCap, + planDollars, + }) + } + + const effectiveUsageDecimal = Decimal.max( + 0, + totalProUsageDecimal.minus(toDecimal(dailyRefreshDeduction)) + ) const { basePrice } = getPlanPricing(sub.plan ?? '') - totalOverageDecimal = Decimal.max(0, totalProUsageDecimal.minus(basePrice)) + totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(basePrice)) - logger.info('Calculated pro overage', { + logger.info('Calculated personal pro overage', { subscriptionId: sub.id, - totalProUsage: toNumber(totalProUsageDecimal), + joinedOrgMidCycle, + personalCurrentUsage, + snapshot: snapshotUsage, + billedUsage: toNumber(totalProUsageDecimal), + dailyRefreshDeduction, basePrice, totalOverage: toNumber(totalOverageDecimal), }) } else { - // Free plan or unknown plan type - const usage = await getUserUsageData(sub.referenceId) + // Free or unknown plan. Same direct-read rationale as the Pro branch. + const [statsRow] = await db + .select({ currentPeriodCost: userStats.currentPeriodCost }) + .from(userStats) + .where(eq(userStats.userId, sub.referenceId)) + .limit(1) + const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0 const { basePrice } = getPlanPricing(sub.plan || 'free') - totalOverageDecimal = Decimal.max(0, toDecimal(usage.currentUsage).minus(basePrice)) + totalOverageDecimal = Decimal.max(0, toDecimal(personalCurrentUsage).minus(basePrice)) logger.info('Calculated overage for plan', { subscriptionId: sub.id, plan: sub.plan || 'free', - usage: usage.currentUsage, + usage: personalCurrentUsage, basePrice, totalOverage: toNumber(totalOverageDecimal), }) @@ -240,10 +344,7 @@ export async function getSimplifiedBillingSummary( ): Promise<{ type: 'individual' | 'organization' plan: string - basePrice: number currentUsage: number - overageAmount: number - totalProjected: number usageLimit: number percentUsed: number isWarning: boolean @@ -251,17 +352,15 @@ export async function getSimplifiedBillingSummary( daysRemaining: number creditBalance: number billingInterval: 'month' | 'year' - tierCredits: number - basePriceCredits: number - currentUsageCredits: number - overageAmountCredits: number - totalProjectedCredits: number - usageLimitCredits: number // Subscription details isPaid: boolean isPro: boolean isTeam: boolean isEnterprise: boolean + /** True when the subscription's `referenceId` is an organization id. */ + isOrgScoped: boolean + /** Present when `isOrgScoped` is true. */ + organizationId: string | null status: string | null seats: number | null metadata: any @@ -281,21 +380,6 @@ export async function getSimplifiedBillingSummary( lastPeriodCopilotCost: number daysRemaining: number copilotCost: number - currentCredits: number - limitCredits: number - lastPeriodCostCredits: number - lastPeriodCopilotCostCredits: number - copilotCostCredits: number - } - organizationData?: { - seatCount: number - memberCount: number - totalBasePrice: number - totalCurrentUsage: number - totalOverage: number - totalBasePriceCredits: number - totalCurrentUsageCredits: number - totalOverageCredits: number } }> { try { @@ -307,13 +391,14 @@ export async function getSimplifiedBillingSummary( getUserUsageData(userId), ]) - // Determine subscription type flags const plan = subscription?.plan || 'free' const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status) const planIsPaid = hasPaidEntitlement && isPaid(plan) const planIsPro = hasPaidEntitlement && isPro(plan) const planIsTeam = hasPaidEntitlement && isTeam(plan) const planIsEnterprise = hasPaidEntitlement && isEnterprise(plan) + const orgScoped = isOrgScopedSubscription(subscription, userId) + const subscriptionOrgId = orgScoped && subscription ? subscription.referenceId : null if (organizationId) { // Organization billing summary @@ -321,96 +406,79 @@ export async function getSimplifiedBillingSummary( return getDefaultBillingSummary('organization') } - // Get all organization members - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)) - - const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan) - // Use licensed seats from Stripe as source of truth - const licensedSeats = subscription.seats ?? 0 - const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription - - let totalCurrentUsageDecimal = new Decimal(0) - let totalCopilotCostDecimal = new Decimal(0) - let totalLastPeriodCopilotCostDecimal = new Decimal(0) - - // Calculate total team usage across all members - for (const memberInfo of members) { - const memberUsageData = await getUserUsageData(memberInfo.userId) - totalCurrentUsageDecimal = totalCurrentUsageDecimal.plus( - toDecimal(memberUsageData.currentUsage) - ) - - // Fetch copilot cost for this member - const memberStats = await db - .select({ - currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, - lastPeriodCopilotCost: userStats.lastPeriodCopilotCost, - }) - .from(userStats) - .where(eq(userStats.userId, memberInfo.userId)) - .limit(1) - - if (memberStats.length > 0) { - totalCopilotCostDecimal = totalCopilotCostDecimal.plus( - toDecimal(memberStats[0].currentPeriodCopilotCost) - ) - totalLastPeriodCopilotCostDecimal = totalLastPeriodCopilotCostDecimal.plus( - toDecimal(memberStats[0].lastPeriodCopilotCost) + // Pool usage/copilot across all members in one query. Must not use + // `getUserUsageData` per-member — it now returns the pool itself + // for org-scoped subs, which would N-times-count. + const pooled = await aggregateOrgMemberStats(organizationId) + + const rawCurrentUsage = pooled.currentPeriodCost + const totalCopilotCost = pooled.currentPeriodCopilotCost + const totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost + + // Deduct daily-refresh credits against this specific org's pool. + // `usageData` is derived from the caller's priority subscription + // and may not match the requested org (multi-org admins, personal + // priority sub, etc.), so it cannot be reused here. + let refreshDeduction = 0 + if (isPaid(plan) && subscription.periodStart) { + const planDollars = getPlanTierDollars(plan) + if (planDollars > 0) { + const userBounds = await getOrgMemberRefreshBounds( + organizationId, + subscription.periodStart ) + refreshDeduction = await computeDailyRefreshConsumed({ + userIds: pooled.memberIds, + periodStart: subscription.periodStart, + periodEnd: subscription.periodEnd ?? null, + planDollars, + seats: subscription.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, + }) } } + const effectiveCurrentUsage = Math.max(0, rawCurrentUsage - refreshDeduction) - const totalCurrentUsage = toNumber(totalCurrentUsageDecimal) - const totalCopilotCost = toNumber(totalCopilotCostDecimal) - const totalLastPeriodCopilotCost = toNumber(totalLastPeriodCopilotCostDecimal) - - // Calculate team-level overage: total usage beyond what was already paid to Stripe - const totalOverage = toNumber(Decimal.max(0, totalCurrentUsageDecimal.minus(totalBasePrice))) + const { limit: orgUsageLimit } = await getOrgUsageLimit( + organizationId, + plan, + subscription.seats ?? null + ) - // Get user's personal limits for warnings const percentUsed = - usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0 + orgUsageLimit > 0 ? Math.round((effectiveCurrentUsage / orgUsageLimit) * 100) : 0 + const isExceeded = effectiveCurrentUsage >= orgUsageLimit + const isWarning = !isExceeded && percentUsed >= 80 // Calculate days remaining in billing period - const daysRemaining = usageData.billingPeriodEnd + const daysRemaining = subscription.periodEnd ? Math.max( 0, - Math.ceil((usageData.billingPeriodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + Math.ceil((subscription.periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) ) : 0 const orgCredits = await getCreditBalance(userId) - const orgTotalProjected = totalBasePrice + totalOverage const orgBillingInterval = getBillingInterval(subscription.metadata as SubscriptionMetadata) return { type: 'organization', plan: subscription.plan, - basePrice: totalBasePrice, - currentUsage: totalCurrentUsage, - overageAmount: totalOverage, - totalProjected: orgTotalProjected, - usageLimit: usageData.limit, + currentUsage: effectiveCurrentUsage, + usageLimit: orgUsageLimit, percentUsed, - isWarning: percentUsed >= 80 && percentUsed < 100, - isExceeded: usageData.currentUsage >= usageData.limit, + isWarning, + isExceeded, daysRemaining, creditBalance: orgCredits.balance, billingInterval: orgBillingInterval, - tierCredits: getPlanTierCredits(subscription.plan), - basePriceCredits: dollarsToCredits(totalBasePrice), - currentUsageCredits: dollarsToCredits(totalCurrentUsage), - overageAmountCredits: dollarsToCredits(totalOverage), - totalProjectedCredits: dollarsToCredits(orgTotalProjected), - usageLimitCredits: dollarsToCredits(usageData.limit), // Subscription details isPaid: planIsPaid, isPro: planIsPro, isTeam: planIsTeam, isEnterprise: planIsEnterprise, + isOrgScoped: true, + organizationId: organizationId, status: subscription.status || null, seats: subscription.seats || null, metadata: subscription.metadata || null, @@ -419,40 +487,21 @@ export async function getSimplifiedBillingSummary( cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined, // Usage details usage: { - current: usageData.currentUsage, - limit: usageData.limit, + current: effectiveCurrentUsage, + limit: orgUsageLimit, percentUsed, - isWarning: percentUsed >= 80 && percentUsed < 100, - isExceeded: usageData.currentUsage >= usageData.limit, - billingPeriodStart: usageData.billingPeriodStart, - billingPeriodEnd: usageData.billingPeriodEnd, + isWarning, + isExceeded, + billingPeriodStart: subscription.periodStart ?? null, + billingPeriodEnd: subscription.periodEnd ?? null, lastPeriodCost: usageData.lastPeriodCost, lastPeriodCopilotCost: totalLastPeriodCopilotCost, daysRemaining, copilotCost: totalCopilotCost, - currentCredits: dollarsToCredits(usageData.currentUsage), - limitCredits: dollarsToCredits(usageData.limit), - lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost), - lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost), - copilotCostCredits: dollarsToCredits(totalCopilotCost), - }, - organizationData: { - seatCount: licensedSeats, - memberCount: members.length, - totalBasePrice, - totalCurrentUsage, - totalOverage, - totalBasePriceCredits: dollarsToCredits(totalBasePrice), - totalCurrentUsageCredits: dollarsToCredits(totalCurrentUsage), - totalOverageCredits: dollarsToCredits(totalOverage), }, } } - // Individual billing summary - const { basePrice } = getPlanPricing(plan) - - // Fetch user stats for copilot cost breakdown const userStatsRows = await db .select({ currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, @@ -468,52 +517,17 @@ export async function getSimplifiedBillingSummary( const lastPeriodCopilotCost = userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].lastPeriodCopilotCost)) : 0 - // For team and enterprise plans, calculate total team usage instead of individual usage - let currentUsage = usageData.currentUsage + const currentUsage = usageData.currentUsage let totalCopilotCost = copilotCost let totalLastPeriodCopilotCost = lastPeriodCopilotCost - if (isOrgPlan(plan) && subscription?.referenceId) { - // Get all team members and sum their usage - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, subscription.referenceId)) - - let totalTeamUsageDecimal = new Decimal(0) - let totalTeamCopilotCostDecimal = new Decimal(0) - let totalTeamLastPeriodCopilotCostDecimal = new Decimal(0) - for (const teamMember of teamMembers) { - const memberUsageData = await getUserUsageData(teamMember.userId) - totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(memberUsageData.currentUsage)) - - // Fetch copilot cost for this team member - const memberStats = await db - .select({ - currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, - lastPeriodCopilotCost: userStats.lastPeriodCopilotCost, - }) - .from(userStats) - .where(eq(userStats.userId, teamMember.userId)) - .limit(1) - - if (memberStats.length > 0) { - totalTeamCopilotCostDecimal = totalTeamCopilotCostDecimal.plus( - toDecimal(memberStats[0].currentPeriodCopilotCost) - ) - totalTeamLastPeriodCopilotCostDecimal = totalTeamLastPeriodCopilotCostDecimal.plus( - toDecimal(memberStats[0].lastPeriodCopilotCost) - ) - } - } - currentUsage = toNumber(totalTeamUsageDecimal) - totalCopilotCost = toNumber(totalTeamCopilotCostDecimal) - totalLastPeriodCopilotCost = toNumber(totalTeamLastPeriodCopilotCostDecimal) + if (orgScoped && subscription?.referenceId) { + const pooled = await aggregateOrgMemberStats(subscription.referenceId) + totalCopilotCost = pooled.currentPeriodCopilotCost + totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost } - const overageAmount = toNumber(Decimal.max(0, toDecimal(currentUsage).minus(basePrice))) const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0 - // Calculate days remaining in billing period const daysRemaining = usageData.billingPeriodEnd ? Math.max( 0, @@ -522,7 +536,6 @@ export async function getSimplifiedBillingSummary( : 0 const userCredits = await getCreditBalance(userId) - const individualTotalProjected = basePrice + overageAmount const individualBillingInterval = getBillingInterval( subscription?.metadata as SubscriptionMetadata ) @@ -530,10 +543,7 @@ export async function getSimplifiedBillingSummary( return { type: 'individual', plan, - basePrice, - currentUsage: currentUsage, - overageAmount, - totalProjected: individualTotalProjected, + currentUsage, usageLimit: usageData.limit, percentUsed, isWarning: percentUsed >= 80 && percentUsed < 100, @@ -541,17 +551,13 @@ export async function getSimplifiedBillingSummary( daysRemaining, creditBalance: userCredits.balance, billingInterval: individualBillingInterval, - tierCredits: getPlanTierCredits(plan), - basePriceCredits: dollarsToCredits(basePrice), - currentUsageCredits: dollarsToCredits(currentUsage), - overageAmountCredits: dollarsToCredits(overageAmount), - totalProjectedCredits: dollarsToCredits(individualTotalProjected), - usageLimitCredits: dollarsToCredits(usageData.limit), // Subscription details isPaid: planIsPaid, isPro: planIsPro, isTeam: planIsTeam, isEnterprise: planIsEnterprise, + isOrgScoped: orgScoped, + organizationId: subscriptionOrgId, status: subscription?.status || null, seats: subscription?.seats || null, metadata: subscription?.metadata || null, @@ -571,11 +577,6 @@ export async function getSimplifiedBillingSummary( lastPeriodCopilotCost: totalLastPeriodCopilotCost, daysRemaining, copilotCost: totalCopilotCost, - currentCredits: dollarsToCredits(currentUsage), - limitCredits: dollarsToCredits(usageData.limit), - lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost), - lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost), - copilotCostCredits: dollarsToCredits(totalCopilotCost), }, } } catch (error) { @@ -592,10 +593,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { return { type, plan: 'free', - basePrice: 0, currentUsage: 0, - overageAmount: 0, - totalProjected: 0, usageLimit: freeTierLimit, percentUsed: 0, isWarning: false, @@ -603,17 +601,13 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { daysRemaining: 0, creditBalance: 0, billingInterval: 'month' as const, - tierCredits: 0, - basePriceCredits: 0, - currentUsageCredits: 0, - overageAmountCredits: 0, - totalProjectedCredits: 0, - usageLimitCredits: dollarsToCredits(freeTierLimit), // Subscription details isPaid: false, isPro: false, isTeam: false, isEnterprise: false, + isOrgScoped: false, + organizationId: null, status: null, seats: null, metadata: null, @@ -632,23 +626,6 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { lastPeriodCopilotCost: 0, daysRemaining: 0, copilotCost: 0, - currentCredits: 0, - limitCredits: dollarsToCredits(freeTierLimit), - lastPeriodCostCredits: 0, - lastPeriodCopilotCostCredits: 0, - copilotCostCredits: 0, }, - ...(type === 'organization' && { - organizationData: { - seatCount: 0, - memberCount: 0, - totalBasePrice: 0, - totalCurrentUsage: 0, - totalOverage: 0, - totalBasePriceCredits: 0, - totalCurrentUsageCredits: 0, - totalOverageCredits: 0, - }, - }), } } diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index c025e722b6d..f23f38a3d6e 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -1,44 +1,23 @@ import { db } from '@sim/db' -import { member, organization, subscription, user, userStats } from '@sim/db/schema' +import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' -import { getPlanPricing } from '@/lib/billing/core/billing' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' +import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing' +import { + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' +import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { - ENTITLED_SUBSCRIPTION_STATUSES, getEffectiveSeats, getFreeTierLimit, hasUsableSubscriptionStatus, } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' const logger = createLogger('OrganizationBilling') -/** - * Get organization subscription directly by organization ID - * This is for our new pattern where referenceId = organizationId - */ -async function getOrganizationSubscription(organizationId: string) { - try { - const orgSubs = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) - - return orgSubs.length > 0 ? orgSubs[0] : null - } catch (error) { - logger.error('Error getting organization subscription', { error, organizationId }) - return null - } -} - function roundCurrency(value: number): number { return Math.round(value * 100) / 100 } @@ -143,51 +122,47 @@ export async function getOrganizationBillingData( // Calculate aggregated statistics let totalCurrentUsage = members.reduce((sum, m) => sum + m.currentUsage, 0) - // Deduct daily refresh from pooled usage if (isPaid(subscription.plan) && subscription.periodStart) { const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { const memberIds = members.map((m) => m.userId) + const userBounds = await getOrgMemberRefreshBounds( + subscription.referenceId, + subscription.periodStart + ) const refreshConsumed = await computeDailyRefreshConsumed({ userIds: memberIds, periodStart: subscription.periodStart, periodEnd: subscription.periodEnd ?? null, planDollars, - seats: subscription.seats ?? 1, + seats: subscription.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) totalCurrentUsage = Math.max(0, totalCurrentUsage - refreshConsumed) } } - // Get per-seat pricing for the plan const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan) - const licensedSeats = subscription.seats ?? 0 + // Stripe subscription quantity; `||` not `??` because 0 seats is + // never valid for a paid sub — fall through to 1. + const licensedSeats = subscription.seats || 1 - // For seat count used in UI (invitations, team management): - // Team: seats column (Stripe quantity) - // Enterprise: metadata.seats (allocated seats, not Stripe quantity which is always 1) + // UI seat count — metadata.seats on enterprise (column is always 1). const effectiveSeats = getEffectiveSeats(subscription) - // Calculate minimum billing amount let minimumBillingAmount: number let totalUsageLimit: number if (isEnterprise(subscription.plan)) { - // Enterprise has fixed pricing set through custom Stripe product - // Their usage limit is configured to match their monthly cost - const configuredLimit = organizationData.orgUsageLimit - ? Number.parseFloat(organizationData.orgUsageLimit) - : 0 - minimumBillingAmount = configuredLimit // For enterprise, this equals their fixed monthly cost - totalUsageLimit = configuredLimit // Same as their monthly cost + const configuredLimit = toNumber(toDecimal(organizationData.orgUsageLimit)) + minimumBillingAmount = configuredLimit + totalUsageLimit = configuredLimit } else { - // Team plan: Billing is based on licensed seats from Stripe minimumBillingAmount = licensedSeats * pricePerSeat - // Total usage limit: never below the minimum based on licensed seats const configuredLimit = organizationData.orgUsageLimit - ? Number.parseFloat(organizationData.orgUsageLimit) + ? toNumber(toDecimal(organizationData.orgUsageLimit)) : null totalUsageLimit = configuredLimit !== null @@ -197,7 +172,6 @@ export async function getOrganizationBillingData( const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0 - // Billing period comes from the organization's subscription const billingPeriodStart = subscription.periodStart || null const billingPeriodEnd = subscription.periodEnd || null @@ -206,9 +180,9 @@ export async function getOrganizationBillingData( organizationName: organizationData.name || '', subscriptionPlan: subscription.plan, subscriptionStatus: subscription.status || 'inactive', - totalSeats: effectiveSeats, // Uses metadata.seats for enterprise, seats column for team + totalSeats: effectiveSeats, usedSeats: members.length, - seatsCount: licensedSeats, // Used for billing calculations (Stripe quantity) + seatsCount: licensedSeats, totalCurrentUsage: roundCurrency(totalCurrentUsage), totalUsageLimit: roundCurrency(totalUsageLimit), minimumBillingAmount: roundCurrency(minimumBillingAmount), @@ -255,7 +229,6 @@ export async function updateOrganizationUsageLimit( return { success: false, error: 'An active subscription is required to edit usage limits' } } - // Enterprise plans have fixed usage limits that cannot be changed if (isEnterprise(subscription.plan)) { return { success: false, @@ -263,18 +236,17 @@ export async function updateOrganizationUsageLimit( } } - // Only team plans can update their usage limits - if (!isTeam(subscription.plan)) { + if (!isPaid(subscription.plan)) { return { success: false, - error: 'Only team organizations can update usage limits', + error: 'Organization is not on a paid plan', } } const { basePrice } = getPlanPricing(subscription.plan) - const minimumLimit = (subscription.seats ?? 0) * basePrice + const seatCount = subscription.seats || 1 + const minimumLimit = seatCount * basePrice - // Validate new limit is not below minimum if (newLimit < minimumLimit) { return { success: false, @@ -282,8 +254,6 @@ export async function updateOrganizationUsageLimit( } } - // Update the organization usage limit - // Convert number to string for decimal column await db .update(organization) .set({ diff --git a/apps/sim/lib/billing/core/plan.ts b/apps/sim/lib/billing/core/plan.ts index efed6996b3a..cb8fea848db 100644 --- a/apps/sim/lib/billing/core/plan.ts +++ b/apps/sim/lib/billing/core/plan.ts @@ -15,7 +15,17 @@ export type HighestPrioritySubscription = Awaited Team > Pro > Free + * + * Selection order: + * 1. Plan tier: Enterprise > Team > Pro > Free + * 2. Within the same tier, **org-scoped subs beat personally-scoped subs**. + * + * The tie-break matters because a user can legitimately hold both scopes + * at once — e.g. they accepted an org invite while their own personal Pro + * is still in its `cancelAtPeriodEnd` grace window. In that case the org + * is already paying for their usage, so pooled resources should win over + * the runoff personal sub; otherwise usage, credits, and rate limits would + * leak onto the user's row until the next billing cycle. */ export async function getHighestPrioritySubscription(userId: string) { try { @@ -59,17 +69,19 @@ export async function getHighestPrioritySubscription(userId: string) { } } - const allSubs = [...personalSubs, ...orgSubs] + if (personalSubs.length === 0 && orgSubs.length === 0) return null - if (allSubs.length === 0) return null + // Within each tier, prefer org-scoped over personally-scoped. + const pickAtTier = (predicate: (sub: (typeof personalSubs)[number]) => boolean) => + orgSubs.find(predicate) ?? personalSubs.find(predicate) - const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s)) + const enterpriseSub = pickAtTier(checkEnterprisePlan) if (enterpriseSub) return enterpriseSub - const teamSub = allSubs.find((s) => checkTeamPlan(s)) + const teamSub = pickAtTier(checkTeamPlan) if (teamSub) return teamSub - const proSub = allSubs.find((s) => checkProPlan(s)) + const proSub = pickAtTier(checkProPlan) if (proSub) return proSub return null diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 2e62e61206f..f33c52a83f5 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -61,6 +61,63 @@ export async function writeBillingInterval( .where(eq(subscription.id, subscriptionId)) } +/** + * Sync the subscription's `plan` column to match Stripe. Closes a gap + * where plan changes (Pro → Team upgrades, tier swaps) updated price, + * seats, and referenceId at Stripe but left the DB plan stale. Returns + * `true` if a write was issued, `false` if no change was needed. + */ +export async function syncSubscriptionPlan( + subscriptionId: string, + currentPlan: string | null, + planFromStripe: string | null +): Promise { + if (!planFromStripe) return false + if (currentPlan === planFromStripe) return false + + await db + .update(subscription) + .set({ plan: planFromStripe }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Synced subscription plan name from Stripe', { + subscriptionId, + previousPlan: currentPlan, + newPlan: planFromStripe, + }) + + return true +} + +/** + * Get the organization's subscription row when its status is one of + * `USABLE_SUBSCRIPTION_STATUSES` (product access — stricter than + * `ENTITLED_SUBSCRIPTION_STATUSES` which also includes `past_due`). + * Use this for feature-gating ("can this org use the product right + * now"). Use `getOrganizationSubscription` (from `core/billing.ts`) + * when you need the billing-side entitlement row that includes + * past-due subscriptions. Returns `null` when there is no usable sub. + */ +export async function getOrganizationSubscriptionUsable(organizationId: string) { + try { + const [orgSub] = await db + .select() + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + + return orgSub ?? null + } catch (error) { + logger.error('Error getting usable organization subscription', { error, organizationId }) + return null + } +} + /** * Check if a referenceId (user ID or org ID) has a paid subscription row. * Used for duplicate subscription prevention and transfer safety. @@ -198,16 +255,7 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise { return false } - const [orgSub] = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, memberRecord.organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) + const orgSub = await getOrganizationSubscriptionUsable(memberRecord.organizationId) const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)) @@ -311,16 +350,7 @@ export async function isOrganizationOnTeamOrEnterprisePlan( return false } - const [orgSub] = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) + const orgSub = await getOrganizationSubscriptionUsable(organizationId) return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)) } catch (error) { @@ -347,16 +377,7 @@ export async function isOrganizationOnEnterprisePlan(organizationId: string): Pr return false } - const [orgSub] = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) + const orgSub = await getOrganizationSubscriptionUsable(organizationId) return !!orgSub && checkEnterprisePlan(orgSub) } catch (error) { diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 75058dcf4e3..c560acc63a9 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { member, organization, settings, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq, inArray } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { getEmailSubject, renderCreditsExhaustedEmail, @@ -13,15 +13,11 @@ import { getHighestPrioritySubscription, type HighestPrioritySubscription, } from '@/lib/billing/core/plan' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' import { - getPlanTierDollars, - isEnterprise, - isFree, - isOrgPlan, - isPaid, - isPro, -} from '@/lib/billing/plan-helpers' + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' +import { getPlanTierDollars, isEnterprise, isFree, isPaid, isPro } from '@/lib/billing/plan-helpers' import { canEditUsageLimit, getFreeTierLimit, @@ -29,6 +25,7 @@ import { getPlanPricing, hasPaidSubscriptionStatus, hasUsableSubscriptionAccess, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' @@ -46,9 +43,44 @@ export interface OrgUsageLimitResult { } /** - * Calculates the effective usage limit for a team or enterprise organization. - * - Enterprise: Uses orgUsageLimit directly (fixed pricing) - * - Team: Uses orgUsageLimit but never below seats × basePrice + * Sum `currentPeriodCost` across all members of an organization. + * The single source of truth for pooled-usage reads so every caller + * applies identical null-handling and query shape. Does NOT apply + * daily-refresh deduction — callers layer that on top themselves + * because refresh math needs the caller's `sub` context (plan, + * period, seats, per-user bounds). + * + * Uses `LEFT JOIN` so members whose `userStats` row is missing still + * appear (contributing 0), which keeps `memberIds` complete for + * downstream refresh / bounds computations. + */ +export async function getPooledOrgCurrentPeriodCost( + organizationId: string +): Promise<{ memberIds: string[]; currentPeriodCost: number }> { + const rows = await db + .select({ + userId: member.userId, + currentPeriodCost: userStats.currentPeriodCost, + }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + + let pooled = new Decimal(0) + const memberIds: string[] = [] + for (const row of rows) { + memberIds.push(row.userId) + pooled = pooled.plus(toDecimal(row.currentPeriodCost)) + } + + return { memberIds, currentPeriodCost: toNumber(pooled) } +} + +/** + * Calculates the effective usage limit for an organization-scoped plan. + * Enterprise uses the configured orgUsageLimit directly; every other + * paid plan uses `basePrice × seats` (Stripe's `price × quantity`) as a + * floor. Returns `{ limit, minimum }` where `limit = max(configured, minimum)`. */ export async function getOrgUsageLimit( organizationId: string, @@ -76,15 +108,18 @@ export async function getOrgUsageLimit( } const { basePrice } = getPlanPricing(plan) - const minimum = (seats ?? 0) * basePrice + // `||` not `??` — 0 is never a valid seat count for a paid sub. + const seatCount = seats || 1 + const minimum = seatCount * basePrice if (configured !== null) { return { limit: Math.max(configured, minimum), minimum } } - logger.warn('Team org missing usage limit, using seats × basePrice fallback', { + logger.warn('Org missing usage limit, using plan-driven minimum as fallback', { orgId: organizationId, - seats, + plan, + seats: seatCount, minimum, }) return { limit: minimum, minimum } @@ -150,11 +185,13 @@ export async function getUserUsageData(userId: string): Promise { } const stats = userStatsData[0] + const orgScoped = isOrgScopedSubscription(subscription, userId) + let currentUsageDecimal = toDecimal(stats.currentPeriodCost) - // For Pro users, include any snapshotted usage (from when they joined a team) - // This ensures they see their total Pro usage in the UI - if (subscription && isPro(subscription.plan) && subscription.referenceId === userId) { + // For personally-scoped Pro users, include any snapshotted usage from + // a prior org-join so the display reflects their total Pro usage. + if (subscription && isPro(subscription.plan) && !orgScoped) { const snapshotUsageDecimal = toDecimal(stats.proPeriodCostSnapshot) if (snapshotUsageDecimal.greaterThan(0)) { currentUsageDecimal = currentUsageDecimal.plus(snapshotUsageDecimal) @@ -166,47 +203,60 @@ export async function getUserUsageData(userId: string): Promise { }) } } - const currentUsage = toNumber(currentUsageDecimal) + let currentUsage = toNumber(currentUsageDecimal) - // Determine usage limit based on plan type let limit: number + // Shared between the pooled-usage and pooled-refresh blocks so we + // don't issue the member lookup twice per org-scoped call. + let orgMemberIds: string[] = [] - if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) { - // Free/Pro: Use individual user limit from userStats - limit = stats.currentUsageLimit - ? toNumber(toDecimal(stats.currentUsageLimit)) - : getFreeTierLimit() - } else { - // Team/Enterprise: Use organization limit + if (orgScoped && subscription) { const orgLimit = await getOrgUsageLimit( subscription.referenceId, subscription.plan, subscription.seats ) limit = orgLimit.limit + + const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId) + orgMemberIds = pooled.memberIds + currentUsage = pooled.currentPeriodCost + } else { + limit = stats.currentUsageLimit + ? toNumber(toDecimal(stats.currentUsageLimit)) + : getFreeTierLimit() } - // Derive billing period dates from subscription (source of truth). const billingPeriodStart = subscription?.periodStart ?? null const billingPeriodEnd = subscription?.periodEnd ?? null - // Compute daily refresh deduction for individual (non-org) paid plans. - // Org plans apply refresh at the pooled level in getEffectiveCurrentPeriodCost. let dailyRefreshConsumed = 0 - if ( - subscription && - isPaid(subscription.plan) && - !isOrgPlan(subscription.plan) && - billingPeriodStart - ) { + if (subscription && isPaid(subscription.plan) && billingPeriodStart) { const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { - dailyRefreshConsumed = await computeDailyRefreshConsumed({ - userIds: [userId], - periodStart: billingPeriodStart, - periodEnd: billingPeriodEnd, - planDollars, - }) + if (orgScoped) { + if (orgMemberIds.length > 0) { + const userBounds = await getOrgMemberRefreshBounds( + subscription.referenceId, + billingPeriodStart + ) + dailyRefreshConsumed = await computeDailyRefreshConsumed({ + userIds: orgMemberIds, + periodStart: billingPeriodStart, + periodEnd: billingPeriodEnd, + planDollars, + seats: subscription.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, + }) + } + } else { + dailyRefreshConsumed = await computeDailyRefreshConsumed({ + userIds: [userId], + periodStart: billingPeriodStart, + periodEnd: billingPeriodEnd, + planDollars, + }) + } } } @@ -246,21 +296,13 @@ export async function getUserUsageLimitInfo(userId: string): Promise { .limit(1) if (existingStats.length > 0) { - return // User already has usage stats + return } - // Check user's subscription to determine initial limit const subscription = await getHighestPrioritySubscription(userId) - const isTeamOrEnterprise = subscription && isOrgPlan(subscription.plan) + const orgScoped = isOrgScopedSubscription(subscription, userId) - // Create initial usage stats await db.insert(userStats).values({ id: generateId(), userId, - // Team/enterprise: null (use org limit), Free/Pro: individual limit - currentUsageLimit: isTeamOrEnterprise ? null : getFreeTierLimit().toString(), + currentUsageLimit: orgScoped ? null : getFreeTierLimit().toString(), usageLimitUpdatedAt: new Date(), }) logger.info('Initialized user stats', { userId, plan: subscription?.plan || 'free', - hasIndividualLimit: !isTeamOrEnterprise, + hasIndividualLimit: !orgScoped, }) } @@ -330,11 +377,11 @@ export async function updateUserUsageLimit( try { const subscription = await getHighestPrioritySubscription(userId) - // Team/enterprise users don't have individual limits - if (subscription && isOrgPlan(subscription.plan)) { + if (isOrgScopedSubscription(subscription, userId)) { return { success: false, - error: 'Team and enterprise members use organization limits', + error: + 'This subscription is managed at the organization level. Update the organization usage limit instead.', } } @@ -389,9 +436,9 @@ export async function updateUserUsageLimit( } /** - * Get usage limit for a user (used by checkUsageStatus for server-side checks) - * Free/Pro: Individual user limit from userStats - * Team/Enterprise: Organization limit + * Get usage limit for a user (used by checkUsageStatus for server-side + * checks). Org-scoped subs return the organization limit; + * personally-scoped subs return the individual user limit from userStats. */ export async function getUserUsageLimit( userId: string, @@ -402,46 +449,44 @@ export async function getUserUsageLimit( ? preloadedSubscription : await getHighestPrioritySubscription(userId) - if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) { - // Free/Pro: Use individual limit from userStats - const userStatsQuery = await db - .select({ currentUsageLimit: userStats.currentUsageLimit }) - .from(userStats) - .where(eq(userStats.userId, userId)) + if (isOrgScopedSubscription(subscription, userId) && subscription) { + const orgExists = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, subscription.referenceId)) .limit(1) - if (userStatsQuery.length === 0) { - throw new Error( - `No user stats record found for userId: ${userId}. User must be properly initialized before execution.` - ) - } - - // Individual limits should never be null for free/pro users - if (!userStatsQuery[0].currentUsageLimit) { - throw new Error( - `Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}. User stats must be properly initialized.` - ) + if (orgExists.length === 0) { + throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`) } - return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit)) + const orgLimit = await getOrgUsageLimit( + subscription.referenceId, + subscription.plan, + subscription.seats + ) + return orgLimit.limit } - // Team/Enterprise: Verify org exists then use organization limit - const orgExists = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, subscription.referenceId)) + + const userStatsQuery = await db + .select({ currentUsageLimit: userStats.currentUsageLimit }) + .from(userStats) + .where(eq(userStats.userId, userId)) .limit(1) - if (orgExists.length === 0) { - throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`) + if (userStatsQuery.length === 0) { + throw new Error( + `No user stats record found for userId: ${userId}. User must be properly initialized before execution.` + ) } - const orgLimit = await getOrgUsageLimit( - subscription.referenceId, - subscription.plan, - subscription.seats - ) - return orgLimit.limit + if (!userStatsQuery[0].currentUsageLimit) { + throw new Error( + `Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}. User stats must be properly initialized.` + ) + } + + return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit)) } /** @@ -486,8 +531,7 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise { const subscription = await getHighestPrioritySubscription(userId) + const orgScoped = isOrgScopedSubscription(subscription, userId) let rawCost: number let refreshUserIds: string[] = [userId] - if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) { + if (orgScoped && subscription) { + const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId) + if (pooled.memberIds.length === 0) return 0 + refreshUserIds = pooled.memberIds + rawCost = pooled.currentPeriodCost + } else { const rows = await db .select({ current: userStats.currentPeriodCost }) .from(userStats) @@ -605,26 +652,6 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise m.userId) - refreshUserIds = memberIds - const rows = await db - .select({ current: userStats.currentPeriodCost }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - let pooled = new Decimal(0) - for (const r of rows) { - pooled = pooled.plus(toDecimal(r.current)) - } - rawCost = toNumber(pooled) } if (!subscription || !isPaid(subscription.plan) || !subscription.periodStart) { @@ -634,12 +661,18 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise 0 ? userBounds : undefined, }) return Math.max(0, rawCost - refreshConsumed) diff --git a/apps/sim/lib/billing/credits/balance.ts b/apps/sim/lib/billing/credits/balance.ts index 1c2c84f02e1..69a23cea982 100644 --- a/apps/sim/lib/billing/credits/balance.ts +++ b/apps/sim/lib/billing/credits/balance.ts @@ -1,11 +1,14 @@ import { db } from '@sim/db' -import { member, organization, userStats } from '@sim/db/schema' +import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { isOrgPlan, isPro, isTeam } from '@/lib/billing/plan-helpers' -import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils' +import { isPro, isTeam } from '@/lib/billing/plan-helpers' +import { + hasUsableSubscriptionAccess, + isOrgScopedSubscription, +} from '@/lib/billing/subscriptions/utils' import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal' const logger = createLogger('CreditBalance') @@ -16,31 +19,47 @@ export interface CreditBalanceInfo { entityId: string } -export async function getCreditBalance(userId: string): Promise { - const subscription = await getHighestPrioritySubscription(userId) - - if (subscription && isOrgPlan(subscription.plan)) { - const orgRows = await db +/** + * Read credit balance directly from a known entity (user or organization). + * Use this in webhook / admin paths that already know the target entity — + * unlike `getCreditBalance(userId)` it does not route through + * `getHighestPrioritySubscription`, so callers don't need to resolve the + * org owner as a user-id proxy. + */ +export async function getCreditBalanceForEntity( + entityType: 'user' | 'organization', + entityId: string +): Promise { + if (entityType === 'organization') { + const rows = await db .select({ creditBalance: organization.creditBalance }) .from(organization) - .where(eq(organization.id, subscription.referenceId)) + .where(eq(organization.id, entityId)) .limit(1) + return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0 + } + + const rows = await db + .select({ creditBalance: userStats.creditBalance }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0 +} + +export async function getCreditBalance(userId: string): Promise { + const subscription = await getHighestPrioritySubscription(userId) + if (isOrgScopedSubscription(subscription, userId) && subscription) { return { - balance: orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].creditBalance)) : 0, + balance: await getCreditBalanceForEntity('organization', subscription.referenceId), entityType: 'organization', entityId: subscription.referenceId, } } - const userRows = await db - .select({ creditBalance: userStats.creditBalance }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - return { - balance: userRows.length > 0 ? toNumber(toDecimal(userRows[0].creditBalance)) : 0, + balance: await getCreditBalanceForEntity('user', userId), entityType: 'user', entityId: userId, } @@ -155,11 +174,11 @@ export async function deductFromCredits(userId: string, cost: number): Promise { // Enterprise users must contact support to purchase credits return isPro(subscription.plan) || isTeam(subscription.plan) } - -export async function isOrgAdmin(userId: string, organizationId: string): Promise { - const memberRows = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) - .limit(1) - - if (memberRows.length === 0) return false - return memberRows[0].role === 'owner' || memberRows[0].role === 'admin' -} diff --git a/apps/sim/lib/billing/credits/daily-refresh.ts b/apps/sim/lib/billing/credits/daily-refresh.ts index 03d386d965b..48e13d7b2d8 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.ts @@ -12,15 +12,26 @@ */ import { db } from '@sim/db' -import { usageLog } from '@sim/db/schema' +import { member, usageLog, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, gte, inArray, lt, sql, sum } from 'drizzle-orm' +import { and, eq, gte, inArray, lt, or, sql, sum } from 'drizzle-orm' import { DAILY_REFRESH_RATE } from '@/lib/billing/constants' const logger = createLogger('DailyRefresh') const MS_PER_DAY = 86_400_000 +/** + * Optional per-user date window. `usageLog` rows outside + * `[userStart, userEnd)` are excluded from that user's contribution. + * Used to slice refresh around a mid-cycle org join so pre-join and + * post-join refresh are billed by the right subscription. + */ +export interface PerUserBounds { + userStart?: Date | null + userEnd?: Date | null +} + /** * Compute the total daily refresh credits consumed in the current billing period * using a single aggregating SQL query grouped by day offset. @@ -36,8 +47,9 @@ export async function computeDailyRefreshConsumed(params: { periodEnd?: Date | null planDollars: number seats?: number + userBounds?: Record }): Promise { - const { userIds, periodStart, periodEnd, planDollars, seats = 1 } = params + const { userIds, periodStart, periodEnd, planDollars, seats = 1, userBounds } = params if (planDollars <= 0 || userIds.length === 0) return 0 @@ -51,6 +63,39 @@ export async function computeDailyRefreshConsumed(params: { const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY) if (dayCount <= 0) return 0 + const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds + + const boundedClauses = userBounds + ? Object.entries(userBounds).flatMap(([userId, bounds]) => { + if (!userIds.includes(userId)) return [] + const effectiveStart = + bounds.userStart && bounds.userStart > periodStart ? bounds.userStart : periodStart + const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap + if (effectiveEnd <= effectiveStart) return [] + return [ + and( + eq(usageLog.userId, userId), + gte(usageLog.createdAt, effectiveStart), + lt(usageLog.createdAt, effectiveEnd) + ), + ] + }) + : [] + + const rowFilters = + unboundedUsers.length > 0 + ? [ + and( + inArray(usageLog.userId, unboundedUsers), + gte(usageLog.createdAt, periodStart), + lt(usageLog.createdAt, cap) + ), + ...boundedClauses, + ] + : boundedClauses + + if (rowFilters.length === 0) return 0 + const rows = await db .select({ dayIndex: @@ -60,13 +105,7 @@ export async function computeDailyRefreshConsumed(params: { dayTotal: sum(usageLog.cost).as('day_total'), }) .from(usageLog) - .where( - and( - inArray(usageLog.userId, userIds), - gte(usageLog.createdAt, periodStart), - lt(usageLog.createdAt, cap) - ) - ) + .where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters)) .groupBy(sql`day_index`) let totalConsumed = 0 @@ -81,6 +120,7 @@ export async function computeDailyRefreshConsumed(params: { days: dayCount, dailyRefreshDollars, totalConsumed, + hasUserBounds: Boolean(userBounds), }) return totalConsumed @@ -92,3 +132,25 @@ export async function computeDailyRefreshConsumed(params: { export function getDailyRefreshDollars(planDollars: number): number { return planDollars * DAILY_REFRESH_RATE } + +export async function getOrgMemberRefreshBounds( + organizationId: string, + periodStart: Date +): Promise> { + const rows = await db + .select({ + userId: member.userId, + snapshotAt: userStats.proPeriodCostSnapshotAt, + }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + + const bounds: Record = {} + for (const row of rows) { + if (row.snapshotAt && row.snapshotAt > periodStart) { + bounds[row.userId] = { userStart: row.snapshotAt } + } + } + return bounds +} diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts index 69d792c16c0..496f3b0cbb1 100644 --- a/apps/sim/lib/billing/credits/purchase.ts +++ b/apps/sim/lib/billing/credits/purchase.ts @@ -2,12 +2,15 @@ import { db } from '@sim/db' import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import type Stripe from 'stripe' import { getPlanPricing } from '@/lib/billing/core/billing' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { canPurchaseCredits, isOrgAdmin } from '@/lib/billing/credits/balance' -import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' +import { canPurchaseCredits } from '@/lib/billing/credits/balance' +import { isEnterprise } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { getCustomerId, resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' const logger = createLogger('CreditPurchase') @@ -24,8 +27,10 @@ export async function setUsageLimitForCredits( ): Promise { try { const { basePrice } = getPlanPricing(plan) + + const seatCount = seats || 1 const planBase = - entityType === 'organization' ? Number(basePrice) * (seats || 1) : Number(basePrice) + entityType === 'organization' ? Number(basePrice) * seatCount : Number(basePrice) const creditBalanceNum = Number(creditBalance) const newLimit = planBase + creditBalanceNum @@ -36,8 +41,7 @@ export async function setUsageLimitForCredits( .where(eq(organization.id, entityId)) .limit(1) - const currentLimit = - orgRows.length > 0 ? Number.parseFloat(orgRows[0].orgUsageLimit || '0') : 0 + const currentLimit = orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].orgUsageLimit)) : 0 if (newLimit > currentLimit) { await db @@ -63,7 +67,7 @@ export async function setUsageLimitForCredits( .limit(1) const currentLimit = - userStatsRows.length > 0 ? Number.parseFloat(userStatsRows[0].currentUsageLimit || '0') : 0 + userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentUsageLimit)) : 0 if (newLimit > currentLimit) { await db @@ -97,12 +101,6 @@ export interface PurchaseResult { error?: string } -function getPaymentMethodId( - pm: string | Stripe.PaymentMethod | null | undefined -): string | undefined { - return typeof pm === 'string' ? pm : pm?.id -} - export async function purchaseCredits(params: PurchaseCreditsParams): Promise { const { userId, amountDollars, requestId } = params @@ -128,8 +126,10 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise 0 && orgData[0].orgUsageLimit - ? Number.parseFloat(orgData[0].orgUsageLimit) + ? toNumber(toDecimal(orgData[0].orgUsageLimit)) : 0 // Update if no limit set, or if new seat-based minimum is higher @@ -286,8 +290,9 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData }) .where(eq(organization.id, organizationId)) - logger.info('Set organization usage limit for team plan', { + logger.info('Set organization usage limit', { organizationId, + plan: subscription.plan, seats, basePrice, orgLimit, @@ -322,6 +327,64 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData subscriptionId: subscription.id, plan: subscription.plan, }) + + // Bulk version of the per-member transfer in invitation-accept: + // catches members whose personal bytes never made it into the + // org pool (e.g. org upgraded free → paid after they joined). + // `.for('update')` row-locks so concurrent increment/decrement + // calls cannot slip between the snapshot SELECT and the + // zeroing UPDATE and get silently dropped. Idempotent — zeroed + // rows are filtered out. + if (isPaid(subscription.plan)) { + try { + const memberIds = members.map((m) => m.userId) + await db.transaction(async (tx) => { + const personalStorageRows = await tx + .select({ + userId: userStats.userId, + bytes: userStats.storageUsedBytes, + }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + .for('update') + + const toTransfer = personalStorageRows.filter((r) => (r.bytes ?? 0) > 0) + const totalBytes = toTransfer.reduce((acc, r) => acc + (r.bytes ?? 0), 0) + + if (totalBytes === 0) return + + await tx + .update(organization) + .set({ + storageUsedBytes: sql`${organization.storageUsedBytes} + ${totalBytes}`, + }) + .where(eq(organization.id, organizationId)) + + await tx + .update(userStats) + .set({ storageUsedBytes: 0 }) + .where( + inArray( + userStats.userId, + toTransfer.map((r) => r.userId) + ) + ) + + logger.info('Transferred personal storage bytes to org pool during sync', { + organizationId, + subscriptionId: subscription.id, + memberCount: toTransfer.length, + totalBytes, + }) + }) + } catch (storageError) { + logger.error('Failed to transfer personal storage to org pool', { + organizationId, + subscriptionId: subscription.id, + error: storageError, + }) + } + } } } } catch (error) { diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 1f9a8780451..35b08984a32 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -16,10 +16,12 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers' -import { requireStripeClient } from '@/lib/billing/stripe-client' +import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { generateId } from '@/lib/core/utils/uuid' const logger = createLogger('OrganizationMembership') @@ -137,31 +139,28 @@ export async function restoreUserProSubscription(userId: string): Promise { + await tx + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: false }) + .where(eq(subscriptionTable.id, personalPro.id)) + + if (personalPro.stripeSubscriptionId) { + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: personalPro.stripeSubscriptionId, + subscriptionId: personalPro.id, + reason: 'member-left-paid-org', + }) + } }) - } - - try { - await db - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: false }) - .where(eq(subscriptionTable.id, personalPro.id)) result.restored = true - logger.info('Restored personal Pro subscription', { + logger.info('Restored personal Pro subscription (DB committed, Stripe queued)', { userId, subscriptionId: personalPro.id, }) } catch (dbError) { - logger.error('DB update failed when restoring personal Pro', { + logger.error('Failed to restore personal Pro subscription', { userId, subscriptionId: personalPro.id, error: dbError, @@ -179,12 +178,10 @@ export async function restoreUserProSubscription(userId: string): Promise 0) { - const currentNum = Number.parseFloat(currentUsage) const restoredUsage = (currentNum + snapshotNum).toString() await db @@ -192,6 +189,7 @@ export async function restoreUserProSubscription(userId: string): Promise 0) { + await tx + .update(organization) + .set({ + storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`, + }) + .where(eq(organization.id, organizationId)) + + await tx + .update(userStats) + .set({ storageUsedBytes: 0 }) + .where(eq(userStats.userId, userId)) + + logger.info('Transferred personal storage bytes to org pool on admin add', { + userId, + organizationId, + bytes: bytesToTransfer, + }) + } } }) @@ -565,7 +598,7 @@ export async function removeUserFromOrganization( .limit(1) if (departingUserStats?.currentPeriodCost) { - const usage = Number.parseFloat(departingUserStats.currentPeriodCost) + const usage = toNumber(toDecimal(departingUserStats.currentPeriodCost)) if (usage > 0) { await db .update(organization) @@ -627,7 +660,8 @@ export async function removeUserFromOrganization( ) ) - hasAnyPaidTeam = orgPaidSubs.some((s) => isOrgPlan(s.plan)) + // Still covered by a paid org sub → don't restore personal Pro. + hasAnyPaidTeam = orgPaidSubs.some((s) => isPaid(s.plan)) } if (!hasAnyPaidTeam) { diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts index 0031bf3dffe..54e7abbe4b4 100644 --- a/apps/sim/lib/billing/plan-helpers.ts +++ b/apps/sim/lib/billing/plan-helpers.ts @@ -42,6 +42,13 @@ export function isPaid(plan: string | null | undefined): boolean { return isPro(plan) || isTeam(plan) || isEnterprise(plan) } +/** + * True when the plan **name** is a team/enterprise plan. This is a + * plan-name check, NOT a scope check — a `pro_*` plan attached to an + * organization is org-scoped at the billing level even though this + * returns `false` for it. For scope decisions use + * `isOrgScopedSubscription` (sync) or `isSubscriptionOrgScoped` (async). + */ export function isOrgPlan(plan: string | null | undefined): boolean { return isTeam(plan) || isEnterprise(plan) } diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 6838a4b932d..7cfb6b19ff6 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -13,7 +13,8 @@ import { import { organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { getPlanTypeForLimits, isEnterprise, isFree, isOrgPlan } from '@/lib/billing/plan-helpers' +import { getPlanTypeForLimits, isEnterprise, isFree } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { getEnv } from '@/lib/core/config/env' import { isBillingEnabled } from '@/lib/core/config/feature-flags' @@ -78,7 +79,6 @@ export function getStorageLimitForPlan(plan: string, metadata?: any): number { */ export async function getUserStorageLimit(userId: string): Promise { try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) @@ -88,18 +88,9 @@ export async function getUserStorageLimit(userId: string): Promise { return limits.free } - if (!isOrgPlan(sub.plan)) { - const effectivePlan = getPlanTypeForLimits(sub.plan) - const limitByPlan: Record<'free' | 'pro' | 'team', number> = { - free: limits.free, - pro: limits.pro, - team: limits.team, - } - return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free - } - - if (isOrgPlan(sub.plan)) { - // Get organization storage limit + // Org-scoped subs use pooled org-level storage. Custom limits come from the + // subscription metadata; otherwise use the team/enterprise default. + if (isOrgScopedSubscription(sub, userId)) { const orgRecord = await db .select({ metadata: subscription.metadata }) .from(subscription) @@ -113,11 +104,17 @@ export async function getUserStorageLimit(userId: string): Promise { } } - // Default for team/enterprise return isEnterprise(sub.plan) ? limits.enterpriseDefault : limits.team } - return limits.free + // Personally-scoped plans use the per-plan default storage cap. + const effectivePlan = getPlanTypeForLimits(sub.plan) + const limitByPlan: Record<'free' | 'pro' | 'team', number> = { + free: limits.free, + pro: limits.pro, + team: limits.team, + } + return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free } catch (error) { logger.error('Error getting user storage limit:', error) return getStorageLimits().free @@ -130,11 +127,12 @@ export async function getUserStorageLimit(userId: string): Promise { */ export async function getUserStorageUsage(userId: string): Promise { try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - if (sub && isOrgPlan(sub.plan)) { + // Org-scoped subs share pooled `organization.storageUsedBytes`; + // personal plans use `userStats`. + if (isOrgScopedSubscription(sub, userId) && sub) { const orgRecord = await db .select({ storageUsedBytes: organization.storageUsedBytes }) .from(organization) @@ -144,7 +142,6 @@ export async function getUserStorageUsage(userId: string): Promise { return orgRecord.length > 0 ? orgRecord[0].storageUsedBytes || 0 : 0 } - // Free/Pro: Use user stats const stats = await db .select({ storageUsedBytes: userStats.storageUsedBytes }) .from(userStats) diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 4776e2ad558..8fb3d962efb 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -8,7 +8,7 @@ import { db } from '@sim/db' import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' -import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' const logger = createLogger('StorageTracking') @@ -24,11 +24,11 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom } try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - if (sub && isOrgPlan(sub.plan)) { + // Org-scoped subs pool at the org level; personal plans per-user. + if (isOrgScopedSubscription(sub, userId) && sub) { await db .update(organization) .set({ @@ -38,7 +38,6 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom logger.info(`Incremented org storage: ${bytes} bytes for org ${sub.referenceId}`) } else { - // Update user stats storage await db .update(userStats) .set({ @@ -65,11 +64,10 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom } try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - if (sub && isOrgPlan(sub.plan)) { + if (isOrgScopedSubscription(sub, userId) && sub) { await db .update(organization) .set({ @@ -79,7 +77,6 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom logger.info(`Decremented org storage: ${bytes} bytes for org ${sub.referenceId}`) } else { - // Update user stats storage await db .update(userStats) .set({ diff --git a/apps/sim/lib/billing/stripe-payment-method.ts b/apps/sim/lib/billing/stripe-payment-method.ts new file mode 100644 index 00000000000..1bb130ec913 --- /dev/null +++ b/apps/sim/lib/billing/stripe-payment-method.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import type Stripe from 'stripe' + +const logger = createLogger('StripePaymentMethod') + +/** + * Extract the payment-method id from any of the shapes Stripe returns + * for a `default_payment_method` field (id string, full object, null, + * or undefined). + */ +function getPaymentMethodId( + pm: string | Stripe.PaymentMethod | null | undefined +): string | undefined { + return typeof pm === 'string' ? pm : pm?.id +} + +/** + * Extract the customer id from any of the shapes Stripe returns for a + * `customer` field (id string, full `Customer`, or `DeletedCustomer`). + */ +export function getCustomerId( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined +): string | undefined { + if (!customer) return undefined + return typeof customer === 'string' ? customer : customer.id +} + +/** + * Resolve a subscription's default payment method with fallback to the + * customer's invoice-settings PM. Used for ad-hoc invoices that are + * not directly linked to the subscription (overage, credits, threshold + * billing) so Stripe can auto-collect on finalize. + * + * Returns both the resolved PM id and the subscription's collection + * method so callers can pass it through to `invoices.create` without a + * second subscription retrieve. On any Stripe error the returned + * `collectionMethod` is `null` — callers should treat that as + * "unknown" and handle accordingly rather than assuming a default. + */ +export async function resolveDefaultPaymentMethod( + stripe: Stripe, + stripeSubscriptionId: string, + customerId: string +): Promise<{ + paymentMethodId: string | undefined + collectionMethod: 'charge_automatically' | 'send_invoice' | null +}> { + let collectionMethod: 'charge_automatically' | 'send_invoice' | null = null + let paymentMethodId: string | undefined + + try { + const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId) + collectionMethod = + sub.collection_method === 'send_invoice' ? 'send_invoice' : 'charge_automatically' + paymentMethodId = getPaymentMethodId(sub.default_payment_method) + + if (!paymentMethodId && collectionMethod === 'charge_automatically') { + const customer = await stripe.customers.retrieve(customerId) + if (customer && !('deleted' in customer)) { + paymentMethodId = getPaymentMethodId( + (customer as Stripe.Customer).invoice_settings?.default_payment_method + ) + } + } + } catch (error) { + logger.warn('Failed to resolve default payment method', { + stripeSubscriptionId, + customerId, + error: error instanceof Error ? error.message : error, + }) + } + + return { paymentMethodId, collectionMethod } +} diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index 078bc77294d..746a31bf2ce 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -93,7 +93,9 @@ export function getEffectiveSeats(subscription: any): number { return 0 } - if (isTeam(subscription.plan)) { + // Mirrors the Stripe subscription's `quantity`. For personal Pro this + // is null in practice, so `?? 0` returns 0. + if (isTeam(subscription.plan) || isPro(subscription.plan)) { return subscription.seats ?? 0 } @@ -109,9 +111,27 @@ export function checkTeamPlan(subscription: any): boolean { } /** - * Get the minimum usage limit for an individual user (used for validation) - * Only applicable for plans with individual limits (Free/Pro) - * Team and Enterprise plans use organization-level limits instead + * True when the subscription's `referenceId` is an org (i.e. not the + * caller's own `userId`). Prefer this over plan-name checks for scope + * decisions — a `pro_*` sub attached to an org is org-scoped even though + * `isTeam` / `isOrgPlan` return false. + */ +export function isOrgScopedSubscription( + subscription: { referenceId?: string | null } | null | undefined, + userId: string +): boolean { + if (!subscription?.referenceId) return false + return subscription.referenceId !== userId +} + +/** + * Get the minimum usage limit for an individual user (used for validation). + * + * Callers should only invoke this for **personally-scoped** subscriptions — + * any org-scoped subscription (team, enterprise, or `pro_*` attached to an + * organization) uses the organization-level limit instead. Callers are + * responsible for gating with `isOrgScopedSubscription` before calling. + * * @param subscription The subscription object * @returns The per-user minimum limit in dollars */ @@ -127,9 +147,6 @@ export function getPerUserMinimumLimit(subscription: any): number { } if (isOrgPlan(subscription.plan)) { - // Team and Enterprise don't have individual limits - they use organization limits - // This function should not be called for these plans - // Returning 0 to indicate no individual minimum return 0 } diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 8e59b429f3e..254ebdc88bf 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -1,114 +1,28 @@ import { db } from '@sim/db' import { member, organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, sql } from 'drizzle-orm' -import type Stripe from 'stripe' +import { eq, inArray, sql } from 'drizzle-orm' import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants' import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access' -import { calculateSubscriptionOverage, getPlanPricing } from '@/lib/billing/core/billing' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { calculateSubscriptionOverage, computeOrgOverageAmount } from '@/lib/billing/core/billing' import { - getPlanTierDollars, - isEnterprise, - isFree, - isPaid, - isTeam, -} from '@/lib/billing/plan-helpers' -import { requireStripeClient } from '@/lib/billing/stripe-client' + getHighestPrioritySubscription, + getOrganizationSubscriptionUsable, +} from '@/lib/billing/core/subscription' +import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionAccess, - USABLE_SUBSCRIPTION_STATUSES, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { env } from '@/lib/core/config/env' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' const logger = createLogger('ThresholdBilling') const OVERAGE_THRESHOLD = env.OVERAGE_THRESHOLD_DOLLARS || DEFAULT_OVERAGE_THRESHOLD -function parseDecimal(value: string | number | null | undefined): number { - if (value === null || value === undefined) return 0 - return Number.parseFloat(value.toString()) -} - -async function createAndFinalizeOverageInvoice( - stripe: ReturnType, - params: { - customerId: string - stripeSubscriptionId: string - amountCents: number - description: string - itemDescription: string - metadata: Record - idempotencyKey: string - } -): Promise { - const getPaymentMethodId = ( - pm: string | Stripe.PaymentMethod | null | undefined - ): string | undefined => (typeof pm === 'string' ? pm : pm?.id) - - let defaultPaymentMethod: string | undefined - try { - const stripeSub = await stripe.subscriptions.retrieve(params.stripeSubscriptionId) - const subDpm = getPaymentMethodId(stripeSub.default_payment_method) - if (subDpm) { - defaultPaymentMethod = subDpm - } else { - const custObj = await stripe.customers.retrieve(params.customerId) - if (custObj && !('deleted' in custObj)) { - const cust = custObj as Stripe.Customer - const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method) - if (custDpm) defaultPaymentMethod = custDpm - } - } - } catch (e) { - logger.error('Failed to retrieve subscription or customer', { error: e }) - } - - const invoice = await stripe.invoices.create( - { - customer: params.customerId, - collection_method: 'charge_automatically', - auto_advance: false, - description: params.description, - metadata: params.metadata, - ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), - }, - { idempotencyKey: `${params.idempotencyKey}-invoice` } - ) - - await stripe.invoiceItems.create( - { - customer: params.customerId, - invoice: invoice.id, - amount: params.amountCents, - currency: 'usd', - description: params.itemDescription, - metadata: params.metadata, - }, - { idempotencyKey: params.idempotencyKey } - ) - - if (invoice.id) { - const finalized = await stripe.invoices.finalizeInvoice(invoice.id) - - if (finalized.status === 'open' && finalized.id) { - try { - await stripe.invoices.pay(finalized.id, { - payment_method: defaultPaymentMethod, - }) - } catch (payError) { - logger.error('Failed to auto-pay threshold overage invoice', { - error: payError, - invoiceId: finalized.id, - }) - } - } - } - - return invoice.id || '' -} - export async function checkAndBillOverageThreshold(userId: string): Promise { try { const threshold = OVERAGE_THRESHOLD @@ -128,10 +42,12 @@ export async function checkAndBillOverageThreshold(userId: string): Promise 0) { creditsApplied = Math.min(creditBalance, amountToBill) - // Update credit balance within the transaction await tx .update(userStats) .set({ @@ -200,7 +132,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise m.userId !== owner.userId).map((m) => m.userId) @@ -405,34 +304,33 @@ export async function checkAndBillOrganizationOverageThreshold( .where(inArray(userStats.userId, nonOwnerIds)) for (const stats of memberStatsRows) { - totalTeamUsage += parseDecimal(stats.currentPeriodCost) + pooledCurrentPeriodCost += toNumber(toDecimal(stats.currentPeriodCost)) } } - let dailyRefreshDeduction = 0 - if (isPaid(orgSubscription.plan) && orgSubscription.periodStart) { - const planDollars = getPlanTierDollars(orgSubscription.plan) - if (planDollars > 0) { - const allMemberIds = members.map((m) => m.userId) - dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: allMemberIds, - periodStart: orgSubscription.periodStart, - periodEnd: orgSubscription.periodEnd ?? null, - planDollars, - seats: orgSubscription.seats ?? 1, - }) - } - } + const departedMemberUsage = toNumber(toDecimal(orgLock[0].departedMemberUsage)) + + const { + totalOverage: currentOverage, + baseSubscriptionAmount: basePrice, + effectiveUsage: effectiveTeamUsage, + } = await computeOrgOverageAmount({ + plan: orgSubscription.plan, + seats: orgSubscription.seats ?? null, + periodStart: orgSubscription.periodStart ?? null, + periodEnd: orgSubscription.periodEnd ?? null, + organizationId, + pooledCurrentPeriodCost, + departedMemberUsage, + memberIds: members.map((m) => m.userId), + }) - const effectiveTeamUsage = Math.max(0, totalTeamUsage - dailyRefreshDeduction) - const { basePrice: basePricePerSeat } = getPlanPricing(orgSubscription.plan) - const basePrice = basePricePerSeat * (orgSubscription.seats ?? 0) - const currentOverage = Math.max(0, effectiveTeamUsage - basePrice) const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage) logger.debug('Organization threshold billing check', { organizationId, - totalTeamUsage, + totalTeamUsage: pooledCurrentPeriodCost + departedMemberUsage, + effectiveTeamUsage, basePrice, currentOverage, totalBilledOverage, @@ -444,13 +342,24 @@ export async function checkAndBillOrganizationOverageThreshold( return } - // Apply credits to reduce the amount to bill (use locked org's balance) + // Validate Stripe identifiers BEFORE mutating credits/trackers. + const stripeSubscriptionId = orgSubscription.stripeSubscriptionId + if (!stripeSubscriptionId) { + logger.error('No Stripe subscription ID for organization', { organizationId }) + return + } + + const customerId = orgSubscription.stripeCustomerId + if (!customerId) { + logger.error('No Stripe customer ID for organization', { organizationId }) + return + } + let amountToBill = unbilledOverage let creditsApplied = 0 if (orgCreditBalance > 0) { creditsApplied = Math.min(orgCreditBalance, amountToBill) - // Update credit balance within the transaction await tx .update(organization) .set({ @@ -467,7 +376,7 @@ export async function checkAndBillOrganizationOverageThreshold( }) } - // If credits covered everything, just update the billed amount but don't create invoice + // If credits covered everything, bump billed tracker but don't enqueue Stripe invoice. if (amountToBill <= 0) { await tx .update(userStats) @@ -484,19 +393,6 @@ export async function checkAndBillOrganizationOverageThreshold( return } - const stripeSubscriptionId = orgSubscription.stripeSubscriptionId - if (!stripeSubscriptionId) { - logger.error('No Stripe subscription ID for organization', { organizationId }) - return - } - - const stripe = requireStripeClient() - const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) - const customerId = - typeof stripeSubscription.customer === 'string' - ? stripeSubscription.customer - : stripeSubscription.customer.id - const periodEnd = orgSubscription.periodEnd ? Math.floor(orgSubscription.periodEnd.getTime() / 1000) : Math.floor(Date.now() / 1000) @@ -504,23 +400,24 @@ export async function checkAndBillOrganizationOverageThreshold( const amountCents = Math.round(amountToBill * 100) const totalOverageCents = Math.round(currentOverage * 100) - const idempotencyKey = `threshold-overage-org:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}` - - logger.info('Creating organization threshold overage invoice', { - organizationId, - amountToBill, - creditsApplied, - billingPeriod, - }) - - const cents = amountCents + // Bump billed tracker and enqueue Stripe invoice atomically. + // See user-path above for the full retry-invariant reasoning. + await tx + .update(userStats) + .set({ + billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`, + }) + .where(eq(userStats.userId, owner.userId)) - const invoiceId = await createAndFinalizeOverageInvoice(stripe, { + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, { customerId, stripeSubscriptionId, - amountCents: cents, + amountCents, description: `Team threshold overage billing – ${billingPeriod}`, itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`, + billingPeriod, + invoiceIdemKeyStem: `threshold-overage-org-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, + itemIdemKeyStem: `threshold-overage-org-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, metadata: { type: 'overage_threshold_billing_org', organizationId, @@ -528,23 +425,15 @@ export async function checkAndBillOrganizationOverageThreshold( billingPeriod, totalOverageAtTimeOfBilling: currentOverage.toFixed(2), }, - idempotencyKey, }) - await tx - .update(userStats) - .set({ - billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`, - }) - .where(eq(userStats.userId, owner.userId)) - - logger.info('Successfully created and finalized organization threshold overage invoice', { + logger.info('Queued organization threshold overage invoice for Stripe', { organizationId, ownerId: owner.userId, creditsApplied, amountBilled: amountToBill, totalProcessed: unbilledOverage, - invoiceId, + billingPeriod, }) }) } catch (error) { diff --git a/apps/sim/lib/billing/types/index.ts b/apps/sim/lib/billing/types/index.ts index ac8c9736e1f..34addf2983a 100644 --- a/apps/sim/lib/billing/types/index.ts +++ b/apps/sim/lib/billing/types/index.ts @@ -62,6 +62,15 @@ export interface UsageLimitInfo { minimumLimit: number plan: string updatedAt: Date | null + /** + * Whether the limit is stored on the user (`'user'`) or the organization + * (`'organization'`). Callers should route edits to the matching API + * context. Org-scoped includes any subscription whose `referenceId` is + * an organization id, regardless of plan name. + */ + scope: 'user' | 'organization' + /** Present only when `scope === 'organization'`. */ + organizationId: string | null } export interface BillingData { diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index 8712b427584..06472edabeb 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -3,7 +3,7 @@ import { invitation, member, organization, subscription, user, userStats } from import { createLogger } from '@sim/logger' import { and, count, eq } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' -import { isEnterprise, isFree, isPro } from '@/lib/billing/plan-helpers' +import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -62,11 +62,10 @@ export async function validateSeatAvailability( } } - // Free and Pro plans don't support organizations - if (isFree(subscription.plan) || isPro(subscription.plan)) { + if (isFree(subscription.plan)) { return { canInvite: false, - reason: 'Organization features require Team or Enterprise plan', + reason: 'Organization features require a paid plan', currentSeats: 0, maxSeats: 0, availableSeats: 0, @@ -81,9 +80,8 @@ export async function validateSeatAvailability( const currentSeats = memberCount[0]?.count || 0 - // Determine seat limits based on subscription - // Team: seats from Stripe subscription quantity (seats column) - // Enterprise: seats from metadata.seats (not from seats column which is always 1) + // Team: seats from the `seats` column (Stripe quantity). + // Enterprise: seats from metadata.seats (column is always 1). const maxSeats = getEffectiveSeats(subscription) const availableSeats = Math.max(0, maxSeats - currentSeats) @@ -156,7 +154,6 @@ export async function getOrganizationSeatInfo( const currentSeats = memberCount[0]?.count || 0 - // Team: seats from column, Enterprise: seats from metadata const maxSeats = getEffectiveSeats(subscription) const canAddSeats = !isEnterprise(subscription.plan) diff --git a/apps/sim/lib/billing/webhooks/idempotency.ts b/apps/sim/lib/billing/webhooks/idempotency.ts new file mode 100644 index 00000000000..e9613168af0 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/idempotency.ts @@ -0,0 +1,39 @@ +import { IdempotencyService } from '@/lib/core/idempotency/service' + +/** + * Idempotency service for Stripe webhook handlers. + * + * Stripe delivers webhook events at-least-once and retries failed + * deliveries for up to 3 days. Handlers that perform non-idempotent work + * (crediting accounts, removing credits, resetting usage trackers, etc.) + * must be wrapped in a claim so duplicate deliveries are collapsed to a + * single execution. + * + * Storage is **forced to Postgres** regardless of whether Redis is + * configured. Billing handlers mutate `user_stats` / `organization` / + * `subscription` rows via DB transactions — keeping the idempotency + * record in the same Postgres closes the narrow window where the + * operation commits but a Redis `storeResult` fails, which would cause + * Stripe's next retry to re-run the money-affecting work. The latency + * cost (1–5 ms per claim/store) is invisible on webhook responses, and + * volume is low enough (roughly one event per customer per billing + * cycle) that DB storage scales comfortably. + * + * `retryFailures: true` means a thrown handler releases the claim so + * Stripe's next retry runs from scratch — without it, one transient + * failure would poison the key for the whole TTL window. + * + * TTL of 7 days is slightly longer than Stripe's 3-day retry horizon so + * late retries still dedupe against completed work. Rows past their TTL + * are handled two ways: `atomicallyClaimDb` reclaims stale rows inline + * via `ON CONFLICT DO UPDATE WHERE created_at < expired_before` (so + * correctness does not depend on cleanup running), and the external + * cleanup cron (scheduled from the infra repo) hits + * `/api/webhooks/cleanup/idempotency` to bound table size. + */ +export const stripeWebhookIdempotency = new IdempotencyService({ + namespace: 'stripe-webhook', + ttlSeconds: 60 * 60 * 24 * 7, + retryFailures: true, + forceStorage: 'database', +}) diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 63b14c04f83..f0a004059b2 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -92,6 +92,7 @@ vi.mock('@/components/emails', () => ({ vi.mock('@/lib/billing/core/billing', () => ({ calculateSubscriptionOverage: vi.fn(), + isSubscriptionOrgScoped: vi.fn().mockResolvedValue(true), })) vi.mock('@/lib/billing/credits/balance', () => ({ @@ -119,6 +120,36 @@ vi.mock('@/lib/billing/stripe-client', () => ({ requireStripeClient: vi.fn(), })) +vi.mock('@/lib/billing/stripe-payment-method', () => ({ + resolveDefaultPaymentMethod: vi.fn(async () => ({ + paymentMethodId: undefined, + collectionMethod: 'charge_automatically', + })), + getPaymentMethodId: vi.fn(), + getCustomerId: vi.fn(), +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing', 'past_due'], +})) + +vi.mock('@/lib/billing/utils/decimal', () => ({ + toDecimal: vi.fn((v: string | number | null | undefined) => { + if (v === null || v === undefined || v === '') return { toNumber: () => 0 } + return { toNumber: () => Number(v) } + }), + toNumber: vi.fn((d: { toNumber: () => number }) => d.toNumber()), +})) + +vi.mock('@/lib/billing/webhooks/idempotency', () => ({ + stripeWebhookIdempotency: { + executeWithIdempotency: vi.fn( + async (_provider: string, _identifier: string, operation: () => Promise) => + operation() + ), + }, +})) + vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: vi.fn(() => 'https://sim.test'), })) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 398e40804cc..bed1a7834e4 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -8,15 +8,19 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' -import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' -import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance' +import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' +import { addCredits, getCreditBalanceForEntity } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership' -import { isEnterprise, isOrgPlan, isTeam } from '@/lib/billing/plan-helpers' +import { isEnterprise } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' +import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' @@ -30,11 +34,6 @@ const METADATA_SUBSCRIPTION_INVOICE_TYPES = new Set([ 'overage_threshold_billing_org', ]) -function parseDecimal(value: string | number | null | undefined): number { - if (value === null || value === undefined) return 0 - return Number.parseFloat(value.toString()) -} - type InvoiceSubscriptionResolutionSource = | 'parent.subscription_details.subscription' | 'metadata.subscriptionId' @@ -263,11 +262,11 @@ async function sendPaymentFailureEmails( const amountDue = invoice.amount_due / 100 // Convert cents to dollars const { lastFourDigits, failureReason } = await getPaymentMethodDetails(invoice) - // Get users to notify + // Notify based on subscription scope — org-scoped subs alert owners/admins. let usersToNotify: Array<{ email: string; name: string | null }> = [] + const orgScoped = await isSubscriptionOrgScoped(sub) - if (isOrgPlan(sub.plan)) { - // For team/enterprise, notify all owners and admins + if (orgScoped) { const members = await db .select({ userId: member.userId, @@ -276,7 +275,6 @@ async function sendPaymentFailureEmails( .from(member) .where(eq(member.organizationId, sub.referenceId)) - // Get owner/admin user details const ownerAdminIds = members .filter((m) => m.role === 'owner' || m.role === 'admin') .map((m) => m.userId) @@ -290,7 +288,6 @@ async function sendPaymentFailureEmails( usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid) } } else { - // For individual plans, notify the user const users = await db .select({ email: user.email, name: user.name }) .from(user) @@ -343,15 +340,17 @@ async function sendPaymentFailureEmails( } /** - * Get total billed overage for a subscription, handling team vs individual plans - * For team plans: sums billedOverageThisPeriod across all members - * For other plans: gets billedOverageThisPeriod for the user + * Get total billed overage for a subscription, handling org-scoped vs + * personally-scoped plans. + * - Org-scoped (team, enterprise, or `pro_*` attached to an org): + * stored on the org owner's `userStats.billedOverageThisPeriod`. + * - Personally-scoped: the user's own `billedOverageThisPeriod`. */ export async function getBilledOverageForSubscription(sub: { plan: string | null referenceId: string }): Promise { - if (isTeam(sub.plan)) { + if (await isSubscriptionOrgScoped(sub)) { const ownerRows = await db .select({ userId: member.userId }) .from(member) @@ -373,7 +372,7 @@ export async function getBilledOverageForSubscription(sub: { .where(eq(userStats.userId, ownerId)) .limit(1) - return ownerStats.length > 0 ? parseDecimal(ownerStats[0].billedOverageThisPeriod) : 0 + return ownerStats.length > 0 ? toNumber(toDecimal(ownerStats[0].billedOverageThisPeriod)) : 0 } const userStatsRecords = await db @@ -382,11 +381,13 @@ export async function getBilledOverageForSubscription(sub: { .where(eq(userStats.userId, sub.referenceId)) .limit(1) - return userStatsRecords.length > 0 ? parseDecimal(userStatsRecords[0].billedOverageThisPeriod) : 0 + return userStatsRecords.length > 0 + ? toNumber(toDecimal(userStatsRecords[0].billedOverageThisPeriod)) + : 0 } export async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) { - if (isOrgPlan(sub.plan)) { + if (await isSubscriptionOrgScoped(sub)) { const membersRows = await db .select({ userId: member.userId }) .from(member) @@ -409,8 +410,8 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe .set({ lastPeriodCost: current, lastPeriodCopilotCost: currentCopilot, - currentPeriodCost: '0', - currentPeriodCopilotCost: '0', + currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - ${current}::decimal)`, + currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - ${currentCopilot}::decimal)`, billedOverageThisPeriod: '0', }) .where(eq(userStats.userId, m.userId)) @@ -432,23 +433,41 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe .where(eq(userStats.userId, sub.referenceId)) .limit(1) if (currentStats.length > 0) { - // For Pro plans, combine current + snapshot for lastPeriodCost, then clear both - const current = Number.parseFloat(currentStats[0].current?.toString() || '0') - const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0') - const totalLastPeriod = (current + snapshot).toString() + const current = currentStats[0].current || '0' + const snapshot = toNumber(toDecimal(currentStats[0].snapshot)) const currentCopilot = currentStats[0].currentCopilot || '0' - await db - .update(userStats) - .set({ - lastPeriodCost: totalLastPeriod, - lastPeriodCopilotCost: currentCopilot, - currentPeriodCost: '0', - currentPeriodCopilotCost: '0', - proPeriodCostSnapshot: '0', // Clear snapshot at period end - billedOverageThisPeriod: '0', // Clear threshold billing tracker at period end - }) - .where(eq(userStats.userId, sub.referenceId)) + // Snapshot > 0: user joined a paid org mid-cycle. The pre-join + // portion was billed on this invoice (snapshot); `currentPeriodCost` + // is post-join usage the org will bill next cycle-close, so keep + // it. Only retire the personal-billing trackers here. + if (snapshot > 0) { + await db + .update(userStats) + .set({ + lastPeriodCost: snapshot.toString(), + lastPeriodCopilotCost: '0', + proPeriodCostSnapshot: '0', + proPeriodCostSnapshotAt: null, + billedOverageThisPeriod: '0', + }) + .where(eq(userStats.userId, sub.referenceId)) + } else { + const totalLastPeriod = toNumber(toDecimal(current).plus(snapshot)).toString() + // Delta-reset for the same reason as the org branch above. + await db + .update(userStats) + .set({ + lastPeriodCost: totalLastPeriod, + lastPeriodCopilotCost: currentCopilot, + currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - ${current}::decimal)`, + currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - ${currentCopilot}::decimal)`, + proPeriodCostSnapshot: '0', + proPeriodCostSnapshotAt: null, + billedOverageThisPeriod: '0', + }) + .where(eq(userStats.userId, sub.referenceId)) + } } } } @@ -472,90 +491,132 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise 0) { - const sub = subscription[0] - const { balance: newCreditBalance } = await getCreditBalance(entityId) - await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance) + if (!invoice.id) { + logger.error('Credit purchase invoice missing id, cannot dedupe', { + metadata: invoice.metadata, + }) + return } - logger.info('Credit purchase completed via webhook', { - invoiceId: invoice.id, - entityType, - entityId, - amount, - purchasedBy, - }) - - // Send confirmation emails - try { - const { balance: newBalance } = await getCreditBalance( - entityType === 'organization' ? entityId : purchasedBy || entityId - ) - let recipients: Array<{ email: string; name: string | null }> = [] + // Idempotent apply: duplicate Stripe deliveries collapse to a single + // execution. On exception the key is released (retryFailures: true) + // so the next Stripe retry runs from scratch. On success, subsequent + // deliveries short-circuit with the cached result. + // + // CRITICAL: everything after `addCredits` must be either idempotent or + // wrapped in try/catch that does not rethrow. Otherwise a failure + // after credits commit would release the key and the retry would + // double-credit. `setUsageLimitForCredits` and the email are both + // best-effort and wrapped; the subscription lookup before them is a + // read, safe to rerun. + await stripeWebhookIdempotency.executeWithIdempotency('credit-purchase', invoice.id, async () => { + await addCredits(entityType, entityId, amount) + + try { + const subscription = await db + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, entityId), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) - if (entityType === 'organization') { - const members = await db - .select({ userId: member.userId, role: member.role }) - .from(member) - .where(eq(member.organizationId, entityId)) + if (subscription.length > 0) { + const sub = subscription[0] + const newCreditBalance = await getCreditBalanceForEntity(entityType, entityId) + await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance) + } + } catch (limitError) { + // Limit bump is best-effort. Customer already got credits; if the + // cap doesn't auto-raise they can edit it themselves or another + // credit purchase will rebase it. Do NOT rethrow — that would + // release the idempotency claim and double-credit on retry. + logger.error('Failed to update usage limit after credit purchase', { + invoiceId: invoice.id, + entityType, + entityId, + error: limitError, + }) + } - const ownerAdminIds = members - .filter((m) => m.role === 'owner' || m.role === 'admin') - .map((m) => m.userId) + logger.info('Credit purchase completed via webhook', { + invoiceId: invoice.id, + entityType, + entityId, + amount, + purchasedBy, + }) - if (ownerAdminIds.length > 0) { - recipients = await db + try { + const newBalance = await getCreditBalanceForEntity(entityType, entityId) + let recipients: Array<{ email: string; name: string | null }> = [] + + if (entityType === 'organization') { + const members = await db + .select({ userId: member.userId, role: member.role }) + .from(member) + .where(eq(member.organizationId, entityId)) + + const ownerAdminIds = members + .filter((m) => m.role === 'owner' || m.role === 'admin') + .map((m) => m.userId) + + if (ownerAdminIds.length > 0) { + recipients = await db + .select({ email: user.email, name: user.name }) + .from(user) + .where(inArray(user.id, ownerAdminIds)) + } + } else if (purchasedBy) { + const users = await db .select({ email: user.email, name: user.name }) .from(user) - .where(inArray(user.id, ownerAdminIds)) - } - } else if (purchasedBy) { - const users = await db - .select({ email: user.email, name: user.name }) - .from(user) - .where(eq(user.id, purchasedBy)) - .limit(1) + .where(eq(user.id, purchasedBy)) + .limit(1) - recipients = users - } + recipients = users + } - for (const recipient of recipients) { - if (!recipient.email) continue + for (const recipient of recipients) { + if (!recipient.email) continue - const emailHtml = await renderCreditPurchaseEmail({ - userName: recipient.name || undefined, - amount, - newBalance, - }) + const emailHtml = await renderCreditPurchaseEmail({ + userName: recipient.name || undefined, + amount, + newBalance, + }) - await sendEmail({ - to: recipient.email, - subject: getEmailSubject('credit-purchase'), - html: emailHtml, - emailType: 'transactional', - }) + await sendEmail({ + to: recipient.email, + subject: getEmailSubject('credit-purchase'), + html: emailHtml, + emailType: 'transactional', + }) - logger.info('Sent credit purchase confirmation email', { - email: recipient.email, + logger.info('Sent credit purchase confirmation email', { + email: recipient.email, + invoiceId: invoice.id, + }) + } + } catch (emailError) { + // Emails are best-effort — a failure here should NOT release the + // claim (otherwise Stripe retries would re-credit the user). + logger.error('Failed to send credit purchase emails', { + emailError, invoiceId: invoice.id, }) } - } catch (emailError) { - logger.error('Failed to send credit purchase emails', { emailError, invoiceId: invoice.id }) - } + + return { ok: true } + }) } /** @@ -566,77 +627,80 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - // Handle credit purchase invoices if (invoice.metadata?.type === 'credit_purchase') { await handleCreditPurchaseSuccess(invoice) return } - const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_succeeded') - if (!resolvedInvoice) { - return - } - - const { sub } = resolvedInvoice + await stripeWebhookIdempotency.executeWithIdempotency( + 'invoice-payment-succeeded', + event.id, + async () => { + const resolvedInvoice = await resolveInvoiceSubscription( + invoice, + 'invoice.payment_succeeded' + ) + if (!resolvedInvoice) { + return + } - // Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it - let wasBlocked = false - if (isOrgPlan(sub.plan)) { - const membersRows = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) - const memberIds = membersRows.map((m) => m.userId) - if (memberIds.length > 0) { - const blockedRows = await db - .select({ blocked: userStats.billingBlocked }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - wasBlocked = blockedRows.some((row) => !!row.blocked) - } - } else { - const row = await db - .select({ blocked: userStats.billingBlocked }) - .from(userStats) - .where(eq(userStats.userId, sub.referenceId)) - .limit(1) - wasBlocked = row.length > 0 ? !!row[0].blocked : false - } + const { sub } = resolvedInvoice + const subIsOrgScoped = await isSubscriptionOrgScoped(sub) + + let wasBlocked = false + if (subIsOrgScoped) { + const membersRows = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + const memberIds = membersRows.map((m) => m.userId) + if (memberIds.length > 0) { + const blockedRows = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + wasBlocked = blockedRows.some((row) => !!row.blocked) + } + } else { + const row = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(eq(userStats.userId, sub.referenceId)) + .limit(1) + wasBlocked = row.length > 0 ? !!row[0].blocked : false + } - // For proration invoices (mid-cycle upgrades/seat changes), only unblock if real money - // was collected. A $0 credit invoice from a downgrade should not unblock a user who - // was blocked for a different failed payment. - const isProrationInvoice = invoice.billing_reason === 'subscription_update' - const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0 + const isProrationInvoice = invoice.billing_reason === 'subscription_update' + const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0 + + if (shouldUnblock) { + if (subIsOrgScoped) { + await unblockOrgMembers(sub.referenceId, 'payment_failed') + } else { + await db + .update(userStats) + .set({ billingBlocked: false, billingBlockedReason: null }) + .where( + and( + eq(userStats.userId, sub.referenceId), + eq(userStats.billingBlockedReason, 'payment_failed') + ) + ) + } + } else { + logger.info('Skipping unblock for zero-amount proration invoice', { + invoiceId: invoice.id, + billingReason: invoice.billing_reason, + amountPaid: invoice.amount_paid, + }) + } - if (shouldUnblock) { - if (isOrgPlan(sub.plan)) { - await unblockOrgMembers(sub.referenceId, 'payment_failed') - } else { - await db - .update(userStats) - .set({ billingBlocked: false, billingBlockedReason: null }) - .where( - and( - eq(userStats.userId, sub.referenceId), - eq(userStats.billingBlockedReason, 'payment_failed') - ) - ) + if (wasBlocked && !isProrationInvoice) { + await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + } } - } else { - logger.info('Skipping unblock for zero-amount proration invoice', { - invoiceId: invoice.id, - billingReason: invoice.billing_reason, - amountPaid: invoice.amount_paid, - }) - } - - // Only reset usage for cycle renewals — proration invoices should not wipe - // accumulated usage mid-cycle. - if (wasBlocked && !isProrationInvoice) { - await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) - } + ) } catch (error) { logger.error('Failed to handle invoice payment succeeded', { eventId: event.id, error }) throw error @@ -651,96 +715,100 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_failed') - if (!resolvedInvoice) { - return - } - - const { invoiceType, resolutionSource, stripeSubscriptionId, sub } = resolvedInvoice - - // Extract and validate customer ID - const customerId = invoice.customer - if (!customerId || typeof customerId !== 'string') { - logger.error('Invalid customer ID on invoice', { - invoiceId: invoice.id, - customer: invoice.customer, - }) - return - } + await stripeWebhookIdempotency.executeWithIdempotency( + 'invoice-payment-failed', + event.id, + async () => { + const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_failed') + if (!resolvedInvoice) { + return + } - const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars - const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' - const attemptCount = invoice.attempt_count ?? 1 + const { invoiceType, resolutionSource, stripeSubscriptionId, sub } = resolvedInvoice - logger.warn('Invoice payment failed', { - invoiceId: invoice.id, - customerId, - failedAmount, - billingPeriod, - attemptCount, - customerEmail: invoice.customer_email, - hostedInvoiceUrl: invoice.hosted_invoice_url, - invoiceType: invoiceType ?? 'subscription', - resolutionSource, - }) + const customerId = invoice.customer + if (!customerId || typeof customerId !== 'string') { + logger.error('Invalid customer ID on invoice', { + invoiceId: invoice.id, + customer: invoice.customer, + }) + return + } - // Block users after first payment failure - if (attemptCount >= 1) { - logger.error('Payment failure - blocking users', { - customerId, - attemptCount, - invoiceId: invoice.id, - invoiceType: invoiceType ?? 'subscription', - resolutionSource, - stripeSubscriptionId, - }) + const failedAmount = invoice.amount_due / 100 + const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' + const attemptCount = invoice.attempt_count ?? 1 - if (isOrgPlan(sub.plan)) { - const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') - logger.info('Blocked team/enterprise members due to payment failure', { + logger.warn('Invoice payment failed', { + invoiceId: invoice.id, + customerId, + failedAmount, + billingPeriod, + attemptCount, + customerEmail: invoice.customer_email, + hostedInvoiceUrl: invoice.hosted_invoice_url, invoiceType: invoiceType ?? 'subscription', - memberCount, - organizationId: sub.referenceId, + resolutionSource, }) - } else { - await db - .update(userStats) - .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) - .where( - and( - eq(userStats.userId, sub.referenceId), - or( - ne(userStats.billingBlockedReason, 'dispute'), - isNull(userStats.billingBlockedReason) + + if (attemptCount >= 1) { + logger.error('Payment failure - blocking users', { + customerId, + attemptCount, + invoiceId: invoice.id, + invoiceType: invoiceType ?? 'subscription', + resolutionSource, + stripeSubscriptionId, + }) + + if (await isSubscriptionOrgScoped(sub)) { + const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') + logger.info('Blocked org members due to payment failure', { + invoiceType: invoiceType ?? 'subscription', + memberCount, + organizationId: sub.referenceId, + }) + } else { + await db + .update(userStats) + .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) + .where( + and( + eq(userStats.userId, sub.referenceId), + or( + ne(userStats.billingBlockedReason, 'dispute'), + isNull(userStats.billingBlockedReason) + ) + ) ) - ) - ) - logger.info('Blocked user due to payment failure', { - invoiceType: invoiceType ?? 'subscription', - userId: sub.referenceId, - }) - } + logger.info('Blocked user due to payment failure', { + invoiceType: invoiceType ?? 'subscription', + userId: sub.referenceId, + }) + } - if (attemptCount === 1) { - await sendPaymentFailureEmails(sub, invoice, customerId) - logger.info('Payment failure email sent on first attempt', { - customerId, - invoiceId: invoice.id, - }) - } else { - logger.info('Skipping payment failure email on retry attempt', { - attemptCount, - customerId, - invoiceId: invoice.id, - }) + if (attemptCount === 1) { + await sendPaymentFailureEmails(sub, invoice, customerId) + logger.info('Payment failure email sent on first attempt', { + customerId, + invoiceId: invoice.id, + }) + } else { + logger.info('Skipping payment failure email on retry attempt', { + attemptCount, + customerId, + invoiceId: invoice.id, + }) + } + } } - } + ) } catch (error) { logger.error('Failed to handle invoice payment failed', { eventId: event.id, error, }) - throw error // Re-throw to signal webhook failure + throw error } } @@ -751,7 +819,6 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { export async function handleInvoiceFinalized(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - // Only run for subscription renewal invoices (cycle boundary) const subscription = invoice.parent?.subscription_details?.subscription const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id if (!stripeSubscriptionId) { @@ -771,151 +838,222 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { if (records.length === 0) return const sub = records[0] - // Enterprise plans have no overages - reset usage and exit if (isEnterprise(sub.plan)) { await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) return } - const stripe = requireStripeClient() - const periodEnd = - invoice.lines?.data?.[0]?.period?.end || invoice.period_end || Math.floor(Date.now() / 1000) - const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) + await stripeWebhookIdempotency.executeWithIdempotency( + 'invoice-finalized', + event.id, + async () => { + const stripe = requireStripeClient() + const periodEnd = + invoice.lines?.data?.[0]?.period?.end || + invoice.period_end || + Math.floor(Date.now() / 1000) + const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) + + const totalOverage = await calculateSubscriptionOverage(sub) + + const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user' + const entityId = sub.referenceId + + // Resolve the userStats row that holds the `billedOverageThisPeriod` + // tracker. Org subs: the owner's row. Personal: the user's own row. + // Throw if an org has no owner — returning early would cache a + // "successful" no-op, and the next cycle's tracker would still + // reflect this cycle's billed amount, breaking future overage math. + let trackerUserId: string + if (entityType === 'organization') { + const ownerRows = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, entityId), eq(member.role, 'owner'))) + .limit(1) + const ownerId = ownerRows[0]?.userId + if (!ownerId) { + throw new Error( + `Organization ${entityId} has no owner member; cannot process invoice finalization` + ) + } + trackerUserId = ownerId + } else { + trackerUserId = entityId + } - // Compute overage (only for team and pro plans), before resetting usage - const totalOverage = await calculateSubscriptionOverage(sub) + // Phase 1 — atomic commit. Lock the tracker row first so we read + // `billedOverageThisPeriod` serialized against concurrent events; + // then read the credit balance, decrement it, and bump the + // tracker to `totalOverage`. On retry, the locked re-read sees + // `billed == totalOverage` → `remaining == 0` → credit removal + // skipped. That's the invariant preventing double-deduction. + const phase1 = await db.transaction(async (tx) => { + const trackerRows = await tx + .select({ billed: userStats.billedOverageThisPeriod }) + .from(userStats) + .where(eq(userStats.userId, trackerUserId)) + .for('update') + .limit(1) + + const billedInTx = trackerRows.length > 0 ? toNumber(toDecimal(trackerRows[0].billed)) : 0 + const remaining = Math.max(0, totalOverage - billedInTx) + + if (remaining === 0) { + return { billedInTx, applied: 0, billed: 0, remaining: 0 } + } - // Get already-billed overage from threshold billing - const billedOverage = await getBilledOverageForSubscription(sub) + const lockedBalance = + entityType === 'organization' + ? await tx + .select({ creditBalance: organization.creditBalance }) + .from(organization) + .where(eq(organization.id, entityId)) + .for('update') + .limit(1) + : await tx + .select({ creditBalance: userStats.creditBalance }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .for('update') + .limit(1) + + const creditBalance = + lockedBalance.length > 0 ? toNumber(toDecimal(lockedBalance[0].creditBalance)) : 0 + + const applied = Math.min(creditBalance, remaining) + const billed = remaining - applied + + if (applied > 0) { + if (entityType === 'organization') { + await tx + .update(organization) + .set({ + creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${applied})`, + }) + .where(eq(organization.id, entityId)) + } else { + await tx + .update(userStats) + .set({ + creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${applied})`, + }) + .where(eq(userStats.userId, entityId)) + } + } - // Only bill the remaining unbilled overage - let remainingOverage = Math.max(0, totalOverage - billedOverage) + await tx + .update(userStats) + .set({ billedOverageThisPeriod: totalOverage.toString() }) + .where(eq(userStats.userId, trackerUserId)) - // Apply credits to reduce overage at end of cycle - let creditsApplied = 0 - if (remainingOverage > 0) { - const entityType = isOrgPlan(sub.plan) ? 'organization' : 'user' - const entityId = sub.referenceId - const { balance: creditBalance } = await getCreditBalance(entityId) + return { billedInTx, applied, billed, remaining } + }) - if (creditBalance > 0) { - creditsApplied = Math.min(creditBalance, remainingOverage) - await removeCredits(entityType, entityId, creditsApplied) - remainingOverage = remainingOverage - creditsApplied + const creditsApplied = phase1.applied + const amountToBillStripe = phase1.billed - logger.info('Applied credits to reduce overage at cycle end', { + logger.info('Invoice finalized overage calculation', { subscriptionId: sub.id, - creditBalance, + totalOverage, + billedOverageBeforeTx: phase1.billedInTx, creditsApplied, - remainingOverageAfterCredits: remainingOverage, + amountToBillStripe, + billingPeriod, }) - } - } - - logger.info('Invoice finalized overage calculation', { - subscriptionId: sub.id, - totalOverage, - billedOverage, - creditsApplied, - remainingOverage, - billingPeriod, - }) - - if (remainingOverage > 0) { - const customerId = String(invoice.customer) - const cents = Math.round(remainingOverage * 100) - const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - // Inherit billing settings from the Stripe subscription/customer for autopay - const getPaymentMethodId = ( - pm: string | Stripe.PaymentMethod | null | undefined - ): string | undefined => (typeof pm === 'string' ? pm : pm?.id) + // Phase 2 — Stripe invoice. Runs outside any DB transaction. + // Every call uses a deterministic idempotency key so retries + // converge on the same invoice object: re-create returns the + // existing draft, re-finalize no-ops on an already-finalized + // invoice, re-pay no-ops on an already-paid invoice. + if (amountToBillStripe > 0) { + const customerId = String(invoice.customer) + const cents = Math.round(amountToBillStripe * 100) + const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const finalizeIdemKey = `overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const payIdemKey = `overage-pay:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + + const { paymentMethodId: defaultPaymentMethod, collectionMethod } = + await resolveDefaultPaymentMethod(stripe, stripeSubscriptionId, customerId) + + const effectiveCollectionMethod = collectionMethod ?? 'charge_automatically' + + const overageInvoice = await stripe.invoices.create( + { + customer: customerId, + collection_method: effectiveCollectionMethod, + auto_advance: false, + ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), + metadata: { + type: 'overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + }, + }, + { idempotencyKey: invoiceIdemKey } + ) - let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically' - let defaultPaymentMethod: string | undefined - try { - const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId) - if (stripeSub.collection_method === 'send_invoice') { - collectionMethod = 'send_invoice' - } - const subDpm = getPaymentMethodId(stripeSub.default_payment_method) - if (subDpm) { - defaultPaymentMethod = subDpm - } else if (collectionMethod === 'charge_automatically') { - const custObj = await stripe.customers.retrieve(customerId) - if (custObj && !('deleted' in custObj)) { - const cust = custObj as Stripe.Customer - const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method) - if (custDpm) defaultPaymentMethod = custDpm - } - } - } catch (e) { - logger.error('Failed to retrieve subscription or customer', { error: e }) - } + await stripe.invoiceItems.create( + { + customer: customerId, + invoice: overageInvoice.id, + amount: cents, + currency: 'usd', + description: `Usage Based Overage – ${billingPeriod}`, + metadata: { + type: 'overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + }, + }, + { idempotencyKey: itemIdemKey } + ) - // Create a draft invoice first so we can attach the item directly - const overageInvoice = await stripe.invoices.create( - { - customer: customerId, - collection_method: collectionMethod, - auto_advance: false, - ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), - metadata: { - type: 'overage_billing', - billingPeriod, - subscriptionId: stripeSubscriptionId, - }, - }, - { idempotencyKey: invoiceIdemKey } - ) - - // Attach the item to this invoice - await stripe.invoiceItems.create( - { - customer: customerId, - invoice: overageInvoice.id, - amount: cents, - currency: 'usd', - description: `Usage Based Overage – ${billingPeriod}`, - metadata: { - type: 'overage_billing', - billingPeriod, - subscriptionId: stripeSubscriptionId, - }, - }, - { idempotencyKey: itemIdemKey } - ) - - // Finalize to trigger autopay (if charge_automatically and a PM is present) - const draftId = overageInvoice.id - if (typeof draftId !== 'string' || draftId.length === 0) { - logger.error('Stripe created overage invoice without id; aborting finalize') - } else { - const finalized = await stripe.invoices.finalizeInvoice(draftId) - // Some manual invoices may remain open after finalize; ensure we pay immediately when possible - if (collectionMethod === 'charge_automatically' && finalized.status === 'open') { - try { - const payId = finalized.id - if (typeof payId !== 'string' || payId.length === 0) { - logger.error('Finalized invoice missing id') - throw new Error('Finalized invoice missing id') + const draftId = overageInvoice.id + if (typeof draftId !== 'string' || draftId.length === 0) { + logger.error('Stripe created overage invoice without id; aborting finalize') + } else { + const finalized = await stripe.invoices.finalizeInvoice( + draftId, + {}, + { idempotencyKey: finalizeIdemKey } + ) + if ( + effectiveCollectionMethod === 'charge_automatically' && + finalized.status === 'open' + ) { + try { + const payId = finalized.id + if (typeof payId !== 'string' || payId.length === 0) { + logger.error('Finalized invoice missing id') + throw new Error('Finalized invoice missing id') + } + await stripe.invoices.pay( + payId, + { payment_method: defaultPaymentMethod }, + { idempotencyKey: payIdemKey } + ) + } catch (payError) { + logger.error('Failed to auto-pay overage invoice', { + error: payError, + invoiceId: finalized.id, + }) + } } - await stripe.invoices.pay(payId, { - payment_method: defaultPaymentMethod, - }) - } catch (payError) { - logger.error('Failed to auto-pay overage invoice', { - error: payError, - invoiceId: finalized.id, - }) } } - } - } - // Finally, reset usage for this subscription after overage handling - await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + // Phase 3 — reset usage for the new period. Clears trackers and + // rolls `currentPeriodCost` forward by delta. Idempotent on its + // own (delta subtraction of a value that's already been + // subtracted is a no-op). + await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + + return { totalOverage, creditsApplied, amountToBillStripe } + } + ) } catch (error) { logger.error('Failed to handle invoice finalized', { error }) throw error diff --git a/apps/sim/lib/billing/webhooks/outbox-handlers.ts b/apps/sim/lib/billing/webhooks/outbox-handlers.ts new file mode 100644 index 00000000000..64eb64a1973 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/outbox-handlers.ts @@ -0,0 +1,174 @@ +import { db } from '@sim/db' +import { subscription as subscriptionTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { requireStripeClient } from '@/lib/billing/stripe-client' +import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' +import type { OutboxHandler } from '@/lib/core/outbox/service' + +const logger = createLogger('BillingOutboxHandlers') + +export const OUTBOX_EVENT_TYPES = { + /** + * Sync a subscription's `cancel_at_period_end` flag from our DB to + * Stripe. The handler reads the current DB value at processing time + * — so rapid cancel→uncancel→cancel sequences always converge on + * the last-committed DB state regardless of outbox ordering. Callers + * enqueue this event after every DB change to `cancelAtPeriodEnd`. + */ + STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end', + STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice', +} as const + +export interface StripeSyncCancelAtPeriodEndPayload { + stripeSubscriptionId: string + /** The DB subscription row id — also our source-of-truth pointer. */ + subscriptionId: string + /** Optional: reason this was enqueued — e.g. 'member-joined-paid-org'. */ + reason?: string +} + +export interface StripeThresholdOverageInvoicePayload { + customerId: string + stripeSubscriptionId: string + amountCents: number + description: string + itemDescription: string + billingPeriod: string + /** Stripe idempotency key stem — we append the outbox event id for per-retry safety. */ + invoiceIdemKeyStem: string + itemIdemKeyStem: string + metadata?: Record +} + +const stripeSyncCancelAtPeriodEnd: OutboxHandler = async ( + payload, + ctx +) => { + // Read the DB value at processing time (not at enqueue time). This + // makes the handler idempotent across racing enqueues: multiple + // events for the same subscription all push whatever the DB + // currently says, converging on the last committed value. + const rows = await db + .select({ cancelAtPeriodEnd: subscriptionTable.cancelAtPeriodEnd }) + .from(subscriptionTable) + .where(eq(subscriptionTable.id, payload.subscriptionId)) + .limit(1) + + if (rows.length === 0) { + logger.warn('Subscription not found when syncing cancel_at_period_end', { + subscriptionId: payload.subscriptionId, + }) + return + } + + const desiredValue = Boolean(rows[0].cancelAtPeriodEnd) + const stripe = requireStripeClient() + await stripe.subscriptions.update( + payload.stripeSubscriptionId, + { cancel_at_period_end: desiredValue }, + { idempotencyKey: `outbox:${ctx.eventId}` } + ) + logger.info('Synced cancel_at_period_end from DB to Stripe', { + eventId: ctx.eventId, + stripeSubscriptionId: payload.stripeSubscriptionId, + subscriptionId: payload.subscriptionId, + desiredValue, + reason: payload.reason, + }) +} + +const stripeThresholdOverageInvoice: OutboxHandler = async ( + payload, + ctx +) => { + const stripe = requireStripeClient() + + // Resolve default PM from (subscription → customer) so Stripe can + // auto-collect when the invoice finalizes. Without this, an ad-hoc + // invoice (no subscription link) falls back to customer-level PM + // only, which may not be set for customers onboarded via Checkout + // Subscription flows. + const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod( + stripe, + payload.stripeSubscriptionId, + payload.customerId + ) + + // Compose Stripe idempotency keys from caller-provided stem + outbox + // event id so retries of the SAME outbox event collapse on Stripe's + // side. + const invoiceIdemKey = `${payload.invoiceIdemKeyStem}:${ctx.eventId}` + const itemIdemKey = `${payload.itemIdemKeyStem}:${ctx.eventId}` + const finalizeIdemKey = `${payload.invoiceIdemKeyStem}:finalize:${ctx.eventId}` + const payIdemKey = `${payload.invoiceIdemKeyStem}:pay:${ctx.eventId}` + + // `auto_advance: false` + explicit finalize mirrors pre-refactor + // behavior: we control exactly when the invoice finalizes, so it + // doesn't silently convert to paid/open on Stripe's schedule while + // our retry state is still in flight. + const invoice = await stripe.invoices.create( + { + customer: payload.customerId, + collection_method: 'charge_automatically', + auto_advance: false, + description: payload.description, + metadata: payload.metadata, + ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), + }, + { idempotencyKey: invoiceIdemKey } + ) + + if (!invoice.id) { + throw new Error('Stripe returned invoice without id') + } + + await stripe.invoiceItems.create( + { + customer: payload.customerId, + invoice: invoice.id, + amount: payload.amountCents, + currency: 'usd', + description: payload.itemDescription, + metadata: payload.metadata, + }, + { idempotencyKey: itemIdemKey } + ) + + const finalized = await stripe.invoices.finalizeInvoice( + invoice.id, + {}, + { idempotencyKey: finalizeIdemKey } + ) + + if (finalized.status === 'open' && finalized.id && defaultPaymentMethod) { + try { + await stripe.invoices.pay( + finalized.id, + { payment_method: defaultPaymentMethod }, + { idempotencyKey: payIdemKey } + ) + } catch (payError) { + logger.warn('Auto-pay failed for threshold overage invoice — Stripe dunning will retry', { + invoiceId: finalized.id, + error: payError instanceof Error ? payError.message : payError, + }) + } + } + + logger.info('Created threshold overage invoice via outbox', { + eventId: ctx.eventId, + invoiceId: invoice.id, + customerId: payload.customerId, + amountCents: payload.amountCents, + billingPeriod: payload.billingPeriod, + defaultPaymentMethod: defaultPaymentMethod ? 'resolved' : 'none', + }) +} + +export const billingOutboxHandlers = { + [OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END]: + stripeSyncCancelAtPeriodEnd as OutboxHandler, + [OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE]: + stripeThresholdOverageInvoice as OutboxHandler, +} as const diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 8c261a6f458..323f2347847 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -2,13 +2,14 @@ import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, ne } from 'drizzle-orm' -import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' +import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { hasPaidSubscription } from '@/lib/billing/core/subscription' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' -import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' +import { isEnterprise, isPaid, isPro } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency' import { getBilledOverageForSubscription, resetUsageForSubscription, @@ -175,194 +176,206 @@ export async function handleSubscriptionCreated(subscriptionData: { } /** - * Handle subscription deletion/cancellation - bill for final period overages - * This fires when a subscription reaches its cancel_at_period_end date or is cancelled immediately + * Handle subscription deletion/cancellation — bill for final period + * overages, reset usage, restore member Pros, and clean up the org. + * + * Wrapped in `stripeWebhookIdempotency` keyed by Stripe `event.id` so + * that duplicate webhook deliveries collapse to a single execution. The + * three failure-prone side effects each have their own recovery story: + * - Final Stripe invoice: created with a deterministic idempotency key, + * so re-create returns the existing invoice on retry + * - `resetUsageForSubscription`: delta-based reset, near-idempotent + * - `cleanupOrganizationSubscription`: delete is idempotent (ON NOT + * EXISTS); Pro restore flips `cancelAtPeriodEnd=false`, idempotent + * If any step throws, `retryFailures: true` releases the claim so + * Stripe's next retry runs from scratch and recovers. */ -export async function handleSubscriptionDeleted(subscription: { - id: string - plan: string | null - referenceId: string - stripeSubscriptionId: string | null - seats?: number | null -}) { - try { - const stripeSubscriptionId = subscription.stripeSubscriptionId || '' - - logger.info('Processing subscription deletion', { - stripeSubscriptionId, - subscriptionId: subscription.id, - }) - - // Calculate overage for the final billing period - const totalOverage = await calculateSubscriptionOverage(subscription) - const stripe = requireStripeClient() - - // Enterprise plans have no overages - reset usage and cleanup org - if (isEnterprise(subscription.plan)) { - await resetUsageForSubscription({ - plan: subscription.plan, - referenceId: subscription.referenceId, - }) - - const { restoredProCount, membersSynced, organizationDeleted } = - await cleanupOrganizationSubscription(subscription.referenceId) - - logger.info('Successfully processed enterprise subscription cancellation', { - subscriptionId: subscription.id, - stripeSubscriptionId, - restoredProCount, - organizationDeleted, - membersSynced, - }) - - captureServerEvent(subscription.referenceId, 'subscription_cancelled', { - plan: subscription.plan ?? 'unknown', - reference_id: subscription.referenceId, - }) - - return - } +export async function handleSubscriptionDeleted( + subscription: { + id: string + plan: string | null + referenceId: string + stripeSubscriptionId: string | null + seats?: number | null + }, + stripeEventId?: string +) { + const stripeSubscriptionId = subscription.stripeSubscriptionId || '' + + logger.info('Processing subscription deletion', { + stripeEventId, + stripeSubscriptionId, + subscriptionId: subscription.id, + }) + + // Fall back to the subscription DB id when we don't have an event id + // (e.g. called outside the Stripe webhook context). Still dedupes a + // single subscription's deletion, just not event-granular. + const idempotencyIdentifier = stripeEventId ?? `sub:${subscription.id}` - // Get already-billed overage from threshold billing - const billedOverage = await getBilledOverageForSubscription(subscription) + try { + await stripeWebhookIdempotency.executeWithIdempotency( + 'subscription-deleted', + idempotencyIdentifier, + async () => { + const totalOverage = await calculateSubscriptionOverage(subscription) + const stripe = requireStripeClient() + + // Enterprise plans have no overages — reset usage and cleanup org + if (isEnterprise(subscription.plan)) { + await resetUsageForSubscription({ + plan: subscription.plan, + referenceId: subscription.referenceId, + }) + + const { restoredProCount, membersSynced, organizationDeleted } = + await cleanupOrganizationSubscription(subscription.referenceId) + + logger.info('Successfully processed enterprise subscription cancellation', { + subscriptionId: subscription.id, + stripeSubscriptionId, + restoredProCount, + organizationDeleted, + membersSynced, + }) + + captureServerEvent(subscription.referenceId, 'subscription_cancelled', { + plan: subscription.plan ?? 'unknown', + reference_id: subscription.referenceId, + }) + + return { totalOverage: 0, kind: 'enterprise' as const } + } - // Only bill the remaining unbilled overage - const remainingOverage = Math.max(0, totalOverage - billedOverage) + const billedOverage = await getBilledOverageForSubscription(subscription) + const remainingOverage = Math.max(0, totalOverage - billedOverage) - logger.info('Subscription deleted overage calculation', { - subscriptionId: subscription.id, - totalOverage, - billedOverage, - remainingOverage, - }) + logger.info('Subscription deleted overage calculation', { + subscriptionId: subscription.id, + totalOverage, + billedOverage, + remainingOverage, + }) - // Create final overage invoice if needed - if (remainingOverage > 0 && stripeSubscriptionId) { - const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) - const customerId = stripeSubscription.customer as string - const cents = Math.round(remainingOverage * 100) - - // Use the subscription end date for the billing period - const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000) - const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7) - - const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - - try { - // Create a one-time invoice for the final overage - const overageInvoice = await stripe.invoices.create( - { - customer: customerId, - collection_method: 'charge_automatically', - auto_advance: true, // Auto-finalize and attempt payment - description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`, - metadata: { - type: 'final_overage_billing', - billingPeriod, - subscriptionId: stripeSubscriptionId, - cancelledAt: stripeSubscription.canceled_at?.toString() || '', + // Phase — Stripe final overage invoice. Idempotency keys ensure + // retry-safe creation; errors propagate up to the wrapper so the + // webhook gets retried rather than swallowed. + if (remainingOverage > 0 && stripeSubscriptionId) { + const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) + const customerId = stripeSubscription.customer as string + const cents = Math.round(remainingOverage * 100) + const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000) + const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7) + + const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const finalizeIdemKey = `final-overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + + const overageInvoice = await stripe.invoices.create( + { + customer: customerId, + collection_method: 'charge_automatically', + auto_advance: true, + description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`, + metadata: { + type: 'final_overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + cancelledAt: stripeSubscription.canceled_at?.toString() || '', + }, }, - }, - { idempotencyKey: invoiceIdemKey } - ) - - // Add the overage line item - await stripe.invoiceItems.create( - { - customer: customerId, - invoice: overageInvoice.id, - amount: cents, - currency: 'usd', - description: `Usage overage for ${subscription.plan} plan (Final billing period)`, - metadata: { - type: 'final_usage_overage', - usage: remainingOverage.toFixed(2), - totalOverage: totalOverage.toFixed(2), - billedOverage: billedOverage.toFixed(2), - billingPeriod, + { idempotencyKey: invoiceIdemKey } + ) + + await stripe.invoiceItems.create( + { + customer: customerId, + invoice: overageInvoice.id, + amount: cents, + currency: 'usd', + description: `Usage overage for ${subscription.plan} plan (Final billing period)`, + metadata: { + type: 'final_usage_overage', + usage: remainingOverage.toFixed(2), + totalOverage: totalOverage.toFixed(2), + billedOverage: billedOverage.toFixed(2), + billingPeriod, + }, }, - }, - { idempotencyKey: itemIdemKey } - ) - - // Finalize the invoice (this will trigger payment collection) - if (overageInvoice.id) { - await stripe.invoices.finalizeInvoice(overageInvoice.id) + { idempotencyKey: itemIdemKey } + ) + + if (overageInvoice.id) { + await stripe.invoices.finalizeInvoice( + overageInvoice.id, + {}, + { idempotencyKey: finalizeIdemKey } + ) + } + + logger.info('Created final overage invoice for cancelled subscription', { + subscriptionId: subscription.id, + stripeSubscriptionId, + invoiceId: overageInvoice.id, + totalOverage, + billedOverage, + remainingOverage, + cents, + billingPeriod, + }) + } else { + logger.info('No overage to bill for cancelled subscription', { + subscriptionId: subscription.id, + plan: subscription.plan, + }) } - logger.info('Created final overage invoice for cancelled subscription', { - subscriptionId: subscription.id, - stripeSubscriptionId, - invoiceId: overageInvoice.id, - totalOverage, - billedOverage, - remainingOverage, - cents, - billingPeriod, + // Phase — reset usage, then plan-specific cleanup. Both are + // idempotent on re-run (delete is already-no-op if org is gone; + // reset-by-delta is a no-op when trackers are already zeroed). + await resetUsageForSubscription({ + plan: subscription.plan, + referenceId: subscription.referenceId, }) - } catch (invoiceError) { - logger.error('Failed to create final overage invoice', { + + let restoredProCount = 0 + let organizationDeleted = false + let membersSynced = 0 + + if (await isSubscriptionOrgScoped(subscription)) { + const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) + restoredProCount = cleanup.restoredProCount + membersSynced = cleanup.membersSynced + organizationDeleted = cleanup.organizationDeleted + } else if (isPro(subscription.plan)) { + await syncUsageLimitsFromSubscription(subscription.referenceId) + membersSynced = 1 + } + + logger.info('Successfully processed subscription cancellation', { subscriptionId: subscription.id, stripeSubscriptionId, + plan: subscription.plan, totalOverage, - billedOverage, - remainingOverage, - error: invoiceError, + restoredProCount, + organizationDeleted, + membersSynced, }) - // Don't throw - we don't want to fail the webhook - } - } else { - logger.info('No overage to bill for cancelled subscription', { - subscriptionId: subscription.id, - plan: subscription.plan, - }) - } - - // Reset usage after billing - await resetUsageForSubscription({ - plan: subscription.plan, - referenceId: subscription.referenceId, - }) - // Plan-specific cleanup after billing - let restoredProCount = 0 - let organizationDeleted = false - let membersSynced = 0 - - if (isTeam(subscription.plan)) { - const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) - restoredProCount = cleanup.restoredProCount - membersSynced = cleanup.membersSynced - organizationDeleted = cleanup.organizationDeleted - } else if (isPro(subscription.plan)) { - await syncUsageLimitsFromSubscription(subscription.referenceId) - membersSynced = 1 - } - - // Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler - // We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup - - logger.info('Successfully processed subscription cancellation', { - subscriptionId: subscription.id, - stripeSubscriptionId, - plan: subscription.plan, - totalOverage, - restoredProCount, - organizationDeleted, - membersSynced, - }) + captureServerEvent(subscription.referenceId, 'subscription_cancelled', { + plan: subscription.plan ?? 'unknown', + reference_id: subscription.referenceId, + }) - captureServerEvent(subscription.referenceId, 'subscription_cancelled', { - plan: subscription.plan ?? 'unknown', - reference_id: subscription.referenceId, - }) + return { totalOverage, remainingOverage, restoredProCount, organizationDeleted } + } + ) } catch (error) { logger.error('Failed to handle subscription deletion', { subscriptionId: subscription.id, - stripeSubscriptionId: subscription.stripeSubscriptionId || '', + stripeSubscriptionId, error, }) - throw error // Re-throw to signal webhook failure for retry + throw error } } diff --git a/apps/sim/lib/copilot/request/tools/billing.ts b/apps/sim/lib/copilot/request/tools/billing.ts index 2f4b0e2d7ac..43d51d621f1 100644 --- a/apps/sim/lib/copilot/request/tools/billing.ts +++ b/apps/sim/lib/copilot/request/tools/billing.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' -import { isPaid } from '@/lib/billing/plan-helpers' +import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -29,14 +30,25 @@ export async function handleBillingLimitResponse( execContext: ExecutionContext, options: OrchestratorOptions ): Promise { - let action = 'upgrade_plan' + let action: 'upgrade_plan' | 'increase_limit' = 'upgrade_plan' let message = "You've reached your usage limit. Please upgrade your plan to continue." try { const sub = await getHighestPrioritySubscription(userId) if (sub && isPaid(sub.plan)) { + // Paid subs use the existing `increase_limit` action so the UI + // (`UsageUpgradeDisplay`) renders its standard button. The message + // text does the work of clarifying the action when the user can't + // actually self-serve the limit change. action = 'increase_limit' - message = - "You've reached your usage limit for this billing period. Please increase your usage limit to continue." + const orgScoped = isOrgScopedSubscription(sub, userId) + if (orgScoped) { + message = isEnterprise(sub.plan) + ? "You've reached your organization's usage limit for this billing period. Only an organization admin or Sim support can raise an enterprise limit — reach out to them to continue." + : "You've reached your organization's usage limit for this billing period. Only an organization owner or admin can raise the limit — please ask them to update it from the team billing settings." + } else { + message = + "You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue." + } } } catch { logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan') diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index 96ed86a3bb3..14e4cb46cee 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { idempotencyKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { eq, lt } from 'drizzle-orm' import { getRedisClient } from '@/lib/core/config/redis' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getStorageMethod, type StorageMethod } from '@/lib/core/storage' @@ -16,6 +16,20 @@ export interface IdempotencyConfig { namespace?: string /** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */ retryFailures?: boolean + /** + * Force a specific storage backend regardless of the environment's + * auto-detection. Use `'database'` for correctness-critical flows + * (money, billing, compliance) where the claim + operation should + * fate-share with the Postgres transaction — this closes the narrow + * window where the operation commits to DB but `storeResult` to Redis + * fails and the retry re-runs the operation. Latency cost is 1–5ms + * per call, imperceptible on webhook code paths. + * + * Leave unset (or set `'redis'`) for latency-sensitive, high-volume + * flows like app webhook triggers where the scale benefits of Redis + * outweigh the narrow durability window. + */ + forceStorage?: StorageMethod } export interface IdempotencyResult { @@ -50,11 +64,12 @@ const POLL_INTERVAL_MS = 1000 * that need duplicate prevention. * * Storage is determined once based on configuration: - * - If REDIS_URL is set → Redis - * - If REDIS_URL is not set → PostgreSQL + * - If `forceStorage` is set → that backend unconditionally + * - Else if `REDIS_URL` is set → Redis + * - Else → PostgreSQL */ export class IdempotencyService { - private config: Required + private config: Required> private storageMethod: StorageMethod constructor(config: IdempotencyConfig = {}) { @@ -63,9 +78,10 @@ export class IdempotencyService { namespace: config.namespace ?? 'default', retryFailures: config.retryFailures ?? false, } - this.storageMethod = getStorageMethod() + this.storageMethod = config.forceStorage ?? getStorageMethod() logger.info(`IdempotencyService using ${this.storageMethod} storage`, { namespace: this.config.namespace, + forced: Boolean(config.forceStorage), }) } @@ -220,15 +236,31 @@ export class IdempotencyService { normalizedKey: string, inProgressResult: ProcessingResult ): Promise { + const now = new Date() + const expiredBefore = new Date(now.getTime() - this.config.ttlSeconds * 1000) + + // `ON CONFLICT DO UPDATE WHERE created_at < expiredBefore` steals the + // claim when the existing row has outlived the TTL (e.g. a prior + // holder crashed mid-operation and never wrote `completed`/`failed` + // or released the key). RETURNING yields a row in two cases: + // (1) fresh INSERT — no prior row existed; + // (2) UPDATE of an expired row — WHERE matched. + // An empty RETURNING means conflict with an unexpired row; the + // existing holder is still live and we must not steal. const insertResult = await db .insert(idempotencyKey) .values({ key: normalizedKey, result: inProgressResult, - createdAt: new Date(), + createdAt: now, }) - .onConflictDoNothing({ + .onConflictDoUpdate({ target: [idempotencyKey.key], + set: { + result: inProgressResult, + createdAt: now, + }, + setWhere: lt(idempotencyKey.createdAt, expiredBefore), }) .returning({ key: idempotencyKey.key }) @@ -489,7 +521,18 @@ export const pollingIdempotency = new IdempotencyService({ retryFailures: true, }) +/** + * Used by the internal `/api/billing/update-cost` endpoint (copilot, + * workspace-chat, MCP, mothership) to dedupe cost-recording calls. Storage + * is forced to Postgres: the operation writes AI cost to `user_stats`, + * and if Redis evicts the dedup key under memory pressure (high call + * volume) or drops it on restart, a retry would double-record usage — + * real money. DB storage fate-shares with `user_stats` and is + * eviction-proof; ~1-5ms added latency is invisible against LLM call + * latency. + */ export const billingIdempotency = new IdempotencyService({ namespace: 'billing', ttlSeconds: 60 * 60, // 1 hour + forceStorage: 'database', }) diff --git a/apps/sim/lib/core/outbox/service.test.ts b/apps/sim/lib/core/outbox/service.test.ts new file mode 100644 index 00000000000..5b282e7d12d --- /dev/null +++ b/apps/sim/lib/core/outbox/service.test.ts @@ -0,0 +1,385 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type OutboxRow = { + id: string + eventType: string + payload: unknown + status: 'pending' | 'processing' | 'completed' | 'dead_letter' + attempts: number + maxAttempts: number + availableAt: Date + lockedAt: Date | null + lastError: string | null + createdAt: Date + processedAt: Date | null +} + +// Hoisted mock state — all tests manipulate these directly. +const { state, mockDb } = vi.hoisted(() => { + const state = { + // Rows returned from the FOR UPDATE SKIP LOCKED select in claimBatch. + claimedRows: [] as OutboxRow[], + // Whether the terminal update (lease CAS) should report a match. + leaseHeld: true, + // IDs the reaper's UPDATE should return (simulates stuck `processing` rows). + reapedRowIds: [] as string[], + // Everything written (for assertions). + inserts: [] as Array<{ values: unknown }>, + updates: [] as Array<{ set: Record; where?: unknown }>, + } + + const makeUpdateChain = () => { + const row: { set: Record; where?: unknown } = { set: {} } + const chain: Record = {} + chain.set = vi.fn((s: Record) => { + row.set = s + return chain + }) + chain.where = vi.fn((w: unknown) => { + row.where = w + state.updates.push(row) + return chain + }) + chain.returning = vi.fn(async () => { + // Terminal UPDATE (lease CAS): has `attempts` + `availableAt` + // on retry, or explicit completed/dead_letter. Reaper path sets + // status='pending' without attempts/availableAt. + const isReaperUpdate = + row.set.status === 'pending' && !('attempts' in row.set) && !('availableAt' in row.set) + + if (isReaperUpdate) { + return state.reapedRowIds.map((id) => ({ id })) + } + + if ( + row.set.status === 'completed' || + row.set.status === 'dead_letter' || + (row.set.status === 'pending' && 'attempts' in row.set && 'availableAt' in row.set) + ) { + return state.leaseHeld ? [{ id: 'evt-1' }] : [] + } + + return [] + }) + return chain + } + + const makeSelectChain = () => { + const chain: Record = {} + const self = () => chain + chain.from = vi.fn(self) + chain.where = vi.fn(self) + chain.orderBy = vi.fn(self) + chain.limit = vi.fn(self) + chain.for = vi.fn(async () => state.claimedRows) + return chain + } + + const mockDb = { + insert: vi.fn(() => { + const chain: Record = {} + chain.values = vi.fn(async (v: unknown) => { + state.inserts.push({ values: v }) + }) + return chain + }), + update: vi.fn(() => makeUpdateChain()), + select: vi.fn(() => makeSelectChain()), + transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(mockDb)), + } + + return { state, mockDb } +}) + +vi.mock('@sim/db', () => ({ db: mockDb })) + +vi.mock('@sim/db/schema', () => ({ + outboxEvent: { + id: 'outbox_event.id', + eventType: 'outbox_event.event_type', + payload: 'outbox_event.payload', + status: 'outbox_event.status', + attempts: 'outbox_event.attempts', + maxAttempts: 'outbox_event.max_attempts', + availableAt: 'outbox_event.available_at', + lockedAt: 'outbox_event.locked_at', + lastError: 'outbox_event.last_error', + createdAt: 'outbox_event.created_at', + processedAt: 'outbox_event.processed_at', + $inferSelect: {} as OutboxRow, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args) => ({ _op: 'and', args })), + asc: vi.fn((col) => ({ _op: 'asc', col })), + eq: vi.fn((col, val) => ({ _op: 'eq', col, val })), + inArray: vi.fn((col, vals) => ({ _op: 'inArray', col, vals })), + lte: vi.fn((col, val) => ({ _op: 'lte', col, val })), +})) + +vi.mock('@/lib/core/utils/uuid', () => ({ + generateId: vi.fn(() => 'test-event-id'), +})) + +import { enqueueOutboxEvent, processOutboxEvents } from './service' + +function makePendingRow(overrides: Partial = {}): OutboxRow { + return { + id: 'evt-1', + eventType: 'test.event', + payload: { foo: 'bar' }, + status: 'pending', + attempts: 0, + maxAttempts: 10, + availableAt: new Date(Date.now() - 1000), + lockedAt: null, + lastError: null, + createdAt: new Date(Date.now() - 5000), + processedAt: null, + ...overrides, + } +} + +function resetState() { + state.claimedRows = [] + state.leaseHeld = true + state.reapedRowIds = [] + state.inserts.length = 0 + state.updates.length = 0 +} + +describe('enqueueOutboxEvent', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('inserts a row with the given event type and payload', async () => { + const id = await enqueueOutboxEvent(mockDb, 'test.event', { foo: 'bar' }) + expect(id).toBe('test-event-id') + expect(state.inserts[0].values).toMatchObject({ + id: 'test-event-id', + eventType: 'test.event', + payload: { foo: 'bar' }, + maxAttempts: 10, + }) + }) + + it('respects maxAttempts override', async () => { + await enqueueOutboxEvent(mockDb, 'test.event', {}, { maxAttempts: 3 }) + expect(state.inserts[0].values).toMatchObject({ maxAttempts: 3 }) + }) + + it('respects availableAt override for delayed processing', async () => { + const future = new Date(Date.now() + 60_000) + await enqueueOutboxEvent(mockDb, 'test.event', {}, { availableAt: future }) + expect((state.inserts[0].values as { availableAt: Date }).availableAt).toBe(future) + }) +}) + +describe('processOutboxEvents — empty / no handler', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('returns zero counts when no events are due', async () => { + const result = await processOutboxEvents({}) + expect(result).toEqual({ + processed: 0, + retried: 0, + deadLettered: 0, + leaseLost: 0, + reaped: 0, + }) + }) + + it('dead-letters events with no registered handler', async () => { + state.claimedRows = [makePendingRow({ eventType: 'unknown.event' })] + + const result = await processOutboxEvents({}) + + expect(result.deadLettered).toBe(1) + const terminal = state.updates.find((u) => u.set.status === 'dead_letter') + expect(terminal).toBeDefined() + expect(terminal?.set.lastError).toMatch(/No handler registered/) + }) +}) + +describe('processOutboxEvents — handler success and retry', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('transitions to completed on handler success and passes context to handler', async () => { + const handlerCalls: Array<{ payload: unknown; eventId: string; attempts: number }> = [] + const handler = vi.fn(async (payload: unknown, ctx: { eventId: string; attempts: number }) => { + handlerCalls.push({ payload, eventId: ctx.eventId, attempts: ctx.attempts }) + }) + + state.claimedRows = [makePendingRow()] + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.processed).toBe(1) + expect(handlerCalls).toEqual([{ payload: { foo: 'bar' }, eventId: 'evt-1', attempts: 0 }]) + const completeUpdate = state.updates.find((u) => u.set.status === 'completed') + expect(completeUpdate).toBeDefined() + }) + + it('schedules retry with exponential backoff on handler failure below maxAttempts', async () => { + const handler = vi.fn(async () => { + throw new Error('transient failure') + }) + + state.claimedRows = [makePendingRow({ attempts: 2 })] + + const before = Date.now() + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.retried).toBe(1) + const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + expect(retryUpdate).toBeDefined() + expect(retryUpdate?.set.attempts).toBe(3) + expect(retryUpdate?.set.lastError).toBe('transient failure') + // Backoff after nextAttempts=3: 1000 * 2^3 = 8000ms + const scheduledAt = retryUpdate?.set.availableAt as Date + expect(scheduledAt.getTime()).toBeGreaterThan(before + 7500) + expect(scheduledAt.getTime()).toBeLessThan(before + 10_000) + }) + + it('dead-letters on failure when attempts reaches maxAttempts', async () => { + const handler = vi.fn(async () => { + throw new Error('permanent failure') + }) + + state.claimedRows = [makePendingRow({ attempts: 9, maxAttempts: 10 })] + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.deadLettered).toBe(1) + const deadUpdate = state.updates.find((u) => u.set.status === 'dead_letter') + expect(deadUpdate).toBeDefined() + expect(deadUpdate?.set.attempts).toBe(10) + expect(deadUpdate?.set.lastError).toBe('permanent failure') + }) + + it('caps exponential backoff at 1 hour', async () => { + const handler = vi.fn(async () => { + throw new Error('transient') + }) + + state.claimedRows = [makePendingRow({ attempts: 20, maxAttempts: 100 })] + + const before = Date.now() + await processOutboxEvents({ 'test.event': handler }) + + const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + expect(retryUpdate).toBeDefined() + const scheduledAt = retryUpdate?.set.availableAt as Date + // 1hr = 3,600,000ms + expect(scheduledAt.getTime()).toBeLessThan(before + 3_600_000 + 1000) + expect(scheduledAt.getTime()).toBeGreaterThan(before + 3_599_000) + }) +}) + +describe('processOutboxEvents — lease CAS / reaper race', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('reports leaseLost when completion UPDATE affects zero rows', async () => { + const handler = vi.fn(async () => { + // "succeeds" but terminal write will fail the lease CAS + }) + + state.claimedRows = [makePendingRow()] + state.leaseHeld = false + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.leaseLost).toBe(1) + expect(result.processed).toBe(0) + }) + + it('reports leaseLost on retry-schedule UPDATE when row was reclaimed', async () => { + const handler = vi.fn(async () => { + throw new Error('transient') + }) + + state.claimedRows = [makePendingRow({ attempts: 2 })] + state.leaseHeld = false + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.leaseLost).toBe(1) + expect(result.retried).toBe(0) + }) +}) + +describe('processOutboxEvents — handler timeout', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('times out a stuck handler and schedules retry', async () => { + const neverResolves = vi.fn(() => new Promise(() => {})) + + state.claimedRows = [makePendingRow({ attempts: 0 })] + + const promise = processOutboxEvents({ 'test.event': neverResolves }) + // Must exceed DEFAULT_HANDLER_TIMEOUT_MS (90s). + await vi.advanceTimersByTimeAsync(90 * 1000 + 1) + const result = await promise + + expect(result.retried).toBe(1) + const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + expect(retryUpdate?.set.lastError).toMatch(/timed out/) + }) +}) + +describe('processOutboxEvents — reaper recovery', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('reaps stuck processing rows back to pending and reports count', async () => { + state.reapedRowIds = ['stuck-1', 'stuck-2', 'stuck-3'] + + const result = await processOutboxEvents({}) + + expect(result.reaped).toBe(3) + expect(result.processed).toBe(0) + + // The reaper's UPDATE sets status='pending' with NO attempts / availableAt + // fields — that's how runHandler's retry update is distinguished from it. + const reaperUpdate = state.updates.find( + (u) => u.set.status === 'pending' && !('attempts' in u.set) && !('availableAt' in u.set) + ) + expect(reaperUpdate).toBeDefined() + expect(reaperUpdate?.set.lockedAt).toBeNull() + }) + + it('returns zero reaped when no rows are stuck', async () => { + const result = await processOutboxEvents({}) + expect(result.reaped).toBe(0) + }) +}) diff --git a/apps/sim/lib/core/outbox/service.ts b/apps/sim/lib/core/outbox/service.ts new file mode 100644 index 00000000000..e8b29570a30 --- /dev/null +++ b/apps/sim/lib/core/outbox/service.ts @@ -0,0 +1,366 @@ +import { db } from '@sim/db' +import { outboxEvent } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, eq, inArray, lte } from 'drizzle-orm' +import { generateId } from '@/lib/core/utils/uuid' + +const logger = createLogger('OutboxService') + +const DEFAULT_MAX_ATTEMPTS = 10 +const STUCK_PROCESSING_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes +const MAX_BACKOFF_MS = 60 * 60 * 1000 // 1 hour +const BASE_BACKOFF_MS = 1000 // 1 second, doubled per attempt +// Kept below the serverless route `maxDuration` (120s) so our in-process +// timeout fires before the platform kills the invocation and leaves the +// row stranded in `processing` for the 10-minute reaper window. Also well +// under `STUCK_PROCESSING_THRESHOLD_MS` so the reaper cannot steal a row +// a worker is still actively processing. +const DEFAULT_HANDLER_TIMEOUT_MS = 90 * 1000 // 90 seconds + +/** + * Context passed to every outbox handler. Use `eventId` as the Stripe + * (or any external service) idempotency key so that handler retries + * collapse on the external side: a second execution of the same event + * lands on the same Stripe invoice id / charge id rather than creating + * a duplicate. The outbox lease CAS handles our DB side. + */ +export interface OutboxEventContext { + eventId: string + eventType: string + /** How many times this event has been attempted (zero on first run). */ + attempts: number +} + +/** + * A handler invoked by the outbox worker for events of a given type. + * Throwing bumps `attempts` and schedules a retry via exponential + * backoff; a successful return transitions the event to `completed`. + */ +export type OutboxHandler = (payload: T, context: OutboxEventContext) => Promise + +/** + * Map of `eventType` → handler. Register all handlers in one place + * and pass them to `processOutboxEvents`. + */ +export type OutboxHandlerRegistry = Record + +export interface EnqueueOptions { + /** Total attempts before the event moves to `dead_letter`. Default 10. */ + maxAttempts?: number + /** Earliest time a worker may pick up this event. Default now. */ + availableAt?: Date +} + +export interface ProcessOutboxResult { + processed: number + retried: number + deadLettered: number + leaseLost: number + reaped: number +} + +/** + * Transactional outbox for reliable "DB write + external system" flows. + * + * Callers enqueue an event *inside* a `db.transaction` alongside the + * primary write; the event row commits or rolls back with the business + * data. A polling worker (invoked via the cron endpoint) claims pending + * rows with `SELECT ... FOR UPDATE SKIP LOCKED`, marks them as + * `processing`, runs the registered handler outside the transaction, + * and transitions the event to `completed` / `pending` (retry) / + * `dead_letter` (max attempts exceeded). + * + * Two-phase claim-then-process keeps external API calls out of DB + * transactions. A reaper at the top of each run reclaims `processing` + * rows whose worker died mid-operation (stale `lockedAt`). + * + * Enqueue must be called with a `tx` from `db.transaction` so atomicity + * with the primary write is preserved. `db` itself is also accepted but + * then the caller must guarantee the enqueue and the primary write share + * a transaction some other way (or none at all). + */ +export async function enqueueOutboxEvent( + executor: Pick, + eventType: string, + payload: T, + options: EnqueueOptions = {} +): Promise { + const id = generateId() + await executor.insert(outboxEvent).values({ + id, + eventType, + payload: payload as never, + maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + availableAt: options.availableAt ?? new Date(), + }) + logger.info('Enqueued outbox event', { id, eventType }) + return id +} + +/** + * Process one batch of outbox events. Safe to call concurrently from + * multiple workers — `SELECT FOR UPDATE SKIP LOCKED` serializes claims. + */ +export async function processOutboxEvents( + handlers: OutboxHandlerRegistry, + options: { batchSize?: number } = {} +): Promise { + const batchSize = options.batchSize ?? 10 + + const reaped = await reapStuckProcessingRows() + + const claimed = await claimBatch(batchSize) + if (claimed.length === 0) { + return { processed: 0, retried: 0, deadLettered: 0, leaseLost: 0, reaped } + } + + let processed = 0 + let retried = 0 + let deadLettered = 0 + let leaseLost = 0 + + for (const event of claimed) { + const result = await runHandler(event, handlers) + if (result === 'completed') processed++ + else if (result === 'dead_letter') deadLettered++ + else if (result === 'lease_lost') leaseLost++ + else retried++ + } + + return { processed, retried, deadLettered, leaseLost, reaped } +} + +/** + * Reaper: move `processing` rows whose worker died (stale `lockedAt`) + * back to `pending` so another worker can pick them up. Without this, + * a SIGKILL between claim and result-write would permanently strand + * the row in `processing`. + */ +async function reapStuckProcessingRows(): Promise { + const stuckBefore = new Date(Date.now() - STUCK_PROCESSING_THRESHOLD_MS) + const result = await db + .update(outboxEvent) + .set({ status: 'pending', lockedAt: null }) + .where(and(eq(outboxEvent.status, 'processing'), lte(outboxEvent.lockedAt, stuckBefore))) + .returning({ id: outboxEvent.id }) + + if (result.length > 0) { + logger.warn('Reaped stuck outbox processing rows', { + count: result.length, + thresholdMs: STUCK_PROCESSING_THRESHOLD_MS, + }) + } + return result.length +} + +/** + * Phase 1: claim a batch of due pending events. + * + * `SELECT ... FOR UPDATE SKIP LOCKED` atomically picks rows that no + * other worker is currently looking at. We then flip those rows to + * `processing` inside the same tx so the claim survives the lock + * release — the status change becomes the out-of-band mutual exclusion. + */ +async function claimBatch(batchSize: number): Promise<(typeof outboxEvent.$inferSelect)[]> { + const now = new Date() + return db.transaction(async (tx) => { + const rows = await tx + .select() + .from(outboxEvent) + .where(and(eq(outboxEvent.status, 'pending'), lte(outboxEvent.availableAt, now))) + .orderBy(asc(outboxEvent.createdAt)) + .limit(batchSize) + .for('update', { skipLocked: true }) + + if (rows.length === 0) return [] + + await tx + .update(outboxEvent) + .set({ status: 'processing', lockedAt: now }) + .where( + inArray( + outboxEvent.id, + rows.map((r) => r.id) + ) + ) + + // Return rows with the claim state we just committed. `lockedAt` + // on this object is the authoritative lease timestamp used by the + // terminal-update lease CAS (see `runHandler`). + return rows.map((row) => ({ + ...row, + status: 'processing' as const, + lockedAt: now, + })) + }) +} + +/** + * Phase 2: invoke the handler for a claimed event, outside any DB + * transaction, then transition the row to its terminal or retry state. + * + * Every terminal UPDATE is guarded by a lease CAS (`WHERE status = + * 'processing' AND locked_at = event.lockedAt`). This defends against + * the "slow handler + reaper" race: if our handler takes longer than + * `STUCK_PROCESSING_THRESHOLD_MS`, the reaper will have reset the row + * to `pending` and another worker may have reclaimed it with a fresh + * `locked_at`. Our stale terminal write's WHERE clause won't match — + * rowCount is 0 — and we log+skip instead of clobbering the new lease. + */ +async function runHandler( + event: typeof outboxEvent.$inferSelect, + handlers: OutboxHandlerRegistry +): Promise<'completed' | 'pending' | 'dead_letter' | 'lease_lost'> { + const handler = handlers[event.eventType] + + if (!handler) { + logger.error('No handler registered for outbox event type', { + eventId: event.id, + eventType: event.eventType, + }) + await updateIfLeaseHeld(event, { + status: 'dead_letter', + lastError: `No handler registered for event type '${event.eventType}'`, + processedAt: new Date(), + lockedAt: null, + }) + return 'dead_letter' + } + + try { + await runHandlerWithTimeout(handler, event) + const updated = await updateIfLeaseHeld(event, { + status: 'completed', + processedAt: new Date(), + lockedAt: null, + }) + if (!updated) { + logger.warn('Outbox event completion skipped — lease lost (reaped + reclaimed)', { + eventId: event.id, + eventType: event.eventType, + }) + return 'lease_lost' + } + logger.info('Outbox event processed', { + eventId: event.id, + eventType: event.eventType, + attempts: event.attempts + 1, + }) + return 'completed' + } catch (error) { + const nextAttempts = event.attempts + 1 + const isDead = nextAttempts >= event.maxAttempts + const errMsg = error instanceof Error ? error.message : String(error) + + if (isDead) { + const updated = await updateIfLeaseHeld(event, { + attempts: nextAttempts, + status: 'dead_letter', + lastError: errMsg, + processedAt: new Date(), + lockedAt: null, + }) + if (!updated) { + logger.warn('Outbox event dead-letter skipped — lease lost', { + eventId: event.id, + eventType: event.eventType, + }) + return 'lease_lost' + } + logger.error('Outbox event dead-lettered after max attempts', { + eventId: event.id, + eventType: event.eventType, + attempts: nextAttempts, + error: errMsg, + }) + return 'dead_letter' + } + + // Exponential backoff, capped at MAX_BACKOFF_MS. + const backoffMs = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** nextAttempts) + const nextAvailableAt = new Date(Date.now() + backoffMs) + const updated = await updateIfLeaseHeld(event, { + attempts: nextAttempts, + status: 'pending', + lastError: errMsg, + availableAt: nextAvailableAt, + lockedAt: null, + }) + if (!updated) { + logger.warn('Outbox event retry-schedule skipped — lease lost', { + eventId: event.id, + eventType: event.eventType, + }) + return 'lease_lost' + } + logger.warn('Outbox event failed, scheduled retry', { + eventId: event.id, + eventType: event.eventType, + attempts: nextAttempts, + backoffMs, + nextAvailableAt: nextAvailableAt.toISOString(), + error: errMsg, + }) + return 'pending' + } +} + +function runHandlerWithTimeout( + handler: OutboxHandler, + event: typeof outboxEvent.$inferSelect, + timeoutMs: number = DEFAULT_HANDLER_TIMEOUT_MS +): Promise { + const context: OutboxEventContext = { + eventId: event.id, + eventType: event.eventType, + attempts: event.attempts, + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Outbox handler timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + handler(event.payload, context) + .then((value) => { + clearTimeout(timeout) + resolve(value) + }) + .catch((err) => { + clearTimeout(timeout) + reject(err) + }) + }) +} + +/** + * Conditional terminal update scoped to the lease acquired at claim + * time. Returns true if the UPDATE affected a row, false if the row's + * lease was revoked (reaped, reclaimed by another worker). Callers + * treat `false` as a "lease lost" signal and skip without retrying — + * the newer owner is responsible for the row now. + */ +async function updateIfLeaseHeld( + event: typeof outboxEvent.$inferSelect, + patch: { + status: 'completed' | 'pending' | 'dead_letter' + attempts?: number + lastError?: string | null + availableAt?: Date + lockedAt: Date | null + processedAt?: Date | null + } +): Promise { + const whereClauses = [eq(outboxEvent.id, event.id), eq(outboxEvent.status, 'processing')] + if (event.lockedAt) { + whereClauses.push(eq(outboxEvent.lockedAt, event.lockedAt)) + } + + const result = await db + .update(outboxEvent) + .set(patch) + .where(and(...whereClauses)) + .returning({ id: outboxEvent.id }) + + return result.length > 0 +} diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.ts index 50186ce9755..85366e80400 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { toError } from '@/lib/core/utils/helpers' import { createStorageAdapter, type RateLimitStorageAdapter } from './storage' import { @@ -42,7 +42,7 @@ export class RateLimiter { private getRateLimitKey(userId: string, subscription: SubscriptionInfo | null): string { if (!subscription) return userId - if (isOrgPlan(subscription.plan) && subscription.referenceId !== userId) { + if (isOrgScopedSubscription(subscription, userId)) { return subscription.referenceId } diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index afa70de6ea5..ef96063387c 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -16,7 +16,6 @@ import { maybeSendUsageThresholdEmail, } from '@/lib/billing/core/usage' import { type ModelUsageMetadata, recordUsage } from '@/lib/billing/core/usage-log' -import { isOrgPlan } from '@/lib/billing/plan-helpers' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { redactApiKeys } from '@/lib/core/security/redaction' @@ -415,9 +414,11 @@ export class ExecutionLogger implements IExecutionLoggerService { const costDelta = costSummary.totalCost const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') + const { isOrgScopedSubscription } = await import('@/lib/billing/subscriptions/utils') const planName = getDisplayPlanName(sub?.plan) - const scope: 'user' | 'organization' = - sub && isOrgPlan(sub.plan) ? 'organization' : 'user' + const scope: 'user' | 'organization' = isOrgScopedSubscription(sub, usr.id) + ? 'organization' + : 'user' if (scope === 'user') { const before = await checkUsageStatus(usr.id) diff --git a/packages/db/migrations/0191_unusual_mongu.sql b/packages/db/migrations/0191_unusual_mongu.sql new file mode 100644 index 00000000000..22d1f29d83d --- /dev/null +++ b/packages/db/migrations/0191_unusual_mongu.sql @@ -0,0 +1,17 @@ +CREATE TABLE "outbox_event" ( + "id" text PRIMARY KEY NOT NULL, + "event_type" text NOT NULL, + "payload" json NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "max_attempts" integer DEFAULT 10 NOT NULL, + "available_at" timestamp DEFAULT now() NOT NULL, + "locked_at" timestamp, + "last_error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "processed_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "user_stats" ADD COLUMN "pro_period_cost_snapshot_at" timestamp;--> statement-breakpoint +CREATE INDEX "outbox_event_status_available_idx" ON "outbox_event" USING btree ("status","available_at");--> statement-breakpoint +CREATE INDEX "outbox_event_locked_at_idx" ON "outbox_event" USING btree ("locked_at"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0191_snapshot.json b/packages/db/migrations/meta/0191_snapshot.json new file mode 100644 index 00000000000..387f6e38fe8 --- /dev/null +++ b/packages/db/migrations/meta/0191_snapshot.json @@ -0,0 +1,14785 @@ +{ + "id": "28bc7319-8037-40ec-bc22-42d167a46852", + "prevId": "bb103638-c742-4300-a0fe-757734e707f1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_idx": { + "name": "chat_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_idx": { + "name": "doc_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_idx": { + "name": "doc_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_idx": { + "name": "form_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_idx": { + "name": "kc_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_idx": { + "name": "kc_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_access_token_idx": { + "name": "oauth_access_token_access_token_idx", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_token_idx": { + "name": "oauth_access_token_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": ["access_token"] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": ["refresh_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_application_client_id_idx": { + "name": "oauth_application_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_user_client_idx": { + "name": "oauth_consent_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_name_unique": { + "name": "permission_group_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_auto_add_unique": { + "name": "permission_group_org_auto_add_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_user_id_unique": { + "name": "permission_group_member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_idx": { + "name": "webhook_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_idx": { + "name": "workflow_mcp_tool_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_idx": { + "name": "workflow_schedule_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_name_active_unique": { + "name": "workspace_files_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input" + ] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 1c0b60d974c..4086f44b90e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1331,6 +1331,13 @@ "when": 1776114737326, "tag": "0190_shocking_karma", "breakpoints": true + }, + { + "idx": 191, + "version": "7", + "when": 1776502306122, + "tag": "0191_unusual_mongu", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d0efe10e48c..3c9aba327ad 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -768,6 +768,7 @@ export const userStats = pgTable('user_stats', { billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing // Pro usage snapshot when joining a team (to prevent double-billing) proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team + proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'), // When the snapshot was captured (= join moment). Used to cap daily-refresh computation so post-join refresh isn't deducted from pre-join personal Pro usage (and vice-versa for the org's pooled refresh). // Pre-purchased credits (for Pro users only) creditBalance: decimal('credit_balance').notNull().default('0'), // Copilot usage tracking @@ -1971,6 +1972,30 @@ export const idempotencyKey = pgTable( }) ) +export const outboxEvent = pgTable( + 'outbox_event', + { + id: text('id').primaryKey(), + eventType: text('event_type').notNull(), + payload: json('payload').notNull(), + status: text('status').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + maxAttempts: integer('max_attempts').notNull().default(10), + availableAt: timestamp('available_at').notNull().defaultNow(), + lockedAt: timestamp('locked_at'), + lastError: text('last_error'), + createdAt: timestamp('created_at').notNull().defaultNow(), + processedAt: timestamp('processed_at'), + }, + (table) => ({ + statusAvailableIdx: index('outbox_event_status_available_idx').on( + table.status, + table.availableAt + ), + lockedAtIdx: index('outbox_event_locked_at_idx').on(table.lockedAt), + }) +) + export const mcpServers = pgTable( 'mcp_servers', {