simplicifcaiton

This commit is contained in:
Lakee Sivaraya
2026-01-16 12:07:50 -08:00
parent 5173320bb5
commit ea72ab5aa9
5 changed files with 101 additions and 311 deletions

View File

@@ -3,13 +3,8 @@ 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 { deleteTable, getTableById, type TableSchema } from '@/lib/table'
import {
checkTableAccess,
checkTableWriteAccess,
normalizeColumn,
verifyTableWorkspace,
} from '../utils'
import { deleteTable, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils'
const logger = createLogger('TableDetailAPI')
@@ -38,33 +33,19 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
workspaceId: searchParams.get('workspaceId'),
})
const accessCheck = await checkTableAccess(tableId, authResult.userId)
const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
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 ${authResult.userId} attempted to access unauthorized table ${tableId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessCheck.table.workspaceId}`
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const table = await getTableById(tableId)
if (!table) {
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`)
const schemaData = table.schema as TableSchema
@@ -122,23 +103,15 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams)
workspaceId: searchParams.get('workspaceId'),
})
const accessCheck = await checkTableWriteAccess(tableId, authResult.userId)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
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 ${authResult.userId} attempted to delete unauthorized table ${tableId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessCheck.table.workspaceId}`
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

View File

@@ -8,12 +8,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData, TableSchema } from '@/lib/table'
import { validateRowData } from '@/lib/table'
import {
checkAccessOrRespond,
checkTableAccess,
getTableById,
verifyTableWorkspace,
} from '../../../utils'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableRowAPI')
@@ -50,24 +45,15 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
workspaceId: searchParams.get('workspaceId'),
})
const accessCheck = await checkTableAccess(tableId, authResult.userId)
const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
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 ${authResult.userId} attempted to access row from unauthorized table ${tableId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { table } = result
const actualWorkspaceId = validated.workspaceId
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessCheck.table.workspaceId}`
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
@@ -84,7 +70,7 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, actualWorkspaceId)
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.limit(1)
@@ -133,23 +119,19 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
const body: unknown = await request.json()
const validated = UpdateRowSchema.parse(body)
const accessResult = await checkAccessOrRespond(tableId, authResult.userId, requestId, 'write')
if (accessResult instanceof NextResponse) return accessResult
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const actualWorkspaceId = validated.workspaceId
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessResult.table.workspaceId}`
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const table = await getTableById(tableId)
if (!table) {
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
const rowData = validated.data as RowData
const validation = await validateRowData({
@@ -172,7 +154,7 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, actualWorkspaceId)
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()
@@ -222,14 +204,15 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
const body: unknown = await request.json()
const validated = DeleteRowSchema.parse(body)
const accessResult = await checkAccessOrRespond(tableId, authResult.userId, requestId, 'write')
if (accessResult instanceof NextResponse) return accessResult
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const actualWorkspaceId = validated.workspaceId
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessResult.table.workspaceId}`
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
@@ -240,7 +223,7 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, actualWorkspaceId)
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()

View File

@@ -17,7 +17,7 @@ import {
validateUniqueConstraints,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/query-builder'
import { checkAccessOrRespond, checkAccessWithFullTable, verifyTableWorkspace } from '../../utils'
import { accessError, checkAccess, verifyTableWorkspace } from '../../utils'
const logger = createLogger('TableRowsAPI')
@@ -88,10 +88,10 @@ async function handleBatchInsert(
): Promise<NextResponse> {
const validated = BatchInsertRowsSchema.parse(body)
const accessResult = await checkAccessWithFullTable(tableId, userId, requestId, 'write')
if (accessResult instanceof NextResponse) return accessResult
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const table = accessResult.table
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
@@ -178,15 +178,10 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
const validated = InsertRowSchema.parse(body)
const accessResult = await checkAccessWithFullTable(
tableId,
authResult.userId,
requestId,
'write'
)
if (accessResult instanceof NextResponse) return accessResult
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const table = accessResult.table
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
@@ -295,13 +290,8 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
offset,
})
const accessResult = await checkAccessWithFullTable(
tableId,
authResult.userId,
requestId,
'read'
)
if (accessResult instanceof NextResponse) return accessResult
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
@@ -400,15 +390,10 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
const body: unknown = await request.json()
const validated = UpdateRowsByFilterSchema.parse(body)
const accessResult = await checkAccessWithFullTable(
tableId,
authResult.userId,
requestId,
'write'
)
if (accessResult instanceof NextResponse) return accessResult
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const table = accessResult.table
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
@@ -583,13 +568,15 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
const body: unknown = await request.json()
const validated = DeleteRowsByFilterSchema.parse(body)
const accessResult = await checkAccessOrRespond(tableId, authResult.userId, requestId, 'write')
if (accessResult instanceof NextResponse) return accessResult
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessResult.table.workspaceId}`
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

View File

@@ -8,7 +8,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData, TableSchema } from '@/lib/table'
import { getUniqueColumns, validateRowData } from '@/lib/table'
import { checkAccessOrRespond, getTableById, verifyTableWorkspace } from '../../../utils'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableUpsertAPI')
@@ -35,23 +35,17 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
const body: unknown = await request.json()
const validated = UpsertRowSchema.parse(body)
const accessResult = await checkAccessOrRespond(tableId, authResult.userId, requestId, 'write')
if (accessResult instanceof NextResponse) return accessResult
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const actualWorkspaceId = validated.workspaceId || accessResult.table.workspaceId
if (validated.workspaceId) {
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${accessResult.table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
}
const { table } = result
const table = await getTableById(tableId)
if (!table) {
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const schema = table.schema as TableSchema
@@ -102,7 +96,7 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
.where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, actualWorkspaceId),
eq(userTableRows.workspaceId, validated.workspaceId),
...validUniqueFilters
)
)
@@ -110,7 +104,7 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
const now = new Date()
const result = await db.transaction(async (trx) => {
const upsertResult = await db.transaction(async (trx) => {
if (existingRow) {
const [updatedRow] = await trx
.update(userTableRows)
@@ -132,7 +126,7 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
.values({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId,
workspaceId: actualWorkspaceId,
workspaceId: validated.workspaceId,
data: validated.data,
createdAt: now,
updatedAt: now,
@@ -147,20 +141,20 @@ export async function POST(request: NextRequest, { params }: UpsertRouteParams)
})
logger.info(
`[${requestId}] Upserted (${result.operation}) row ${result.row.id} in table ${tableId}`
`[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}`
)
return NextResponse.json({
success: true,
data: {
row: {
id: result.row.id,
data: result.row.data,
createdAt: result.row.createdAt.toISOString(),
updatedAt: result.row.updatedAt.toISOString(),
id: upsertResult.row.id,
data: upsertResult.row.data,
createdAt: upsertResult.row.createdAt.toISOString(),
updatedAt: upsertResult.row.updatedAt.toISOString(),
},
operation: result.operation,
message: `Row ${result.operation === 'update' ? 'updated' : 'inserted'} successfully`,
operation: upsertResult.operation,
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
},
})
} catch (error) {

View File

@@ -1,196 +1,56 @@
import { db } from '@sim/db'
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, TableDefinition } from '@/lib/table'
import { getTableById } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('TableUtils')
type PermissionLevel = 'read' | 'write' | 'admin'
/** @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 type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 }
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(
export async function checkAccess(
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)
level: 'read' | 'write' | 'admin' = 'read'
): Promise<AccessResult> {
const table = await getTableById(tableId)
if (!table) {
return null
return { ok: false, status: 404 }
}
const rowCount = await getTableRowCount(tableId)
return { ...table, rowCount } as unknown as TableDefinition
if (table.createdBy === userId) {
return { ok: true, table }
}
const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
const hasAccess =
permission !== null &&
(level === 'read' ||
(level === 'write' && (permission === 'write' || permission === 'admin')) ||
(level === 'admin' && permission === 'admin'))
return hasAccess ? { ok: true, table } : { ok: false, status: 403 }
}
export function accessError(
result: { ok: false; status: 404 | 403 },
requestId: string,
context?: string
): NextResponse {
const message = result.status === 404 ? 'Table not found' : 'Access denied'
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
return NextResponse.json({ error: message }, { status: result.status })
}
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
): boolean {
if (userPermission === null) return false
switch (requiredLevel) {
case 'read':
return true
case 'write':
return userPermission === 'write' || userPermission === 'admin'
case 'admin':
return userPermission === 'admin'
default:
return false
}
}
async function getTableRowCount(tableId: string): Promise<number> {
const [result] = await db
.select({ count: count() })
.from(userTableRows)
.where(eq(userTableRows.tableId, tableId))
return Number(result?.count ?? 0)
const table = await getTableById(tableId)
return table?.workspaceId === workspaceId
}
export function errorResponse(
@@ -205,30 +65,23 @@ export function errorResponse(
return NextResponse.json(body, { status })
}
export function badRequestResponse(
message: string,
details?: unknown
): NextResponse<ApiErrorResponse> {
export function badRequestResponse(message: string, details?: unknown) {
return errorResponse(message, 400, details)
}
export function unauthorizedResponse(
message = 'Authentication required'
): NextResponse<ApiErrorResponse> {
export function unauthorizedResponse(message = 'Authentication required') {
return errorResponse(message, 401)
}
export function forbiddenResponse(message = 'Access denied'): NextResponse<ApiErrorResponse> {
export function forbiddenResponse(message = 'Access denied') {
return errorResponse(message, 403)
}
export function notFoundResponse(message = 'Resource not found'): NextResponse<ApiErrorResponse> {
export function notFoundResponse(message = 'Resource not found') {
return errorResponse(message, 404)
}
export function serverErrorResponse(
message = 'Internal server error'
): NextResponse<ApiErrorResponse> {
export function serverErrorResponse(message = 'Internal server error') {
return errorResponse(message, 500)
}