diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 70c2029de..0986d3bce 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -6,7 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { createTable, listTables, TABLE_LIMITS, type TableSchema } from '@/lib/table' +import { + canCreateTable, + createTable, + getWorkspaceTableLimits, + listTables, + TABLE_LIMITS, + type TableSchema, +} from '@/lib/table' import { normalizeColumn } from './utils' const logger = createLogger('TableAPI') @@ -141,6 +148,23 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + // Check billing plan limits + const existingTables = await listTables(params.workspaceId) + const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length) + + if (!canCreate) { + return NextResponse.json( + { + error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`, + }, + { status: 403 } + ) + } + + // Get plan-based row limits + const planLimits = await getWorkspaceTableLimits(params.workspaceId) + const maxRowsPerTable = planLimits.maxRowsPerTable + const normalizedSchema: TableSchema = { columns: params.schema.columns.map(normalizeColumn), } @@ -152,6 +176,7 @@ export async function POST(request: NextRequest) { schema: normalizedSchema, workspaceId: params.workspaceId, userId: authResult.userId, + maxRows: maxRowsPerTable, }, requestId ) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 00fd3e2c8..5f8d01073 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -6,6 +6,19 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableUtils') +export interface TableAccessResult { + hasAccess: true + table: TableDefinition +} + +export interface TableAccessDenied { + hasAccess: false + notFound?: boolean + reason?: string +} + +export type TableAccessCheck = TableAccessResult | TableAccessDenied + export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 } export interface ApiErrorResponse { @@ -13,6 +26,71 @@ export interface ApiErrorResponse { details?: unknown } +/** + * Check if a user has read access to a table. + * Read access is granted if: + * 1. User created the table, OR + * 2. User has any permission on the table's workspace (read, write, or admin) + * + * Follows the same pattern as Knowledge Base access checks. + */ +export async function checkTableAccess(tableId: string, userId: string): Promise { + const table = await getTableById(tableId) + + if (!table) { + return { hasAccess: false, notFound: true } + } + + // Case 1: User created the table + if (table.createdBy === userId) { + return { hasAccess: true, table } + } + + // Case 2: Table belongs to a workspace the user has permissions for + const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) + if (userPermission !== null) { + return { hasAccess: true, table } + } + + return { hasAccess: false, reason: 'User does not have access to this table' } +} + +/** + * Check if a user has write access to a table. + * Write access is granted if: + * 1. User created the table, OR + * 2. User has write or admin permissions on the table's workspace + * + * Follows the same pattern as Knowledge Base write access checks. + */ +export async function checkTableWriteAccess( + tableId: string, + userId: string +): Promise { + const table = await getTableById(tableId) + + if (!table) { + return { hasAccess: false, notFound: true } + } + + // Case 1: User created the table + if (table.createdBy === userId) { + return { hasAccess: true, table } + } + + // Case 2: Table belongs to a workspace and user has write/admin permissions + const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) + if (userPermission === 'write' || userPermission === 'admin') { + return { hasAccess: true, table } + } + + return { hasAccess: false, reason: 'User does not have write access to this table' } +} + +/** + * @deprecated Use checkTableAccess or checkTableWriteAccess instead. + * Legacy access check function for backwards compatibility. + */ export async function checkAccess( tableId: string, userId: string, @@ -48,6 +126,21 @@ export function accessError( return NextResponse.json({ error: message }, { status: result.status }) } +/** + * Converts a TableAccessDenied result to an appropriate HTTP response. + * Use with checkTableAccess or checkTableWriteAccess. + */ +export function tableAccessError( + result: TableAccessDenied, + requestId: string, + context?: string +): NextResponse { + const status = result.notFound ? 404 : 403 + const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied') + logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`) + return NextResponse.json({ error: message }, { status }) +} + export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise { const table = await getTableById(tableId) return table?.workspaceId === workspaceId diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx index 29a1cdca8..e321cf97b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal.tsx @@ -7,6 +7,7 @@ import { useParams } from 'next/navigation' import { Button, Checkbox, + Input, Label, Modal, ModalBody, @@ -15,7 +16,6 @@ import { ModalHeader, Textarea, } from '@/components/emcn' -import { Input } from '@/components/ui/input' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' const logger = createLogger('RowModal') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-view.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-view.tsx index 5feb201ca..d35a55300 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-view.tsx @@ -3,8 +3,7 @@ import { useState } from 'react' import { Database, Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' -import { Button, Tooltip } from '@/components/emcn' -import { Input } from '@/components/ui/input' +import { Button, Input, Tooltip } from '@/components/emcn' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useTablesList } from '@/hooks/queries/use-tables' import { useDebounce } from '@/hooks/use-debounce' diff --git a/apps/sim/blocks/blocks/table.ts b/apps/sim/blocks/blocks/table.ts index 4457fbace..1364e2b7d 100644 --- a/apps/sim/blocks/blocks/table.ts +++ b/apps/sim/blocks/blocks/table.ts @@ -1,4 +1,5 @@ import { TableIcon } from '@/components/icons' +import { TABLE_LIMITS } from '@/lib/table' import { filterRulesToFilter, sortRulesToSort } from '@/lib/table/query-builder/converters' import type { BlockConfig } from '@/blocks/types' import type { TableQueryResponse } from '@/tools/table/types' @@ -277,7 +278,7 @@ Return ONLY the data JSON:`, ### INSTRUCTION Return ONLY a valid JSON array of objects. Each object represents one row. No explanations or markdown. -Maximum 1000 rows per batch. +Maximum ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch. IMPORTANT: Reference the table schema to know which columns exist and their types. diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts new file mode 100644 index 000000000..7183ad9b5 --- /dev/null +++ b/apps/sim/lib/table/billing.ts @@ -0,0 +1,83 @@ +/** + * Billing helpers for table feature limits. + * + * Uses workspace billing account to determine plan-based limits. + */ + +import { createLogger } from '@sim/logger' +import { getUserSubscriptionState } from '@/lib/billing/core/subscription' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' +import { type PlanName, TABLE_PLAN_LIMITS, type TablePlanLimits } from './constants' + +const logger = createLogger('TableBilling') + +/** + * Gets the table limits for a workspace based on its billing plan. + * + * Uses the workspace's billed account user to determine the subscription plan, + * then returns the corresponding table limits. + * + * @param workspaceId - The workspace ID to get limits for + * @returns Table limits based on the workspace's billing plan + */ +export async function getWorkspaceTableLimits(workspaceId: string): Promise { + try { + const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) + + if (!billedAccountUserId) { + logger.warn('No billed account found for workspace, using free tier limits', { workspaceId }) + return TABLE_PLAN_LIMITS.free + } + + const subscriptionState = await getUserSubscriptionState(billedAccountUserId) + const planName = subscriptionState.planName as PlanName + + const limits = TABLE_PLAN_LIMITS[planName] ?? TABLE_PLAN_LIMITS.free + + logger.info('Retrieved workspace table limits', { + workspaceId, + billedAccountUserId, + planName, + limits, + }) + + return limits + } catch (error) { + logger.error('Error getting workspace table limits, falling back to free tier', { + workspaceId, + error, + }) + return TABLE_PLAN_LIMITS.free + } +} + +/** + * Checks if a workspace can create more tables based on its plan limits. + * + * @param workspaceId - The workspace ID to check + * @param currentTableCount - The current number of tables in the workspace + * @returns Object with canCreate boolean and limit info + */ +export async function canCreateTable( + workspaceId: string, + currentTableCount: number +): Promise<{ canCreate: boolean; maxTables: number; currentCount: number }> { + const limits = await getWorkspaceTableLimits(workspaceId) + + return { + canCreate: currentTableCount < limits.maxTables, + maxTables: limits.maxTables, + currentCount: currentTableCount, + } +} + +/** + * Gets the maximum rows allowed per table for a workspace based on its plan. + * + * @param workspaceId - The workspace ID + * @returns Maximum rows per table (-1 for unlimited) + */ +export async function getMaxRowsPerTable(workspaceId: string): Promise { + const limits = await getWorkspaceTableLimits(workspaceId) + return limits.maxRowsPerTable +} diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index f9c930033..b6529495b 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -23,6 +23,35 @@ export const TABLE_LIMITS = { MAX_BULK_OPERATION_SIZE: 1000, } as const +/** + * Plan-based table limits. + */ +export const TABLE_PLAN_LIMITS = { + free: { + maxTables: 3, + maxRowsPerTable: 1000, + }, + pro: { + maxTables: 25, + maxRowsPerTable: 5000, + }, + team: { + maxTables: 100, + maxRowsPerTable: 10000, + }, + enterprise: { + maxTables: 10000, + maxRowsPerTable: 1000000, + }, +} as const + +export type PlanName = keyof typeof TABLE_PLAN_LIMITS + +export interface TablePlanLimits { + maxTables: number + maxRowsPerTable: number +} + export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as const export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i diff --git a/apps/sim/lib/table/index.ts b/apps/sim/lib/table/index.ts index 0bde0b3fe..5e960d09f 100644 --- a/apps/sim/lib/table/index.ts +++ b/apps/sim/lib/table/index.ts @@ -5,6 +5,7 @@ * Import hooks directly from '@/lib/table/hooks' in client components. */ +export * from './billing' export * from './constants' export * from './llm' export * from './query-builder' diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index cb9a063c8..a23dfc7da 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -152,6 +152,9 @@ export async function createTable( const tableId = `tbl_${crypto.randomUUID().replace(/-/g, '')}` const now = new Date() + // Use provided maxRows (from billing plan) or fall back to default + const maxRows = data.maxRows ?? TABLE_LIMITS.MAX_ROWS_PER_TABLE + const newTable = { id: tableId, name: data.name, @@ -159,7 +162,7 @@ export async function createTable( schema: data.schema, workspaceId: data.workspaceId, createdBy: data.userId, - maxRows: TABLE_LIMITS.MAX_ROWS_PER_TABLE, + maxRows, createdAt: now, updatedAt: now, } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 0a2b918bd..f1595b703 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -149,6 +149,8 @@ export interface CreateTableData { schema: TableSchema workspaceId: string userId: string + /** Optional max rows override based on billing plan. Defaults to TABLE_LIMITS.MAX_ROWS_PER_TABLE. */ + maxRows?: number } export interface InsertRowData { diff --git a/apps/sim/tools/table/batch-insert-rows.ts b/apps/sim/tools/table/batch-insert-rows.ts index 8c9120368..6c1b3c605 100644 --- a/apps/sim/tools/table/batch-insert-rows.ts +++ b/apps/sim/tools/table/batch-insert-rows.ts @@ -1,3 +1,4 @@ +import { TABLE_LIMITS } from '@/lib/table' import type { ToolConfig } from '@/tools/types' import type { TableBatchInsertParams, TableBatchInsertResponse } from './types' @@ -7,7 +8,7 @@ export const tableBatchInsertRowsTool: ToolConfig< > = { id: 'table_batch_insert_rows', name: 'Batch Insert Rows', - description: 'Insert multiple rows into a table at once (up to 1000 rows)', + description: `Insert multiple rows into a table at once (up to ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows)`, version: '1.0.0', params: { @@ -20,7 +21,7 @@ export const tableBatchInsertRowsTool: ToolConfig< rows: { type: 'array', required: true, - description: 'Array of row data objects (max 1000 rows)', + description: `Array of row data objects (max ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows)`, visibility: 'user-or-llm', }, }, diff --git a/apps/sim/tools/table/delete-rows-by-filter.ts b/apps/sim/tools/table/delete-rows-by-filter.ts index fca0e8f90..a20856445 100644 --- a/apps/sim/tools/table/delete-rows-by-filter.ts +++ b/apps/sim/tools/table/delete-rows-by-filter.ts @@ -1,3 +1,4 @@ +import { TABLE_LIMITS } from '@/lib/table' import type { ToolConfig } from '@/tools/types' import type { TableBulkOperationResponse, TableDeleteByFilterParams } from './types' @@ -27,7 +28,7 @@ export const tableDeleteRowsByFilterTool: ToolConfig< limit: { type: 'number', required: false, - description: 'Maximum number of rows to delete (default: no limit, max: 1000)', + description: `Maximum number of rows to delete (default: no limit, max: ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE})`, visibility: 'user-or-llm', }, }, diff --git a/apps/sim/tools/table/query-rows.ts b/apps/sim/tools/table/query-rows.ts index 28a6a05d6..1cb6e0fe2 100644 --- a/apps/sim/tools/table/query-rows.ts +++ b/apps/sim/tools/table/query-rows.ts @@ -1,3 +1,4 @@ +import { TABLE_LIMITS } from '@/lib/table' import type { ToolConfig } from '@/tools/types' import type { TableQueryResponse, TableRowQueryParams } from './types' @@ -30,7 +31,7 @@ export const tableQueryRowsTool: ToolConfig