mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
rename
This commit is contained in:
@@ -3,17 +3,171 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ColumnDefinition, TableSchema } from '@/lib/table'
|
||||
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('TableUtils')
|
||||
|
||||
/** Permission hierarchy: read < write < admin */
|
||||
type PermissionLevel = 'read' | 'write' | 'admin'
|
||||
|
||||
/**
|
||||
* Checks if a user's permission meets or exceeds the required level.
|
||||
* @deprecated Use TableDefinition from '@/lib/table' instead
|
||||
*/
|
||||
export type TableData = TableDefinition
|
||||
|
||||
export interface TableAccessResult {
|
||||
hasAccess: true
|
||||
table: Pick<TableDefinition, 'id' | 'workspaceId' | 'createdBy'>
|
||||
}
|
||||
|
||||
export interface TableAccessResultFull {
|
||||
hasAccess: true
|
||||
table: TableDefinition
|
||||
}
|
||||
|
||||
export interface TableAccessDenied {
|
||||
hasAccess: false
|
||||
notFound?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export async function checkTableAccess(
|
||||
tableId: string,
|
||||
userId: string
|
||||
): Promise<TableAccessResult | TableAccessDenied> {
|
||||
return checkTableAccessInternal(tableId, userId, 'read')
|
||||
}
|
||||
|
||||
export async function checkTableWriteAccess(
|
||||
tableId: string,
|
||||
userId: string
|
||||
): Promise<TableAccessResult | TableAccessDenied> {
|
||||
return checkTableAccessInternal(tableId, userId, 'write')
|
||||
}
|
||||
|
||||
export async function checkAccessOrRespond(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
level: 'read' | 'write' | 'admin' = 'write'
|
||||
): Promise<TableAccessResult | NextResponse> {
|
||||
const checkFn = level === 'read' ? checkTableAccess : checkTableWriteAccess
|
||||
const accessCheck = await checkFn(tableId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if ('notFound' in accessCheck && accessCheck.notFound) {
|
||||
logger.warn(`[${requestId}] Table not found: ${tableId}`)
|
||||
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
|
||||
}
|
||||
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
return accessCheck
|
||||
}
|
||||
|
||||
export async function checkAccessWithFullTable(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
level: 'read' | 'write' | 'admin' = 'write'
|
||||
): Promise<TableAccessResultFull | NextResponse> {
|
||||
const [tableData] = await db
|
||||
.select()
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (!tableData) {
|
||||
logger.warn(`[${requestId}] Table not found: ${tableId}`)
|
||||
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const rowCount = await getTableRowCount(tableId)
|
||||
const table = { ...tableData, rowCount } as unknown as TableDefinition
|
||||
|
||||
if (table.createdBy === userId) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
|
||||
if (!hasPermissionLevel(userPermission, level)) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
export async function getTableById(tableId: string): Promise<TableDefinition | null> {
|
||||
const [table] = await db
|
||||
.select()
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (!table) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rowCount = await getTableRowCount(tableId)
|
||||
return { ...table, rowCount } as unknown as TableDefinition
|
||||
}
|
||||
|
||||
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
|
||||
const table = await db
|
||||
.select({ workspaceId: userTableDefinitions.workspaceId })
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (table.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return table[0].workspaceId === workspaceId
|
||||
}
|
||||
|
||||
async function checkTableAccessInternal(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
requiredLevel: 'read' | 'write' | 'admin'
|
||||
): Promise<TableAccessResult | TableAccessDenied> {
|
||||
const table = await db
|
||||
.select({
|
||||
id: userTableDefinitions.id,
|
||||
createdBy: userTableDefinitions.createdBy,
|
||||
workspaceId: userTableDefinitions.workspaceId,
|
||||
})
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (table.length === 0) {
|
||||
return { hasAccess: false, notFound: true }
|
||||
}
|
||||
|
||||
const tableData = table[0]
|
||||
|
||||
if (tableData.createdBy === userId) {
|
||||
return { hasAccess: true, table: tableData }
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', tableData.workspaceId)
|
||||
|
||||
if (hasPermissionLevel(userPermission, requiredLevel)) {
|
||||
return { hasAccess: true, table: tableData }
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
function hasPermissionLevel(
|
||||
userPermission: 'read' | 'write' | 'admin' | null,
|
||||
requiredLevel: PermissionLevel
|
||||
@@ -41,361 +195,6 @@ async function getTableRowCount(tableId: string): Promise<number> {
|
||||
return Number(result?.count ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the core data structure for a user-defined table as stored in the database.
|
||||
*
|
||||
* This extends the base TableDefinition with DB-specific fields like createdBy.
|
||||
*/
|
||||
export interface TableData {
|
||||
/** Unique identifier for the table */
|
||||
id: string
|
||||
/** ID of the workspace this table belongs to */
|
||||
workspaceId: string
|
||||
/** ID of the user who created this table */
|
||||
createdBy: string
|
||||
/** Human-readable name of the table */
|
||||
name: string
|
||||
/** Optional description of the table's purpose */
|
||||
description?: string | null
|
||||
/** JSON schema defining the table's column structure */
|
||||
schema: TableSchema
|
||||
/** Maximum number of rows allowed in this table */
|
||||
maxRows: number
|
||||
/** Current number of rows in the table */
|
||||
rowCount: number
|
||||
/** Timestamp when the table was created */
|
||||
createdAt: Date
|
||||
/** Timestamp when the table was last updated */
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when a user has access to a table.
|
||||
*/
|
||||
export interface TableAccessResult {
|
||||
/** Indicates the user has access */
|
||||
hasAccess: true
|
||||
/** Core table information needed for access control */
|
||||
table: Pick<TableData, 'id' | 'workspaceId' | 'createdBy'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when a user has access to a table with full data.
|
||||
*/
|
||||
export interface TableAccessResultFull {
|
||||
/** Indicates the user has access */
|
||||
hasAccess: true
|
||||
/** Full table data */
|
||||
table: TableData
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when a user is denied access to a table.
|
||||
*/
|
||||
export interface TableAccessDenied {
|
||||
/** Indicates the user does not have access */
|
||||
hasAccess: false
|
||||
/** True if the table was not found */
|
||||
notFound?: boolean
|
||||
/** Optional reason for denial */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to check if a user has the required permission level for a table.
|
||||
*
|
||||
* Access is granted if:
|
||||
* 1. User created the table directly, OR
|
||||
* 2. User has the required permission level on the table's workspace
|
||||
*
|
||||
* @param tableId - The unique identifier of the table to check
|
||||
* @param userId - The unique identifier of the user requesting access
|
||||
* @param requiredLevel - The minimum permission level required ('read', 'write', or 'admin')
|
||||
* @returns A promise resolving to the access check result
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
async function checkTableAccessInternal(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
requiredLevel: 'read' | 'write' | 'admin'
|
||||
): Promise<TableAccessResult | TableAccessDenied> {
|
||||
// Fetch table data
|
||||
const table = await db
|
||||
.select({
|
||||
id: userTableDefinitions.id,
|
||||
createdBy: userTableDefinitions.createdBy,
|
||||
workspaceId: userTableDefinitions.workspaceId,
|
||||
})
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (table.length === 0) {
|
||||
return { hasAccess: false, notFound: true }
|
||||
}
|
||||
|
||||
const tableData = table[0]
|
||||
|
||||
// Case 1: User created the table directly (always has full access)
|
||||
if (tableData.createdBy === userId) {
|
||||
return { hasAccess: true, table: tableData }
|
||||
}
|
||||
|
||||
// Case 2: Check workspace permissions
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', tableData.workspaceId)
|
||||
|
||||
if (hasPermissionLevel(userPermission, requiredLevel)) {
|
||||
return { hasAccess: true, table: tableData }
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has read access to a table.
|
||||
*
|
||||
* Access is granted if:
|
||||
* 1. User created the table directly, OR
|
||||
* 2. User has any permission (read/write/admin) on the table's workspace
|
||||
*
|
||||
* @param tableId - The unique identifier of the table to check
|
||||
* @param userId - The unique identifier of the user requesting access
|
||||
* @returns A promise resolving to the access check result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const accessCheck = await checkTableAccess(tableId, userId)
|
||||
* if (!accessCheck.hasAccess) {
|
||||
* if ('notFound' in accessCheck && accessCheck.notFound) {
|
||||
* return NotFoundResponse()
|
||||
* }
|
||||
* return ForbiddenResponse()
|
||||
* }
|
||||
* // User has access, proceed with operation
|
||||
* ```
|
||||
*/
|
||||
export async function checkTableAccess(
|
||||
tableId: string,
|
||||
userId: string
|
||||
): Promise<TableAccessResult | TableAccessDenied> {
|
||||
return checkTableAccessInternal(tableId, userId, 'read')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has write access to a table.
|
||||
*
|
||||
* Write access is granted if:
|
||||
* 1. User created the table directly, OR
|
||||
* 2. User has write or admin permissions on the table's workspace
|
||||
*
|
||||
* @param tableId - The unique identifier of the table to check
|
||||
* @param userId - The unique identifier of the user requesting write access
|
||||
* @returns A promise resolving to the access check result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const accessCheck = await checkTableWriteAccess(tableId, userId)
|
||||
* if (!accessCheck.hasAccess) {
|
||||
* return ForbiddenResponse()
|
||||
* }
|
||||
* // User has write access, proceed with modification
|
||||
* ```
|
||||
*/
|
||||
export async function checkTableWriteAccess(
|
||||
tableId: string,
|
||||
userId: string
|
||||
): Promise<TableAccessResult | TableAccessDenied> {
|
||||
return checkTableAccessInternal(tableId, userId, 'write')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks table access and returns either the access result or an error response.
|
||||
*
|
||||
* This is a convenience function that combines access checking with automatic
|
||||
* error response generation, reducing boilerplate in route handlers.
|
||||
*
|
||||
* @param tableId - The unique identifier of the table to check
|
||||
* @param userId - The unique identifier of the user requesting access
|
||||
* @param requestId - Request ID for logging
|
||||
* @param level - Permission level required ('read' or 'write')
|
||||
* @returns Either a TableAccessResult with table info, or a NextResponse with error
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const accessResult = await checkAccessOrRespond(tableId, userId, requestId, 'write')
|
||||
* if (accessResult instanceof NextResponse) return accessResult
|
||||
*
|
||||
* // Access granted - use accessResult.table
|
||||
* const { table } = accessResult
|
||||
* ```
|
||||
*/
|
||||
export async function checkAccessOrRespond(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
level: 'read' | 'write' | 'admin' = 'write'
|
||||
): Promise<TableAccessResult | NextResponse> {
|
||||
const checkFn = level === 'read' ? checkTableAccess : checkTableWriteAccess
|
||||
const accessCheck = await checkFn(tableId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if ('notFound' in accessCheck && accessCheck.notFound) {
|
||||
logger.warn(`[${requestId}] Table not found: ${tableId}`)
|
||||
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
|
||||
}
|
||||
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
return accessCheck
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks table access and returns full table data or an error response.
|
||||
*
|
||||
* This is an optimized version of checkAccessOrRespond that fetches the full
|
||||
* table data in a single query, avoiding a redundant getTableById call.
|
||||
*
|
||||
* @param tableId - The unique identifier of the table to check
|
||||
* @param userId - The unique identifier of the user requesting access
|
||||
* @param requestId - Request ID for logging
|
||||
* @param level - Permission level required ('read' or 'write')
|
||||
* @returns Either a TableAccessResultFull with full table data, or a NextResponse with error
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await checkAccessWithFullTable(tableId, userId, requestId, 'write')
|
||||
* if (result instanceof NextResponse) return result
|
||||
*
|
||||
* // Access granted - use result.table which has full table data
|
||||
* const schema = result.table.schema
|
||||
* const rowCount = result.table.rowCount
|
||||
* ```
|
||||
*/
|
||||
export async function checkAccessWithFullTable(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
level: 'read' | 'write' | 'admin' = 'write'
|
||||
): Promise<TableAccessResultFull | NextResponse> {
|
||||
// Fetch full table data in one query
|
||||
const [tableData] = await db
|
||||
.select()
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (!tableData) {
|
||||
logger.warn(`[${requestId}] Table not found: ${tableId}`)
|
||||
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const rowCount = await getTableRowCount(tableId)
|
||||
const table = { ...tableData, rowCount } as unknown as TableData
|
||||
|
||||
// Case 1: User created the table directly (always has full access)
|
||||
if (table.createdBy === userId) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
// Case 2: Check workspace permissions
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
|
||||
if (!hasPermissionLevel(userPermission, level)) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a table by ID.
|
||||
*
|
||||
* @param tableId - The unique identifier of the table to fetch
|
||||
* @returns Promise resolving to table data or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const table = await getTableById(tableId)
|
||||
* if (!table) {
|
||||
* return NextResponse.json({ error: 'Table not found' }, { status: 404 })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getTableById(tableId: string): Promise<TableData | null> {
|
||||
const [table] = await db
|
||||
.select()
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (!table) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rowCount = await getTableRowCount(tableId)
|
||||
return { ...table, rowCount } as unknown as TableData
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a table belongs to a specific workspace.
|
||||
*
|
||||
* This is a security check to prevent workspace ID spoofing.
|
||||
* Use this when workspaceId is provided as a parameter to ensure
|
||||
* it matches the table's actual workspace.
|
||||
*
|
||||
* @param tableId - The unique identifier of the table
|
||||
* @param workspaceId - The workspace ID to verify against
|
||||
* @returns A promise resolving to true if the table belongs to the workspace
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (providedWorkspaceId) {
|
||||
* const isValid = await verifyTableWorkspace(tableId, providedWorkspaceId)
|
||||
* if (!isValid) {
|
||||
* return BadRequestResponse('Invalid workspace ID')
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
|
||||
const table = await db
|
||||
.select({ workspaceId: userTableDefinitions.workspaceId })
|
||||
.from(userTableDefinitions)
|
||||
.where(eq(userTableDefinitions.id, tableId))
|
||||
.limit(1)
|
||||
|
||||
if (table.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return table[0].workspaceId === workspaceId
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error response structure for table API routes.
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error response.
|
||||
*
|
||||
* @param message - Error message to display
|
||||
* @param status - HTTP status code
|
||||
* @param details - Optional additional error details
|
||||
* @returns NextResponse with standardized error format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* return errorResponse('Table not found', 404)
|
||||
* return errorResponse('Validation error', 400, zodError.errors)
|
||||
* ```
|
||||
*/
|
||||
export function errorResponse(
|
||||
message: string,
|
||||
status: number,
|
||||
@@ -408,9 +207,6 @@ export function errorResponse(
|
||||
return NextResponse.json(body, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 400 Bad Request error response.
|
||||
*/
|
||||
export function badRequestResponse(
|
||||
message: string,
|
||||
details?: unknown
|
||||
@@ -418,44 +214,26 @@ export function badRequestResponse(
|
||||
return errorResponse(message, 400, details)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 401 Unauthorized error response.
|
||||
*/
|
||||
export function unauthorizedResponse(
|
||||
message = 'Authentication required'
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 403 Forbidden error response.
|
||||
*/
|
||||
export function forbiddenResponse(message = 'Access denied'): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, 403)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 404 Not Found error response.
|
||||
*/
|
||||
export function notFoundResponse(message = 'Resource not found'): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, 404)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 500 Internal Server Error response.
|
||||
*/
|
||||
export function serverErrorResponse(
|
||||
message = 'Internal server error'
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a column definition by ensuring all optional fields have explicit values.
|
||||
*
|
||||
* @param col - The column definition to normalize
|
||||
* @returns A normalized column with explicit required and unique values
|
||||
*/
|
||||
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
|
||||
return {
|
||||
name: col.name,
|
||||
|
||||
@@ -6,23 +6,24 @@ import { nanoid } from 'nanoid'
|
||||
import { Button, Combobox, Input } from '@/components/emcn'
|
||||
import type { FilterCondition, SortCondition } from '@/lib/table/filters/constants'
|
||||
import { useFilterBuilder } from '@/lib/table/filters/use-builder'
|
||||
import { conditionsToFilter } from '@/lib/table/filters/utils'
|
||||
import type { JsonValue } from '@/lib/table/types'
|
||||
import { conditionsToFilter, sortConditionToSort } from '@/lib/table/filters/utils'
|
||||
import type { ColumnDefinition, Filter, Sort } from '@/lib/table/types'
|
||||
|
||||
export interface QueryOptions {
|
||||
filter: Record<string, JsonValue> | null
|
||||
sort: SortCondition | null
|
||||
/**
|
||||
* Result of applying query builder filters and sorts.
|
||||
* Contains the converted API-ready filter and sort objects.
|
||||
*/
|
||||
export interface BuilderQueryResult {
|
||||
/** MongoDB-style filter object for API queries */
|
||||
filter: Filter | null
|
||||
/** Sort specification for API queries */
|
||||
sort: Sort | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Column definition for filter building.
|
||||
* Column definition for filter building (subset of ColumnDefinition).
|
||||
*/
|
||||
interface Column {
|
||||
/** Column name */
|
||||
name: string
|
||||
/** Column data type */
|
||||
type: 'string' | 'number' | 'boolean' | 'json' | 'date'
|
||||
}
|
||||
type Column = Pick<ColumnDefinition, 'name' | 'type'>
|
||||
|
||||
/**
|
||||
* Props for the TableQueryBuilder component.
|
||||
@@ -31,7 +32,7 @@ interface TableQueryBuilderProps {
|
||||
/** Available columns for filtering */
|
||||
columns: Column[]
|
||||
/** Callback when query options should be applied */
|
||||
onApply: (options: QueryOptions) => void
|
||||
onApply: (options: BuilderQueryResult) => void
|
||||
/** Callback to add a new row */
|
||||
onAddRow: () => void
|
||||
/** Whether a query is currently loading */
|
||||
@@ -89,10 +90,8 @@ export function TableQueryBuilder({
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const filter = conditionsToFilter(conditions)
|
||||
onApply({
|
||||
filter,
|
||||
sort: sortCondition,
|
||||
})
|
||||
const sort = sortConditionToSort(sortCondition)
|
||||
onApply({ filter, sort })
|
||||
}, [conditions, sortCondition, onApply])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
|
||||
@@ -16,16 +16,10 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { ColumnDefinition, TableRow, TableSchema } from '@/lib/table'
|
||||
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
|
||||
|
||||
const logger = createLogger('TableRowModal')
|
||||
|
||||
export interface TableInfo {
|
||||
id: string
|
||||
name: string
|
||||
schema: TableSchema
|
||||
}
|
||||
|
||||
export interface TableRowModalProps {
|
||||
mode: 'add' | 'edit' | 'delete'
|
||||
isOpen: boolean
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { TableDefinition, TableRow } from '@/lib/table'
|
||||
import type { QueryOptions } from '../../components/table-query-builder'
|
||||
import type { BuilderQueryResult } from '../../components/table-query-builder'
|
||||
import { ROWS_PER_PAGE } from '../constants'
|
||||
|
||||
interface UseTableDataParams {
|
||||
workspaceId: string
|
||||
tableId: string
|
||||
queryOptions: QueryOptions
|
||||
queryOptions: BuilderQueryResult
|
||||
currentPage: number
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ export function useTableData({
|
||||
}
|
||||
|
||||
if (queryOptions.sort) {
|
||||
const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction }
|
||||
searchParams.set('sort', JSON.stringify(sortParam))
|
||||
// sort is already in the correct format: { column: direction }
|
||||
searchParams.set('sort', JSON.stringify(queryOptions.sort))
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -74,9 +73,7 @@ const DialogContent = React.forwardRef<
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<VisuallyHidden.Root>
|
||||
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||
</VisuallyHidden.Root>
|
||||
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Shared constants and types for table filtering and sorting.
|
||||
* Shared constants for table filtering and sorting UI.
|
||||
*
|
||||
* Types (FilterCondition, SortCondition) are defined in ../types.ts
|
||||
* and re-exported here for convenience.
|
||||
*/
|
||||
|
||||
/** Comparison operators for filter conditions (maps to query-builder.ts) */
|
||||
// Re-export UI builder types from central types file
|
||||
export type { FilterCondition, SortCondition } from '../types'
|
||||
|
||||
/** Comparison operators for filter conditions (maps to ConditionOperators in types.ts) */
|
||||
export const COMPARISON_OPERATORS = [
|
||||
{ value: 'eq', label: 'equals' },
|
||||
{ value: 'ne', label: 'not equals' },
|
||||
@@ -23,35 +29,9 @@ export const LOGICAL_OPERATORS = [
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Sort direction options.
|
||||
* Sort direction options for UI dropdowns.
|
||||
*/
|
||||
export const SORT_DIRECTIONS = [
|
||||
{ value: 'asc', label: 'ascending' },
|
||||
{ value: 'desc', label: 'descending' },
|
||||
] as const
|
||||
|
||||
/** Single filter condition used by filter builder UI */
|
||||
export interface FilterCondition {
|
||||
/** Unique identifier for the condition (used as React key) */
|
||||
id: string
|
||||
/** How this condition combines with the previous one */
|
||||
logicalOperator: 'and' | 'or'
|
||||
/** Column to filter on */
|
||||
column: string
|
||||
/** Comparison operator */
|
||||
operator: string
|
||||
/** Value to compare against */
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a sort configuration.
|
||||
*/
|
||||
export interface SortCondition {
|
||||
/** Unique identifier for the condition (used as React key) */
|
||||
id: string
|
||||
/** Column to sort by */
|
||||
column: string
|
||||
/** Sort direction */
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { ColumnOption } from '../types'
|
||||
import {
|
||||
COMPARISON_OPERATORS,
|
||||
type FilterCondition,
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
type SortCondition,
|
||||
} from './constants'
|
||||
|
||||
// Re-export ColumnOption for consumers of this module
|
||||
export type { ColumnOption }
|
||||
|
||||
/**
|
||||
* Hook that provides filter builder logic for managing filter conditions.
|
||||
*
|
||||
@@ -173,11 +177,6 @@ export function useSortBuilder({
|
||||
}
|
||||
}
|
||||
|
||||
export interface ColumnOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface UseFilterBuilderProps {
|
||||
columns: ColumnOption[]
|
||||
conditions: FilterCondition[]
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
/**
|
||||
* Shared utilities for filter builder UI components.
|
||||
*
|
||||
* These utilities convert between UI builder types (FilterCondition, SortCondition)
|
||||
* and API types (Filter, Sort).
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { JsonValue } from '../types'
|
||||
import type { FilterCondition, SortCondition } from './constants'
|
||||
import type {
|
||||
Filter,
|
||||
FilterCondition,
|
||||
JsonValue,
|
||||
Sort,
|
||||
SortCondition,
|
||||
SortDirection,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* Converts builder filter conditions to MongoDB-style filter object.
|
||||
@@ -12,9 +21,7 @@ import type { FilterCondition, SortCondition } from './constants'
|
||||
* @param conditions - Array of filter conditions from the builder UI
|
||||
* @returns Filter object or null if no conditions
|
||||
*/
|
||||
export function conditionsToFilter(
|
||||
conditions: FilterCondition[]
|
||||
): Record<string, JsonValue> | null {
|
||||
export function conditionsToFilter(conditions: FilterCondition[]): Filter | null {
|
||||
if (conditions.length === 0) return null
|
||||
|
||||
const orGroups: Record<string, JsonValue>[] = []
|
||||
@@ -45,12 +52,12 @@ export function conditionsToFilter(
|
||||
* @param filter - Filter object to convert
|
||||
* @returns Array of filter conditions for the builder UI
|
||||
*/
|
||||
export function filterToConditions(filter: Record<string, JsonValue> | null): FilterCondition[] {
|
||||
export function filterToConditions(filter: Filter | null): FilterCondition[] {
|
||||
if (!filter) return []
|
||||
|
||||
if (filter.$or && Array.isArray(filter.$or)) {
|
||||
const groups = filter.$or
|
||||
.map((orGroup) => parseFilterGroup(orGroup as Record<string, JsonValue>))
|
||||
.map((orGroup) => parseFilterGroup(orGroup as Filter))
|
||||
.filter((group) => group.length > 0)
|
||||
return applyLogicalOperators(groups)
|
||||
}
|
||||
@@ -59,15 +66,26 @@ export function filterToConditions(filter: Record<string, JsonValue> | null): Fi
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder sort conditions to sort object.
|
||||
* Converts a single builder sort condition to Sort object.
|
||||
*
|
||||
* @param condition - Single sort condition from the builder UI
|
||||
* @returns Sort object or null if no condition
|
||||
*/
|
||||
export function sortConditionToSort(condition: SortCondition | null): Sort | null {
|
||||
if (!condition || !condition.column) return null
|
||||
return { [condition.column]: condition.direction }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder sort conditions (array) to Sort object.
|
||||
*
|
||||
* @param conditions - Array of sort conditions from the builder UI
|
||||
* @returns Sort object or null if no conditions
|
||||
*/
|
||||
export function sortConditionsToSort(conditions: SortCondition[]): Record<string, string> | null {
|
||||
export function sortConditionsToSort(conditions: SortCondition[]): Sort | null {
|
||||
if (conditions.length === 0) return null
|
||||
|
||||
const sort: Record<string, string> = {}
|
||||
const sort: Sort = {}
|
||||
for (const condition of conditions) {
|
||||
if (condition.column) {
|
||||
sort[condition.column] = condition.direction
|
||||
@@ -78,12 +96,12 @@ export function sortConditionsToSort(conditions: SortCondition[]): Record<string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts sort object to builder conditions.
|
||||
* Converts Sort object to builder conditions.
|
||||
*
|
||||
* @param sort - Sort object to convert
|
||||
* @returns Array of sort conditions for the builder UI
|
||||
*/
|
||||
export function sortToConditions(sort: Record<string, string> | null): SortCondition[] {
|
||||
export function sortToConditions(sort: Sort | null): SortCondition[] {
|
||||
if (!sort) return []
|
||||
|
||||
return Object.entries(sort).map(([column, direction]) => ({
|
||||
@@ -137,7 +155,7 @@ function parseScalar(value: string): JsonValue {
|
||||
return value
|
||||
}
|
||||
|
||||
function parseFilterGroup(group: Record<string, JsonValue>): FilterCondition[] {
|
||||
function parseFilterGroup(group: Filter): FilterCondition[] {
|
||||
if (!group || typeof group !== 'object' || Array.isArray(group)) return []
|
||||
|
||||
const conditions: FilterCondition[] = []
|
||||
@@ -153,7 +171,7 @@ function parseFilterGroup(group: Record<string, JsonValue>): FilterCondition[] {
|
||||
logicalOperator: 'and',
|
||||
column,
|
||||
operator: op.substring(1),
|
||||
value: formatValueForBuilder(opValue),
|
||||
value: formatValueForBuilder(opValue as JsonValue),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -165,7 +183,7 @@ function parseFilterGroup(group: Record<string, JsonValue>): FilterCondition[] {
|
||||
logicalOperator: 'and',
|
||||
column,
|
||||
operator: 'eq',
|
||||
value: formatValueForBuilder(value),
|
||||
value: formatValueForBuilder(value as JsonValue),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,6 +197,6 @@ function formatValueForBuilder(value: JsonValue): string {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function normalizeSortDirection(direction: string): 'asc' | 'desc' {
|
||||
function normalizeSortDirection(direction: string): SortDirection {
|
||||
return direction === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface ColumnOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
import type { ColumnOption } from '../types'
|
||||
|
||||
interface UseTableColumnsOptions {
|
||||
tableId: string | null | undefined
|
||||
|
||||
@@ -21,6 +21,15 @@ export type SortDirection = 'asc' | 'desc'
|
||||
* key is the column name and value is the sort direction */
|
||||
export type Sort = Record<string, SortDirection>
|
||||
|
||||
/**
|
||||
* Option for column/dropdown selection in UI components.
|
||||
* Used by filter builders, sort builders, and column selectors.
|
||||
*/
|
||||
export interface ColumnOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Column definition within a table schema.
|
||||
*/
|
||||
@@ -49,10 +58,16 @@ export interface TableDefinition {
|
||||
rowCount: number
|
||||
maxRows: number
|
||||
workspaceId: string
|
||||
createdBy: string
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of TableDefinition for UI components that only need basic info.
|
||||
*/
|
||||
export type TableInfo = Pick<TableDefinition, 'id' | 'name' | 'schema'>
|
||||
|
||||
/**
|
||||
* Row stored in a user-defined table.
|
||||
*/
|
||||
@@ -137,6 +152,43 @@ export interface ValidationResult {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UI Builder Types
|
||||
// These types represent the state of filter/sort builder UI components.
|
||||
// They have `id` fields for React keys and string values for form inputs.
|
||||
// Use the conversion utilities in filters/utils.ts to convert to API types.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Single filter condition in the UI builder.
|
||||
* This is the UI representation - use `Filter` for API queries.
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
/** Unique identifier for the condition (used as React key) */
|
||||
id: string
|
||||
/** How this condition combines with the previous one */
|
||||
logicalOperator: 'and' | 'or'
|
||||
/** Column to filter on */
|
||||
column: string
|
||||
/** Comparison operator (eq, ne, gt, gte, lt, lte, contains, in) */
|
||||
operator: string
|
||||
/** Value to compare against (as string for form input) */
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Single sort condition in the UI builder.
|
||||
* This is the UI representation - use `Sort` for API queries.
|
||||
*/
|
||||
export interface SortCondition {
|
||||
/** Unique identifier for the condition (used as React key) */
|
||||
id: string
|
||||
/** Column to sort by */
|
||||
column: string
|
||||
/** Sort direction */
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for querying table rows.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user