mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
updates
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<TableAccessCheck> {
|
||||
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<TableAccessCheck> {
|
||||
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<boolean> {
|
||||
const table = await getTableById(tableId)
|
||||
return table?.workspaceId === workspaceId
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
83
apps/sim/lib/table/billing.ts
Normal file
83
apps/sim/lib/table/billing.ts
Normal file
@@ -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<TablePlanLimits> {
|
||||
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<number> {
|
||||
const limits = await getWorkspaceTableLimits(workspaceId)
|
||||
return limits.maxRowsPerTable
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<TableRowQueryParams, TableQueryRespo
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Maximum rows to return (default: 100, max: 1000)',
|
||||
description: `Maximum rows to return (default: ${TABLE_LIMITS.DEFAULT_QUERY_LIMIT}, max: ${TABLE_LIMITS.MAX_QUERY_LIMIT})`,
|
||||
visibility: 'user-or-llm',
|
||||
},
|
||||
offset: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TABLE_LIMITS } from '@/lib/table'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { TableBulkOperationResponse, TableUpdateByFilterParams } from './types'
|
||||
|
||||
@@ -33,7 +34,7 @@ export const tableUpdateRowsByFilterTool: ToolConfig<
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Maximum number of rows to update (default: no limit, max: 1000)',
|
||||
description: `Maximum number of rows to update (default: no limit, max: ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE})`,
|
||||
visibility: 'user-or-llm',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user