renames & refactors

This commit is contained in:
Lakee Sivaraya
2026-01-14 19:54:20 -08:00
parent cbb93c65b6
commit c9373c7b3e
13 changed files with 129 additions and 218 deletions

View File

@@ -1,13 +1,16 @@
import { db } from '@sim/db'
import { userTableDefinitions, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
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 type { TableColumnData, TableSchemaData } from '../utils'
import { checkTableAccess, checkTableWriteAccess, verifyTableWorkspace } from '../utils'
import { deleteTable, getTableById } from '@/lib/table'
import type { TableSchemaData } from '../utils'
import {
checkTableAccess,
checkTableWriteAccess,
normalizeColumn,
verifyTableWorkspace,
} from '../utils'
const logger = createLogger('TableDetailAPI')
@@ -28,21 +31,6 @@ interface TableRouteParams {
params: Promise<{ tableId: string }>
}
/**
* Normalizes a column definition ensuring all optional fields have explicit values.
*
* @param col - The column data to normalize
* @returns Normalized column with explicit required and unique values
*/
function normalizeColumn(col: TableColumnData): TableColumnData {
return {
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
}
}
/**
* GET /api/table/[tableId]?workspaceId=xxx
*
@@ -109,12 +97,8 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
}
}
// Get table (workspaceId validation is now handled by access check)
const [table] = await db
.select()
.from(userTableDefinitions)
.where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.deletedAt)))
.limit(1)
// Get table using service layer
const table = await getTableById(tableId)
if (!table) {
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
@@ -136,8 +120,14 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt.toISOString(),
updatedAt: table.updatedAt.toISOString(),
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
},
})
@@ -157,14 +147,15 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
/**
* DELETE /api/table/[tableId]?workspaceId=xxx
*
* Permanently deletes a table and all its rows.
* Soft deletes a table.
*
* @param request - The incoming HTTP request
* @param context - Route context containing tableId param
* @returns JSON response confirming deletion or error
*
* @remarks
* This performs a hard delete, removing all data permanently.
* This performs a soft delete, marking the table as deleted.
* Rows remain in the database but become inaccessible.
* The operation requires write access to the table.
*/
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
@@ -192,20 +183,8 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Delete all rows first
await db.delete(userTableRows).where(eq(userTableRows.tableId, tableId))
// Hard delete table
const [deletedTable] = await db
.delete(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.returning()
if (!deletedTable) {
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
logger.info(`[${requestId}] Deleted table ${tableId} for user ${authResult.userId}`)
// Soft delete table using service layer
await deleteTable(tableId, requestId)
return NextResponse.json({
success: true,

View File

@@ -1,14 +1,13 @@
import { db } from '@sim/db'
import { permissions, userTableDefinitions, workspace } from '@sim/db/schema'
import { permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
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 { TABLE_LIMITS, validateTableName, validateTableSchema } from '@/lib/table'
import type { TableSchema } from '@/lib/table/validation/schema'
import type { TableColumnData, TableSchemaData } from './utils'
import { createTable, listTables, TABLE_LIMITS } from '@/lib/table'
import { normalizeColumn, type TableSchemaData } from './utils'
const logger = createLogger('TableAPI')
@@ -147,31 +146,6 @@ async function checkWorkspaceAccess(
}
}
/**
* Column input type that accepts both Zod-inferred columns and database columns.
*/
interface ColumnInput {
name: string
type: 'string' | 'number' | 'boolean' | 'date' | 'json'
required?: boolean
unique?: boolean
}
/**
* 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
*/
function normalizeColumn(col: ColumnInput): TableColumnData {
return {
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
}
}
/**
* POST /api/table
*
@@ -207,24 +181,6 @@ export async function POST(request: NextRequest) {
const body: unknown = await request.json()
const params = CreateTableSchema.parse(body)
// Validate table name
const nameValidation = validateTableName(params.name)
if (!nameValidation.valid) {
return NextResponse.json(
{ error: 'Invalid table name', details: nameValidation.errors },
{ status: 400 }
)
}
// Validate schema
const schemaValidation = validateTableSchema(params.schema as TableSchema)
if (!schemaValidation.valid) {
return NextResponse.json(
{ error: 'Invalid table schema', details: schemaValidation.errors },
{ status: 400 }
)
}
// Check workspace access
const { hasAccess, canWrite } = await checkWorkspaceAccess(
params.workspaceId,
@@ -235,72 +191,22 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check workspace table limit
const [tableCount] = await db
.select({ count: sql<number>`count(*)` })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, params.workspaceId),
isNull(userTableDefinitions.deletedAt)
)
)
if (Number(tableCount.count) >= TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE) {
return NextResponse.json(
{
error: `Workspace table limit reached (${TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE} tables max)`,
},
{ status: 400 }
)
}
// Check for duplicate table name
const [existing] = await db
.select({ id: userTableDefinitions.id })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, params.workspaceId),
eq(userTableDefinitions.name, params.name),
isNull(userTableDefinitions.deletedAt)
)
)
.limit(1)
if (existing) {
return NextResponse.json(
{ error: `Table "${params.name}" already exists in this workspace` },
{ status: 400 }
)
}
// Normalize schema to ensure all fields have explicit defaults
const normalizedSchema: TableSchemaData = {
columns: params.schema.columns.map(normalizeColumn),
}
// Create table
const tableId = `tbl_${crypto.randomUUID().replace(/-/g, '')}`
const now = new Date()
const [table] = await db
.insert(userTableDefinitions)
.values({
id: tableId,
workspaceId: params.workspaceId,
// Create table using service layer
const table = await createTable(
{
name: params.name,
description: params.description,
schema: normalizedSchema,
maxRows: TABLE_LIMITS.MAX_ROWS_PER_TABLE,
rowCount: 0,
createdBy: authResult.userId,
createdAt: now,
updatedAt: now,
})
.returning()
logger.info(`[${requestId}] Created table ${tableId} in workspace ${params.workspaceId}`)
workspaceId: params.workspaceId,
userId: authResult.userId,
},
requestId
)
return NextResponse.json({
success: true,
@@ -312,8 +218,14 @@ export async function POST(request: NextRequest) {
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt.toISOString(),
updatedAt: table.updatedAt.toISOString(),
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
message: 'Table created successfully',
},
@@ -326,6 +238,18 @@ export async function POST(request: NextRequest) {
)
}
// Handle service layer errors with specific messages
if (error instanceof Error) {
if (
error.message.includes('Invalid table name') ||
error.message.includes('Invalid schema') ||
error.message.includes('already exists') ||
error.message.includes('maximum table limit')
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error creating table:`, error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
}
@@ -368,26 +292,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Get tables
const tables = await db
.select({
id: userTableDefinitions.id,
name: userTableDefinitions.name,
description: userTableDefinitions.description,
schema: userTableDefinitions.schema,
rowCount: userTableDefinitions.rowCount,
maxRows: userTableDefinitions.maxRows,
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
})
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, params.workspaceId),
isNull(userTableDefinitions.deletedAt)
)
)
.orderBy(userTableDefinitions.createdAt)
// Get tables using service layer
const tables = await listTables(params.workspaceId)
logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`)
@@ -401,8 +307,10 @@ export async function GET(request: NextRequest) {
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
}
}),
totalCount: tables.length,

View File

@@ -485,3 +485,18 @@ export function serverErrorResponse(
): 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: TableColumnData): TableColumnData {
return {
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
}
}

View File

@@ -3,6 +3,7 @@
*
* @module tables/[tableId]/components
*/
export * from './filter-builder'
export * from './row-modal'
export * from './table-action-bar'
export * from './table-query-builder'
export * from './table-row-modal'

View File

@@ -42,9 +42,9 @@ interface Column {
}
/**
* Props for the FilterBuilder component.
* Props for the TableQueryBuilder component.
*/
interface FilterBuilderProps {
interface TableQueryBuilderProps {
/** Available columns for filtering */
columns: Column[]
/** Callback when query options should be applied */
@@ -169,14 +169,14 @@ function conditionsToFilter(conditions: FilterCondition[]): Record<string, Filte
*
* @example
* ```tsx
* <FilterBuilder
* <TableQueryBuilder
* columns={tableColumns}
* onApply={(options) => setQueryOptions(options)}
* onAddRow={() => setShowAddModal(true)}
* />
* ```
*/
export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps) {
export function TableQueryBuilder({ columns, onApply, onAddRow }: TableQueryBuilderProps) {
const [conditions, setConditions] = useState<FilterCondition[]>([])
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null)

View File

@@ -18,7 +18,7 @@ import {
import { Input } from '@/components/ui/input'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
const logger = createLogger('RowModal')
const logger = createLogger('TableRowModal')
/**
* Represents row data from the table.
@@ -49,14 +49,14 @@ export interface TableInfo {
/**
* Modal mode determines the operation and UI.
*/
export type RowModalMode = 'add' | 'edit' | 'delete'
export type TableRowModalMode = 'add' | 'edit' | 'delete'
/**
* Props for the RowModal component.
* Props for the TableRowModal component.
*/
export interface RowModalProps {
export interface TableRowModalProps {
/** The operation mode */
mode: RowModalMode
mode: TableRowModalMode
/** Whether the modal is open */
isOpen: boolean
/** Callback when the modal should close */
@@ -147,7 +147,7 @@ function formatValueForInput(value: unknown, type: string): string {
*
* @example Add mode:
* ```tsx
* <RowModal
* <TableRowModal
* mode="add"
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
@@ -158,7 +158,7 @@ function formatValueForInput(value: unknown, type: string): string {
*
* @example Edit mode:
* ```tsx
* <RowModal
* <TableRowModal
* mode="edit"
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
@@ -170,7 +170,7 @@ function formatValueForInput(value: unknown, type: string): string {
*
* @example Delete mode:
* ```tsx
* <RowModal
* <TableRowModal
* mode="delete"
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
@@ -180,7 +180,15 @@ function formatValueForInput(value: unknown, type: string): string {
* />
* ```
*/
export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) {
export function TableRowModal({
mode,
isOpen,
onClose,
table,
row,
rowIds,
onSuccess,
}: TableRowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string

View File

@@ -4,10 +4,10 @@
* @module tables/[tableId]/table-data-viewer/components
*/
export * from './cell-renderer'
export * from './cell-viewer-modal'
export * from './pagination'
export * from './row-context-menu'
export * from './schema-viewer-modal'
export * from './table-body-states'
export * from './table-cell-renderer'
export * from './table-header-bar'
export * from './table-pagination'

View File

@@ -9,14 +9,14 @@ import { Button, TableCell, TableRow } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import type { ColumnDefinition } from '@/lib/table'
interface LoadingRowsProps {
interface TableLoadingRowsProps {
columns: ColumnDefinition[]
}
/**
* Renders skeleton rows while table data is loading.
*/
export function LoadingRows({ columns }: LoadingRowsProps) {
export function TableLoadingRows({ columns }: TableLoadingRowsProps) {
return (
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
@@ -52,7 +52,7 @@ export function LoadingRows({ columns }: LoadingRowsProps) {
)
}
interface EmptyRowsProps {
interface TableEmptyRowsProps {
columnCount: number
hasFilter: boolean
onAddRow: () => void
@@ -61,7 +61,7 @@ interface EmptyRowsProps {
/**
* Renders an empty state when no rows are present.
*/
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
export function TableEmptyRows({ columnCount, hasFilter, onAddRow }: TableEmptyRowsProps) {
return (
<TableRow>
<TableCell colSpan={columnCount + 1} className='h-[160px] text-center'>

View File

@@ -1,14 +1,14 @@
/**
* Cell value renderer for different column types.
*
* @module tables/[tableId]/table-data-viewer/components/cell-renderer
* @module tables/[tableId]/table-data-viewer/components/table-cell-renderer
*/
import type { ColumnDefinition } from '@/lib/table'
import { STRING_TRUNCATE_LENGTH } from '../constants'
import type { CellViewerData } from '../types'
interface CellRendererProps {
interface TableCellRendererProps {
value: unknown
column: ColumnDefinition
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
@@ -20,7 +20,7 @@ interface CellRendererProps {
* @param props - Component props
* @returns Formatted cell content
*/
export function CellRenderer({ value, column, onCellClick }: CellRendererProps) {
export function TableCellRenderer({ value, column, onCellClick }: TableCellRendererProps) {
const isNull = value === null || value === undefined
if (isNull) {

View File

@@ -1,12 +1,12 @@
/**
* Pagination controls for the table.
*
* @module tables/[tableId]/table-data-viewer/components/pagination
* @module tables/[tableId]/table-data-viewer/components/table-pagination
*/
import { Button } from '@/components/emcn'
interface PaginationProps {
interface TablePaginationProps {
currentPage: number
totalPages: number
totalCount: number
@@ -18,15 +18,15 @@ interface PaginationProps {
* Renders pagination controls for navigating table pages.
*
* @param props - Component props
* @returns Pagination controls or null if only one page
* @returns Table pagination controls or null if only one page
*/
export function Pagination({
export function TablePagination({
currentPage,
totalPages,
totalCount,
onPreviousPage,
onNextPage,
}: PaginationProps) {
}: TablePaginationProps) {
if (totalPages <= 1) return null
return (

View File

@@ -5,7 +5,7 @@
*/
import { useQuery } from '@tanstack/react-query'
import type { QueryOptions } from '../../components/filter-builder'
import type { QueryOptions } from '../../components/table-query-builder'
import { ROWS_PER_PAGE } from '../constants'
import type { TableData, TableRowData } from '../types'

View File

@@ -19,16 +19,16 @@ import {
TableRow,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { FilterBuilder, type QueryOptions, RowModal, TableActionBar } from '../components'
import { type QueryOptions, TableActionBar, TableQueryBuilder, TableRowModal } from '../components'
import {
CellRenderer,
CellViewerModal,
EmptyRows,
LoadingRows,
Pagination,
RowContextMenu,
SchemaViewerModal,
TableCellRenderer,
TableEmptyRows,
TableHeaderBar,
TableLoadingRows,
TablePagination,
} from './components'
import { useContextMenu, useRowSelection, useTableData } from './hooks'
import type { CellViewerData, TableRowData } from './types'
@@ -199,7 +199,7 @@ export function TableDataViewer() {
/>
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
<FilterBuilder
<TableQueryBuilder
columns={columns}
onApply={handleApplyQueryOptions}
onAddRow={handleAddRow}
@@ -241,9 +241,9 @@ export function TableDataViewer() {
</TableHeader>
<TableBody>
{isLoadingRows ? (
<LoadingRows columns={columns} />
<TableLoadingRows columns={columns} />
) : rows.length === 0 ? (
<EmptyRows
<TableEmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={handleAddRow}
@@ -268,7 +268,7 @@ export function TableDataViewer() {
{columns.map((column) => (
<TableCell key={column.name}>
<div className='max-w-[300px] truncate text-[13px]'>
<CellRenderer
<TableCellRenderer
value={row.data[column.name]}
column={column}
onCellClick={handleCellClick}
@@ -283,7 +283,7 @@ export function TableDataViewer() {
</Table>
</div>
<Pagination
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
@@ -291,7 +291,7 @@ export function TableDataViewer() {
onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
/>
<RowModal
<TableRowModal
mode='add'
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
@@ -303,7 +303,7 @@ export function TableDataViewer() {
/>
{editingRow && (
<RowModal
<TableRowModal
mode='edit'
isOpen={true}
onClose={() => setEditingRow(null)}
@@ -317,7 +317,7 @@ export function TableDataViewer() {
)}
{deletingRows.length > 0 && (
<RowModal
<TableRowModal
mode='delete'
isOpen={true}
onClose={() => setDeletingRows([])}

View File

@@ -2,7 +2,7 @@
* Hook for filter builder functionality.
*
* Provides reusable filter condition management logic shared between
* the table data viewer's FilterBuilder and workflow block's FilterFormat.
* the table data viewer's TableQueryBuilder and workflow block's FilterFormat.
*/
import { useCallback, useMemo } from 'react'