This commit is contained in:
Lakee Sivaraya
2026-01-16 17:16:27 -08:00
parent 292cd39cfb
commit 118e4f65f0
14 changed files with 251 additions and 11 deletions

View File

@@ -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
)

View File

@@ -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

View File

@@ -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')

View File

@@ -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'

View File

@@ -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.

View 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
}

View File

@@ -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

View File

@@ -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'

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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',
},
},

View File

@@ -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',
},
},

View File

@@ -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: {

View File

@@ -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',
},
},