chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Admin Access Control (Permission Groups) API
|
||||
*
|
||||
* GET /api/v1/admin/access-control
|
||||
* List all permission groups with optional filtering.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId?: string - Filter by organization ID
|
||||
*
|
||||
* Response: { data: AdminPermissionGroup[], pagination: PaginationMeta }
|
||||
*
|
||||
* DELETE /api/v1/admin/access-control
|
||||
* Delete permission groups scoped to an organization.
|
||||
* Used when an enterprise plan churns to clean up access control data.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - organizationId: string - Delete all permission groups for this organization
|
||||
* - reason?: string - Reason recorded in audit log (default: "Enterprise plan churn cleanup")
|
||||
*
|
||||
* Response: { success: true, deletedCount: number, membersRemoved: number }
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq, inArray, sql } from 'drizzle-orm'
|
||||
import {
|
||||
type AdminV1PermissionGroup,
|
||||
adminV1DeleteAccessControlContract,
|
||||
adminV1ListAccessControlContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminAccessControlAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(
|
||||
adminV1ListAccessControlContract,
|
||||
request,
|
||||
{},
|
||||
{ validationErrorResponse: adminValidationErrorResponse }
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { organizationId } = parsed.data.query
|
||||
|
||||
try {
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
organizationName: organization.name,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
createdByUserId: permissionGroup.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(organization, eq(permissionGroup.organizationId, organization.id))
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
|
||||
const groups = organizationId
|
||||
? await baseQuery.where(eq(permissionGroup.organizationId, organizationId))
|
||||
: await baseQuery
|
||||
|
||||
const groupsWithCounts = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const [memberCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, group.id))
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
organizationId: group.organizationId,
|
||||
organizationName: group.organizationName,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
isDefault: group.isDefault,
|
||||
memberCount: memberCount?.count ?? 0,
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
createdByUserId: group.createdByUserId,
|
||||
createdByEmail: group.createdByEmail,
|
||||
} as AdminV1PermissionGroup
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('Admin API: Listed permission groups', {
|
||||
organizationId,
|
||||
count: groupsWithCounts.length,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
data: groupsWithCounts,
|
||||
pagination: {
|
||||
total: groupsWithCounts.length,
|
||||
limit: groupsWithCounts.length,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list permission groups', {
|
||||
error,
|
||||
organizationId,
|
||||
})
|
||||
return internalErrorResponse('Failed to list permission groups')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(
|
||||
adminV1DeleteAccessControlContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { organizationId, reason: rawReason } = parsed.data.query
|
||||
// The contract's refine guarantees this at the boundary; this explicit guard
|
||||
// narrows the type (avoiding a non-null assertion) and stays correct even if
|
||||
// the contract changes.
|
||||
if (!organizationId) {
|
||||
return badRequestResponse('organizationId is required')
|
||||
}
|
||||
const reason = rawReason || 'Enterprise plan churn cleanup'
|
||||
|
||||
try {
|
||||
const existingGroups = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
|
||||
if (existingGroups.length === 0) {
|
||||
logger.info('Admin API: No permission groups to delete', { organizationId })
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
membersRemoved: 0,
|
||||
message: 'No permission groups found for the given scope',
|
||||
})
|
||||
}
|
||||
|
||||
const groupIds = existingGroups.map((g) => g.id)
|
||||
|
||||
const [memberCountResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(permissionGroupMember)
|
||||
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
|
||||
|
||||
const membersToRemove = Number(memberCountResult?.count ?? 0)
|
||||
|
||||
await db.delete(permissionGroup).where(inArray(permissionGroup.id, groupIds))
|
||||
|
||||
for (const group of existingGroups) {
|
||||
recordAudit({
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.PERMISSION_GROUP_DELETED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: group.id,
|
||||
resourceName: group.name,
|
||||
description: `Admin API deleted permission group "${group.name}"`,
|
||||
metadata: { reason, organizationId },
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Admin API: Deleted permission groups', {
|
||||
organizationId,
|
||||
deletedCount: existingGroups.length,
|
||||
membersRemoved: membersToRemove,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
deletedCount: existingGroups.length,
|
||||
membersRemoved: membersToRemove,
|
||||
reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete permission groups', {
|
||||
error,
|
||||
organizationId,
|
||||
})
|
||||
return internalErrorResponse('Failed to delete permission groups')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GET /api/v1/admin/audit-logs/[id]
|
||||
*
|
||||
* Get a single audit log entry by ID.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminAuditLog>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { auditLog } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { v1AdminGetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { toAdminAuditLog } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminAuditLogDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(v1AdminGetAuditLogContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1)
|
||||
|
||||
if (!log) {
|
||||
return notFoundResponse('AuditLog')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved audit log ${id}`)
|
||||
|
||||
return singleResponse(toAdminAuditLog(log))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get audit log', { error, id })
|
||||
return internalErrorResponse('Failed to get audit log')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* GET /api/v1/admin/audit-logs
|
||||
*
|
||||
* List all audit logs with pagination and filtering.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
* - action: string (optional) - Filter by action (e.g., "workflow.created")
|
||||
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
|
||||
* - resourceId: string (optional) - Filter by resource ID
|
||||
* - workspaceId: string (optional) - Filter by workspace ID
|
||||
* - actorId: string (optional) - Filter by actor user ID
|
||||
* - actorEmail: string (optional) - Filter by actor email
|
||||
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
|
||||
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
|
||||
*
|
||||
* Response: AdminListResponse<AdminAuditLog>
|
||||
*/
|
||||
|
||||
import { dbReplica } from '@sim/db'
|
||||
import { auditLog } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, desc } from 'drizzle-orm'
|
||||
import { v1AdminListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminAuditLog,
|
||||
createPaginationMeta,
|
||||
parsePaginationParams,
|
||||
toAdminAuditLog,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
import { buildFilterConditions } from '@/app/api/v1/audit-logs/query'
|
||||
|
||||
const logger = createLogger('AdminAuditLogsAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, offset } = parsePaginationParams(url)
|
||||
|
||||
const parsed = await parseRequest(
|
||||
v1AdminListAuditLogsContract,
|
||||
request,
|
||||
{},
|
||||
{ validationErrorResponse: adminValidationErrorResponse }
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
try {
|
||||
const query = parsed.data.query
|
||||
const conditions = buildFilterConditions({
|
||||
action: query.action,
|
||||
resourceType: query.resourceType,
|
||||
resourceId: query.resourceId,
|
||||
workspaceId: query.workspaceId,
|
||||
actorId: query.actorId,
|
||||
actorEmail: query.actorEmail,
|
||||
startDate: query.startDate,
|
||||
endDate: query.endDate,
|
||||
})
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [countResult, logs] = await Promise.all([
|
||||
dbReplica.select({ total: count() }).from(auditLog).where(whereClause),
|
||||
dbReplica
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(auditLog.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminAuditLog[] = logs.map(toAdminAuditLog)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list audit logs', { error })
|
||||
return internalErrorResponse('Failed to list audit logs')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Admin API Authentication
|
||||
*
|
||||
* Authenticates admin API requests using the ADMIN_API_KEY environment variable.
|
||||
* Designed for self-hosted deployments where GitOps/scripted access is needed.
|
||||
*
|
||||
* Usage:
|
||||
* curl -H "x-admin-key: your_admin_key" https://your-instance/api/v1/admin/...
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('AdminAuth')
|
||||
|
||||
interface AdminAuthSuccess {
|
||||
authenticated: true
|
||||
}
|
||||
|
||||
interface AdminAuthFailure {
|
||||
authenticated: false
|
||||
error: string
|
||||
notConfigured?: boolean
|
||||
}
|
||||
|
||||
export type AdminAuthResult = AdminAuthSuccess | AdminAuthFailure
|
||||
|
||||
/**
|
||||
* Authenticate an admin API request.
|
||||
*
|
||||
* @param request - The incoming Next.js request
|
||||
* @returns Authentication result with success status and optional error
|
||||
*/
|
||||
export function authenticateAdminRequest(request: NextRequest): AdminAuthResult {
|
||||
const adminKey = env.ADMIN_API_KEY
|
||||
|
||||
if (!adminKey) {
|
||||
logger.warn('ADMIN_API_KEY environment variable is not set')
|
||||
return {
|
||||
authenticated: false,
|
||||
error: 'Admin API is not configured. Set ADMIN_API_KEY environment variable.',
|
||||
notConfigured: true,
|
||||
}
|
||||
}
|
||||
|
||||
const providedKey = request.headers.get('x-admin-key')
|
||||
|
||||
if (!providedKey) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: 'Admin API key required. Provide x-admin-key header.',
|
||||
}
|
||||
}
|
||||
|
||||
if (!safeCompare(providedKey, adminKey)) {
|
||||
logger.warn('Invalid admin API key attempted', { keyPrefix: providedKey.slice(0, 8) })
|
||||
return {
|
||||
authenticated: false,
|
||||
error: 'Invalid admin API key',
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated: true }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* POST /api/v1/admin/credits
|
||||
*
|
||||
* Issue credits to a user by user ID or email.
|
||||
*
|
||||
* Body:
|
||||
* - userId?: string - The user ID to issue credits to
|
||||
* - email?: string - The user email to issue credits to (alternative to userId)
|
||||
* - amount: number - The amount of credits to issue (in dollars)
|
||||
* - reason?: string - Reason for issuing credits (for audit logging)
|
||||
*
|
||||
* Response: AdminSingleResponse<{
|
||||
* success: true,
|
||||
* entityType: 'user' | 'organization',
|
||||
* entityId: string,
|
||||
* amount: number,
|
||||
* newCreditBalance: number,
|
||||
* newUsageLimit: number,
|
||||
* }>
|
||||
*
|
||||
* For Pro users: credits are added to user_stats.credit_balance
|
||||
* For Team users: credits are added to organization.credit_balance
|
||||
* Usage limits are updated accordingly to allow spending the credits.
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { organization, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { adminV1IssueCreditsContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { addCredits } from '@/lib/billing/credits/balance'
|
||||
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||
import { isPaid } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
ENTITLED_SUBSCRIPTION_STATUSES,
|
||||
getEffectiveSeats,
|
||||
isOrgScopedSubscription,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminInvalidJsonResponse,
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminCreditsAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(
|
||||
adminV1IssueCreditsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJsonResponse: adminInvalidJsonResponse,
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
try {
|
||||
const { userId, email, amount, reason } = parsed.data.body
|
||||
|
||||
let resolvedUserId: string
|
||||
let userEmail: string | null = null
|
||||
|
||||
if (userId) {
|
||||
const [userData] = await db
|
||||
.select({ id: user.id, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
resolvedUserId = userData.id
|
||||
userEmail = userData.email
|
||||
} else {
|
||||
if (!email) {
|
||||
return badRequestResponse('Either userId or email is required')
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
const [userData] = await db
|
||||
.select({ id: user.id, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.email, normalizedEmail))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User with email')
|
||||
}
|
||||
resolvedUserId = userData.id
|
||||
userEmail = userData.email
|
||||
}
|
||||
|
||||
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
|
||||
|
||||
if (!userSubscription || !isPaid(userSubscription.plan)) {
|
||||
return badRequestResponse(
|
||||
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
|
||||
)
|
||||
}
|
||||
|
||||
let entityType: 'user' | 'organization'
|
||||
let entityId: string
|
||||
const plan = userSubscription.plan
|
||||
let seats: number | null = null
|
||||
|
||||
// Route admin credits to the subscription's entity (org if org-scoped).
|
||||
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
|
||||
entityType = 'organization'
|
||||
entityId = userSubscription.referenceId
|
||||
|
||||
const [orgExists] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, entityId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgExists) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [subData] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, entityId),
|
||||
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
seats = getEffectiveSeats(subData)
|
||||
} else {
|
||||
entityType = 'user'
|
||||
entityId = resolvedUserId
|
||||
|
||||
const [existingStats] = await db
|
||||
.select({ id: userStats.id })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, entityId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingStats) {
|
||||
await db.insert(userStats).values({
|
||||
id: generateShortId(),
|
||||
userId: entityId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await addCredits(entityType, entityId, amount)
|
||||
|
||||
let newCreditBalance: number
|
||||
if (entityType === 'organization') {
|
||||
const [orgData] = await db
|
||||
.select({ creditBalance: organization.creditBalance })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, entityId))
|
||||
.limit(1)
|
||||
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
|
||||
} else {
|
||||
const [stats] = await db
|
||||
.select({ creditBalance: userStats.creditBalance })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, entityId))
|
||||
.limit(1)
|
||||
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
|
||||
}
|
||||
|
||||
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
|
||||
|
||||
let newUsageLimit: number
|
||||
if (entityType === 'organization') {
|
||||
const [orgData] = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, entityId))
|
||||
.limit(1)
|
||||
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
|
||||
} else {
|
||||
const [stats] = await db
|
||||
.select({ currentUsageLimit: userStats.currentUsageLimit })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, entityId))
|
||||
.limit(1)
|
||||
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
|
||||
}
|
||||
|
||||
logger.info('Admin API: Issued credits', {
|
||||
resolvedUserId,
|
||||
userEmail,
|
||||
entityType,
|
||||
entityId,
|
||||
amount,
|
||||
newCreditBalance,
|
||||
newUsageLimit,
|
||||
reason: reason || 'No reason provided',
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.CREDIT_ISSUED,
|
||||
resourceType: AuditResourceType.BILLING,
|
||||
resourceId: entityId,
|
||||
description: `Admin API issued $${Number(amount).toFixed(2)} credits to ${entityType} ${entityId}`,
|
||||
metadata: {
|
||||
targetUserId: resolvedUserId,
|
||||
...(entityType === 'organization'
|
||||
? { targetOrgId: entityId, organizationId: entityId }
|
||||
: {}),
|
||||
entityType,
|
||||
amount,
|
||||
currency: 'usd',
|
||||
reason: reason || null,
|
||||
newCreditBalance,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
userId: resolvedUserId,
|
||||
userEmail,
|
||||
entityType,
|
||||
entityId,
|
||||
amount,
|
||||
newCreditBalance,
|
||||
newUsageLimit,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to issue credits', { error })
|
||||
return internalErrorResponse('Failed to issue credits')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* GET /api/v1/admin/folders/[id]/export
|
||||
*
|
||||
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
|
||||
*
|
||||
* Query Parameters:
|
||||
* - format: 'zip' (default) or 'json'
|
||||
*
|
||||
* Response:
|
||||
* - ZIP file download (Content-Type: application/zip)
|
||||
* - JSON: FolderExportFullPayload
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { adminV1ExportFolderContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { encodeFilenameForHeader } from '@/app/api/files/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type FolderExportPayload,
|
||||
parseWorkflowVariables,
|
||||
type WorkflowExportState,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminFolderExportAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface CollectedWorkflow {
|
||||
id: string
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all workflows within a folder and its subfolders.
|
||||
*/
|
||||
function collectWorkflowsInFolder(
|
||||
folderId: string,
|
||||
allWorkflows: Array<{ id: string; folderId: string | null }>,
|
||||
allFolders: Array<{ id: string; parentId: string | null }>
|
||||
): CollectedWorkflow[] {
|
||||
const collected: CollectedWorkflow[] = []
|
||||
|
||||
for (const wf of allWorkflows) {
|
||||
if (wf.folderId === folderId) {
|
||||
collected.push({ id: wf.id, folderId: wf.folderId })
|
||||
}
|
||||
}
|
||||
|
||||
for (const folder of allFolders) {
|
||||
if (folder.parentId === folderId) {
|
||||
const childWorkflows = collectWorkflowsInFolder(folder.id, allWorkflows, allFolders)
|
||||
collected.push(...childWorkflows)
|
||||
}
|
||||
}
|
||||
|
||||
return collected
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all subfolders recursively under a root folder.
|
||||
* Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null.
|
||||
*/
|
||||
function collectSubfolders(
|
||||
rootFolderId: string,
|
||||
allFolders: Array<{ id: string; name: string; parentId: string | null }>
|
||||
): FolderExportPayload[] {
|
||||
const subfolders: FolderExportPayload[] = []
|
||||
|
||||
function collect(parentId: string) {
|
||||
for (const folder of allFolders) {
|
||||
if (folder.parentId === parentId) {
|
||||
subfolders.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
|
||||
})
|
||||
collect(folder.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(rootFolderId)
|
||||
return subfolders
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ExportFolderContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: folderId } = parsed.data.params
|
||||
const { format } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [folderData] = await db
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
name: workflowFolder.name,
|
||||
workspaceId: workflowFolder.workspaceId,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, folderId))
|
||||
.limit(1)
|
||||
|
||||
if (!folderData) {
|
||||
return notFoundResponse('Folder')
|
||||
}
|
||||
|
||||
const allWorkflows = await db
|
||||
.select({ id: workflow.id, folderId: workflow.folderId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, folderData.workspaceId))
|
||||
|
||||
const allFolders = await db
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
name: workflowFolder.name,
|
||||
parentId: workflowFolder.parentId,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, folderData.workspaceId))
|
||||
|
||||
const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders)
|
||||
const subfolders = collectSubfolders(folderId, allFolders)
|
||||
|
||||
const workflowExports: Array<{
|
||||
workflow: {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
}
|
||||
state: WorkflowExportState
|
||||
}> = []
|
||||
|
||||
for (const collectedWf of workflowsInFolder) {
|
||||
try {
|
||||
const [wfData] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, collectedWf.id))
|
||||
.limit(1)
|
||||
|
||||
if (!wfData) {
|
||||
logger.warn(`Skipping workflow ${collectedWf.id} - not found`)
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id)
|
||||
|
||||
if (!normalizedData) {
|
||||
logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`)
|
||||
continue
|
||||
}
|
||||
|
||||
const variables = parseWorkflowVariables(wfData.variables)
|
||||
|
||||
const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId
|
||||
|
||||
const state: WorkflowExportState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
metadata: {
|
||||
name: wfData.name,
|
||||
description: wfData.description ?? undefined,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables,
|
||||
}
|
||||
|
||||
workflowExports.push({
|
||||
workflow: {
|
||||
id: wfData.id,
|
||||
name: wfData.name,
|
||||
description: wfData.description,
|
||||
folderId: remappedFolderId,
|
||||
},
|
||||
state,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load workflow ${collectedWf.id}:`, { error })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders`
|
||||
)
|
||||
|
||||
if (format === 'json') {
|
||||
const exportPayload = {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
folder: {
|
||||
id: folderData.id,
|
||||
name: folderData.name,
|
||||
},
|
||||
workflows: workflowExports,
|
||||
folders: subfolders,
|
||||
}
|
||||
|
||||
return singleResponse(exportPayload)
|
||||
}
|
||||
|
||||
const zipWorkflows = workflowExports.map((wf) => ({
|
||||
workflow: {
|
||||
id: wf.workflow.id,
|
||||
name: wf.workflow.name,
|
||||
description: wf.workflow.description ?? undefined,
|
||||
folderId: wf.workflow.folderId,
|
||||
},
|
||||
state: wf.state,
|
||||
variables: wf.state.variables,
|
||||
}))
|
||||
|
||||
const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders)
|
||||
const arrayBuffer = await zipBlob.arrayBuffer()
|
||||
|
||||
const sanitizedName = sanitizePathSegment(folderData.name)
|
||||
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
|
||||
|
||||
return new NextResponse(arrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`,
|
||||
'Content-Length': arrayBuffer.byteLength.toString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to export folder', { error, folderId })
|
||||
return internalErrorResponse('Failed to export folder')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { authenticateAdminRequest } from '@/app/api/v1/admin/auth'
|
||||
import { notConfiguredResponse, unauthorizedResponse } from '@/app/api/v1/admin/responses'
|
||||
|
||||
export type AdminRouteHandler = (request: NextRequest) => Promise<NextResponse>
|
||||
|
||||
export type AdminRouteHandlerWithParams<TParams> = (
|
||||
request: NextRequest,
|
||||
context: { params: Promise<TParams> }
|
||||
) => Promise<NextResponse>
|
||||
|
||||
/**
|
||||
* Wrap a route handler with admin authentication.
|
||||
* Returns early with an error response if authentication fails.
|
||||
*/
|
||||
export function withAdminAuth(handler: AdminRouteHandler): AdminRouteHandler {
|
||||
return async (request: NextRequest) => {
|
||||
const auth = authenticateAdminRequest(request)
|
||||
|
||||
if (!auth.authenticated) {
|
||||
if (auth.notConfigured) {
|
||||
return notConfiguredResponse()
|
||||
}
|
||||
return unauthorizedResponse(auth.error)
|
||||
}
|
||||
|
||||
return handler(request)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a route handler with params with admin authentication.
|
||||
* Returns early with an error response if authentication fails.
|
||||
*/
|
||||
export function withAdminAuthParams<TParams>(
|
||||
handler: AdminRouteHandlerWithParams<TParams>
|
||||
): AdminRouteHandlerWithParams<TParams> {
|
||||
return async (request: NextRequest, context: { params: Promise<TParams> }) => {
|
||||
const auth = authenticateAdminRequest(request)
|
||||
|
||||
if (!auth.authenticated) {
|
||||
if (auth.notConfigured) {
|
||||
return notConfiguredResponse()
|
||||
}
|
||||
return unauthorizedResponse(auth.error)
|
||||
}
|
||||
|
||||
return handler(request, context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/billing
|
||||
*
|
||||
* Get organization billing summary including usage, seats, and member data.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganizationBillingSummary>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]/billing
|
||||
*
|
||||
* Update organization billing settings.
|
||||
*
|
||||
* Body:
|
||||
* - orgUsageLimit?: number - New usage limit (null to clear)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, orgUsageLimit: string | null }>
|
||||
*/
|
||||
|
||||
import { db, dbReplica } from '@sim/db'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1GetOrganizationBillingContract,
|
||||
adminV1UpdateOrganizationBillingContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminOrganizationBillingSummary } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationBillingAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetOrganizationBillingContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
try {
|
||||
if (!isBillingEnabled) {
|
||||
const [[orgData], [memberCount]] = await Promise.all([
|
||||
db
|
||||
.select({ id: organization.id, name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId)),
|
||||
])
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const data: AdminOrganizationBillingSummary = {
|
||||
organizationId: orgData.id,
|
||||
organizationName: orgData.name,
|
||||
subscriptionPlan: 'none',
|
||||
subscriptionStatus: 'none',
|
||||
totalSeats: Number.MAX_SAFE_INTEGER,
|
||||
usedSeats: memberCount?.count || 0,
|
||||
availableSeats: Number.MAX_SAFE_INTEGER,
|
||||
totalCurrentUsage: 0,
|
||||
totalUsageLimit: Number.MAX_SAFE_INTEGER,
|
||||
minimumBillingAmount: 0,
|
||||
averageUsagePerMember: 0,
|
||||
usagePercentage: 0,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
membersOverLimit: 0,
|
||||
membersNearLimit: 0,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)`
|
||||
)
|
||||
|
||||
return singleResponse(data)
|
||||
}
|
||||
|
||||
const billingData = await getOrganizationBillingData(organizationId, dbReplica)
|
||||
|
||||
if (!billingData) {
|
||||
return notFoundResponse('Organization or subscription')
|
||||
}
|
||||
|
||||
const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length
|
||||
const membersNearLimit = billingData.members.filter(
|
||||
(m) => !m.isOverLimit && m.percentUsed >= 80
|
||||
).length
|
||||
const usagePercentage =
|
||||
billingData.totalUsageLimit > 0
|
||||
? Math.round((billingData.totalCurrentUsage / billingData.totalUsageLimit) * 10000) / 100
|
||||
: 0
|
||||
|
||||
const data: AdminOrganizationBillingSummary = {
|
||||
organizationId: billingData.organizationId,
|
||||
organizationName: billingData.organizationName,
|
||||
subscriptionPlan: billingData.subscriptionPlan,
|
||||
subscriptionStatus: billingData.subscriptionStatus,
|
||||
totalSeats: billingData.totalSeats,
|
||||
usedSeats: billingData.usedSeats,
|
||||
availableSeats: billingData.totalSeats - billingData.usedSeats,
|
||||
totalCurrentUsage: billingData.totalCurrentUsage,
|
||||
totalUsageLimit: billingData.totalUsageLimit,
|
||||
minimumBillingAmount: billingData.minimumBillingAmount,
|
||||
averageUsagePerMember: billingData.averageUsagePerMember,
|
||||
usagePercentage,
|
||||
billingPeriodStart: billingData.billingPeriodStart?.toISOString() ?? null,
|
||||
billingPeriodEnd: billingData.billingPeriodEnd?.toISOString() ?? null,
|
||||
membersOverLimit,
|
||||
membersNearLimit,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved billing summary for organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get organization billing', { error, organizationId })
|
||||
return internalErrorResponse('Failed to get organization billing')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const routeParams = await context.params
|
||||
const { id: organizationId } = routeParams
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(
|
||||
adminV1UpdateOrganizationBillingContract,
|
||||
request,
|
||||
{ params: routeParams },
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJson: 'throw',
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const [orgData] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const { orgUsageLimit } = parsed.data.body
|
||||
|
||||
if (orgUsageLimit !== undefined) {
|
||||
let newLimit: string | null = null
|
||||
|
||||
if (orgUsageLimit === null) {
|
||||
newLimit = null
|
||||
} else {
|
||||
newLimit = orgUsageLimit.toFixed(2)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(organization)
|
||||
.set({
|
||||
orgUsageLimit: newLimit,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(organization.id, organizationId))
|
||||
|
||||
logger.info(`Admin API: Updated usage limit for organization ${organizationId}`, {
|
||||
newLimit,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
orgUsageLimit: newLimit,
|
||||
})
|
||||
}
|
||||
|
||||
return badRequestResponse('No valid fields to update')
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update organization billing', { error, organizationId })
|
||||
return internalErrorResponse('Failed to update organization billing')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/members/[memberId]
|
||||
*
|
||||
* Get member details.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminMemberDetail>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]/members/[memberId]
|
||||
*
|
||||
* Update member role.
|
||||
*
|
||||
* Body:
|
||||
* - role: string - New role ('admin' | 'member')
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminMember>
|
||||
*
|
||||
* DELETE /api/v1/admin/organizations/[id]/members/[memberId]
|
||||
*
|
||||
* Remove member from organization with full billing logic.
|
||||
* Handles departed usage capture and Pro restoration like the regular flow.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - skipBillingLogic: boolean - Skip billing logic (default: false)
|
||||
*
|
||||
* Response: { success: true, memberId: string, billingActions: {...} }
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1GetOrganizationMemberContract,
|
||||
adminV1RemoveOrganizationMemberContract,
|
||||
adminV1UpdateOrganizationMemberContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
|
||||
import {
|
||||
removeUserFromOrganization,
|
||||
WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminMember, AdminMemberDetail } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationMemberDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
memberId: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetOrganizationMemberContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId, memberId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [memberData] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
billingBlocked: userStats.billingBlocked,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberData) {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
|
||||
// currentPeriodCost is only a baseline; add this member's attributed
|
||||
// usage_log for the org's period so admin shows real current usage.
|
||||
const ledgerByUser = await getOrgMemberLedgerByUser(organizationId)
|
||||
|
||||
const data: AdminMemberDetail = {
|
||||
id: memberData.id,
|
||||
userId: memberData.userId,
|
||||
organizationId: memberData.organizationId,
|
||||
role: memberData.role,
|
||||
createdAt: memberData.createdAt.toISOString(),
|
||||
userName: memberData.userName,
|
||||
userEmail: memberData.userEmail,
|
||||
currentPeriodCost: (
|
||||
Number(memberData.currentPeriodCost ?? 0) + (ledgerByUser.get(memberData.userId) ?? 0)
|
||||
).toString(),
|
||||
currentUsageLimit: memberData.currentUsageLimit,
|
||||
billingBlocked: memberData.billingBlocked ?? false,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved member ${memberId} from organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get member', { error, organizationId, memberId })
|
||||
return internalErrorResponse('Failed to get member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const routeParams = await context.params
|
||||
const { id: organizationId, memberId } = routeParams
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(
|
||||
adminV1UpdateOrganizationMemberContract,
|
||||
request,
|
||||
{ params: routeParams },
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJson: 'throw',
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { role } = parsed.data.body
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
|
||||
if (existingMember.role === 'owner') {
|
||||
return badRequestResponse('Cannot change owner role')
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(member)
|
||||
.set({ role })
|
||||
.where(eq(member.id, memberId))
|
||||
.returning()
|
||||
|
||||
const [userData] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, updated.userId))
|
||||
.limit(1)
|
||||
|
||||
const data: AdminMember = {
|
||||
id: updated.id,
|
||||
userId: updated.userId,
|
||||
organizationId: updated.organizationId,
|
||||
role: updated.role,
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
userName: userData?.name ?? '',
|
||||
userEmail: userData?.email ?? '',
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated member ${memberId} role to ${role}`, {
|
||||
organizationId,
|
||||
previousRole: existingMember.role,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
description: `Admin API changed organization member role to ${role}`,
|
||||
metadata: {
|
||||
memberId,
|
||||
targetUserId: existingMember.userId,
|
||||
previousRole: existingMember.role,
|
||||
role,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update member', { error, organizationId, memberId })
|
||||
return internalErrorResponse('Failed to update member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1RemoveOrganizationMemberContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId, memberId } = parsed.data.params
|
||||
const skipBillingLogic = !isBillingEnabled || parsed.data.query.skipBillingLogic
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
|
||||
const userId = existingMember.userId
|
||||
|
||||
const result = await removeUserFromOrganization({
|
||||
userId,
|
||||
organizationId,
|
||||
memberId,
|
||||
skipBillingLogic,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error === 'Cannot remove organization owner') {
|
||||
return badRequestResponse(result.error)
|
||||
}
|
||||
if (result.error === 'Member not found') {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
if (result.error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR) {
|
||||
return badRequestResponse(result.error)
|
||||
}
|
||||
return internalErrorResponse(result.error || 'Failed to remove member')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Removed member ${memberId} from organization ${organizationId}`, {
|
||||
userId,
|
||||
billingActions: result.billingActions,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.ORG_MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
description: 'Admin API removed member from organization',
|
||||
metadata: { memberId, targetUserId: userId },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
memberId,
|
||||
userId,
|
||||
billingActions: {
|
||||
usageCaptured: result.billingActions.usageCaptured,
|
||||
proRestored: result.billingActions.proRestored,
|
||||
usageRestored: result.billingActions.usageRestored,
|
||||
skipBillingLogic,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to remove member', { error, organizationId, memberId })
|
||||
return internalErrorResponse('Failed to remove member')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/members
|
||||
*
|
||||
* List all members of an organization with their billing info.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminMemberDetail>
|
||||
*
|
||||
* POST /api/v1/admin/organizations/[id]/members
|
||||
*
|
||||
* Add a user to an organization with full billing logic.
|
||||
* Validates seat availability before adding (uses same logic as invitation flow):
|
||||
* - Team plans: checks seats column
|
||||
* - Enterprise plans: checks metadata.seats
|
||||
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
|
||||
* If user is already a member, updates their role if different.
|
||||
*
|
||||
* Body:
|
||||
* - userId: string - User ID to add
|
||||
* - role: string - Role ('admin' | 'member')
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminMember & {
|
||||
* action: 'created' | 'updated' | 'already_member',
|
||||
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
|
||||
* }>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1AddOrganizationMemberContract,
|
||||
adminV1ListOrganizationMembersContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
|
||||
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminInvalidJsonResponse,
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminMember,
|
||||
type AdminMemberDetail,
|
||||
createPaginationMeta,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationMembersAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListOrganizationMembersContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [countResult, membersData] = await Promise.all([
|
||||
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
|
||||
db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
billingBlocked: userStats.billingBlocked,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
.orderBy(member.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].count
|
||||
|
||||
// currentPeriodCost is only a baseline; add each member's attributed
|
||||
// usage_log for the org's period so admin shows real current usage.
|
||||
const usageByUser = await getOrgMemberLedgerByUser(organizationId)
|
||||
|
||||
const data: AdminMemberDetail[] = membersData.map((m) => ({
|
||||
id: m.id,
|
||||
userId: m.userId,
|
||||
organizationId: m.organizationId,
|
||||
role: m.role,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
userName: m.userName,
|
||||
userEmail: m.userEmail,
|
||||
currentPeriodCost: (
|
||||
Number(m.currentPeriodCost ?? 0) + (usageByUser.get(m.userId) ?? 0)
|
||||
).toString(),
|
||||
currentUsageLimit: m.currentUsageLimit,
|
||||
billingBlocked: m.billingBlocked ?? false,
|
||||
}))
|
||||
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} members for organization ${organizationId}`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list organization members', { error, organizationId })
|
||||
return internalErrorResponse('Failed to list organization members')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1AddOrganizationMemberContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJsonResponse: adminInvalidJsonResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const { userId, role } = parsed.data.body
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id, name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
organizationId: member.organizationId,
|
||||
})
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existingMember) {
|
||||
if (existingMember.organizationId === organizationId) {
|
||||
if (existingMember.role === 'owner') {
|
||||
return badRequestResponse(
|
||||
'Cannot change the owner role via this endpoint. Use POST /api/v1/admin/organizations/[id]/transfer-ownership instead.'
|
||||
)
|
||||
}
|
||||
|
||||
if (existingMember.role !== role) {
|
||||
await db.update(member).set({ role }).where(eq(member.id, existingMember.id))
|
||||
|
||||
logger.info(
|
||||
`Admin API: Updated user ${userId} role in organization ${organizationId}`,
|
||||
{
|
||||
previousRole: existingMember.role,
|
||||
newRole: role,
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
description: `Admin API changed organization member role to ${role}`,
|
||||
metadata: { targetUserId: userId, previousRole: existingMember.role, role },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
id: existingMember.id,
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
createdAt: existingMember.createdAt.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
action: 'updated' as const,
|
||||
billingActions: {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
id: existingMember.id,
|
||||
userId,
|
||||
organizationId,
|
||||
role: existingMember.role,
|
||||
createdAt: existingMember.createdAt.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
action: 'already_member' as const,
|
||||
billingActions: {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return badRequestResponse(
|
||||
`User is already a member of another organization. Users can only belong to one organization at a time.`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await addUserToOrganization({
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
skipBillingLogic: !isBillingEnabled,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return badRequestResponse(result.error || 'Failed to add member')
|
||||
}
|
||||
|
||||
const data: AdminMember = {
|
||||
id: result.memberId!,
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
createdAt: new Date().toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Added user ${userId} to organization ${organizationId}`, {
|
||||
role,
|
||||
memberId: result.memberId,
|
||||
billingActions: result.billingActions,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.ORG_MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
description: `Admin API added member to organization as ${role}`,
|
||||
metadata: { targetUserId: userId, role, memberId: result.memberId },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
...data,
|
||||
action: 'created' as const,
|
||||
billingActions: {
|
||||
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
|
||||
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to add organization member', { error, organizationId })
|
||||
return internalErrorResponse('Failed to add organization member')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]
|
||||
*
|
||||
* Get organization details including member count and subscription.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganizationDetail>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]
|
||||
*
|
||||
* Update organization details.
|
||||
*
|
||||
* Body:
|
||||
* - name?: string - Organization name
|
||||
* - slug?: string - Organization slug
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganization>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, eq, inArray } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1GetOrganizationContract,
|
||||
adminV1UpdateOrganizationContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import {
|
||||
ensureOrganizationSlugAvailable,
|
||||
OrganizationSlugInvalidError,
|
||||
OrganizationSlugTakenError,
|
||||
validateOrganizationSlugOrThrow,
|
||||
} from '@/lib/billing/organizations/create-organization'
|
||||
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminInvalidJsonResponse,
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminOrganizationDetail,
|
||||
toAdminOrganization,
|
||||
toAdminSubscription,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetOrganizationContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [memberCountResult, subscriptionData] = await Promise.all([
|
||||
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
|
||||
db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, organizationId),
|
||||
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
const data: AdminOrganizationDetail = {
|
||||
...toAdminOrganization(orgData),
|
||||
memberCount: memberCountResult[0].count,
|
||||
subscription: subscriptionData[0] ? toAdminSubscription(subscriptionData[0]) : null,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get organization', { error, organizationId })
|
||||
return internalErrorResponse('Failed to get organization')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1UpdateOrganizationContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJsonResponse: adminInvalidJsonResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const validatedBody = parsed.data.body
|
||||
|
||||
if (validatedBody.name !== undefined) {
|
||||
updateData.name = validatedBody.name
|
||||
}
|
||||
|
||||
if (validatedBody.slug !== undefined) {
|
||||
const nextSlug = validatedBody.slug
|
||||
validateOrganizationSlugOrThrow(nextSlug)
|
||||
await ensureOrganizationSlugAvailable({
|
||||
slug: nextSlug,
|
||||
excludeOrganizationId: organizationId,
|
||||
})
|
||||
updateData.slug = nextSlug
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 1) {
|
||||
return badRequestResponse(
|
||||
'No valid fields to update. Use /billing endpoint for orgUsageLimit.'
|
||||
)
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(organization)
|
||||
.set(updateData)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Admin API: Updated organization ${organizationId}`, {
|
||||
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.ORGANIZATION_UPDATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
resourceName: updated.name,
|
||||
description: `Admin API updated organization "${updated.name}"`,
|
||||
metadata: { fields: Object.keys(updateData).filter((k) => k !== 'updatedAt') },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse(toAdminOrganization(updated))
|
||||
} catch (error) {
|
||||
if (error instanceof OrganizationSlugInvalidError) {
|
||||
return badRequestResponse(
|
||||
'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.'
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof OrganizationSlugTakenError) {
|
||||
return badRequestResponse('This slug is already taken')
|
||||
}
|
||||
|
||||
logger.error('Admin API: Failed to update organization', { error, organizationId })
|
||||
return internalErrorResponse('Failed to update organization')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/seats
|
||||
*
|
||||
* Get organization seat analytics including member activity.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSeatAnalytics>
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { adminV1GetOrganizationSeatsContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminSeatAnalytics } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationSeatsAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetOrganizationSeatsContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const analytics = await getOrganizationSeatAnalytics(organizationId)
|
||||
|
||||
if (!analytics) {
|
||||
return notFoundResponse('Organization or subscription')
|
||||
}
|
||||
|
||||
const data: AdminSeatAnalytics = {
|
||||
organizationId: analytics.organizationId,
|
||||
organizationName: analytics.organizationName,
|
||||
currentSeats: analytics.currentSeats,
|
||||
maxSeats: analytics.maxSeats,
|
||||
availableSeats: analytics.availableSeats,
|
||||
subscriptionPlan: analytics.subscriptionPlan,
|
||||
canAddSeats: analytics.canAddSeats,
|
||||
utilizationRate: analytics.utilizationRate,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved seat analytics for organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get organization seats', { error, organizationId })
|
||||
return internalErrorResponse('Failed to get organization seats')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { adminV1TransferOwnershipContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { transferOrganizationOwnership } from '@/lib/billing/organizations/membership'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminTransferOwnershipAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const routeParams = await context.params
|
||||
const { id: organizationId } = routeParams
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(
|
||||
adminV1TransferOwnershipContract,
|
||||
request,
|
||||
{ params: routeParams },
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJsonResponse: () => badRequestResponse('Invalid request body'),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { newOwnerUserId, currentOwnerUserId: currentOwnerUserIdOverride } = parsed.data.body
|
||||
|
||||
const [orgRow] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgRow) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
let currentOwnerUserId: string
|
||||
if (currentOwnerUserIdOverride) {
|
||||
currentOwnerUserId = currentOwnerUserIdOverride
|
||||
} else {
|
||||
const [ownerMembership] = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (!ownerMembership) {
|
||||
return badRequestResponse(
|
||||
'Organization has no owner; provide currentOwnerUserId explicitly to seed ownership'
|
||||
)
|
||||
}
|
||||
|
||||
currentOwnerUserId = ownerMembership.userId
|
||||
}
|
||||
|
||||
if (currentOwnerUserId === newOwnerUserId) {
|
||||
return badRequestResponse('New owner must differ from current owner')
|
||||
}
|
||||
|
||||
const [newOwnerMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId)))
|
||||
.limit(1)
|
||||
|
||||
if (!newOwnerMember) {
|
||||
return badRequestResponse('Target user is not a member of this organization')
|
||||
}
|
||||
|
||||
const result = await transferOrganizationOwnership({
|
||||
organizationId,
|
||||
currentOwnerUserId,
|
||||
newOwnerUserId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return internalErrorResponse(result.error ?? 'Failed to transfer ownership')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Transferred ownership of organization ${organizationId}`, {
|
||||
currentOwnerUserId,
|
||||
newOwnerUserId,
|
||||
workspacesReassigned: result.workspacesReassigned,
|
||||
billedAccountReassigned: result.billedAccountReassigned,
|
||||
overageMigrated: result.overageMigrated,
|
||||
billingBlockInherited: result.billingBlockInherited,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
description: 'Admin API transferred organization ownership',
|
||||
metadata: {
|
||||
currentOwnerUserId,
|
||||
newOwnerUserId,
|
||||
workspacesReassigned: result.workspacesReassigned,
|
||||
billedAccountReassigned: result.billedAccountReassigned,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
organizationId,
|
||||
currentOwnerUserId,
|
||||
newOwnerUserId,
|
||||
workspacesReassigned: result.workspacesReassigned,
|
||||
billedAccountReassigned: result.billedAccountReassigned,
|
||||
overageMigrated: result.overageMigrated,
|
||||
billingBlockInherited: result.billingBlockInherited,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to transfer organization ownership', {
|
||||
organizationId,
|
||||
error,
|
||||
})
|
||||
return internalErrorResponse('Failed to transfer ownership')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations
|
||||
*
|
||||
* List all organizations with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminOrganization>
|
||||
*
|
||||
* POST /api/v1/admin/organizations
|
||||
*
|
||||
* Create a new organization.
|
||||
*
|
||||
* Body:
|
||||
* - name: string - Organization name (required)
|
||||
* - slug: string - Organization slug (optional, auto-generated from name if not provided)
|
||||
* - ownerId: string - User ID of the organization owner (required)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganization & { memberId: string }>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db, dbReplica } from '@sim/db'
|
||||
import { member, organization, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1CreateOrganizationContract,
|
||||
adminV1ListOrganizationsContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import {
|
||||
createOrganizationWithOwner,
|
||||
OrganizationSlugInvalidError,
|
||||
OrganizationSlugTakenError,
|
||||
} from '@/lib/billing/organizations/create-organization'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminInvalidJsonResponse,
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminOrganization,
|
||||
createPaginationMeta,
|
||||
toAdminOrganization,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationsAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(
|
||||
adminV1ListOrganizationsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [countResult, organizations] = await Promise.all([
|
||||
dbReplica.select({ total: count() }).from(organization),
|
||||
dbReplica
|
||||
.select({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
logo: organization.logo,
|
||||
orgUsageLimit: organization.orgUsageLimit,
|
||||
storageUsedBytes: organization.storageUsedBytes,
|
||||
departedMemberUsage: organization.departedMemberUsage,
|
||||
createdAt: organization.createdAt,
|
||||
updatedAt: organization.updatedAt,
|
||||
})
|
||||
.from(organization)
|
||||
.orderBy(organization.name)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminOrganization[] = organizations.map(toAdminOrganization)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} organizations (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list organizations', { error })
|
||||
return internalErrorResponse('Failed to list organizations')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(
|
||||
adminV1CreateOrganizationContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJsonResponse: adminInvalidJsonResponse,
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
try {
|
||||
const { name, ownerId, slug: requestedSlug } = parsed.data.body
|
||||
|
||||
const [ownerData] = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, ownerId))
|
||||
.limit(1)
|
||||
|
||||
if (!ownerData) {
|
||||
return notFoundResponse('Owner user')
|
||||
}
|
||||
|
||||
const [existingMembership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, ownerId))
|
||||
.limit(1)
|
||||
|
||||
if (existingMembership) {
|
||||
return badRequestResponse(
|
||||
'User is already a member of another organization. Users can only belong to one organization at a time.'
|
||||
)
|
||||
}
|
||||
|
||||
const slug =
|
||||
requestedSlug?.trim() ||
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
const { organizationId, memberId } = await createOrganizationWithOwner({
|
||||
ownerUserId: ownerId,
|
||||
name,
|
||||
slug,
|
||||
})
|
||||
|
||||
const [createdOrg] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
logger.info(`Admin API: Created organization ${organizationId}`, {
|
||||
name,
|
||||
slug,
|
||||
ownerId,
|
||||
memberId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.ORGANIZATION_CREATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
resourceName: name,
|
||||
description: `Admin API created organization "${name}"`,
|
||||
metadata: { slug, ownerId, memberId },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
...toAdminOrganization(createdOrg),
|
||||
memberId,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof OrganizationSlugInvalidError) {
|
||||
return badRequestResponse(
|
||||
'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.'
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof OrganizationSlugTakenError) {
|
||||
return badRequestResponse('This slug is already taken')
|
||||
}
|
||||
|
||||
logger.error('Admin API: Failed to create organization', { error })
|
||||
return internalErrorResponse('Failed to create organization')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import { db } from '@sim/db'
|
||||
import { outboxEvent } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { adminV1RequeueOutboxEventContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
|
||||
const logger = createLogger('AdminOutboxRequeueAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const invalidOutboxEventResponse = (message: string) =>
|
||||
NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/outbox/[id]/requeue
|
||||
*
|
||||
* Move a dead-lettered outbox event back to `pending` so the worker
|
||||
* will retry it. Resets `attempts`, `lastError`, and `availableAt` so
|
||||
* the next poll picks it up. Only dead-lettered events can be
|
||||
* requeued — completed/pending/processing rows are rejected to avoid
|
||||
* operator errors.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<{ id: string }>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1RequeueOutboxEventContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
invalidOutboxEventResponse(getValidationErrorMessage(error, 'Invalid event ID')),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.update(outboxEvent)
|
||||
.set({
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
availableAt: new Date(),
|
||||
lockedAt: null,
|
||||
processedAt: null,
|
||||
})
|
||||
.where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter')))
|
||||
.returning({ id: outboxEvent.id, eventType: outboxEvent.eventType })
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Event not found or not in dead_letter status. Only dead-lettered events can be requeued.',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Requeued dead-lettered outbox event', {
|
||||
eventId: result[0].id,
|
||||
eventType: result[0].eventType,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requeued: result[0],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message })
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
import { db } from '@sim/db'
|
||||
import { outboxEvent } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { adminV1ListOutboxContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
|
||||
const logger = createLogger('AdminOutboxAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const invalidOutboxQueryResponse = (message: string) =>
|
||||
NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/outbox?status=dead_letter&eventType=...&limit=100
|
||||
*
|
||||
* Inspect outbox events for operator triage. Primary use: list
|
||||
* dead-lettered rows to reconcile Stripe state manually after a
|
||||
* permanent handler failure (e.g. Stripe account frozen, subscription
|
||||
* already canceled by another path, etc.).
|
||||
*
|
||||
* Filters:
|
||||
* - `status`: 'pending' | 'processing' | 'completed' | 'dead_letter' (default 'dead_letter')
|
||||
* - `eventType`: exact match on event_type
|
||||
* - `limit`: cap rows returned (default 100, max 500)
|
||||
*
|
||||
* Response includes aggregate counts by status for quick health read.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request: NextRequest) => {
|
||||
try {
|
||||
const parsed = await parseRequest(
|
||||
adminV1ListOutboxContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
invalidOutboxQueryResponse(getValidationErrorMessage(error, 'Invalid outbox query')),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { status, eventType, limit } = parsed.data.query
|
||||
|
||||
const whereConditions = [eq(outboxEvent.status, status)]
|
||||
if (eventType) {
|
||||
whereConditions.push(eq(outboxEvent.eventType, eventType))
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(outboxEvent)
|
||||
.where(and(...whereConditions))
|
||||
.orderBy(desc(outboxEvent.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
// Aggregate counts per (status, eventType) for at-a-glance health.
|
||||
const counts = await db
|
||||
.select({
|
||||
status: outboxEvent.status,
|
||||
eventType: outboxEvent.eventType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(outboxEvent)
|
||||
.groupBy(outboxEvent.status, outboxEvent.eventType)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
filter: { status, eventType, limit },
|
||||
rows,
|
||||
counts,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to list outbox events', { error: toError(error).message })
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* GET /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* List Stripe promotion codes with cursor-based pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 100)
|
||||
* - starting_after: string (cursor — Stripe promotion code ID)
|
||||
* - active: 'true' | 'false' (optional filter)
|
||||
*
|
||||
* POST /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* Create a Stripe coupon and an associated promotion code.
|
||||
*
|
||||
* Body:
|
||||
* - name: string (required) — Display name for the coupon
|
||||
* - percentOff: number (required, 1–100) — Percentage discount
|
||||
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
|
||||
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
|
||||
* - durationInMonths: number (required when duration is 'repeating')
|
||||
* - maxRedemptions: number (optional) — Total redemption cap
|
||||
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
|
||||
* - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
|
||||
* Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import {
|
||||
type AdminV1PromoCode,
|
||||
type AdminV1ReferralCampaignAppliesTo,
|
||||
type AdminV1ReferralCampaignDuration,
|
||||
adminV1CreateReferralCampaignContract,
|
||||
adminV1ListReferralCampaignsContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { getPlans } from '@/lib/billing/plans'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminPromoCodes')
|
||||
|
||||
function formatPromoCode(promo: {
|
||||
id: string
|
||||
code: string
|
||||
coupon: {
|
||||
id: string
|
||||
name: string | null
|
||||
percent_off: number | null
|
||||
duration: string
|
||||
duration_in_months: number | null
|
||||
applies_to?: { products: string[] }
|
||||
}
|
||||
max_redemptions: number | null
|
||||
expires_at: number | null
|
||||
active: boolean
|
||||
times_redeemed: number
|
||||
created: number
|
||||
}): AdminV1PromoCode {
|
||||
return {
|
||||
id: promo.id,
|
||||
code: promo.code,
|
||||
couponId: promo.coupon.id,
|
||||
name: promo.coupon.name ?? '',
|
||||
percentOff: promo.coupon.percent_off ?? 0,
|
||||
duration: promo.coupon.duration,
|
||||
durationInMonths: promo.coupon.duration_in_months,
|
||||
appliesToProductIds: promo.coupon.applies_to?.products ?? null,
|
||||
maxRedemptions: promo.max_redemptions,
|
||||
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
|
||||
active: promo.active,
|
||||
timesRedeemed: promo.times_redeemed,
|
||||
createdAt: new Date(promo.created * 1000).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve appliesTo values to unique Stripe product IDs.
|
||||
* Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
|
||||
* Specific plan names ('pro_6000', 'team_25000') match exactly.
|
||||
*/
|
||||
async function resolveProductIds(
|
||||
stripe: Stripe,
|
||||
targets: AdminV1ReferralCampaignAppliesTo[]
|
||||
): Promise<string[]> {
|
||||
const plans = getPlans()
|
||||
const priceIds: string[] = []
|
||||
|
||||
const broadMatchers: Record<string, (name: string) => boolean> = {
|
||||
pro: isPro,
|
||||
team: isTeam,
|
||||
}
|
||||
|
||||
for (const plan of plans) {
|
||||
const matches = targets.some((target) => {
|
||||
const matcher = broadMatchers[target]
|
||||
return matcher ? matcher(plan.name) : plan.name === target
|
||||
})
|
||||
if (!matches) continue
|
||||
if (plan.priceId) priceIds.push(plan.priceId)
|
||||
if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId)
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
priceIds.map(async (priceId) => {
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
return typeof price.product === 'string' ? price.product : price.product.id
|
||||
})
|
||||
)
|
||||
|
||||
const failures = results.filter((r) => r.status === 'rejected')
|
||||
if (failures.length > 0) {
|
||||
logger.error('Failed to resolve all Stripe products for appliesTo', {
|
||||
failed: failures.length,
|
||||
total: priceIds.length,
|
||||
})
|
||||
throw new Error('Could not resolve all Stripe products for the specified plan categories.')
|
||||
}
|
||||
|
||||
const productIds = new Set<string>()
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') productIds.add(r.value)
|
||||
}
|
||||
|
||||
return [...productIds]
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
const parsed = await parseRequest(
|
||||
adminV1ListReferralCampaignsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const query = parsed.data.query
|
||||
|
||||
const listParams: Record<string, unknown> = { limit: query.limit }
|
||||
if (query.starting_after) listParams.starting_after = query.starting_after
|
||||
if (query.active !== undefined) listParams.active = query.active
|
||||
|
||||
const promoCodes = await stripe.promotionCodes.list(listParams)
|
||||
|
||||
const data = promoCodes.data.map(formatPromoCode)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
hasMore: promoCodes.has_more,
|
||||
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list promotion codes', { error })
|
||||
return internalErrorResponse('Failed to list promotion codes')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
const parsed = await parseRequest(
|
||||
adminV1CreateReferralCampaignContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJson: 'throw',
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } =
|
||||
parsed.data.body
|
||||
const appliesTo = parsed.data.body.appliesTo ?? undefined
|
||||
const effectiveDuration: AdminV1ReferralCampaignDuration = duration
|
||||
|
||||
let appliesToProducts: string[] | undefined
|
||||
if (appliesTo?.length) {
|
||||
appliesToProducts = await resolveProductIds(stripe, appliesTo)
|
||||
if (appliesToProducts.length === 0) {
|
||||
return badRequestResponse(
|
||||
'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const coupon = await stripe.coupons.create({
|
||||
name: name.trim(),
|
||||
percent_off: percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
|
||||
...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}),
|
||||
})
|
||||
|
||||
let promoCode
|
||||
try {
|
||||
const promoParams: Stripe.PromotionCodeCreateParams = {
|
||||
coupon: coupon.id,
|
||||
...(code ? { code: code.trim().toUpperCase() } : {}),
|
||||
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
|
||||
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
|
||||
}
|
||||
|
||||
promoCode = await stripe.promotionCodes.create(promoParams)
|
||||
} catch (promoError) {
|
||||
try {
|
||||
await stripe.coupons.del(coupon.id)
|
||||
} catch (cleanupError) {
|
||||
logger.error(
|
||||
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
|
||||
{
|
||||
couponId: coupon.id,
|
||||
cleanupError,
|
||||
}
|
||||
)
|
||||
}
|
||||
throw promoError
|
||||
}
|
||||
|
||||
logger.info('Admin API: Created Stripe promotion code', {
|
||||
promoCodeId: promoCode.id,
|
||||
code: promoCode.code,
|
||||
couponId: coupon.id,
|
||||
percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(appliesTo ? { appliesTo } : {}),
|
||||
})
|
||||
|
||||
return singleResponse(formatPromoCode(promoCode))
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'type' in error &&
|
||||
(error as { type: string }).type === 'StripeInvalidRequestError'
|
||||
) {
|
||||
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to create promotion code', { error })
|
||||
return internalErrorResponse('Failed to create promotion code')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Admin API Response Helpers
|
||||
*
|
||||
* Consistent response formatting for all Admin API endpoints.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { z } from 'zod'
|
||||
import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server'
|
||||
import type {
|
||||
AdminErrorResponse,
|
||||
AdminListResponse,
|
||||
AdminSingleResponse,
|
||||
PaginationMeta,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
/**
|
||||
* Create a successful list response with pagination
|
||||
*/
|
||||
export function listResponse<T>(
|
||||
data: T[],
|
||||
pagination: PaginationMeta
|
||||
): NextResponse<AdminListResponse<T>> {
|
||||
return NextResponse.json({ data, pagination })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a successful single resource response
|
||||
*/
|
||||
export function singleResponse<T>(data: T): NextResponse<AdminSingleResponse<T>> {
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error response
|
||||
*/
|
||||
export function errorResponse(
|
||||
code: string,
|
||||
message: string,
|
||||
status: number,
|
||||
details?: unknown
|
||||
): NextResponse<AdminErrorResponse> {
|
||||
const body: AdminErrorResponse = {
|
||||
error: { code, message },
|
||||
}
|
||||
|
||||
if (details !== undefined) {
|
||||
body.error.details = details
|
||||
}
|
||||
|
||||
return NextResponse.json(body, { status })
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Common Error Responses
|
||||
// =============================================================================
|
||||
|
||||
export function unauthorizedResponse(message = 'Authentication required'): NextResponse {
|
||||
return errorResponse('UNAUTHORIZED', message, 401)
|
||||
}
|
||||
|
||||
export function forbiddenResponse(message = 'Access denied'): NextResponse {
|
||||
return errorResponse('FORBIDDEN', message, 403)
|
||||
}
|
||||
|
||||
export function notFoundResponse(resource: string): NextResponse {
|
||||
return errorResponse('NOT_FOUND', `${resource} not found`, 404)
|
||||
}
|
||||
|
||||
export function badRequestResponse(message: string, details?: unknown): NextResponse {
|
||||
return errorResponse('BAD_REQUEST', message, 400, details)
|
||||
}
|
||||
|
||||
export function adminValidationErrorResponse(error: z.ZodError): NextResponse {
|
||||
return badRequestResponse(
|
||||
getValidationErrorMessage(error, 'Invalid request body'),
|
||||
serializeZodIssues(error)
|
||||
)
|
||||
}
|
||||
|
||||
export function adminInvalidJsonResponse(): NextResponse {
|
||||
return badRequestResponse('Request body must be valid JSON')
|
||||
}
|
||||
|
||||
export function internalErrorResponse(message = 'Internal server error'): NextResponse {
|
||||
return errorResponse('INTERNAL_ERROR', message, 500)
|
||||
}
|
||||
|
||||
export function notConfiguredResponse(): NextResponse {
|
||||
return errorResponse(
|
||||
'NOT_CONFIGURED',
|
||||
'Admin API is not configured. Set ADMIN_API_KEY environment variable.',
|
||||
503
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* GET /api/v1/admin/subscriptions/[id]
|
||||
*
|
||||
* Get subscription details.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSubscription>
|
||||
*
|
||||
* DELETE /api/v1/admin/subscriptions/[id]
|
||||
*
|
||||
* Cancel a subscription by triggering Stripe cancellation.
|
||||
* The Stripe webhook handles all cleanup (same as platform cancellation):
|
||||
* - Updates subscription status to canceled
|
||||
* - Bills final period overages
|
||||
* - Resets usage
|
||||
* - Restores member Pro subscriptions (for team/enterprise)
|
||||
* - Deletes organization (for team/enterprise)
|
||||
* - Syncs usage limits to free tier
|
||||
*
|
||||
* Query Parameters:
|
||||
* - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false)
|
||||
* - reason?: string - Reason for cancellation (for audit logging)
|
||||
*
|
||||
* Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1CancelSubscriptionContract,
|
||||
adminV1GetSubscriptionContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
|
||||
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { toAdminSubscription } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminSubscriptionDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetSubscriptionContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: subscriptionId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [subData] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (!subData) {
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved subscription ${subscriptionId}`)
|
||||
|
||||
return singleResponse(toAdminSubscription(subData))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to get subscription')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1CancelSubscriptionContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: subscriptionId } = parsed.data.params
|
||||
const { atPeriodEnd, reason: rawReason } = parsed.data.query
|
||||
const reason = rawReason || 'Admin cancellation (no reason provided)'
|
||||
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
if (existing.status === 'canceled') {
|
||||
return badRequestResponse('Subscription is already canceled')
|
||||
}
|
||||
|
||||
if (!existing.stripeSubscriptionId) {
|
||||
return badRequestResponse('Subscription has no Stripe subscription ID')
|
||||
}
|
||||
|
||||
if (atPeriodEnd) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(subscription)
|
||||
.set({ cancelAtPeriodEnd: true })
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
|
||||
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
subscriptionId: existing.id,
|
||||
reason: reason ?? 'admin-cancel-at-period-end',
|
||||
})
|
||||
})
|
||||
|
||||
logger.info(
|
||||
'Admin API: Scheduled subscription cancellation at period end (DB committed, Stripe queued)',
|
||||
{
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
plan: existing.plan,
|
||||
referenceId: existing.referenceId,
|
||||
periodEnd: existing.periodEnd,
|
||||
reason,
|
||||
}
|
||||
)
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
message: 'Subscription scheduled to cancel at period end.',
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
atPeriodEnd: true,
|
||||
periodEnd: existing.periodEnd?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Immediate cancellation — stays synchronous. Stripe's
|
||||
// `customer.subscription.deleted` webhook triggers full cleanup
|
||||
// (overage bill, usage reset, Pro restore, org delete) via
|
||||
// `handleSubscriptionDeleted`, so no outbox needed here.
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.cancel(
|
||||
existing.stripeSubscriptionId,
|
||||
{ prorate: true, invoice_now: true },
|
||||
{ idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` }
|
||||
)
|
||||
|
||||
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
plan: existing.plan,
|
||||
referenceId: existing.referenceId,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
message: 'Subscription cancellation triggered. Webhook will complete cleanup.',
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
atPeriodEnd: false,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to cancel subscription')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* GET /api/v1/admin/subscriptions
|
||||
*
|
||||
* List all subscriptions with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
* - plan: string (optional) - Filter by plan (free, pro, team, enterprise)
|
||||
* - status: string (optional) - Filter by status (active, canceled, etc.)
|
||||
*
|
||||
* Response: AdminListResponse<AdminSubscription>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, eq, type SQL } from 'drizzle-orm'
|
||||
import { adminV1ListSubscriptionsContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminSubscription,
|
||||
createPaginationMeta,
|
||||
toAdminSubscription,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminSubscriptionsAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(
|
||||
adminV1ListSubscriptionsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { limit, offset, plan: planFilter, status: statusFilter } = parsed.data.query
|
||||
|
||||
try {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
if (planFilter) {
|
||||
conditions.push(eq(subscription.plan, planFilter))
|
||||
}
|
||||
if (statusFilter) {
|
||||
conditions.push(eq(subscription.status, statusFilter))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [countResult, subscriptions] = await Promise.all([
|
||||
db.select({ total: count() }).from(subscription).where(whereClause),
|
||||
db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(whereClause)
|
||||
.orderBy(subscription.plan)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminSubscription[] = subscriptions.map(toAdminSubscription)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} subscriptions (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list subscriptions', { error })
|
||||
return internalErrorResponse('Failed to list subscriptions')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* Admin API Types
|
||||
*
|
||||
* This file defines the types for the Admin API endpoints.
|
||||
* All responses follow a consistent structure for predictability.
|
||||
*/
|
||||
|
||||
import type {
|
||||
auditLog,
|
||||
member,
|
||||
organization,
|
||||
subscription,
|
||||
user,
|
||||
userStats,
|
||||
workflow,
|
||||
workflowFolder,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
// =============================================================================
|
||||
// Database Model Types (inferred from schema)
|
||||
// =============================================================================
|
||||
|
||||
export type DbUser = InferSelectModel<typeof user>
|
||||
export type DbWorkspace = InferSelectModel<typeof workspace>
|
||||
export type DbWorkflow = InferSelectModel<typeof workflow>
|
||||
export type DbWorkflowFolder = InferSelectModel<typeof workflowFolder>
|
||||
export type DbOrganization = InferSelectModel<typeof organization>
|
||||
export type DbSubscription = InferSelectModel<typeof subscription>
|
||||
export type DbMember = InferSelectModel<typeof member>
|
||||
export type DbUserStats = InferSelectModel<typeof userStats>
|
||||
|
||||
// =============================================================================
|
||||
// Pagination
|
||||
// =============================================================================
|
||||
|
||||
export interface PaginationParams {
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_LIMIT = 50
|
||||
export const MAX_LIMIT = 250
|
||||
|
||||
export function parsePaginationParams(url: URL): PaginationParams {
|
||||
return {
|
||||
limit: parsePaginationNumber(url.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT),
|
||||
offset: parsePaginationNumber(url.searchParams.get('offset'), 0),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePaginationNumber(value: string | null, fallback: number, max?: number): number {
|
||||
const parsed = value ? Number.parseInt(value, 10) : fallback
|
||||
if (!Number.isInteger(parsed) || parsed < 1) return fallback
|
||||
return max === undefined ? parsed : Math.min(parsed, max)
|
||||
}
|
||||
|
||||
export function createPaginationMeta(total: number, limit: number, offset: number): PaginationMeta {
|
||||
return {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Response Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminListResponse<T> {
|
||||
data: T[]
|
||||
pagination: PaginationMeta
|
||||
}
|
||||
|
||||
export interface AdminSingleResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface AdminErrorResponse {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
emailVerified: boolean
|
||||
image: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function toAdminUser(dbUser: DbUser): AdminUser {
|
||||
return {
|
||||
id: dbUser.id,
|
||||
name: dbUser.name,
|
||||
email: dbUser.email,
|
||||
emailVerified: dbUser.emailVerified,
|
||||
image: dbUser.image,
|
||||
createdAt: dbUser.createdAt.toISOString(),
|
||||
updatedAt: dbUser.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Workspace Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminWorkspaceDetail extends AdminWorkspace {
|
||||
workflowCount: number
|
||||
folderCount: number
|
||||
}
|
||||
|
||||
export function toAdminWorkspace(dbWorkspace: DbWorkspace): AdminWorkspace {
|
||||
return {
|
||||
id: dbWorkspace.id,
|
||||
name: dbWorkspace.name,
|
||||
ownerId: dbWorkspace.ownerId,
|
||||
createdAt: dbWorkspace.createdAt.toISOString(),
|
||||
updatedAt: dbWorkspace.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Folder Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminFolder {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
color: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function toAdminFolder(dbFolder: DbWorkflowFolder): AdminFolder {
|
||||
return {
|
||||
id: dbFolder.id,
|
||||
name: dbFolder.name,
|
||||
parentId: dbFolder.parentId,
|
||||
color: dbFolder.color,
|
||||
sortOrder: dbFolder.sortOrder,
|
||||
createdAt: dbFolder.createdAt.toISOString(),
|
||||
updatedAt: dbFolder.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Workflow Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminWorkflow {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
workspaceId: string | null
|
||||
folderId: string | null
|
||||
isDeployed: boolean
|
||||
deployedAt: string | null
|
||||
runCount: number
|
||||
lastRunAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminWorkflowDetail extends AdminWorkflow {
|
||||
blockCount: number
|
||||
edgeCount: number
|
||||
}
|
||||
|
||||
export type AdminWorkflowSource = Pick<
|
||||
DbWorkflow,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'workspaceId'
|
||||
| 'folderId'
|
||||
| 'isDeployed'
|
||||
| 'deployedAt'
|
||||
| 'runCount'
|
||||
| 'lastRunAt'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
>
|
||||
|
||||
export function toAdminWorkflow(dbWorkflow: AdminWorkflowSource): AdminWorkflow {
|
||||
return {
|
||||
id: dbWorkflow.id,
|
||||
name: dbWorkflow.name,
|
||||
description: dbWorkflow.description,
|
||||
workspaceId: dbWorkflow.workspaceId,
|
||||
folderId: dbWorkflow.folderId,
|
||||
isDeployed: dbWorkflow.isDeployed,
|
||||
deployedAt: dbWorkflow.deployedAt?.toISOString() ?? null,
|
||||
runCount: dbWorkflow.runCount,
|
||||
lastRunAt: dbWorkflow.lastRunAt?.toISOString() ?? null,
|
||||
createdAt: dbWorkflow.createdAt.toISOString(),
|
||||
updatedAt: dbWorkflow.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Workflow Variable Types
|
||||
// =============================================================================
|
||||
|
||||
export type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
|
||||
|
||||
export interface WorkflowVariable {
|
||||
id: string
|
||||
name: string
|
||||
type: VariableType
|
||||
value: unknown
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export/Import Types
|
||||
// =============================================================================
|
||||
|
||||
export interface WorkflowExportState {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
metadata?: {
|
||||
name?: string
|
||||
description?: string
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: Record<string, WorkflowVariable>
|
||||
}
|
||||
|
||||
export interface WorkflowExportPayload {
|
||||
version: '1.0'
|
||||
exportedAt: string
|
||||
workflow: {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
workspaceId: string | null
|
||||
folderId: string | null
|
||||
}
|
||||
state: WorkflowExportState
|
||||
}
|
||||
|
||||
export interface FolderExportPayload {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
export interface WorkspaceExportPayload {
|
||||
version: '1.0'
|
||||
exportedAt: string
|
||||
workspace: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
workflows: Array<{
|
||||
workflow: WorkflowExportPayload['workflow']
|
||||
state: WorkflowExportState
|
||||
}>
|
||||
folders: FolderExportPayload[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Import Types
|
||||
// =============================================================================
|
||||
|
||||
export interface WorkflowImportRequest {
|
||||
workspaceId: string
|
||||
folderId?: string
|
||||
name?: string
|
||||
workflow: WorkflowExportPayload | WorkflowExportState | string
|
||||
}
|
||||
|
||||
export interface WorkspaceImportRequest {
|
||||
workflows: Array<{
|
||||
content: string | WorkflowExportPayload | WorkflowExportState
|
||||
name?: string
|
||||
folderPath?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
workflowId: string
|
||||
name: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WorkspaceImportResponse {
|
||||
imported: number
|
||||
failed: number
|
||||
results: ImportResult[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse workflow variables from database JSON format to Record format.
|
||||
* Handles both legacy Array and current Record<string, Variable> formats.
|
||||
*/
|
||||
export function parseWorkflowVariables(
|
||||
dbVariables: DbWorkflow['variables']
|
||||
): Record<string, WorkflowVariable> | undefined {
|
||||
if (!dbVariables) return undefined
|
||||
|
||||
try {
|
||||
const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
|
||||
|
||||
// Handle legacy Array format by converting to Record
|
||||
if (Array.isArray(varsObj)) {
|
||||
const result: Record<string, WorkflowVariable> = {}
|
||||
for (const v of varsObj) {
|
||||
result[v.id] = {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Already Record format - normalize and return
|
||||
if (typeof varsObj === 'object' && varsObj !== null) {
|
||||
const result: Record<string, WorkflowVariable> = {}
|
||||
for (const [key, v] of Object.entries(varsObj)) {
|
||||
const variable = v as { id: string; name: string; type: VariableType; value: unknown }
|
||||
result[key] = {
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
value: variable.value,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract workflow metadata from various export formats.
|
||||
* Handles both full export payload and raw state formats.
|
||||
*/
|
||||
export function extractWorkflowMetadata(
|
||||
workflowJson: unknown,
|
||||
overrideName?: string
|
||||
): { name: string; description: string } {
|
||||
const defaults = {
|
||||
name: overrideName || 'Imported Workflow',
|
||||
description: 'Imported via Admin API',
|
||||
}
|
||||
|
||||
if (!workflowJson || typeof workflowJson !== 'object') {
|
||||
return defaults
|
||||
}
|
||||
|
||||
const parsed = workflowJson as Record<string, unknown>
|
||||
|
||||
const name =
|
||||
overrideName ||
|
||||
getNestedString(parsed, 'workflow.name') ||
|
||||
getNestedString(parsed, 'state.metadata.name') ||
|
||||
getNestedString(parsed, 'metadata.name') ||
|
||||
defaults.name
|
||||
|
||||
const description =
|
||||
getNestedString(parsed, 'workflow.description') ||
|
||||
getNestedString(parsed, 'state.metadata.description') ||
|
||||
getNestedString(parsed, 'metadata.description') ||
|
||||
defaults.description
|
||||
|
||||
return { name, description }
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get a nested string value from an object.
|
||||
*/
|
||||
function getNestedString(obj: Record<string, unknown>, path: string): string | undefined {
|
||||
const parts = path.split('.')
|
||||
let current: unknown = obj
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || typeof current !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : undefined
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminOrganization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo: string | null
|
||||
orgUsageLimit: string | null
|
||||
storageUsedBytes: number
|
||||
departedMemberUsage: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminOrganizationDetail extends AdminOrganization {
|
||||
memberCount: number
|
||||
subscription: AdminSubscription | null
|
||||
}
|
||||
|
||||
export type AdminOrganizationSource = Pick<
|
||||
DbOrganization,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'slug'
|
||||
| 'logo'
|
||||
| 'orgUsageLimit'
|
||||
| 'storageUsedBytes'
|
||||
| 'departedMemberUsage'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
>
|
||||
|
||||
export function toAdminOrganization(dbOrg: AdminOrganizationSource): AdminOrganization {
|
||||
return {
|
||||
id: dbOrg.id,
|
||||
name: dbOrg.name,
|
||||
slug: dbOrg.slug,
|
||||
logo: dbOrg.logo,
|
||||
orgUsageLimit: dbOrg.orgUsageLimit,
|
||||
storageUsedBytes: dbOrg.storageUsedBytes,
|
||||
departedMemberUsage: dbOrg.departedMemberUsage,
|
||||
createdAt: dbOrg.createdAt.toISOString(),
|
||||
updatedAt: dbOrg.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Subscription Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminSubscription {
|
||||
id: string
|
||||
plan: string
|
||||
referenceId: string
|
||||
stripeCustomerId: string | null
|
||||
stripeSubscriptionId: string | null
|
||||
status: string | null
|
||||
periodStart: string | null
|
||||
periodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean | null
|
||||
seats: number | null
|
||||
trialStart: string | null
|
||||
trialEnd: string | null
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export function toAdminSubscription(dbSub: DbSubscription): AdminSubscription {
|
||||
return {
|
||||
id: dbSub.id,
|
||||
plan: dbSub.plan,
|
||||
referenceId: dbSub.referenceId,
|
||||
stripeCustomerId: dbSub.stripeCustomerId,
|
||||
stripeSubscriptionId: dbSub.stripeSubscriptionId,
|
||||
status: dbSub.status,
|
||||
periodStart: dbSub.periodStart?.toISOString() ?? null,
|
||||
periodEnd: dbSub.periodEnd?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: dbSub.cancelAtPeriodEnd,
|
||||
seats: dbSub.seats,
|
||||
trialStart: dbSub.trialStart?.toISOString() ?? null,
|
||||
trialEnd: dbSub.trialEnd?.toISOString() ?? null,
|
||||
metadata: dbSub.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Member Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminMember {
|
||||
id: string
|
||||
userId: string
|
||||
organizationId: string
|
||||
role: string
|
||||
createdAt: string
|
||||
// Joined user info
|
||||
userName: string
|
||||
userEmail: string
|
||||
}
|
||||
|
||||
export interface AdminMemberDetail extends AdminMember {
|
||||
// Billing/usage info from userStats
|
||||
currentPeriodCost: string
|
||||
currentUsageLimit: string | null
|
||||
billingBlocked: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Workspace Member Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminWorkspaceMember {
|
||||
id: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
permissions: 'admin' | 'write' | 'read'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
userImage: string | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Billing Types
|
||||
// =============================================================================
|
||||
|
||||
interface AdminUserBilling {
|
||||
userId: string
|
||||
// User info
|
||||
userName: string
|
||||
userEmail: string
|
||||
stripeCustomerId: string | null
|
||||
// Usage stats
|
||||
currentUsageLimit: string | null
|
||||
currentPeriodCost: string
|
||||
lastPeriodCost: string | null
|
||||
billedOverageThisPeriod: string
|
||||
storageUsedBytes: number
|
||||
billingBlocked: boolean
|
||||
// Copilot usage (active per-period baselines)
|
||||
currentPeriodCopilotCost: string
|
||||
lastPeriodCopilotCost: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserBillingWithSubscription extends AdminUserBilling {
|
||||
subscriptions: AdminSubscription[]
|
||||
organizationMemberships: Array<{
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
role: string
|
||||
}>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Billing Summary Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminOrganizationBillingSummary {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
subscriptionPlan: string
|
||||
subscriptionStatus: string
|
||||
// Seats
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
availableSeats: number
|
||||
// Usage
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
minimumBillingAmount: number
|
||||
averageUsagePerMember: number
|
||||
usagePercentage: number
|
||||
// Billing period
|
||||
billingPeriodStart: string | null
|
||||
billingPeriodEnd: string | null
|
||||
// Alerts
|
||||
membersOverLimit: number
|
||||
membersNearLimit: number
|
||||
}
|
||||
|
||||
export interface AdminSeatAnalytics {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
currentSeats: number
|
||||
maxSeats: number
|
||||
availableSeats: number
|
||||
subscriptionPlan: string
|
||||
canAddSeats: boolean
|
||||
utilizationRate: number
|
||||
}
|
||||
|
||||
export interface AdminDeploymentVersion {
|
||||
id: string
|
||||
version: number
|
||||
name: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
createdBy: string | null
|
||||
deployedByName: string | null
|
||||
}
|
||||
|
||||
export interface AdminDeployResult {
|
||||
isDeployed: boolean
|
||||
version: number
|
||||
deployedAt: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface AdminUndeployResult {
|
||||
isDeployed: boolean
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Audit Log Types
|
||||
// =============================================================================
|
||||
|
||||
export type DbAuditLog = InferSelectModel<typeof auditLog>
|
||||
|
||||
export interface AdminAuditLog {
|
||||
id: string
|
||||
workspaceId: string | null
|
||||
actorId: string | null
|
||||
actorName: string | null
|
||||
actorEmail: string | null
|
||||
action: string
|
||||
resourceType: string
|
||||
resourceId: string | null
|
||||
resourceName: string | null
|
||||
description: string | null
|
||||
metadata: unknown
|
||||
ipAddress: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog {
|
||||
return {
|
||||
id: dbLog.id,
|
||||
workspaceId: dbLog.workspaceId,
|
||||
actorId: dbLog.actorId,
|
||||
actorName: dbLog.actorName,
|
||||
actorEmail: dbLog.actorEmail,
|
||||
action: dbLog.action,
|
||||
resourceType: dbLog.resourceType,
|
||||
resourceId: dbLog.resourceId,
|
||||
resourceName: dbLog.resourceName,
|
||||
description: dbLog.description,
|
||||
metadata: dbLog.metadata,
|
||||
ipAddress: dbLog.ipAddress,
|
||||
userAgent: dbLog.userAgent,
|
||||
createdAt: dbLog.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* GET /api/v1/admin/users/[id]/billing
|
||||
*
|
||||
* Get user billing information including usage stats, subscriptions, and org memberships.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminUserBillingWithSubscription>
|
||||
*
|
||||
* PATCH /api/v1/admin/users/[id]/billing
|
||||
*
|
||||
* Update user billing settings with proper validation.
|
||||
*
|
||||
* Body:
|
||||
* - currentUsageLimit?: number | null - Usage limit (null to use default)
|
||||
* - billingBlocked?: boolean - Block/unblock billing
|
||||
* - currentPeriodCost?: number - Reset/adjust current period cost (use with caution)
|
||||
* - reason?: string - Reason for the change (for audit logging)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { eq, or } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1GetUserBillingContract,
|
||||
adminV1UpdateUserBillingContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminInvalidJsonResponse,
|
||||
adminValidationErrorResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminUserBillingWithSubscription,
|
||||
toAdminSubscription,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminUserBillingAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetUserBillingContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: userId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1)
|
||||
|
||||
// currentPeriodCost is now only a baseline; canonical current-period usage
|
||||
// (baseline + attributed usage_log, refresh-adjusted) comes from the same
|
||||
// helper users see, so admin reflects real usage instead of a stale 0.
|
||||
const usage = await getUserUsageData(userId)
|
||||
|
||||
const memberOrgs = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
organizationName: organization.name,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(organization, eq(member.organizationId, organization.id))
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberOrgs.map((m) => m.organizationId)
|
||||
|
||||
const subscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
orgIds.length > 0
|
||||
? or(
|
||||
eq(subscription.referenceId, userId),
|
||||
...orgIds.map((orgId) => eq(subscription.referenceId, orgId))
|
||||
)
|
||||
: eq(subscription.referenceId, userId)
|
||||
)
|
||||
|
||||
const data: AdminUserBillingWithSubscription = {
|
||||
userId: userData.id,
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
stripeCustomerId: userData.stripeCustomerId,
|
||||
currentUsageLimit: stats?.currentUsageLimit ?? null,
|
||||
currentPeriodCost: usage.currentUsage.toString(),
|
||||
lastPeriodCost: stats?.lastPeriodCost ?? null,
|
||||
billedOverageThisPeriod: stats?.billedOverageThisPeriod ?? '0',
|
||||
storageUsedBytes: stats?.storageUsedBytes ?? 0,
|
||||
billingBlocked: stats?.billingBlocked ?? false,
|
||||
currentPeriodCopilotCost: stats?.currentPeriodCopilotCost ?? '0',
|
||||
lastPeriodCopilotCost: stats?.lastPeriodCopilotCost ?? null,
|
||||
subscriptions: subscriptions.map(toAdminSubscription),
|
||||
organizationMemberships: memberOrgs.map((m) => ({
|
||||
organizationId: m.organizationId,
|
||||
organizationName: m.organizationName,
|
||||
role: m.role,
|
||||
})),
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved billing for user ${userId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get user billing', { error, userId })
|
||||
return internalErrorResponse('Failed to get user billing')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1UpdateUserBillingContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
invalidJsonResponse: adminInvalidJsonResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: userId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const {
|
||||
currentUsageLimit,
|
||||
billingBlocked,
|
||||
currentPeriodCost,
|
||||
reason: providedReason,
|
||||
} = parsed.data.body
|
||||
const reason = providedReason || 'Admin update (no reason provided)'
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [existingStats] = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const userSubscription = await getHighestPrioritySubscription(userId)
|
||||
const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId)
|
||||
|
||||
const [orgMembership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
const updated: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
if (currentUsageLimit !== undefined) {
|
||||
if (isOrgScopedMember && orgMembership) {
|
||||
warnings.push(
|
||||
'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.'
|
||||
)
|
||||
}
|
||||
|
||||
if (currentUsageLimit === null) {
|
||||
updateData.currentUsageLimit = null
|
||||
} else {
|
||||
const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0')
|
||||
if (currentUsageLimit < currentCost) {
|
||||
warnings.push(
|
||||
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.`
|
||||
)
|
||||
}
|
||||
updateData.currentUsageLimit = currentUsageLimit.toFixed(2)
|
||||
}
|
||||
updateData.usageLimitUpdatedAt = new Date()
|
||||
updated.push('currentUsageLimit')
|
||||
}
|
||||
|
||||
if (billingBlocked !== undefined) {
|
||||
if (billingBlocked === false && existingStats?.billingBlocked === true) {
|
||||
warnings.push(
|
||||
'Unblocking user. Ensure payment issues are resolved to prevent re-blocking on next invoice.'
|
||||
)
|
||||
}
|
||||
|
||||
updateData.billingBlocked = billingBlocked
|
||||
// Clear the reason when unblocking
|
||||
if (billingBlocked === false) {
|
||||
updateData.billingBlockedReason = null
|
||||
}
|
||||
updated.push('billingBlocked')
|
||||
}
|
||||
|
||||
if (currentPeriodCost !== undefined) {
|
||||
const previousCost = existingStats?.currentPeriodCost || '0'
|
||||
warnings.push(
|
||||
`Manually adjusting currentPeriodCost from $${previousCost} to $${currentPeriodCost.toFixed(2)}. This may affect billing accuracy.`
|
||||
)
|
||||
|
||||
updateData.currentPeriodCost = currentPeriodCost.toFixed(2)
|
||||
updated.push('currentPeriodCost')
|
||||
}
|
||||
|
||||
if (updated.length === 0) {
|
||||
return badRequestResponse('No valid fields to update')
|
||||
}
|
||||
|
||||
if (existingStats) {
|
||||
await db.update(userStats).set(updateData).where(eq(userStats.userId, userId))
|
||||
} else {
|
||||
await db.insert(userStats).values({
|
||||
id: generateShortId(),
|
||||
userId,
|
||||
...updateData,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated billing for user ${userId}`, {
|
||||
updated,
|
||||
warnings,
|
||||
reason,
|
||||
previousValues: existingStats
|
||||
? {
|
||||
currentUsageLimit: existingStats.currentUsageLimit,
|
||||
billingBlocked: existingStats.billingBlocked,
|
||||
currentPeriodCost: existingStats.currentPeriodCost,
|
||||
}
|
||||
: null,
|
||||
newValues: updateData,
|
||||
isTeamMember: !!orgMembership,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
updated,
|
||||
warnings,
|
||||
reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update user billing', { error, userId })
|
||||
return internalErrorResponse('Failed to update user billing')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* GET /api/v1/admin/users/[id]
|
||||
*
|
||||
* Get user details.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminUser>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { adminV1GetUserContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { toAdminUser } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminUserDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetUserContract, request, context, {
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: userId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const data = toAdminUser(userData)
|
||||
|
||||
logger.info(`Admin API: Retrieved user ${userId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get user', { error, userId })
|
||||
return internalErrorResponse('Failed to get user')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* GET /api/v1/admin/users
|
||||
*
|
||||
* List all users with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminUser>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count } from 'drizzle-orm'
|
||||
import { adminV1ListUsersContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
adminValidationErrorResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { type AdminUser, createPaginationMeta, toAdminUser } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminUsersAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(
|
||||
adminV1ListUsersContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: adminValidationErrorResponse,
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [countResult, users] = await Promise.all([
|
||||
db.select({ total: count() }).from(user),
|
||||
db.select().from(user).orderBy(user.name).limit(limit).offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminUser[] = users.map(toAdminUser)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} users (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list users', { error })
|
||||
return internalErrorResponse('Failed to list users')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
|
||||
import {
|
||||
adminV1DeployWorkflowContract,
|
||||
adminV1UndeployWorkflowContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowDeployAPI')
|
||||
export const maxDuration = 120
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — Deploy a workflow via admin API.
|
||||
*
|
||||
* `userId` is set to the workflow owner so that webhook credential resolution
|
||||
* (OAuth token lookups for providers like Airtable, Attio, etc.) uses a real
|
||||
* user. `actorId` is set to `'admin-api'` so that the `deployedBy` field on
|
||||
* the deployment version and audit log entries are correctly attributed to an
|
||||
* admin action rather than the workflow owner.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1DeployWorkflowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workflowId } = parsed.data.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const workflowRecord = await getActiveWorkflowRecord(workflowId)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const result = await performFullDeploy({
|
||||
workflowId,
|
||||
userId: workflowRecord.userId,
|
||||
workflowName: workflowRecord.name,
|
||||
requestId,
|
||||
request,
|
||||
actorId: 'admin-api',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (result.errorCode === 'not_found') return notFoundResponse('Workflow state')
|
||||
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
|
||||
return internalErrorResponse(result.error || 'Failed to deploy workflow')
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`)
|
||||
|
||||
const response: AdminDeployResult = {
|
||||
isDeployed: true,
|
||||
version: result.version!,
|
||||
deployedAt: result.deployedAt!.toISOString(),
|
||||
warnings: result.warnings,
|
||||
}
|
||||
|
||||
return singleResponse(response)
|
||||
} catch (error) {
|
||||
logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error })
|
||||
return internalErrorResponse('Failed to deploy workflow')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1UndeployWorkflowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workflowId } = parsed.data.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const workflowRecord = await getActiveWorkflowRecord(workflowId)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const result = await performFullUndeploy({
|
||||
workflowId,
|
||||
userId: workflowRecord.userId,
|
||||
requestId,
|
||||
actorId: 'admin-api',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
|
||||
|
||||
const response: AdminUndeployResult = {
|
||||
isDeployed: false,
|
||||
warnings: result.warnings,
|
||||
}
|
||||
|
||||
return singleResponse(response)
|
||||
} catch (error) {
|
||||
logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error })
|
||||
return internalErrorResponse('Failed to undeploy workflow')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workflows/[id]/export
|
||||
*
|
||||
* Export a single workflow as JSON (raw, unsanitized for admin backup/restore).
|
||||
*
|
||||
* Response: AdminSingleResponse<WorkflowExportPayload>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { adminV1ExportWorkflowContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
parseWorkflowVariables,
|
||||
type WorkflowExportPayload,
|
||||
type WorkflowExportState,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowExportAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ExportWorkflowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workflowId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [workflowData] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowData) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
if (!normalizedData) {
|
||||
return notFoundResponse('Workflow state')
|
||||
}
|
||||
|
||||
const variables = parseWorkflowVariables(workflowData.variables)
|
||||
|
||||
const state: WorkflowExportState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
metadata: {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description ?? undefined,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables,
|
||||
}
|
||||
|
||||
const exportPayload: WorkflowExportPayload = {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
workflow: {
|
||||
id: workflowData.id,
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
workspaceId: workflowData.workspaceId,
|
||||
folderId: workflowData.folderId,
|
||||
},
|
||||
state,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Exported workflow ${workflowId}`)
|
||||
|
||||
return singleResponse(exportPayload)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to export workflow', { error, workflowId })
|
||||
return internalErrorResponse('Failed to export workflow')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workflows/[id]
|
||||
*
|
||||
* Get workflow details including block and edge counts.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkflowDetail>
|
||||
*
|
||||
* DELETE /api/v1/admin/workflows/[id]
|
||||
*
|
||||
* Delete a workflow and all its associated data.
|
||||
*
|
||||
* Response: { success: true, workflowId: string }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflowBlocks, workflowEdges } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
adminV1DeleteWorkflowContract,
|
||||
adminV1GetWorkflowContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkflowDetail, toAdminWorkflow } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetWorkflowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workflowId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workflowData = await getActiveWorkflowRecord(workflowId)
|
||||
|
||||
if (!workflowData) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const [blockCountResult, edgeCountResult] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, workflowId)),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(workflowEdges)
|
||||
.where(eq(workflowEdges.workflowId, workflowId)),
|
||||
])
|
||||
|
||||
const data: AdminWorkflowDetail = {
|
||||
...toAdminWorkflow(workflowData),
|
||||
blockCount: blockCountResult[0].count,
|
||||
edgeCount: edgeCountResult[0].count,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved workflow ${workflowId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get workflow', { error, workflowId })
|
||||
return internalErrorResponse('Failed to get workflow')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1DeleteWorkflowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workflowId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workflowData = await getActiveWorkflowRecord(workflowId)
|
||||
|
||||
if (!workflowData) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const result = await performDeleteWorkflow({
|
||||
workflowId,
|
||||
userId: workflowData.userId,
|
||||
skipLastWorkflowGuard: true,
|
||||
requestId: `admin-workflow-${workflowId}`,
|
||||
actorId: 'admin-api',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return internalErrorResponse(result.error || 'Failed to delete workflow')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`)
|
||||
|
||||
return NextResponse.json({ success: true, workflowId })
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete workflow', { error, workflowId })
|
||||
return internalErrorResponse('Failed to delete workflow')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
|
||||
import { adminV1ActivateWorkflowVersionContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { performActivateVersion } from '@/lib/workflows/orchestration'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminWorkflowActivateVersionAPI')
|
||||
export const maxDuration = 120
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
versionId: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const requestId = generateRequestId()
|
||||
const parsed = await parseRequest(adminV1ActivateWorkflowVersionContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workflowId, versionId: versionNum } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workflowRecord = await getActiveWorkflowRecord(workflowId)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const result = await performActivateVersion({
|
||||
workflowId,
|
||||
version: versionNum,
|
||||
userId: workflowRecord.userId,
|
||||
workflow: workflowRecord as Record<string, unknown>,
|
||||
requestId,
|
||||
request,
|
||||
actorId: 'admin-api',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (result.errorCode === 'not_found') return notFoundResponse('Deployment version')
|
||||
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
|
||||
return internalErrorResponse(result.error || 'Failed to activate version')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}`
|
||||
)
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
version: versionNum,
|
||||
deployedAt: result.deployedAt!.toISOString(),
|
||||
warnings: result.warnings,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
|
||||
{ error }
|
||||
)
|
||||
return internalErrorResponse('Failed to activate deployment version')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
|
||||
import { adminV1ListWorkflowVersionsContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminDeploymentVersion } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowVersionsAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkflowVersionsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workflowId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workflowRecord = await getActiveWorkflowRecord(workflowId)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const { versions } = await listWorkflowVersions(workflowId)
|
||||
|
||||
const response: AdminDeploymentVersion[] = versions.map((v) => ({
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
name: v.name,
|
||||
isActive: v.isActive,
|
||||
createdAt: v.createdAt.toISOString(),
|
||||
createdBy: v.createdBy,
|
||||
deployedByName: v.deployedByName,
|
||||
}))
|
||||
|
||||
logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`)
|
||||
|
||||
return singleResponse({ versions: response })
|
||||
} catch (error) {
|
||||
logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error })
|
||||
return internalErrorResponse('Failed to list deployment versions')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* POST /api/v1/admin/workflows/export
|
||||
*
|
||||
* Export multiple workflows as a ZIP file or JSON array (raw, unsanitized for admin backup/restore).
|
||||
*
|
||||
* Request Body:
|
||||
* - ids: string[] - Array of workflow IDs to export
|
||||
*
|
||||
* Query Parameters:
|
||||
* - format: 'zip' (default) or 'json'
|
||||
*
|
||||
* Response:
|
||||
* - ZIP file download (Content-Type: application/zip) - each workflow as JSON in root
|
||||
* - JSON: AdminListResponse<WorkflowExportPayload[]>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import JSZip from 'jszip'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { adminV1ExportWorkflowsContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
parseWorkflowVariables,
|
||||
type WorkflowExportPayload,
|
||||
type WorkflowExportState,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowsExportAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(adminV1ExportWorkflowsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { format } = parsed.data.query
|
||||
const body = parsed.data.body
|
||||
|
||||
try {
|
||||
const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids))
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return badRequestResponse('No workflows found with the provided IDs')
|
||||
}
|
||||
|
||||
const workflowExports: WorkflowExportPayload[] = []
|
||||
|
||||
for (const wf of workflows) {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
|
||||
|
||||
if (!normalizedData) {
|
||||
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
|
||||
continue
|
||||
}
|
||||
|
||||
const variables = parseWorkflowVariables(wf.variables)
|
||||
|
||||
const state: WorkflowExportState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
metadata: {
|
||||
name: wf.name,
|
||||
description: wf.description ?? undefined,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables,
|
||||
}
|
||||
|
||||
const exportPayload: WorkflowExportPayload = {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
workflow: {
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
workspaceId: wf.workspaceId,
|
||||
folderId: wf.folderId,
|
||||
},
|
||||
state,
|
||||
}
|
||||
|
||||
workflowExports.push(exportPayload)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load workflow ${wf.id}:`, { error })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Exporting ${workflowExports.length} workflows`)
|
||||
|
||||
const auditExport = () => {
|
||||
if (workflowExports.length === 0) return
|
||||
recordAudit({
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.WORKFLOW_EXPORTED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
description: `Admin API exported ${workflowExports.length} workflow(s)`,
|
||||
metadata: {
|
||||
format,
|
||||
requestedCount: body.ids.length,
|
||||
exportedCount: workflowExports.length,
|
||||
requestedIds: body.ids,
|
||||
},
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
auditExport()
|
||||
return listResponse(workflowExports, {
|
||||
total: workflowExports.length,
|
||||
limit: workflowExports.length,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
})
|
||||
}
|
||||
|
||||
const zip = new JSZip()
|
||||
|
||||
for (const exportPayload of workflowExports) {
|
||||
const filename = `${sanitizePathSegment(exportPayload.workflow.name)}.json`
|
||||
zip.file(filename, JSON.stringify(exportPayload, null, 2))
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
const arrayBuffer = await zipBlob.arrayBuffer()
|
||||
|
||||
const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip`
|
||||
|
||||
auditExport()
|
||||
return new NextResponse(arrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': arrayBuffer.byteLength.toString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to export workflows', { error, ids: body.ids })
|
||||
return internalErrorResponse('Failed to export workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* POST /api/v1/admin/workflows/import
|
||||
*
|
||||
* Import a single workflow into a workspace.
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* workspaceId: string, // Required: target workspace
|
||||
* folderId?: string, // Optional: target folder
|
||||
* name?: string, // Optional: override workflow name
|
||||
* workflow: object | string // The workflow JSON (from export or raw state)
|
||||
* }
|
||||
*
|
||||
* Response: { workflowId: string, name: string, success: true }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
extractWorkflowMetadata,
|
||||
type VariableType,
|
||||
type WorkflowImportRequest,
|
||||
type WorkflowVariable,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowImportAPI')
|
||||
|
||||
interface ImportSuccessResponse {
|
||||
workflowId: string
|
||||
name: string
|
||||
success: true
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
try {
|
||||
const parsed = await parseRequest(adminV1ImportWorkflowContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const body = parsed.data.body as WorkflowImportRequest
|
||||
const { workspaceId, folderId, name: overrideName } = body
|
||||
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id, ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const workflowContent =
|
||||
typeof body.workflow === 'string' ? body.workflow : JSON.stringify(body.workflow)
|
||||
|
||||
const { data: workflowData, errors } = parseWorkflowJson(workflowContent)
|
||||
|
||||
if (!workflowData || errors.length > 0) {
|
||||
return badRequestResponse(`Invalid workflow: ${errors.join(', ')}`)
|
||||
}
|
||||
|
||||
const parsedWorkflow =
|
||||
typeof body.workflow === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(body.workflow)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: body.workflow
|
||||
|
||||
const { name: workflowName, description: workflowDescription } = extractWorkflowMetadata(
|
||||
parsedWorkflow,
|
||||
overrideName
|
||||
)
|
||||
|
||||
const workflowId = generateId()
|
||||
const now = new Date()
|
||||
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null)
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: workspaceData.ownerId,
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
name: dedupedName,
|
||||
description: workflowDescription,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
|
||||
|
||||
if (!saveResult.success) {
|
||||
await db.delete(workflow).where(eq(workflow.id, workflowId))
|
||||
return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`)
|
||||
}
|
||||
|
||||
if (
|
||||
workflowData.variables &&
|
||||
typeof workflowData.variables === 'object' &&
|
||||
!Array.isArray(workflowData.variables)
|
||||
) {
|
||||
const variablesRecord: Record<string, WorkflowVariable> = {}
|
||||
const vars = workflowData.variables as Record<
|
||||
string,
|
||||
{ id?: string; name: string; type?: VariableType; value: unknown }
|
||||
>
|
||||
Object.entries(vars).forEach(([key, v]) => {
|
||||
const varId = v.id || key
|
||||
variablesRecord[varId] = {
|
||||
id: varId,
|
||||
name: v.name,
|
||||
type: v.type ?? 'string',
|
||||
value: v.value,
|
||||
}
|
||||
})
|
||||
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({ variables: variablesRecord, updatedAt: new Date() })
|
||||
.where(eq(workflow.id, workflowId))
|
||||
} else if (workflowData.variables && Array.isArray(workflowData.variables)) {
|
||||
const variablesRecord: Record<string, WorkflowVariable> = {}
|
||||
workflowData.variables.forEach((v) => {
|
||||
const varId = v.id || generateId()
|
||||
variablesRecord[varId] = {
|
||||
id: varId,
|
||||
name: v.name,
|
||||
type: (v.type as VariableType) ?? 'string',
|
||||
value: v.value,
|
||||
}
|
||||
})
|
||||
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({ variables: variablesRecord, updatedAt: new Date() })
|
||||
.where(eq(workflow.id, workflowId))
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Admin API: Imported workflow ${workflowId} (${dedupedName}) into workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
const response: ImportSuccessResponse = {
|
||||
workflowId,
|
||||
name: dedupedName,
|
||||
success: true,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to import workflow', { error })
|
||||
return internalErrorResponse('Failed to import workflow')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workflows
|
||||
*
|
||||
* List all workflows across all workspaces with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminWorkflow>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count } from 'drizzle-orm'
|
||||
import { adminV1ListWorkflowsContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkflowsAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkflowsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [countResult, workflows] = await Promise.all([
|
||||
db.select({ total: count() }).from(workflow),
|
||||
db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
deployedAt: workflow.deployedAt,
|
||||
runCount: workflow.runCount,
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflow)
|
||||
.orderBy(workflow.name)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminWorkflow[] = workflows.map(toAdminWorkflow)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} workflows (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workflows', { error })
|
||||
return internalErrorResponse('Failed to list workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/export
|
||||
*
|
||||
* Export an entire workspace as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
|
||||
*
|
||||
* Query Parameters:
|
||||
* - format: 'zip' (default) or 'json'
|
||||
*
|
||||
* Response:
|
||||
* - ZIP file download (Content-Type: application/zip)
|
||||
* - JSON: WorkspaceExportPayload
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { encodeFilenameForHeader } from '@/app/api/files/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type FolderExportPayload,
|
||||
parseWorkflowVariables,
|
||||
type WorkflowExportState,
|
||||
type WorkspaceExportPayload,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceExportAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ExportWorkspaceContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { format } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const workflows = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
const folders = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId))
|
||||
|
||||
const workflowExports: Array<{
|
||||
workflow: WorkspaceExportPayload['workflows'][number]['workflow']
|
||||
state: WorkflowExportState
|
||||
}> = []
|
||||
|
||||
for (const wf of workflows) {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
|
||||
|
||||
if (!normalizedData) {
|
||||
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
|
||||
continue
|
||||
}
|
||||
|
||||
const variables = parseWorkflowVariables(wf.variables)
|
||||
|
||||
const state: WorkflowExportState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
metadata: {
|
||||
name: wf.name,
|
||||
description: wf.description ?? undefined,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables,
|
||||
}
|
||||
|
||||
workflowExports.push({
|
||||
workflow: {
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
workspaceId: wf.workspaceId,
|
||||
folderId: wf.folderId,
|
||||
},
|
||||
state,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load workflow ${wf.id}:`, { error })
|
||||
}
|
||||
}
|
||||
|
||||
const folderExports: FolderExportPayload[] = folders.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parentId: f.parentId,
|
||||
}))
|
||||
|
||||
logger.info(
|
||||
`Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders`
|
||||
)
|
||||
|
||||
const auditExport = () =>
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.WORKSPACE_EXPORTED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
resourceName: workspaceData.name,
|
||||
description: `Admin API exported workspace "${workspaceData.name}"`,
|
||||
metadata: {
|
||||
format,
|
||||
workflowCount: workflowExports.length,
|
||||
folderCount: folderExports.length,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
if (format === 'json') {
|
||||
auditExport()
|
||||
const exportPayload: WorkspaceExportPayload = {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
workspace: {
|
||||
id: workspaceData.id,
|
||||
name: workspaceData.name,
|
||||
},
|
||||
workflows: workflowExports,
|
||||
folders: folderExports,
|
||||
}
|
||||
|
||||
return singleResponse(exportPayload)
|
||||
}
|
||||
|
||||
const zipWorkflows = workflowExports.map((wf) => ({
|
||||
workflow: {
|
||||
id: wf.workflow.id,
|
||||
name: wf.workflow.name,
|
||||
description: wf.workflow.description ?? undefined,
|
||||
folderId: wf.workflow.folderId,
|
||||
},
|
||||
state: wf.state,
|
||||
variables: wf.state.variables,
|
||||
}))
|
||||
|
||||
const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports)
|
||||
const arrayBuffer = await zipBlob.arrayBuffer()
|
||||
|
||||
const sanitizedName = sanitizePathSegment(workspaceData.name)
|
||||
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
|
||||
|
||||
auditExport()
|
||||
return new NextResponse(arrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`,
|
||||
'Content-Length': arrayBuffer.byteLength.toString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to export workspace', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to export workspace')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/folders
|
||||
*
|
||||
* List all folders in a workspace with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminFolder>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
|
||||
import { type AdminFolder, createPaginationMeta, toAdminFolder } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceFoldersAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspaceFoldersContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [countResult, folders] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId)),
|
||||
db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId))
|
||||
.orderBy(workflowFolder.sortOrder, workflowFolder.name)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminFolder[] = folders.map(toAdminFolder)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(
|
||||
`Admin API: Listed ${data.length} folders in workspace ${workspaceId} (total: ${total})`
|
||||
)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspace folders', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to list folders')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* POST /api/v1/admin/workspaces/[id]/import
|
||||
*
|
||||
* Import workflows into a workspace from a ZIP file or JSON.
|
||||
*
|
||||
* Content-Type:
|
||||
* - application/zip or multipart/form-data (with 'file' field) for ZIP upload
|
||||
* - application/json for JSON payload
|
||||
*
|
||||
* JSON Body:
|
||||
* {
|
||||
* workflows: Array<{
|
||||
* content: string | object, // Workflow JSON
|
||||
* name?: string, // Override name
|
||||
* folderPath?: string[] // Folder path to create
|
||||
* }>
|
||||
* }
|
||||
*
|
||||
* Query Parameters:
|
||||
* - createFolders: 'true' (default) or 'false'
|
||||
* - rootFolderName: optional name for root import folder
|
||||
*
|
||||
* Response: WorkspaceImportResponse
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
adminV1ImportWorkspaceContract,
|
||||
adminV1WorkspaceImportBodySchema,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseJsonBody, parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type {
|
||||
ImportResult,
|
||||
WorkflowVariable,
|
||||
WorkspaceImportRequest,
|
||||
WorkspaceImportResponse,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceImportAPI')
|
||||
|
||||
/**
|
||||
* Body cap for admin bulk workflow imports, which can carry many serialized
|
||||
* workflows and legitimately exceed the default contract-route limit.
|
||||
*/
|
||||
const ADMIN_IMPORT_MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface ParsedWorkflow {
|
||||
content: string
|
||||
name: string
|
||||
folderPath: string[]
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { createFolders, rootFolderName } = parsed.data.query
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceWithOwner(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
let workflowsToImport: ParsedWorkflow[] = []
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const rawBody = await parseJsonBody(request, 'response', ADMIN_IMPORT_MAX_BODY_BYTES)
|
||||
|
||||
if (!rawBody.success) {
|
||||
// Preserve the 413 for an oversized body; only invalid JSON maps to 400.
|
||||
return rawBody.reason === 'too_large'
|
||||
? rawBody.response
|
||||
: badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
|
||||
}
|
||||
|
||||
const validation = adminV1WorkspaceImportBodySchema.safeParse(rawBody.data)
|
||||
if (!validation.success) {
|
||||
return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
|
||||
}
|
||||
|
||||
const body = validation.data as WorkspaceImportRequest
|
||||
workflowsToImport = body.workflows.map((w) => ({
|
||||
content: typeof w.content === 'string' ? w.content : JSON.stringify(w.content),
|
||||
name: w.name || 'Imported Workflow',
|
||||
folderPath: w.folderPath || [],
|
||||
}))
|
||||
} else if (
|
||||
contentType.includes('application/zip') ||
|
||||
contentType.includes('multipart/form-data')
|
||||
) {
|
||||
let zipBuffer: ArrayBuffer
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return badRequestResponse('No file provided in form data. Use field name "file".')
|
||||
}
|
||||
|
||||
zipBuffer = await file.arrayBuffer()
|
||||
} else {
|
||||
zipBuffer = await request.arrayBuffer()
|
||||
}
|
||||
|
||||
const blob = new Blob([zipBuffer], { type: 'application/zip' })
|
||||
const file = new File([blob], 'import.zip', { type: 'application/zip' })
|
||||
|
||||
const { workflows } = await extractWorkflowsFromZip(file)
|
||||
workflowsToImport = workflows
|
||||
} else {
|
||||
return badRequestResponse(
|
||||
'Unsupported Content-Type. Use application/json or application/zip.'
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowsToImport.length === 0) {
|
||||
return badRequestResponse('No workflows found to import')
|
||||
}
|
||||
|
||||
let rootFolderId: string | undefined
|
||||
if (rootFolderName && createFolders) {
|
||||
rootFolderId = generateId()
|
||||
await db.insert(workflowFolder).values({
|
||||
id: rootFolderId,
|
||||
name: rootFolderName,
|
||||
userId: workspaceData.ownerId,
|
||||
workspaceId,
|
||||
parentId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
const folderMap = new Map<string, string>()
|
||||
const results: ImportResult[] = []
|
||||
|
||||
for (const wf of workflowsToImport) {
|
||||
const result = await importSingleWorkflow(
|
||||
wf,
|
||||
workspaceId,
|
||||
workspaceData.ownerId,
|
||||
createFolders,
|
||||
rootFolderId,
|
||||
folderMap
|
||||
)
|
||||
results.push(result)
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Admin API: Imported workflow ${result.workflowId} (${result.name})`)
|
||||
} else {
|
||||
logger.warn(`Admin API: Failed to import workflow ${result.name}: ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const imported = results.filter((r) => r.success).length
|
||||
const failed = results.filter((r) => !r.success).length
|
||||
|
||||
logger.info(`Admin API: Import complete - ${imported} succeeded, ${failed} failed`)
|
||||
|
||||
const response: WorkspaceImportResponse = { imported, failed, results }
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to import into workspace', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to import workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
async function importSingleWorkflow(
|
||||
wf: ParsedWorkflow,
|
||||
workspaceId: string,
|
||||
ownerId: string,
|
||||
createFolders: boolean,
|
||||
rootFolderId: string | undefined,
|
||||
folderMap: Map<string, string>
|
||||
): Promise<ImportResult> {
|
||||
try {
|
||||
const { data: workflowData, errors } = parseWorkflowJson(wf.content)
|
||||
|
||||
if (!workflowData || errors.length > 0) {
|
||||
return {
|
||||
workflowId: '',
|
||||
name: wf.name,
|
||||
success: false,
|
||||
error: `Parse error: ${errors.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
const workflowName = extractWorkflowName(wf.content, wf.name)
|
||||
let targetFolderId: string | null = rootFolderId || null
|
||||
|
||||
if (createFolders && wf.folderPath.length > 0) {
|
||||
let parentId = rootFolderId || null
|
||||
|
||||
for (let i = 0; i < wf.folderPath.length; i++) {
|
||||
const pathSegment = wf.folderPath.slice(0, i + 1).join('/')
|
||||
const fullPath = rootFolderId ? `root/${pathSegment}` : pathSegment
|
||||
|
||||
if (!folderMap.has(fullPath)) {
|
||||
const folderId = generateId()
|
||||
await db.insert(workflowFolder).values({
|
||||
id: folderId,
|
||||
name: wf.folderPath[i],
|
||||
userId: ownerId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
folderMap.set(fullPath, folderId)
|
||||
parentId = folderId
|
||||
} else {
|
||||
parentId = folderMap.get(fullPath)!
|
||||
}
|
||||
}
|
||||
|
||||
const fullFolderPath = rootFolderId
|
||||
? `root/${wf.folderPath.join('/')}`
|
||||
: wf.folderPath.join('/')
|
||||
targetFolderId = folderMap.get(fullFolderPath) || parentId
|
||||
}
|
||||
|
||||
const workflowId = generateId()
|
||||
const now = new Date()
|
||||
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId)
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: ownerId,
|
||||
workspaceId,
|
||||
folderId: targetFolderId,
|
||||
name: dedupedName,
|
||||
description: workflowData.metadata?.description || 'Imported via Admin API',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
|
||||
|
||||
if (!saveResult.success) {
|
||||
await db.delete(workflow).where(eq(workflow.id, workflowId))
|
||||
return {
|
||||
workflowId: '',
|
||||
name: dedupedName,
|
||||
success: false,
|
||||
error: `Failed to save state: ${saveResult.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowData.variables && Array.isArray(workflowData.variables)) {
|
||||
const variablesRecord: Record<string, WorkflowVariable> = {}
|
||||
workflowData.variables.forEach((v) => {
|
||||
const varId = v.id || generateId()
|
||||
variablesRecord[varId] = {
|
||||
id: varId,
|
||||
name: v.name,
|
||||
type: v.type || 'string',
|
||||
value: v.value,
|
||||
}
|
||||
})
|
||||
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({ variables: variablesRecord, updatedAt: new Date() })
|
||||
.where(eq(workflow.id, workflowId))
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
name: dedupedName,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowId: '',
|
||||
name: wf.name,
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/members/[memberId]
|
||||
*
|
||||
* Get workspace member details.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceMember>
|
||||
*
|
||||
* PATCH /api/v1/admin/workspaces/[id]/members/[memberId]
|
||||
*
|
||||
* Update member permissions.
|
||||
*
|
||||
* Body:
|
||||
* - permissions: 'admin' | 'write' | 'read' - New permission level
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceMember>
|
||||
*
|
||||
* DELETE /api/v1/admin/workspaces/[id]/members/[memberId]
|
||||
*
|
||||
* Remove member from workspace.
|
||||
*
|
||||
* Response: AdminSingleResponse<{ removed: true, memberId: string, userId: string }>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, user, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1GetWorkspaceMemberContract,
|
||||
adminV1RemoveWorkspaceMemberContract,
|
||||
adminV1UpdateWorkspaceMemberContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
|
||||
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
|
||||
WorkspaceBillingAccountRemovalError,
|
||||
} from '@/lib/workspaces/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminWorkspaceMember } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceMemberDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
memberId: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId, memberId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [memberData] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
updatedAt: permissions.updatedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.id, memberId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!memberData) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
const data: AdminWorkspaceMember = {
|
||||
id: memberData.id,
|
||||
workspaceId,
|
||||
userId: memberData.userId,
|
||||
permissions: memberData.permissionType,
|
||||
createdAt: memberData.createdAt.toISOString(),
|
||||
updatedAt: memberData.updatedAt.toISOString(),
|
||||
userName: memberData.userName,
|
||||
userEmail: memberData.userEmail,
|
||||
userImage: memberData.userImage,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId })
|
||||
return internalErrorResponse('Failed to get workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1UpdateWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId, memberId } = parsed.data.params
|
||||
const { permissions: permissionLevel } = parsed.data.body
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.id, memberId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (
|
||||
workspaceBilling?.billedAccountUserId === existingMember.userId &&
|
||||
permissionLevel !== 'admin'
|
||||
) {
|
||||
return badRequestResponse('Workspace billing account must retain admin permissions')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(permissions)
|
||||
.set({ permissionType: permissionLevel, updatedAt: now })
|
||||
.where(eq(permissions.id, memberId))
|
||||
|
||||
const [userData] = await db
|
||||
.select({ name: user.name, email: user.email, image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, existingMember.userId))
|
||||
.limit(1)
|
||||
|
||||
const data: AdminWorkspaceMember = {
|
||||
id: existingMember.id,
|
||||
workspaceId,
|
||||
userId: existingMember.userId,
|
||||
permissions: permissionLevel,
|
||||
createdAt: existingMember.createdAt.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
userName: userData?.name ?? '',
|
||||
userEmail: userData?.email ?? '',
|
||||
userImage: userData?.image ?? null,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated member ${memberId} permissions to ${permissionLevel}`, {
|
||||
workspaceId,
|
||||
previousPermissions: existingMember.permissionType,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
|
||||
metadata: {
|
||||
memberId,
|
||||
targetUserId: existingMember.userId,
|
||||
previousPermissions: existingMember.permissionType,
|
||||
permissions: permissionLevel,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId })
|
||||
return internalErrorResponse('Failed to update workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1RemoveWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId, memberId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.id, memberId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceBilling?.billedAccountUserId === existingMember.userId) {
|
||||
return badRequestResponse(
|
||||
'Cannot remove the workspace billing account. Please reassign billing first.'
|
||||
)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
|
||||
tx,
|
||||
workspaceId,
|
||||
departingUserId: existingMember.userId,
|
||||
})
|
||||
|
||||
const workflowOwnershipReassignment =
|
||||
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx,
|
||||
workspaceIds: [workspaceId],
|
||||
departingUserId: existingMember.userId,
|
||||
})
|
||||
if (workflowOwnershipReassignment.unresolved.length > 0) {
|
||||
throw new WorkspaceBillingAccountRemovalError()
|
||||
}
|
||||
|
||||
await tx.delete(permissions).where(eq(permissions.id, memberId))
|
||||
|
||||
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId)
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
|
||||
userId: existingMember.userId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: 'Admin API removed member from workspace',
|
||||
metadata: { memberId, targetUserId: existingMember.userId },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
removed: true,
|
||||
memberId,
|
||||
userId: existingMember.userId,
|
||||
workspaceId,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceBillingAccountRemovalError) {
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId })
|
||||
return internalErrorResponse('Failed to remove workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/members
|
||||
*
|
||||
* List all members of a workspace with their permission details.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminWorkspaceMember>
|
||||
*
|
||||
* POST /api/v1/admin/workspaces/[id]/members
|
||||
*
|
||||
* Add a user to a workspace with a specific permission level.
|
||||
* If the user already has permissions, updates their permission level.
|
||||
*
|
||||
* Body:
|
||||
* - userId: string - User ID to add
|
||||
* - permissions: 'admin' | 'write' | 'read' - Permission level
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceMember & { action: 'created' | 'updated' }>
|
||||
*
|
||||
* DELETE /api/v1/admin/workspaces/[id]/members
|
||||
*
|
||||
* Remove a user from a workspace.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - userId: string - User ID to remove
|
||||
*
|
||||
* Response: AdminSingleResponse<{ removed: true }>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1CreateWorkspaceMemberContract,
|
||||
adminV1DeleteWorkspaceMemberContract,
|
||||
adminV1ListWorkspaceMembersContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
|
||||
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
|
||||
WorkspaceBillingAccountRemovalError,
|
||||
} from '@/lib/workspaces/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkspaceMember, createPaginationMeta } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceMembersAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspaceMembersContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [countResult, membersData] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
|
||||
),
|
||||
db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
updatedAt: permissions.updatedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(
|
||||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
|
||||
)
|
||||
.orderBy(permissions.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].count
|
||||
const data: AdminWorkspaceMember[] = membersData.map((m) => ({
|
||||
id: m.id,
|
||||
workspaceId,
|
||||
userId: m.userId,
|
||||
permissions: m.permissionType,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
userName: m.userName,
|
||||
userEmail: m.userEmail,
|
||||
userImage: m.userImage,
|
||||
}))
|
||||
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspace members', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to list workspace members')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1CreateWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { userId, permissions: permissionLevel } = parsed.data.body
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceBilling?.billedAccountUserId === userId && permissionLevel !== 'admin') {
|
||||
return badRequestResponse('Workspace billing account must retain admin permissions')
|
||||
}
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email, image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [existingPermission] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
updatedAt: permissions.updatedAt,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingPermission) {
|
||||
if (existingPermission.permissionType !== permissionLevel) {
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(permissions)
|
||||
.set({ permissionType: permissionLevel, updatedAt: now })
|
||||
.where(eq(permissions.id, existingPermission.id))
|
||||
|
||||
logger.info(`Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`, {
|
||||
previousPermissions: existingPermission.permissionType,
|
||||
newPermissions: permissionLevel,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
|
||||
metadata: {
|
||||
targetUserId: userId,
|
||||
previousPermissions: existingPermission.permissionType,
|
||||
permissions: permissionLevel,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
id: existingPermission.id,
|
||||
workspaceId,
|
||||
userId,
|
||||
permissions: permissionLevel,
|
||||
createdAt: existingPermission.createdAt.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
userImage: userData.image,
|
||||
action: 'updated' as const,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
id: existingPermission.id,
|
||||
workspaceId,
|
||||
userId,
|
||||
permissions: existingPermission.permissionType,
|
||||
createdAt: existingPermission.createdAt.toISOString(),
|
||||
updatedAt: existingPermission.updatedAt.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
userImage: userData.image,
|
||||
action: 'already_member' as const,
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const permissionId = generateId()
|
||||
|
||||
await db.insert(permissions).values({
|
||||
id: permissionId,
|
||||
userId,
|
||||
entityType: 'workspace',
|
||||
entityId: workspaceId,
|
||||
permissionType: permissionLevel,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
|
||||
permissions: permissionLevel,
|
||||
permissionId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: `Admin API added member to workspace with ${permissionLevel} permissions`,
|
||||
metadata: { targetUserId: userId, permissions: permissionLevel },
|
||||
request,
|
||||
})
|
||||
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: userId,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
id: permissionId,
|
||||
workspaceId,
|
||||
userId,
|
||||
permissions: permissionLevel,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
userImage: userData.image,
|
||||
action: 'created' as const,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to add workspace member', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to add workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1DeleteWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { userId } = parsed.data.query
|
||||
let targetUserId: string | undefined
|
||||
|
||||
try {
|
||||
targetUserId = userId
|
||||
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceBilling?.billedAccountUserId === userId) {
|
||||
return badRequestResponse(
|
||||
'Cannot remove the workspace billing account. Please reassign billing first.'
|
||||
)
|
||||
}
|
||||
|
||||
const [existingPermission] = await db
|
||||
.select({ id: permissions.id })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingPermission) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
|
||||
tx,
|
||||
workspaceId,
|
||||
departingUserId: userId,
|
||||
})
|
||||
|
||||
const workflowOwnershipReassignment =
|
||||
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx,
|
||||
workspaceIds: [workspaceId],
|
||||
departingUserId: userId,
|
||||
})
|
||||
if (workflowOwnershipReassignment.unresolved.length > 0) {
|
||||
throw new WorkspaceBillingAccountRemovalError()
|
||||
}
|
||||
|
||||
await tx.delete(permissions).where(eq(permissions.id, existingPermission.id))
|
||||
|
||||
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: 'Admin API removed member from workspace',
|
||||
metadata: { targetUserId: userId },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({ removed: true, userId, workspaceId })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceBillingAccountRemovalError) {
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to remove workspace member', {
|
||||
error,
|
||||
workspaceId,
|
||||
userId: targetUserId,
|
||||
})
|
||||
return internalErrorResponse('Failed to remove workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]
|
||||
*
|
||||
* Get workspace details including workflow and folder counts.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceDetail>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkspaceDetail, toAdminWorkspace } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetWorkspaceContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [workflowCountResult, folderCountResult] = await Promise.all([
|
||||
db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId)),
|
||||
])
|
||||
|
||||
const data: AdminWorkspaceDetail = {
|
||||
...toAdminWorkspace(workspaceData),
|
||||
workflowCount: workflowCountResult[0].count,
|
||||
folderCount: folderCountResult[0].count,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved workspace ${workspaceId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get workspace', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to get workspace')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/workflows
|
||||
*
|
||||
* List all workflows in a workspace with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminWorkflow>
|
||||
*
|
||||
* DELETE /api/v1/admin/workspaces/[id]/workflows
|
||||
*
|
||||
* Delete all workflows in a workspace (clean slate for reimport).
|
||||
*
|
||||
* Response: { success: true, deleted: number }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
adminV1DeleteWorkspaceWorkflowsContract,
|
||||
adminV1ListWorkspaceWorkflowsContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceWorkflowsAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspaceWorkflowsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [countResult, workflows] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
|
||||
db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
deployedAt: workflow.deployedAt,
|
||||
runCount: workflow.runCount,
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
|
||||
.orderBy(workflow.name)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminWorkflow[] = workflows.map(toAdminWorkflow)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(
|
||||
`Admin API: Listed ${data.length} workflows in workspace ${workspaceId} (total: ${total})`
|
||||
)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspace workflows', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to list workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1DeleteWorkspaceWorkflowsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const workflowsToDelete = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
|
||||
|
||||
if (workflowsToDelete.length === 0) {
|
||||
return NextResponse.json({ success: true, deleted: 0 })
|
||||
}
|
||||
|
||||
const deletedCount = await archiveWorkflowsForWorkspace(workspaceId, {
|
||||
requestId: `admin-workspace-${workspaceId}`,
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Deleted ${deletedCount} workflows from workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({ success: true, deleted: deletedCount })
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete workspace workflows', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to delete workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces
|
||||
*
|
||||
* List all workspaces with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminWorkspace>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count } from 'drizzle-orm'
|
||||
import { adminV1ListWorkspacesContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminWorkspace,
|
||||
createPaginationMeta,
|
||||
toAdminWorkspace,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspacesAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspacesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [countResult, workspaces] = await Promise.all([
|
||||
db.select({ total: count() }).from(workspace),
|
||||
db.select().from(workspace).orderBy(workspace.name).limit(limit).offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminWorkspace[] = workspaces.map(toAdminWorkspace)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} workspaces (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspaces', { error })
|
||||
return internalErrorResponse('Failed to list workspaces')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Tests for GET /api/v1/audit-logs/[id] — verifies the lookup is constrained
|
||||
* by the organization scope and 404s for rows outside it.
|
||||
*/
|
||||
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckRateLimit,
|
||||
mockValidateEnterpriseAuditAccess,
|
||||
mockBuildOrgScopeCondition,
|
||||
mockGetOrgWorkspaceIds,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
mockValidateEnterpriseAuditAccess: vi.fn(),
|
||||
mockBuildOrgScopeCondition: vi.fn(),
|
||||
mockGetOrgWorkspaceIds: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('@/app/api/v1/middleware', () => ({
|
||||
checkRateLimit: mockCheckRateLimit,
|
||||
createRateLimitResponse: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/audit-logs/auth', () => ({
|
||||
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/audit-logs/query', () => ({
|
||||
buildOrgScopeCondition: mockBuildOrgScopeCondition,
|
||||
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/logs/meta', () => ({
|
||||
getUserLimits: vi.fn().mockResolvedValue({}),
|
||||
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/v1/audit-logs/[id]/route'
|
||||
|
||||
const ORG_ID = 'org-1'
|
||||
const MEMBER_IDS = ['admin-1', 'member-1']
|
||||
const ORG_WORKSPACE_IDS = ['ws-org-1']
|
||||
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
|
||||
|
||||
const AUDIT_ROW = {
|
||||
id: 'log-1',
|
||||
workspaceId: 'ws-org-1',
|
||||
actorId: 'member-1',
|
||||
actorName: 'Member',
|
||||
actorEmail: 'member@example.com',
|
||||
action: 'workflow.created',
|
||||
resourceType: 'workflow',
|
||||
resourceId: 'wf-1',
|
||||
resourceName: 'My Workflow',
|
||||
description: 'Created workflow',
|
||||
metadata: {},
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test',
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
}
|
||||
|
||||
function callRoute(id: string) {
|
||||
const request = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
`http://localhost:3000/api/v1/audit-logs/${id}`
|
||||
)
|
||||
return GET(request, { params: Promise.resolve({ id }) })
|
||||
}
|
||||
|
||||
describe('GET /api/v1/audit-logs/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' })
|
||||
mockValidateEnterpriseAuditAccess.mockResolvedValue({
|
||||
success: true,
|
||||
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
|
||||
})
|
||||
mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS)
|
||||
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
|
||||
})
|
||||
|
||||
it('constrains the lookup with the org scope condition (includeDeparted)', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([AUDIT_ROW])
|
||||
|
||||
const response = await callRoute('log-1')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockBuildOrgScopeCondition).toHaveBeenCalledWith({
|
||||
organizationId: ORG_ID,
|
||||
orgWorkspaceIds: ORG_WORKSPACE_IDS,
|
||||
orgMemberIds: MEMBER_IDS,
|
||||
includeDeparted: true,
|
||||
})
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'and',
|
||||
conditions: expect.arrayContaining([SCOPE_SENTINEL]),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 404 when the row is outside the organization scope', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
const response = await callRoute('log-outside-org')
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('Audit log not found')
|
||||
})
|
||||
|
||||
it('excludes ipAddress and userAgent from the response', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([AUDIT_ROW])
|
||||
|
||||
const response = await callRoute('log-1')
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.data.id).toBe('log-1')
|
||||
expect(body.data.ipAddress).toBeUndefined()
|
||||
expect(body.data.userAgent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* GET /api/v1/audit-logs/[id]
|
||||
*
|
||||
* Get a single audit log entry by ID, scoped to the authenticated user's organization.
|
||||
* Requires enterprise subscription and org admin/owner role.
|
||||
*
|
||||
* Scope is the organization boundary: logs within org-attached workspaces and
|
||||
* org-level events (including those from departed members or system actions
|
||||
* with null actorId).
|
||||
*
|
||||
* Response: { data: AuditLogEntry, limits: UserLimits }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { auditLog } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
|
||||
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
|
||||
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1AuditLogDetailAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'audit-logs')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetAuditLogContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid audit log ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const authResult = await validateEnterpriseAuditAccess(userId)
|
||||
if (!authResult.success) {
|
||||
return authResult.response
|
||||
}
|
||||
|
||||
const { organizationId, orgMemberIds } = authResult.context
|
||||
|
||||
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
|
||||
const scopeCondition = buildOrgScopeCondition({
|
||||
organizationId,
|
||||
orgWorkspaceIds,
|
||||
orgMemberIds,
|
||||
includeDeparted: true,
|
||||
})
|
||||
|
||||
const [log] = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(and(eq(auditLog.id, id), scopeCondition))
|
||||
.limit(1)
|
||||
|
||||
if (!log) {
|
||||
return NextResponse.json({ error: 'Audit log not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit)
|
||||
|
||||
return NextResponse.json(response.body, { headers: response.headers })
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
logger.error(`[${requestId}] Audit log detail fetch error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Enterprise audit log authorization.
|
||||
*
|
||||
* Validates that the authenticated user is an admin/owner of an enterprise organization
|
||||
* and returns the organization context needed for scoped queries.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
|
||||
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
const logger = createLogger('V1AuditLogsAuth')
|
||||
|
||||
interface EnterpriseAuditContext {
|
||||
organizationId: string
|
||||
orgMemberIds: string[]
|
||||
}
|
||||
|
||||
type AuthResult =
|
||||
| { success: true; context: EnterpriseAuditContext }
|
||||
| { success: false; response: NextResponse }
|
||||
|
||||
/**
|
||||
* Validates enterprise audit log access for the given user.
|
||||
*
|
||||
* Checks:
|
||||
* 1. User belongs to an organization
|
||||
* 2. User has admin or owner role
|
||||
* 3. Organization has an active enterprise subscription
|
||||
*
|
||||
* Returns the organization ID and all member user IDs on success,
|
||||
* or an error response on failure.
|
||||
*/
|
||||
export async function validateEnterpriseAuditAccess(userId: string): Promise<AuthResult> {
|
||||
const [membership] = await db
|
||||
.select({ organizationId: member.organizationId, role: member.role })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }),
|
||||
}
|
||||
}
|
||||
|
||||
if (membership.role !== 'admin' && membership.role !== 'owner') {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Organization admin or owner role required' },
|
||||
{ status: 403 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const billingStatus = await getEffectiveBillingStatus(userId)
|
||||
if (billingStatus.billingBlocked) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Active enterprise subscription required' },
|
||||
{ status: 403 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const [orgSub, orgMembers] = await Promise.all([
|
||||
db
|
||||
.select({ id: subscription.id })
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, membership.organizationId),
|
||||
eq(subscription.plan, 'enterprise'),
|
||||
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, membership.organizationId)),
|
||||
])
|
||||
|
||||
if (orgSub.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Active enterprise subscription required' },
|
||||
{ status: 403 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const orgMemberIds = orgMembers.map((m) => m.userId)
|
||||
|
||||
logger.info('Enterprise audit access validated', {
|
||||
userId,
|
||||
organizationId: membership.organizationId,
|
||||
memberCount: orgMemberIds.length,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
context: {
|
||||
organizationId: membership.organizationId,
|
||||
orgMemberIds,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Enterprise audit log response formatting.
|
||||
*
|
||||
* Defines the shape returned by the enterprise audit log API.
|
||||
* Excludes `ipAddress` and `userAgent` for privacy.
|
||||
*/
|
||||
|
||||
import type { auditLog } from '@sim/db/schema'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
|
||||
type DbAuditLog = InferSelectModel<typeof auditLog>
|
||||
|
||||
export interface EnterpriseAuditLogEntry {
|
||||
id: string
|
||||
workspaceId: string | null
|
||||
actorId: string | null
|
||||
actorName: string | null
|
||||
actorEmail: string | null
|
||||
action: string
|
||||
resourceType: string
|
||||
resourceId: string | null
|
||||
resourceName: string | null
|
||||
description: string | null
|
||||
metadata: unknown
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function formatAuditLogEntry(log: DbAuditLog): EnterpriseAuditLogEntry {
|
||||
return {
|
||||
id: log.id,
|
||||
workspaceId: log.workspaceId,
|
||||
actorId: log.actorId,
|
||||
actorName: log.actorName,
|
||||
actorEmail: log.actorEmail,
|
||||
action: log.action,
|
||||
resourceType: log.resourceType,
|
||||
resourceId: log.resourceId,
|
||||
resourceName: log.resourceName,
|
||||
description: log.description,
|
||||
metadata: log.metadata,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Tests for the enterprise audit-log tenant boundary. The global drizzle-orm
|
||||
* mock returns structured operator objects, so these tests assert directly on
|
||||
* the predicate tree.
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
|
||||
|
||||
const ORG_ID = 'org-1'
|
||||
const MEMBER_IDS = ['user-1', 'user-2']
|
||||
const WORKSPACE_IDS = ['ws-1', 'ws-2']
|
||||
|
||||
interface MockCondition {
|
||||
type?: string
|
||||
conditions?: MockCondition[]
|
||||
column?: string
|
||||
values?: string[]
|
||||
left?: string
|
||||
right?: string
|
||||
strings?: string[]
|
||||
}
|
||||
|
||||
function asCondition(value: unknown): MockCondition {
|
||||
return value as MockCondition
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the condition matches null-workspace rows tied to the organization
|
||||
* via metadata or the organization resource itself.
|
||||
*/
|
||||
function expectOrgLevelCondition(condition: MockCondition, organizationId: string): void {
|
||||
expect(condition.type).toBe('and')
|
||||
const [nullCheck, orgLink] = condition.conditions!
|
||||
expect(nullCheck).toMatchObject({ type: 'isNull', column: 'workspaceId' })
|
||||
|
||||
expect(orgLink.type).toBe('or')
|
||||
const [metadataMatch, orgResourceMatch] = orgLink.conditions!
|
||||
expect(metadataMatch.strings?.join('?')).toContain("->>'organizationId' =")
|
||||
expect(metadataMatch.values).toContain(organizationId)
|
||||
|
||||
expect(orgResourceMatch.type).toBe('and')
|
||||
expect(orgResourceMatch.conditions).toEqual([
|
||||
expect.objectContaining({ type: 'eq', left: 'resourceType', right: 'organization' }),
|
||||
expect.objectContaining({ type: 'eq', left: 'resourceId', right: organizationId }),
|
||||
])
|
||||
}
|
||||
|
||||
describe('buildOrgScopeCondition', () => {
|
||||
it('never uses actor membership as a standalone boundary (default scope)', () => {
|
||||
const condition = asCondition(
|
||||
buildOrgScopeCondition({
|
||||
organizationId: ORG_ID,
|
||||
orgWorkspaceIds: WORKSPACE_IDS,
|
||||
orgMemberIds: MEMBER_IDS,
|
||||
includeDeparted: false,
|
||||
})
|
||||
)
|
||||
|
||||
expect(condition.type).toBe('and')
|
||||
const [orgScope, actorFilter] = condition.conditions!
|
||||
|
||||
expect(orgScope.type).toBe('or')
|
||||
const [workspaceScope, orgLevel] = orgScope.conditions!
|
||||
expect(workspaceScope).toMatchObject({
|
||||
type: 'inArray',
|
||||
column: 'workspaceId',
|
||||
values: WORKSPACE_IDS,
|
||||
})
|
||||
expectOrgLevelCondition(orgLevel, ORG_ID)
|
||||
|
||||
expect(actorFilter).toMatchObject({
|
||||
type: 'or',
|
||||
conditions: [
|
||||
expect.objectContaining({ type: 'inArray', column: 'actorId', values: MEMBER_IDS }),
|
||||
expect.objectContaining({ type: 'isNull', column: 'actorId' }),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the actor filter entirely when includeDeparted is true', () => {
|
||||
const condition = asCondition(
|
||||
buildOrgScopeCondition({
|
||||
organizationId: ORG_ID,
|
||||
orgWorkspaceIds: WORKSPACE_IDS,
|
||||
orgMemberIds: MEMBER_IDS,
|
||||
includeDeparted: true,
|
||||
})
|
||||
)
|
||||
|
||||
expect(condition.type).toBe('or')
|
||||
const [workspaceScope, orgLevel] = condition.conditions!
|
||||
expect(workspaceScope).toMatchObject({
|
||||
type: 'inArray',
|
||||
column: 'workspaceId',
|
||||
values: WORKSPACE_IDS,
|
||||
})
|
||||
expectOrgLevelCondition(orgLevel, ORG_ID)
|
||||
|
||||
expect(JSON.stringify(condition)).not.toContain('actorId')
|
||||
})
|
||||
|
||||
it('falls back to the org-level branch alone when the org has no workspaces', () => {
|
||||
const condition = asCondition(
|
||||
buildOrgScopeCondition({
|
||||
organizationId: ORG_ID,
|
||||
orgWorkspaceIds: [],
|
||||
orgMemberIds: MEMBER_IDS,
|
||||
includeDeparted: true,
|
||||
})
|
||||
)
|
||||
|
||||
expectOrgLevelCondition(condition, ORG_ID)
|
||||
})
|
||||
|
||||
it('still applies the actor filter on top of the org scope with no workspaces', () => {
|
||||
const condition = asCondition(
|
||||
buildOrgScopeCondition({
|
||||
organizationId: ORG_ID,
|
||||
orgWorkspaceIds: [],
|
||||
orgMemberIds: MEMBER_IDS,
|
||||
includeDeparted: false,
|
||||
})
|
||||
)
|
||||
|
||||
expect(condition.type).toBe('and')
|
||||
const [orgLevel, actorFilter] = condition.conditions!
|
||||
expectOrgLevelCondition(orgLevel, ORG_ID)
|
||||
expect(actorFilter).toMatchObject({
|
||||
type: 'or',
|
||||
conditions: [
|
||||
expect.objectContaining({ type: 'inArray', column: 'actorId', values: MEMBER_IDS }),
|
||||
expect.objectContaining({ type: 'isNull', column: 'actorId' }),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('only matches system events when the org has no current members', () => {
|
||||
const condition = asCondition(
|
||||
buildOrgScopeCondition({
|
||||
organizationId: ORG_ID,
|
||||
orgWorkspaceIds: WORKSPACE_IDS,
|
||||
orgMemberIds: [],
|
||||
includeDeparted: false,
|
||||
})
|
||||
)
|
||||
|
||||
expect(condition.type).toBe('and')
|
||||
const [, actorFilter] = condition.conditions!
|
||||
expect(actorFilter).toMatchObject({ type: 'isNull', column: 'actorId' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrgWorkspaceIds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('selects workspaces by organization ownership, not member ownership', async () => {
|
||||
const ids = await getOrgWorkspaceIds(ORG_ID)
|
||||
|
||||
expect(ids).toEqual([])
|
||||
expect(dbChainMockFns.where).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'eq', left: 'organizationId', right: ORG_ID })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { AuditResourceType } from '@sim/audit'
|
||||
import { db, dbReplica } from '@sim/db'
|
||||
import { auditLog, workspace } from '@sim/db/schema'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import { and, desc, eq, gte, ilike, inArray, isNull, lt, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
|
||||
type DbAuditLog = InferSelectModel<typeof auditLog>
|
||||
|
||||
interface CursorData {
|
||||
createdAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function encodeCursor(data: CursorData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): CursorData | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditLogFilterParams {
|
||||
action?: string
|
||||
resourceType?: string
|
||||
resourceId?: string
|
||||
workspaceId?: string
|
||||
actorId?: string
|
||||
actorEmail?: string
|
||||
search?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export function buildFilterConditions(params: AuditLogFilterParams): SQL<unknown>[] {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
|
||||
if (params.action) conditions.push(eq(auditLog.action, params.action))
|
||||
if (params.resourceType) {
|
||||
const types = params.resourceType.split(',').filter(Boolean)
|
||||
if (types.length === 1) conditions.push(eq(auditLog.resourceType, types[0]))
|
||||
else if (types.length > 1) conditions.push(inArray(auditLog.resourceType, types))
|
||||
}
|
||||
if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId))
|
||||
if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId))
|
||||
if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId))
|
||||
if (params.actorEmail) conditions.push(eq(auditLog.actorEmail, params.actorEmail))
|
||||
|
||||
if (params.search) {
|
||||
const escaped = params.search.replace(/[%_\\]/g, '\\$&')
|
||||
const searchTerm = `%${escaped}%`
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(auditLog.action, searchTerm),
|
||||
ilike(auditLog.actorEmail, searchTerm),
|
||||
ilike(auditLog.actorName, searchTerm),
|
||||
ilike(auditLog.resourceName, searchTerm),
|
||||
ilike(auditLog.description, searchTerm)
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate)))
|
||||
if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate)))
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all workspaces attached to the organization.
|
||||
*/
|
||||
export async function getOrgWorkspaceIds(organizationId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.organizationId, organizationId))
|
||||
return rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
export interface OrgScopeParams {
|
||||
organizationId: string
|
||||
orgWorkspaceIds: string[]
|
||||
orgMemberIds: string[]
|
||||
includeDeparted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the tenant-boundary predicate for organization audit log access:
|
||||
* rows in org-attached workspaces, plus org-level rows (`workspace_id IS
|
||||
* NULL`) tied to the org via `metadata.organizationId` or the organization
|
||||
* resource itself. Actor membership is never a standalone boundary — when
|
||||
* `includeDeparted` is false it only narrows the org scope to current members
|
||||
* and system events (null actor).
|
||||
*/
|
||||
export function buildOrgScopeCondition(params: OrgScopeParams): SQL<unknown> {
|
||||
const { organizationId, orgWorkspaceIds, orgMemberIds, includeDeparted } = params
|
||||
|
||||
const orgLevelCondition = and(
|
||||
isNull(auditLog.workspaceId),
|
||||
or(
|
||||
sql`${auditLog.metadata}->>'organizationId' = ${organizationId}`,
|
||||
and(
|
||||
eq(auditLog.resourceType, AuditResourceType.ORGANIZATION),
|
||||
eq(auditLog.resourceId, organizationId)
|
||||
)
|
||||
)
|
||||
)!
|
||||
|
||||
const orgScope =
|
||||
orgWorkspaceIds.length > 0
|
||||
? or(inArray(auditLog.workspaceId, orgWorkspaceIds), orgLevelCondition)!
|
||||
: orgLevelCondition
|
||||
|
||||
if (includeDeparted) {
|
||||
return orgScope
|
||||
}
|
||||
|
||||
const currentActorCondition =
|
||||
orgMemberIds.length > 0
|
||||
? or(inArray(auditLog.actorId, orgMemberIds), isNull(auditLog.actorId))!
|
||||
: isNull(auditLog.actorId)
|
||||
|
||||
return and(orgScope, currentActorCondition)!
|
||||
}
|
||||
|
||||
function buildCursorCondition(cursor: string): SQL<unknown> | null {
|
||||
const cursorData = decodeCursor(cursor)
|
||||
if (!cursorData?.createdAt || !cursorData.id) return null
|
||||
|
||||
const cursorDate = new Date(cursorData.createdAt)
|
||||
if (Number.isNaN(cursorDate.getTime())) return null
|
||||
|
||||
return or(
|
||||
lt(auditLog.createdAt, cursorDate),
|
||||
and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id))
|
||||
)!
|
||||
}
|
||||
|
||||
interface CursorPaginatedResult {
|
||||
data: DbAuditLog[]
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
export async function queryAuditLogs(
|
||||
conditions: SQL<unknown>[],
|
||||
limit: number,
|
||||
cursor?: string
|
||||
): Promise<CursorPaginatedResult> {
|
||||
const allConditions = [...conditions]
|
||||
|
||||
if (cursor) {
|
||||
const cursorCondition = buildCursorCondition(cursor)
|
||||
if (cursorCondition) allConditions.push(cursorCondition)
|
||||
}
|
||||
|
||||
const rows = await dbReplica
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(allConditions.length > 0 ? and(...allConditions) : undefined)
|
||||
.orderBy(desc(auditLog.createdAt), desc(auditLog.id))
|
||||
.limit(limit + 1)
|
||||
|
||||
const hasMore = rows.length > limit
|
||||
const data = rows.slice(0, limit)
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (hasMore && data.length > 0) {
|
||||
const last = data[data.length - 1]
|
||||
nextCursor = encodeCursor({
|
||||
createdAt: last.createdAt.toISOString(),
|
||||
id: last.id,
|
||||
})
|
||||
}
|
||||
|
||||
return { data, nextCursor }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Tests for GET /api/v1/audit-logs — verifies filters are validated against
|
||||
* the caller's organization and the scope is built from the org context.
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckRateLimit,
|
||||
mockValidateEnterpriseAuditAccess,
|
||||
mockBuildOrgScopeCondition,
|
||||
mockGetOrgWorkspaceIds,
|
||||
mockQueryAuditLogs,
|
||||
mockBuildFilterConditions,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
mockValidateEnterpriseAuditAccess: vi.fn(),
|
||||
mockBuildOrgScopeCondition: vi.fn(),
|
||||
mockGetOrgWorkspaceIds: vi.fn(),
|
||||
mockQueryAuditLogs: vi.fn(),
|
||||
mockBuildFilterConditions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/middleware', () => ({
|
||||
checkRateLimit: mockCheckRateLimit,
|
||||
createRateLimitResponse: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/audit-logs/auth', () => ({
|
||||
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/audit-logs/query', () => ({
|
||||
buildFilterConditions: mockBuildFilterConditions,
|
||||
buildOrgScopeCondition: mockBuildOrgScopeCondition,
|
||||
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
|
||||
queryAuditLogs: mockQueryAuditLogs,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/logs/meta', () => ({
|
||||
getUserLimits: vi.fn().mockResolvedValue({}),
|
||||
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/v1/audit-logs/route'
|
||||
|
||||
const ORG_ID = 'org-1'
|
||||
const MEMBER_IDS = ['admin-1', 'member-1']
|
||||
const ORG_WORKSPACE_IDS = ['ws-org-1', 'ws-org-2']
|
||||
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
|
||||
|
||||
function makeRequest(query: string) {
|
||||
return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/v1/audit-logs${query}`)
|
||||
}
|
||||
|
||||
describe('GET /api/v1/audit-logs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' })
|
||||
mockValidateEnterpriseAuditAccess.mockResolvedValue({
|
||||
success: true,
|
||||
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
|
||||
})
|
||||
mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS)
|
||||
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
|
||||
mockBuildFilterConditions.mockReturnValue([])
|
||||
mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined })
|
||||
})
|
||||
|
||||
it('rejects an actorId that is not a current org member', async () => {
|
||||
const response = await GET(makeRequest('?actorId=outsider-1'))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('actorId is not a member of your organization')
|
||||
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a workspaceId that does not belong to the organization', async () => {
|
||||
const response = await GET(makeRequest('?workspaceId=ws-other-org'))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('workspaceId does not belong to your organization')
|
||||
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a workspaceId that belongs to the organization', async () => {
|
||||
const response = await GET(makeRequest('?workspaceId=ws-org-1'))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockQueryAuditLogs).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds the scope from the organization context, never from actors alone', async () => {
|
||||
const response = await GET(makeRequest('?actorId=member-1'))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockBuildOrgScopeCondition).toHaveBeenCalledWith({
|
||||
organizationId: ORG_ID,
|
||||
orgWorkspaceIds: ORG_WORKSPACE_IDS,
|
||||
orgMemberIds: MEMBER_IDS,
|
||||
includeDeparted: false,
|
||||
})
|
||||
|
||||
const [conditions] = mockQueryAuditLogs.mock.calls[0]
|
||||
expect(conditions[0]).toBe(SCOPE_SENTINEL)
|
||||
})
|
||||
|
||||
it('passes includeDeparted through to the scope builder', async () => {
|
||||
const response = await GET(makeRequest('?includeDeparted=true'))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockBuildOrgScopeCondition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeDeparted: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the auth failure response when enterprise access is denied', async () => {
|
||||
const denied = new Response(JSON.stringify({ error: 'nope' }), { status: 403 })
|
||||
mockValidateEnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied })
|
||||
|
||||
const response = await GET(makeRequest(''))
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* GET /api/v1/audit-logs
|
||||
*
|
||||
* List audit logs scoped to the authenticated user's organization.
|
||||
* Requires enterprise subscription and org admin/owner role.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - action: string (optional) - Filter by action (e.g., "workflow.created")
|
||||
* - resourceType: string (optional) - Filter by resource type(s), comma-separated (e.g., "workflow,api_key")
|
||||
* - resourceId: string (optional) - Filter by resource ID
|
||||
* - workspaceId: string (optional) - Filter by workspace ID
|
||||
* - actorId: string (optional) - Filter by actor user ID (must be an org member)
|
||||
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
|
||||
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
|
||||
* - includeDeparted: boolean (optional, default: false) - Include logs from departed members
|
||||
* - limit: number (optional, default: 50, max: 100)
|
||||
* - cursor: string (optional) - Opaque cursor for pagination
|
||||
*
|
||||
* Response: { data: AuditLogEntry[], nextCursor?: string, limits: UserLimits }
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
|
||||
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
|
||||
import {
|
||||
buildFilterConditions,
|
||||
buildOrgScopeCondition,
|
||||
getOrgWorkspaceIds,
|
||||
queryAuditLogs,
|
||||
} from '@/app/api/v1/audit-logs/query'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1AuditLogsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'audit-logs')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
const authResult = await validateEnterpriseAuditAccess(userId)
|
||||
if (!authResult.success) {
|
||||
return authResult.response
|
||||
}
|
||||
|
||||
const { organizationId, orgMemberIds } = authResult.context
|
||||
|
||||
const parsed = await parseRequest(
|
||||
v1ListAuditLogsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: getValidationErrorMessage(error, 'Invalid parameters'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const params = parsed.data.query
|
||||
|
||||
if (params.actorId && !orgMemberIds.includes(params.actorId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'actorId is not a member of your organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
|
||||
|
||||
if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId does not belong to your organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const scopeCondition = buildOrgScopeCondition({
|
||||
organizationId,
|
||||
orgWorkspaceIds,
|
||||
orgMemberIds,
|
||||
includeDeparted: params.includeDeparted,
|
||||
})
|
||||
const filterConditions = buildFilterConditions({
|
||||
action: params.action,
|
||||
resourceType: params.resourceType,
|
||||
resourceId: params.resourceId,
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: params.actorId,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
})
|
||||
|
||||
const { data, nextCursor } = await queryAuditLogs(
|
||||
[scopeCondition, ...filterConditions],
|
||||
params.limit,
|
||||
params.cursor
|
||||
)
|
||||
|
||||
const formattedLogs = data.map(formatAuditLogEntry)
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
const response = createApiResponse({ data: formattedLogs, nextCursor }, limits, rateLimit)
|
||||
|
||||
return NextResponse.json(response.body, { headers: response.headers })
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
logger.error(`[${requestId}] Audit logs fetch error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { isAuthDisabled } from '@/lib/core/config/env-flags'
|
||||
|
||||
const logger = createLogger('V1Auth')
|
||||
|
||||
export interface AuthResult {
|
||||
authenticated: boolean
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function authenticateV1Request(request: NextRequest): Promise<AuthResult> {
|
||||
if (isAuthDisabled) {
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
keyType: 'personal',
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: 'API key required',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authenticateApiKeyFromHeader(apiKey)
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) })
|
||||
return {
|
||||
authenticated: false,
|
||||
error: result.error || 'Invalid API key',
|
||||
}
|
||||
}
|
||||
|
||||
await updateApiKeyLastUsed(result.keyId!)
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: result.userId!,
|
||||
workspaceId: result.workspaceId,
|
||||
keyType: result.keyType,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error', { error })
|
||||
return {
|
||||
authenticated: false,
|
||||
error: 'Authentication failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Tests for the deprecated v1 copilot chat API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { POST } from '@/app/api/v1/copilot/chat/route'
|
||||
|
||||
const URL = 'http://localhost:3000/api/v1/copilot/chat'
|
||||
|
||||
describe('Deprecated v1 copilot chat route', () => {
|
||||
it('POST returns 410 with a success:false error body', async () => {
|
||||
const request = new NextRequest(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': 'sk-test' },
|
||||
body: JSON.stringify({ message: 'hello' }),
|
||||
})
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(410)
|
||||
|
||||
const body = (await response.json()) as { success?: boolean; error?: string }
|
||||
expect(body.success).toBe(false)
|
||||
expect(body.error).toContain('deprecated')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
/**
|
||||
* POST /api/v1/copilot/chat
|
||||
*
|
||||
* Deprecated: the v1 headless copilot chat API has been removed. The endpoint
|
||||
* returns 410 Gone for all callers.
|
||||
*/
|
||||
export const POST = withRouteHandler(async () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'The v1 copilot chat API has been deprecated and is no longer available.',
|
||||
},
|
||||
{ status: 410, headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1FileDetailAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface FileRouteParams {
|
||||
params: Promise<{ fileId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/files/[fileId] — Download file content. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'file-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1DownloadFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { fileId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
|
||||
if (accessError) return accessError
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const buffer = await fetchWorkspaceFileBuffer(fileRecord)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.FILE_DOWNLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: fileRecord.id,
|
||||
resourceName: fileRecord.name,
|
||||
description: `Downloaded file "${fileRecord.name}" via API`,
|
||||
metadata: {
|
||||
fileId: fileRecord.id,
|
||||
fileName: fileRecord.name,
|
||||
bytes: buffer.length,
|
||||
source: 'api_v1',
|
||||
},
|
||||
request,
|
||||
})
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'file_downloaded',
|
||||
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': fileRecord.type || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
|
||||
'Content-Length': String(buffer.length),
|
||||
'X-File-Id': fileRecord.id,
|
||||
'X-File-Name': encodeURIComponent(fileRecord.name),
|
||||
'X-Uploaded-At':
|
||||
fileRecord.uploadedAt instanceof Date
|
||||
? fileRecord.uploadedAt.toISOString()
|
||||
: String(fileRecord.uploadedAt),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error downloading file:`, error)
|
||||
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/v1/files/[fileId] — Archive a file. */
|
||||
export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'file-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1DeleteFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { fileId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
|
||||
if (accessError) return accessError
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!fileRecord) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId,
|
||||
fileIds: [fileId],
|
||||
})
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'File archived successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting file:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/contracts/v1/files'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
isPayloadSizeLimitError,
|
||||
MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
readFileToBufferWithLimit,
|
||||
readFormDataWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
FileConflictError,
|
||||
getWorkspaceFile,
|
||||
listWorkspaceFiles,
|
||||
uploadWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1FilesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
|
||||
/** GET /api/v1/files — List all files in a workspace. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'files')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1ListFilesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
|
||||
if (accessError) return accessError
|
||||
|
||||
const files = await listWorkspaceFiles(workspaceId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
files: files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
key: f.key,
|
||||
uploadedBy: f.uploadedBy,
|
||||
uploadedAt:
|
||||
f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt),
|
||||
})),
|
||||
totalCount: files.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing files:`, error)
|
||||
return NextResponse.json({ error: 'Failed to list files' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/v1/files — Upload a file to a workspace. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'files')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await readFormDataWithLimit(request, {
|
||||
maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
label: 'workspace file upload body',
|
||||
})
|
||||
} catch (error) {
|
||||
if (isPayloadSizeLimitError(error)) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Request body must be valid multipart form data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const rawFile = formData.get('file')
|
||||
const file = rawFile instanceof File ? rawFile : null
|
||||
const rawWorkspaceId = formData.get('workspaceId')
|
||||
const formFieldsResult = v1UploadFileFormFieldsSchema.safeParse({
|
||||
workspaceId: typeof rawWorkspaceId === 'string' ? rawWorkspaceId : '',
|
||||
})
|
||||
|
||||
if (!formFieldsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(formFieldsResult.error, 'Invalid form data') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { workspaceId } = formFieldsResult.data
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'file form field is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
|
||||
if (accessError) return accessError
|
||||
|
||||
const buffer = await readFileToBufferWithLimit(file, {
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
label: 'workspace upload file',
|
||||
})
|
||||
|
||||
const userFile = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
userId,
|
||||
buffer,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream'
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Uploaded file: ${file.name} to workspace ${workspaceId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.FILE_UPLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: userFile.id,
|
||||
resourceName: file.name,
|
||||
description: `Uploaded file "${file.name}" via API`,
|
||||
metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' },
|
||||
request,
|
||||
})
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, userFile.id)
|
||||
const uploadedAt =
|
||||
fileRecord?.uploadedAt instanceof Date
|
||||
? fileRecord.uploadedAt.toISOString()
|
||||
: fileRecord?.uploadedAt
|
||||
? String(fileRecord.uploadedAt)
|
||||
: new Date().toISOString()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
file: {
|
||||
id: userFile.id,
|
||||
name: userFile.name,
|
||||
size: userFile.size,
|
||||
type: userFile.type,
|
||||
key: userFile.key,
|
||||
uploadedBy: userId,
|
||||
uploadedAt,
|
||||
},
|
||||
message: 'File uploaded successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (isPayloadSizeLimitError(error)) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 })
|
||||
}
|
||||
|
||||
const errorMessage = getErrorMessage(error, 'Failed to upload file')
|
||||
const isDuplicate =
|
||||
error instanceof FileConflictError || errorMessage.includes('already exists')
|
||||
|
||||
if (isDuplicate) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 409 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error uploading file:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { document, knowledgeConnector } from '@sim/db/schema'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1DeleteKnowledgeDocumentContract,
|
||||
v1GetKnowledgeDocumentContract,
|
||||
} from '@/lib/api/contracts/v1/knowledge'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { deleteDocument } from '@/lib/knowledge/documents/service'
|
||||
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
|
||||
import { authenticateRequest } from '@/app/api/v1/middleware'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface DocumentDetailRouteParams {
|
||||
params: Promise<{ id: string; documentId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/knowledge/[id]/documents/[documentId] — Get document details. */
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: DocumentDetailRouteParams) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-detail')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1GetKnowledgeDocumentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: knowledgeBaseId, documentId } = parsed.data.params
|
||||
|
||||
const result = await resolveKnowledgeBase(
|
||||
knowledgeBaseId,
|
||||
parsed.data.query.workspaceId,
|
||||
userId,
|
||||
rateLimit
|
||||
)
|
||||
if (result instanceof NextResponse) return result
|
||||
|
||||
const docs = await db
|
||||
.select({
|
||||
id: document.id,
|
||||
knowledgeBaseId: document.knowledgeBaseId,
|
||||
filename: document.filename,
|
||||
fileSize: document.fileSize,
|
||||
mimeType: document.mimeType,
|
||||
processingStatus: document.processingStatus,
|
||||
processingError: document.processingError,
|
||||
processingStartedAt: document.processingStartedAt,
|
||||
processingCompletedAt: document.processingCompletedAt,
|
||||
chunkCount: document.chunkCount,
|
||||
tokenCount: document.tokenCount,
|
||||
characterCount: document.characterCount,
|
||||
enabled: document.enabled,
|
||||
uploadedAt: document.uploadedAt,
|
||||
connectorId: document.connectorId,
|
||||
connectorType: knowledgeConnector.connectorType,
|
||||
sourceUrl: document.sourceUrl,
|
||||
})
|
||||
.from(document)
|
||||
.leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id))
|
||||
.where(
|
||||
and(
|
||||
eq(document.id, documentId),
|
||||
eq(document.knowledgeBaseId, knowledgeBaseId),
|
||||
eq(document.userExcluded, false),
|
||||
isNull(document.archivedAt),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (docs.length === 0) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const doc = docs[0]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
document: {
|
||||
id: doc.id,
|
||||
knowledgeBaseId: doc.knowledgeBaseId,
|
||||
filename: doc.filename,
|
||||
fileSize: doc.fileSize,
|
||||
mimeType: doc.mimeType,
|
||||
processingStatus: doc.processingStatus,
|
||||
processingError: doc.processingError,
|
||||
processingStartedAt: serializeDate(doc.processingStartedAt),
|
||||
processingCompletedAt: serializeDate(doc.processingCompletedAt),
|
||||
chunkCount: doc.chunkCount,
|
||||
tokenCount: doc.tokenCount,
|
||||
characterCount: doc.characterCount,
|
||||
enabled: doc.enabled,
|
||||
connectorId: doc.connectorId,
|
||||
connectorType: doc.connectorType,
|
||||
sourceUrl: doc.sourceUrl,
|
||||
createdAt: serializeDate(doc.uploadedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to get document')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/** DELETE /api/v1/knowledge/[id]/documents/[documentId] — Delete a document. */
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: DocumentDetailRouteParams) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-detail')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1DeleteKnowledgeDocumentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: knowledgeBaseId, documentId } = parsed.data.params
|
||||
|
||||
const result = await resolveKnowledgeBase(
|
||||
knowledgeBaseId,
|
||||
parsed.data.query.workspaceId,
|
||||
userId,
|
||||
rateLimit,
|
||||
'write'
|
||||
)
|
||||
if (result instanceof NextResponse) return result
|
||||
|
||||
const docs = await db
|
||||
.select({ id: document.id, filename: document.filename })
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.id, documentId),
|
||||
eq(document.knowledgeBaseId, knowledgeBaseId),
|
||||
eq(document.userExcluded, false),
|
||||
isNull(document.archivedAt),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (docs.length === 0) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await deleteDocument(documentId, requestId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: parsed.data.query.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.DOCUMENT_DELETED,
|
||||
resourceType: AuditResourceType.DOCUMENT,
|
||||
resourceId: documentId,
|
||||
resourceName: docs[0].filename,
|
||||
description: `Deleted document "${docs[0].filename}" from knowledge base via API`,
|
||||
metadata: { knowledgeBaseId },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Document deleted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to delete document')
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Tests for the v1 knowledge document upload route's bounded multipart read.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockAuthenticateRequest,
|
||||
mockResolveKnowledgeBase,
|
||||
mockCheckActorUsageLimits,
|
||||
mockUploadWorkspaceFile,
|
||||
mockCreateSingleDocument,
|
||||
mockProcessDocumentsWithQueue,
|
||||
mockValidateFileType,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
mockResolveKnowledgeBase: vi.fn(),
|
||||
mockCheckActorUsageLimits: vi.fn(),
|
||||
mockUploadWorkspaceFile: vi.fn(),
|
||||
mockCreateSingleDocument: vi.fn(),
|
||||
mockProcessDocumentsWithQueue: vi.fn(),
|
||||
mockValidateFileType: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/middleware', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/knowledge/utils', () => ({
|
||||
resolveKnowledgeBase: mockResolveKnowledgeBase,
|
||||
serializeDate: (date: unknown) => (date instanceof Date ? date.toISOString() : date),
|
||||
handleError: (_requestId: string, error: unknown) =>
|
||||
new Response(JSON.stringify({ error: getErrorMessage(error, 'error') }), {
|
||||
status: 500,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
|
||||
checkActorUsageLimits: mockCheckActorUsageLimits,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
uploadWorkspaceFile: mockUploadWorkspaceFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/utils/validation', () => ({
|
||||
validateFileType: mockValidateFileType,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/documents/service', () => ({
|
||||
createSingleDocument: mockCreateSingleDocument,
|
||||
getDocuments: vi.fn(),
|
||||
processDocumentsWithQueue: mockProcessDocumentsWithQueue,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/v1/knowledge/[id]/documents/route'
|
||||
|
||||
const routeContext = { params: Promise.resolve({ id: 'kb-1' }) }
|
||||
|
||||
function buildFormData(file: File, workspaceId = 'ws-1'): FormData {
|
||||
const formData = new FormData()
|
||||
formData.append('workspaceId', workspaceId)
|
||||
formData.append('file', file)
|
||||
return formData
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a pull-based stream that emits fixed-size chunks on demand, so the
|
||||
* size-capped reader's `reader.cancel()` simply stops future `pull` calls
|
||||
* instead of racing an external (e.g. undici FormData) chunk producer.
|
||||
*/
|
||||
function makeChunkedOverLimitBody(
|
||||
chunkBytes: number,
|
||||
chunkCount: number
|
||||
): ReadableStream<Uint8Array> {
|
||||
let emitted = 0
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (emitted >= chunkCount) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
emitted++
|
||||
controller.enqueue(new Uint8Array(chunkBytes))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('v1 knowledge document upload route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
requestId: 'req-1',
|
||||
userId: 'user-1',
|
||||
rateLimit: {},
|
||||
})
|
||||
mockResolveKnowledgeBase.mockResolvedValue({ kb: { id: 'kb-1', workspaceId: 'ws-1' } })
|
||||
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
|
||||
mockValidateFileType.mockReturnValue(null)
|
||||
mockUploadWorkspaceFile.mockResolvedValue({
|
||||
url: 'https://example.com/file.txt',
|
||||
})
|
||||
mockCreateSingleDocument.mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
filename: 'file.txt',
|
||||
fileSize: 100,
|
||||
mimeType: 'text/plain',
|
||||
enabled: true,
|
||||
uploadedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
})
|
||||
mockProcessDocumentsWithQueue.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('rejects a declared content-length above the limit before reading the body', async () => {
|
||||
const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
|
||||
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': String(200 * 1024 * 1024) },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req, routeContext)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(data.error).toContain('exceeds maximum size')
|
||||
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
|
||||
const body = makeChunkedOverLimitBody(1024 * 1024, 200)
|
||||
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
|
||||
method: 'POST',
|
||||
body,
|
||||
// @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
|
||||
duplex: 'half',
|
||||
})
|
||||
expect(req.headers.get('content-length')).toBeNull()
|
||||
|
||||
const response = await POST(req, routeContext)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(data.error).toContain('exceeds maximum size')
|
||||
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads a normal, well-under-limit document successfully', async () => {
|
||||
const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
|
||||
const formData = buildFormData(file)
|
||||
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'content-length': '1024' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const response = await POST(req, routeContext)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreateSingleDocument).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1ListKnowledgeDocumentsContract,
|
||||
v1UploadKnowledgeDocumentContract,
|
||||
} from '@/lib/api/contracts/v1/knowledge'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
|
||||
import {
|
||||
isPayloadSizeLimitError,
|
||||
MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
readFormDataWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
createSingleDocument,
|
||||
type DocumentData,
|
||||
getDocuments,
|
||||
processDocumentsWithQueue,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { validateFileType } from '@/lib/uploads/utils/validation'
|
||||
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
|
||||
import { authenticateRequest } from '@/app/api/v1/middleware'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
|
||||
|
||||
interface DocumentsRouteParams {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/knowledge/[id]/documents — List documents in a knowledge base. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-detail')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1ListKnowledgeDocumentsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } =
|
||||
parsed.data.query
|
||||
const { id: knowledgeBaseId } = parsed.data.params
|
||||
|
||||
const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit)
|
||||
if (result instanceof NextResponse) return result
|
||||
|
||||
const documentsResult = await getDocuments(
|
||||
knowledgeBaseId,
|
||||
{
|
||||
enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter,
|
||||
search,
|
||||
limit,
|
||||
offset,
|
||||
sortBy: sortBy as DocumentSortField,
|
||||
sortOrder: sortOrder as SortOrder,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
documents: documentsResult.documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
knowledgeBaseId,
|
||||
filename: doc.filename,
|
||||
fileSize: doc.fileSize,
|
||||
mimeType: doc.mimeType,
|
||||
processingStatus: doc.processingStatus,
|
||||
chunkCount: doc.chunkCount,
|
||||
tokenCount: doc.tokenCount,
|
||||
characterCount: doc.characterCount,
|
||||
enabled: doc.enabled,
|
||||
createdAt: serializeDate(doc.uploadedAt),
|
||||
})),
|
||||
pagination: documentsResult.pagination,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to list documents')
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/v1/knowledge/[id]/documents — Upload a document to a knowledge base. */
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: DocumentsRouteParams) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-detail')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1UploadKnowledgeDocumentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: knowledgeBaseId } = parsed.data.params
|
||||
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await readFormDataWithLimit(request, {
|
||||
maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
|
||||
label: 'knowledge document upload body',
|
||||
})
|
||||
} catch (error) {
|
||||
if (isPayloadSizeLimitError(error)) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Request body must be valid multipart form data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const rawFile = formData.get('file')
|
||||
const file = rawFile instanceof File ? rawFile : null
|
||||
const rawWorkspaceId = formData.get('workspaceId')
|
||||
const workspaceId = typeof rawWorkspaceId === 'string' ? rawWorkspaceId : null
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'workspaceId form field is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'file form field is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const fileTypeError = validateFileType(file.name, file.type || '')
|
||||
if (fileTypeError) {
|
||||
return NextResponse.json({ error: fileTypeError.message }, { status: 415 })
|
||||
}
|
||||
|
||||
const result = await resolveKnowledgeBase(
|
||||
knowledgeBaseId,
|
||||
workspaceId,
|
||||
userId,
|
||||
rateLimit,
|
||||
'write'
|
||||
)
|
||||
if (result instanceof NextResponse) return result
|
||||
|
||||
// Fast usage gate before the storage write + indexing (the async backstop
|
||||
// in processDocumentAsync still covers non-HTTP paths).
|
||||
const usage = await checkActorUsageLimits(userId, workspaceId)
|
||||
if (usage.isExceeded) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
},
|
||||
{ status: 402 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const contentType = file.type || 'application/octet-stream'
|
||||
|
||||
const uploadedFile = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
userId,
|
||||
buffer,
|
||||
file.name,
|
||||
contentType
|
||||
)
|
||||
|
||||
const newDocument = await createSingleDocument(
|
||||
{
|
||||
filename: file.name,
|
||||
fileUrl: uploadedFile.url,
|
||||
fileSize: file.size,
|
||||
mimeType: contentType,
|
||||
},
|
||||
knowledgeBaseId,
|
||||
requestId,
|
||||
userId
|
||||
)
|
||||
|
||||
const documentData: DocumentData = {
|
||||
documentId: newDocument.id,
|
||||
filename: file.name,
|
||||
fileUrl: uploadedFile.url,
|
||||
fileSize: file.size,
|
||||
mimeType: contentType,
|
||||
}
|
||||
|
||||
processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => {
|
||||
// Processing errors are logged internally
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.DOCUMENT_UPLOADED,
|
||||
resourceType: AuditResourceType.DOCUMENT,
|
||||
resourceId: newDocument.id,
|
||||
resourceName: file.name,
|
||||
description: `Uploaded document "${file.name}" to knowledge base via API`,
|
||||
metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
document: {
|
||||
id: newDocument.id,
|
||||
knowledgeBaseId,
|
||||
filename: newDocument.filename,
|
||||
fileSize: newDocument.fileSize,
|
||||
mimeType: newDocument.mimeType,
|
||||
processingStatus: 'pending',
|
||||
chunkCount: 0,
|
||||
tokenCount: 0,
|
||||
characterCount: 0,
|
||||
enabled: newDocument.enabled,
|
||||
createdAt: serializeDate(newDocument.uploadedAt),
|
||||
},
|
||||
message: 'Document uploaded successfully. Processing will begin shortly.',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to upload document')
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1DeleteKnowledgeBaseContract,
|
||||
v1GetKnowledgeBaseContract,
|
||||
v1UpdateKnowledgeBaseContract,
|
||||
} from '@/lib/api/contracts/v1/knowledge'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service'
|
||||
import {
|
||||
formatKnowledgeBase,
|
||||
handleError,
|
||||
resolveKnowledgeBase,
|
||||
} from '@/app/api/v1/knowledge/utils'
|
||||
import { authenticateRequest } from '@/app/api/v1/middleware'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface KnowledgeRouteParams {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/knowledge/[id] — Get knowledge base details. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-detail')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1GetKnowledgeBaseContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const result = await resolveKnowledgeBase(id, parsed.data.query.workspaceId, userId, rateLimit)
|
||||
if (result instanceof NextResponse) return result
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
knowledgeBase: formatKnowledgeBase(result.kb),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to get knowledge base')
|
||||
}
|
||||
})
|
||||
|
||||
/** PUT /api/v1/knowledge/[id] — Update a knowledge base. */
|
||||
export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-detail')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1UpdateKnowledgeBaseContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const { workspaceId, name, description, chunkingConfig } = parsed.data.body
|
||||
|
||||
const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write')
|
||||
if (result instanceof NextResponse) return result
|
||||
|
||||
const updates: {
|
||||
name?: string
|
||||
description?: string
|
||||
chunkingConfig?: { maxSize: number; minSize: number; overlap: number }
|
||||
} = {}
|
||||
if (name !== undefined) updates.name = name
|
||||
if (description !== undefined) updates.description = description
|
||||
if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig
|
||||
|
||||
const updatedKb = await updateKnowledgeBase(id, updates, requestId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.KNOWLEDGE_BASE_UPDATED,
|
||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||
resourceId: id,
|
||||
resourceName: updatedKb.name,
|
||||
description: `Updated knowledge base "${updatedKb.name}" via API`,
|
||||
metadata: { updatedFields: Object.keys(updates) },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
knowledgeBase: formatKnowledgeBase(updatedKb),
|
||||
message: 'Knowledge base updated successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to update knowledge base')
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/v1/knowledge/[id] — Delete a knowledge base. */
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: KnowledgeRouteParams) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-detail')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1DeleteKnowledgeBaseContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const result = await resolveKnowledgeBase(
|
||||
id,
|
||||
parsed.data.query.workspaceId,
|
||||
userId,
|
||||
rateLimit,
|
||||
'write'
|
||||
)
|
||||
if (result instanceof NextResponse) return result
|
||||
|
||||
await deleteKnowledgeBase(id, requestId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: parsed.data.query.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.KNOWLEDGE_BASE_DELETED,
|
||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||
resourceId: id,
|
||||
resourceName: result.kb.name,
|
||||
description: `Deleted knowledge base "${result.kb.name}" via API`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Knowledge base deleted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to delete knowledge base')
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1CreateKnowledgeBaseContract,
|
||||
v1ListKnowledgeBasesContract,
|
||||
} from '@/lib/api/contracts/v1/knowledge'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings'
|
||||
import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service'
|
||||
import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils'
|
||||
import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
/** GET /api/v1/knowledge — List knowledge bases in a workspace. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1ListKnowledgeBasesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
|
||||
if (accessError) return accessError
|
||||
|
||||
const knowledgeBases = await getKnowledgeBases(userId, workspaceId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
knowledgeBases: knowledgeBases.map(formatKnowledgeBase),
|
||||
totalCount: knowledgeBases.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to list knowledge bases')
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/v1/knowledge — Create a new knowledge base. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1CreateKnowledgeBaseContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId, name, description, chunkingConfig } = parsed.data.body
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
|
||||
if (accessError) return accessError
|
||||
|
||||
const kb = await createKnowledgeBase(
|
||||
{
|
||||
name,
|
||||
description,
|
||||
workspaceId,
|
||||
userId,
|
||||
embeddingModel: getConfiguredEmbeddingModel(),
|
||||
embeddingDimension: EMBEDDING_DIMENSIONS,
|
||||
chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 },
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.KNOWLEDGE_BASE_CREATED,
|
||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
||||
resourceId: kb.id,
|
||||
resourceName: kb.name,
|
||||
description: `Created knowledge base "${kb.name}" via API`,
|
||||
metadata: { chunkingConfig },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
knowledgeBase: formatKnowledgeBase(kb),
|
||||
message: 'Knowledge base created successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to create knowledge base')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Tests for v1 knowledge search API route.
|
||||
* Specifically guards the per-KB embedding model resolution and the
|
||||
* multi-model rejection so the v1 endpoint stays in lockstep with the
|
||||
* internal route.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createMockRequest, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns } from '@sim/testing'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockHandleVectorOnlySearch,
|
||||
mockHandleTagOnlySearch,
|
||||
mockHandleTagAndVectorSearch,
|
||||
mockGetQueryStrategy,
|
||||
mockGenerateSearchEmbedding,
|
||||
mockGetDocumentMetadataByIds,
|
||||
mockAuthenticateRequest,
|
||||
mockValidateWorkspaceAccess,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleVectorOnlySearch: vi.fn(),
|
||||
mockHandleTagOnlySearch: vi.fn(),
|
||||
mockHandleTagAndVectorSearch: vi.fn(),
|
||||
mockGetQueryStrategy: vi.fn(),
|
||||
mockGenerateSearchEmbedding: vi.fn(),
|
||||
mockGetDocumentMetadataByIds: vi.fn(),
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
mockValidateWorkspaceAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/knowledge/search/utils', () => ({
|
||||
handleVectorOnlySearch: mockHandleVectorOnlySearch,
|
||||
handleTagOnlySearch: mockHandleTagOnlySearch,
|
||||
handleTagAndVectorSearch: mockHandleTagAndVectorSearch,
|
||||
getQueryStrategy: mockGetQueryStrategy,
|
||||
generateSearchEmbedding: mockGenerateSearchEmbedding,
|
||||
getDocumentMetadataByIds: mockGetDocumentMetadataByIds,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
|
||||
|
||||
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
|
||||
checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/middleware', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
validateWorkspaceAccess: mockValidateWorkspaceAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/knowledge/utils', () => ({
|
||||
handleError: (e: unknown) =>
|
||||
new Response(JSON.stringify({ error: getErrorMessage(e, 'error') }), {
|
||||
status: 500,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||
getDocumentTagDefinitions: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/v1/knowledge/search/route'
|
||||
|
||||
const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess
|
||||
|
||||
const baseKb = (id: string, embeddingModel: string) => ({
|
||||
id,
|
||||
userId: 'user-1',
|
||||
name: `KB ${id}`,
|
||||
workspaceId: 'ws-1',
|
||||
embeddingModel,
|
||||
deletedAt: null,
|
||||
})
|
||||
|
||||
describe('v1 knowledge search route — per-KB embedding model', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
requestId: 'req-1',
|
||||
userId: 'user-1',
|
||||
rateLimit: {},
|
||||
})
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(null)
|
||||
mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 })
|
||||
mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3])
|
||||
mockHandleVectorOnlySearch.mockResolvedValue([])
|
||||
mockGetDocumentMetadataByIds.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('passes the KB embedding model into generateSearchEmbedding', async () => {
|
||||
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
|
||||
hasAccess: true,
|
||||
knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'ws-1',
|
||||
knowledgeBaseIds: 'kb-gemini',
|
||||
query: 'hello',
|
||||
})
|
||||
const res = await POST(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
'gemini-embedding-001',
|
||||
'ws-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects cross-KB queries with mixed embedding models', async () => {
|
||||
mockCheckKnowledgeBaseAccess
|
||||
.mockResolvedValueOnce({
|
||||
hasAccess: true,
|
||||
knowledgeBase: baseKb('kb-openai', 'text-embedding-3-small'),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
hasAccess: true,
|
||||
knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'ws-1',
|
||||
knowledgeBaseIds: ['kb-openai', 'kb-gemini'],
|
||||
query: 'hello',
|
||||
})
|
||||
const res = await POST(req)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces sourceUrl from document metadata in search results', async () => {
|
||||
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
|
||||
hasAccess: true,
|
||||
knowledgeBase: baseKb('kb-confluence', 'text-embedding-3-small'),
|
||||
})
|
||||
mockHandleVectorOnlySearch.mockResolvedValue([
|
||||
{
|
||||
documentId: 'doc-confluence',
|
||||
knowledgeBaseId: 'kb-confluence',
|
||||
content: 'page content',
|
||||
chunkIndex: 0,
|
||||
distance: 0.1,
|
||||
},
|
||||
])
|
||||
mockGetDocumentMetadataByIds.mockResolvedValue({
|
||||
'doc-confluence': {
|
||||
filename: 'Runbook.md',
|
||||
sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345',
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'ws-1',
|
||||
knowledgeBaseIds: 'kb-confluence',
|
||||
query: 'runbook',
|
||||
})
|
||||
const res = await POST(req)
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.results[0].sourceUrl).toBe(
|
||||
'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345'
|
||||
)
|
||||
expect(body.data.results[0].documentName).toBe('Runbook.md')
|
||||
})
|
||||
|
||||
it('allows tag-only search across mixed embedding models', async () => {
|
||||
mockHandleTagOnlySearch.mockResolvedValue([])
|
||||
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
|
||||
hasAccess: true,
|
||||
knowledgeBase: baseKb('kb-mixed', 'text-embedding-3-small'),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'ws-1',
|
||||
knowledgeBaseIds: 'kb-mixed',
|
||||
tagFilters: [{ tagName: 'category', operator: 'eq', value: 'docs' }],
|
||||
})
|
||||
const res = await POST(req)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
// tagName "category" is undefined in our empty getDocumentTagDefinitions mock,
|
||||
// so the route returns 400 before reaching the search handlers — but crucially
|
||||
// it never tries to generate an embedding.
|
||||
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1KnowledgeSearchContract } from '@/lib/api/contracts/v1/knowledge'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings'
|
||||
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import {
|
||||
generateSearchEmbedding,
|
||||
getDocumentMetadataByIds,
|
||||
getQueryStrategy,
|
||||
handleTagAndVectorSearch,
|
||||
handleTagOnlySearch,
|
||||
handleVectorOnlySearch,
|
||||
type SearchResult,
|
||||
} from '@/app/api/knowledge/search/utils'
|
||||
import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils'
|
||||
import { handleError } from '@/app/api/v1/knowledge/utils'
|
||||
import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
/** POST /api/v1/knowledge/search — Vector search across knowledge bases. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const auth = await authenticateRequest(request, 'knowledge-search')
|
||||
if (auth instanceof NextResponse) return auth
|
||||
const { requestId, userId, rateLimit } = auth
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(v1KnowledgeSearchContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId, topK, query, tagFilters } = parsed.data.body
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
|
||||
if (accessError) return accessError
|
||||
|
||||
// A query incurs hosted embedding (+ optional rerank) cost — gate the actor's
|
||||
// usage and frozen status before spending. Tag-only search is free, so skip it.
|
||||
if (query && query.trim().length > 0) {
|
||||
const usage = await checkActorUsageLimits(userId, workspaceId)
|
||||
if (usage.isExceeded) {
|
||||
return NextResponse.json(
|
||||
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
|
||||
{ status: 402 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds)
|
||||
? parsed.data.body.knowledgeBaseIds
|
||||
: [parsed.data.body.knowledgeBaseIds]
|
||||
|
||||
const accessChecks = await Promise.all(
|
||||
knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId))
|
||||
)
|
||||
const accessibleKbs = accessChecks
|
||||
.filter(
|
||||
(ac): ac is KnowledgeBaseAccessResult =>
|
||||
ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId
|
||||
)
|
||||
.map((ac) => ac.knowledgeBase)
|
||||
const accessibleKbIds = accessibleKbs.map((kb) => kb.id)
|
||||
|
||||
if (accessibleKbIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Knowledge base not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id))
|
||||
if (inaccessibleKbIds.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
let structuredFilters: StructuredFilter[] = []
|
||||
const tagDefsCache = new Map<string, Awaited<ReturnType<typeof getDocumentTagDefinitions>>>()
|
||||
|
||||
if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tag filters are only supported when searching a single knowledge base' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) {
|
||||
const kbId = accessibleKbIds[0]
|
||||
const tagDefs = await getDocumentTagDefinitions(kbId)
|
||||
tagDefsCache.set(kbId, tagDefs)
|
||||
|
||||
const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {}
|
||||
tagDefs.forEach((def) => {
|
||||
displayNameToTagDef[def.displayName] = {
|
||||
tagSlot: def.tagSlot,
|
||||
fieldType: def.fieldType,
|
||||
}
|
||||
})
|
||||
|
||||
const undefinedTags: string[] = []
|
||||
const typeErrors: string[] = []
|
||||
|
||||
for (const filter of tagFilters) {
|
||||
const tagDef = displayNameToTagDef[filter.tagName]
|
||||
if (!tagDef) {
|
||||
undefinedTags.push(filter.tagName)
|
||||
continue
|
||||
}
|
||||
const validationError = validateTagValue(
|
||||
filter.tagName,
|
||||
String(filter.value),
|
||||
tagDef.fieldType
|
||||
)
|
||||
if (validationError) {
|
||||
typeErrors.push(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
if (undefinedTags.length > 0 || typeErrors.length > 0) {
|
||||
const errorParts: string[] = []
|
||||
if (undefinedTags.length > 0) {
|
||||
errorParts.push(buildUndefinedTagsError(undefinedTags))
|
||||
}
|
||||
if (typeErrors.length > 0) {
|
||||
errorParts.push(...typeErrors)
|
||||
}
|
||||
return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 })
|
||||
}
|
||||
|
||||
structuredFilters = tagFilters.map((filter) => {
|
||||
const tagDef = displayNameToTagDef[filter.tagName]!
|
||||
return {
|
||||
tagSlot: tagDef.tagSlot,
|
||||
fieldType: tagDef.fieldType,
|
||||
operator: filter.operator,
|
||||
value: filter.value,
|
||||
valueTo: filter.valueTo,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hasQuery = query && query.trim().length > 0
|
||||
const hasFilters = structuredFilters.length > 0
|
||||
|
||||
const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel)))
|
||||
if (hasQuery && embeddingModels.length > 1) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const queryEmbeddingModel = embeddingModels[0]
|
||||
|
||||
let results: SearchResult[]
|
||||
let queryEmbeddingIsBYOK: boolean | null = null
|
||||
|
||||
if (!hasQuery && hasFilters) {
|
||||
results = await handleTagOnlySearch({
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK,
|
||||
structuredFilters,
|
||||
})
|
||||
} else if (hasQuery && hasFilters) {
|
||||
const strategy = getQueryStrategy(accessibleKbIds.length, topK)
|
||||
const queryEmbeddingResult = await generateSearchEmbedding(
|
||||
query!,
|
||||
queryEmbeddingModel,
|
||||
workspaceId
|
||||
)
|
||||
queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
|
||||
const queryVector = JSON.stringify(queryEmbeddingResult.embedding)
|
||||
results = await handleTagAndVectorSearch({
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK,
|
||||
structuredFilters,
|
||||
queryVector,
|
||||
distanceThreshold: strategy.distanceThreshold,
|
||||
})
|
||||
} else if (hasQuery) {
|
||||
const strategy = getQueryStrategy(accessibleKbIds.length, topK)
|
||||
const queryEmbeddingResult = await generateSearchEmbedding(
|
||||
query!,
|
||||
queryEmbeddingModel,
|
||||
workspaceId
|
||||
)
|
||||
queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
|
||||
const queryVector = JSON.stringify(queryEmbeddingResult.embedding)
|
||||
results = await handleVectorOnlySearch({
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK,
|
||||
queryVector,
|
||||
distanceThreshold: strategy.distanceThreshold,
|
||||
})
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either query or tagFilters must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (queryEmbeddingIsBYOK !== null) {
|
||||
await recordSearchEmbeddingUsage({
|
||||
userId,
|
||||
workspaceId,
|
||||
embeddingModel: queryEmbeddingModel,
|
||||
query: query!,
|
||||
isBYOK: queryEmbeddingIsBYOK,
|
||||
sourceReference: `v1-kb-search:${requestId}`,
|
||||
})
|
||||
}
|
||||
|
||||
const tagDefsResults = await Promise.all(
|
||||
accessibleKbIds.map(async (kbId) => {
|
||||
try {
|
||||
const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId))
|
||||
const map: Record<string, string> = {}
|
||||
tagDefs.forEach((def) => {
|
||||
map[def.tagSlot] = def.displayName
|
||||
})
|
||||
return { kbId, map }
|
||||
} catch {
|
||||
return { kbId, map: {} as Record<string, string> }
|
||||
}
|
||||
})
|
||||
)
|
||||
const tagDefinitionsMap: Record<string, Record<string, string>> = {}
|
||||
tagDefsResults.forEach(({ kbId, map }) => {
|
||||
tagDefinitionsMap[kbId] = map
|
||||
})
|
||||
|
||||
const documentIds = results.map((r) => r.documentId)
|
||||
const documentMetadataMap = await getDocumentMetadataByIds(documentIds)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
results: results.map((result) => {
|
||||
const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {}
|
||||
const tags: Record<string, string | number | boolean | Date | null> = {}
|
||||
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tagValue = result[slot as keyof SearchResult]
|
||||
if (tagValue !== null && tagValue !== undefined) {
|
||||
const displayName = kbTagMap[slot] || slot
|
||||
tags[displayName] = tagValue as string | number | boolean | Date | null
|
||||
}
|
||||
})
|
||||
|
||||
const docMeta = documentMetadataMap[result.documentId]
|
||||
return {
|
||||
documentId: result.documentId,
|
||||
documentName: docMeta?.filename || undefined,
|
||||
sourceUrl: docMeta?.sourceUrl ?? null,
|
||||
content: result.content,
|
||||
chunkIndex: result.chunkIndex,
|
||||
metadata: tags,
|
||||
similarity: hasQuery ? 1 - result.distance : 1,
|
||||
}
|
||||
}),
|
||||
query: query || '',
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
topK,
|
||||
totalResults: results.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return handleError(requestId, error, 'Failed to perform search')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validationErrorResponseFromError } from '@/lib/api/server'
|
||||
import { getKnowledgeBaseById } from '@/lib/knowledge/service'
|
||||
import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
|
||||
import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1KnowledgeAPI')
|
||||
|
||||
/**
|
||||
* Fetches a KB by ID, validates it exists, belongs to the workspace,
|
||||
* and the user has permission. Returns the KB or a NextResponse error.
|
||||
*/
|
||||
export async function resolveKnowledgeBase(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
rateLimit: RateLimitResult,
|
||||
level: 'read' | 'write' = 'read'
|
||||
): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> {
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, level)
|
||||
if (accessError) return accessError
|
||||
|
||||
const kb = await getKnowledgeBaseById(id)
|
||||
if (!kb) {
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
if (kb.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
return { kb }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a date value for JSON responses.
|
||||
*/
|
||||
export function serializeDate(date: Date | string | null | undefined): string | null {
|
||||
if (date === null || date === undefined) return null
|
||||
if (date instanceof Date) return date.toISOString()
|
||||
return String(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a KnowledgeBaseWithCounts into the API response shape.
|
||||
*/
|
||||
export function formatKnowledgeBase(kb: KnowledgeBaseWithCounts) {
|
||||
return {
|
||||
id: kb.id,
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
tokenCount: kb.tokenCount,
|
||||
embeddingModel: kb.embeddingModel,
|
||||
embeddingDimension: kb.embeddingDimension,
|
||||
chunkingConfig: kb.chunkingConfig,
|
||||
docCount: kb.docCount,
|
||||
connectorTypes: kb.connectorTypes,
|
||||
createdAt: serializeDate(kb.createdAt),
|
||||
updatedAt: serializeDate(kb.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unexpected errors with consistent logging and response.
|
||||
*/
|
||||
export function handleError(
|
||||
requestId: string,
|
||||
error: unknown,
|
||||
defaultMessage: string
|
||||
): NextResponse {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('does not have permission')) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const isStorageLimitError =
|
||||
error.message.includes('Storage limit exceeded') || error.message.includes('storage limit')
|
||||
if (isStorageLimitError) {
|
||||
return NextResponse.json({ error: 'Storage limit exceeded' }, { status: 413 })
|
||||
}
|
||||
|
||||
const isDuplicate = error.message.includes('already exists')
|
||||
if (isDuplicate) {
|
||||
return NextResponse.json({ error: 'Resource already exists' }, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] ${defaultMessage}:`, error)
|
||||
return NextResponse.json({ error: defaultMessage }, { status: 500 })
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1GetLogContract } from '@/lib/api/contracts/v1/logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1LogDetailsAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'logs-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetLogContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid log ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
workspaceId: workflowExecutionLogs.workspaceId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
costTotal: workflowExecutionLogs.costTotal,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
workflowFolderId: workflow.folderId,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
workflowCreatedAt: workflow.createdAt,
|
||||
workflowUpdatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.where(eq(workflowExecutionLogs.id, id))
|
||||
.limit(1)
|
||||
|
||||
const log = rows[0]
|
||||
if (!log) {
|
||||
return NextResponse.json({ error: 'Log not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId)
|
||||
if (accessError) {
|
||||
return NextResponse.json({ error: 'Log not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName || 'Deleted Workflow',
|
||||
description: log.workflowDescription,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
deleted: !log.workflowName,
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
level: log.level,
|
||||
trigger: log.trigger,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
endedAt: log.endedAt?.toISOString() || null,
|
||||
totalDurationMs: log.totalDurationMs,
|
||||
files: log.files || undefined,
|
||||
workflow: workflowSummary,
|
||||
executionData: (await materializeExecutionData(
|
||||
log.executionData as Record<string, unknown> | null,
|
||||
{
|
||||
workspaceId: log.workspaceId,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
}
|
||||
)) as any,
|
||||
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
}
|
||||
|
||||
// Get user's workflow execution limits and usage
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
// Create response with limits information
|
||||
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Log details fetch error`, { error: error.message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1ExecutionAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => {
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'logs-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetExecutionContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid execution ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { executionId } = parsed.data.params
|
||||
|
||||
logger.debug(`Fetching execution data for: ${executionId}`)
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowLog = rows[0]
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId)
|
||||
if (accessError) {
|
||||
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [snapshot] = await db
|
||||
.select()
|
||||
.from(workflowExecutionSnapshots)
|
||||
.where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId))
|
||||
.limit(1)
|
||||
|
||||
if (!snapshot) {
|
||||
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const response = {
|
||||
executionId,
|
||||
workflowId: workflowLog.workflowId,
|
||||
workflowState: snapshot.stateData,
|
||||
executionMetadata: {
|
||||
trigger: workflowLog.trigger,
|
||||
startedAt: workflowLog.startedAt.toISOString(),
|
||||
endedAt: workflowLog.endedAt?.toISOString(),
|
||||
totalDurationMs: workflowLog.totalDurationMs,
|
||||
// Sourced from the cost_total projection of the usage_log ledger
|
||||
// (the deprecated cost jsonb column was dropped).
|
||||
cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null,
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug(`Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(
|
||||
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
)
|
||||
|
||||
// Get user's workflow execution limits and usage
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
// Create response with limits information
|
||||
const apiResponse = createApiResponse(
|
||||
{
|
||||
...response,
|
||||
},
|
||||
limits,
|
||||
rateLimit
|
||||
)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching execution data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
|
||||
|
||||
export interface LogFilters {
|
||||
workspaceId: string
|
||||
workflowIds?: string[]
|
||||
folderIds?: string[]
|
||||
triggers?: string[]
|
||||
level?: 'info' | 'error'
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
executionId?: string
|
||||
minDurationMs?: number
|
||||
maxDurationMs?: number
|
||||
minCost?: number
|
||||
maxCost?: number
|
||||
model?: string
|
||||
cursor?: {
|
||||
startedAt: string
|
||||
id: string
|
||||
}
|
||||
order?: 'desc' | 'asc'
|
||||
}
|
||||
|
||||
export function buildLogFilters(filters: LogFilters): SQL<unknown> {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
|
||||
conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId))
|
||||
|
||||
// Cursor-based pagination
|
||||
if (filters.cursor) {
|
||||
const cursorDate = new Date(filters.cursor.startedAt)
|
||||
if (filters.order === 'desc') {
|
||||
conditions.push(
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${cursorDate}, ${filters.cursor.id})`
|
||||
)
|
||||
} else {
|
||||
conditions.push(
|
||||
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${cursorDate}, ${filters.cursor.id})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow IDs filter
|
||||
if (filters.workflowIds && filters.workflowIds.length > 0) {
|
||||
conditions.push(inArray(workflow.id, filters.workflowIds))
|
||||
}
|
||||
|
||||
// Folder IDs filter
|
||||
if (filters.folderIds && filters.folderIds.length > 0) {
|
||||
conditions.push(inArray(workflow.folderId, filters.folderIds))
|
||||
}
|
||||
|
||||
// Triggers filter
|
||||
if (filters.triggers && filters.triggers.length > 0 && !filters.triggers.includes('all')) {
|
||||
conditions.push(inArray(workflowExecutionLogs.trigger, filters.triggers))
|
||||
}
|
||||
|
||||
// Level filter
|
||||
if (filters.level) {
|
||||
conditions.push(eq(workflowExecutionLogs.level, filters.level))
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
if (filters.startDate) {
|
||||
conditions.push(gte(workflowExecutionLogs.startedAt, filters.startDate))
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(lte(workflowExecutionLogs.startedAt, filters.endDate))
|
||||
}
|
||||
|
||||
// Search filter (execution ID)
|
||||
if (filters.executionId) {
|
||||
conditions.push(eq(workflowExecutionLogs.executionId, filters.executionId))
|
||||
}
|
||||
|
||||
// Duration filters
|
||||
if (filters.minDurationMs !== undefined) {
|
||||
conditions.push(gte(workflowExecutionLogs.totalDurationMs, filters.minDurationMs))
|
||||
}
|
||||
|
||||
if (filters.maxDurationMs !== undefined) {
|
||||
conditions.push(lte(workflowExecutionLogs.totalDurationMs, filters.maxDurationMs))
|
||||
}
|
||||
|
||||
// Cost filters — indexed projection of the usage_log ledger (dollars).
|
||||
if (filters.minCost !== undefined) {
|
||||
conditions.push(sql`${workflowExecutionLogs.costTotal} >= ${filters.minCost}`)
|
||||
}
|
||||
|
||||
if (filters.maxCost !== undefined) {
|
||||
conditions.push(sql`${workflowExecutionLogs.costTotal} <= ${filters.maxCost}`)
|
||||
}
|
||||
|
||||
// Model filter — uses the models_used projection (includes zero-cost/BYOK
|
||||
// models, which the usage_log ledger drops), preserving prior behavior.
|
||||
if (filters.model) {
|
||||
conditions.push(sql`${workflowExecutionLogs.modelsUsed} @> ARRAY[${filters.model}]::text[]`)
|
||||
}
|
||||
|
||||
// Combine all conditions with AND
|
||||
return conditions.length > 0 ? and(...conditions)! : sql`true`
|
||||
}
|
||||
|
||||
export function getOrderBy(order: 'desc' | 'asc' = 'desc') {
|
||||
return order === 'desc'
|
||||
? desc(workflowExecutionLogs.startedAt)
|
||||
: sql`${workflowExecutionLogs.startedAt} ASC`
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
|
||||
export interface UserLimits {
|
||||
workflowExecutionRateLimit: {
|
||||
sync: {
|
||||
requestsPerMinute: number
|
||||
maxBurst: number
|
||||
remaining: number
|
||||
resetAt: string
|
||||
}
|
||||
async: {
|
||||
requestsPerMinute: number
|
||||
maxBurst: number
|
||||
remaining: number
|
||||
resetAt: string
|
||||
}
|
||||
}
|
||||
usage: {
|
||||
currentPeriodCost: number
|
||||
limit: number
|
||||
plan: string
|
||||
isExceeded: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserLimits(userId: string): Promise<UserLimits> {
|
||||
const [userSubscription, usageCheck, effectiveCost, rateLimiter] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
checkServerSideUsageLimits(userId),
|
||||
getEffectiveCurrentPeriodCost(userId),
|
||||
Promise.resolve(new RateLimiter()),
|
||||
])
|
||||
|
||||
const [syncStatus, asyncStatus] = await Promise.all([
|
||||
rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', false),
|
||||
rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', true),
|
||||
])
|
||||
|
||||
return {
|
||||
workflowExecutionRateLimit: {
|
||||
sync: {
|
||||
requestsPerMinute: syncStatus.requestsPerMinute,
|
||||
maxBurst: syncStatus.maxBurst,
|
||||
remaining: syncStatus.remaining,
|
||||
resetAt: syncStatus.resetAt.toISOString(),
|
||||
},
|
||||
async: {
|
||||
requestsPerMinute: asyncStatus.requestsPerMinute,
|
||||
maxBurst: asyncStatus.maxBurst,
|
||||
remaining: asyncStatus.remaining,
|
||||
resetAt: asyncStatus.resetAt.toISOString(),
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
currentPeriodCost: effectiveCost,
|
||||
limit: usageCheck.limit,
|
||||
plan: userSubscription?.plan || 'free',
|
||||
isExceeded: usageCheck.isExceeded,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiResponse<T>(
|
||||
data: T,
|
||||
limits: UserLimits,
|
||||
apiRateLimit: { limit: number; remaining: number; resetAt: Date }
|
||||
) {
|
||||
return {
|
||||
body: {
|
||||
...data,
|
||||
limits,
|
||||
},
|
||||
headers: {
|
||||
'X-RateLimit-Limit': apiRateLimit.limit.toString(),
|
||||
'X-RateLimit-Remaining': apiRateLimit.remaining.toString(),
|
||||
'X-RateLimit-Reset': apiRateLimit.resetAt.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
|
||||
import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1LogsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface CursorData {
|
||||
startedAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function encodeCursor(data: CursorData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): CursorData | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'logs')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(
|
||||
v1ListLogsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: getValidationErrorMessage(error, 'Invalid parameters'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const params = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read')
|
||||
if (accessError) return accessError
|
||||
|
||||
logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, {
|
||||
userId,
|
||||
filters: {
|
||||
workflowIds: params.workflowIds,
|
||||
triggers: params.triggers,
|
||||
level: params.level,
|
||||
},
|
||||
})
|
||||
|
||||
const filters = {
|
||||
workspaceId: params.workspaceId,
|
||||
workflowIds: params.workflowIds?.split(',').filter(Boolean),
|
||||
folderIds: params.folderIds?.split(',').filter(Boolean),
|
||||
triggers: params.triggers?.split(',').filter(Boolean),
|
||||
level: params.level,
|
||||
startDate: params.startDate ? new Date(params.startDate) : undefined,
|
||||
endDate: params.endDate ? new Date(params.endDate) : undefined,
|
||||
executionId: params.executionId,
|
||||
minDurationMs: params.minDurationMs,
|
||||
maxDurationMs: params.maxDurationMs,
|
||||
minCost: params.minCost,
|
||||
maxCost: params.maxCost,
|
||||
model: params.model,
|
||||
cursor: params.cursor ? decodeCursor(params.cursor) || undefined : undefined,
|
||||
order: params.order,
|
||||
}
|
||||
|
||||
const conditions = buildLogFilters(filters)
|
||||
const orderBy = getOrderBy(params.order)
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
workspaceId: workflowExecutionLogs.workspaceId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
costTotal: workflowExecutionLogs.costTotal,
|
||||
files: workflowExecutionLogs.files,
|
||||
executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
|
||||
const logs = await baseQuery
|
||||
.where(conditions)
|
||||
.orderBy(orderBy)
|
||||
.limit(params.limit + 1)
|
||||
|
||||
const hasMore = logs.length > params.limit
|
||||
const data = logs.slice(0, params.limit)
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (hasMore && data.length > 0) {
|
||||
const lastLog = data[data.length - 1]
|
||||
nextCursor = encodeCursor({
|
||||
startedAt: lastLog.startedAt.toISOString(),
|
||||
id: lastLog.id,
|
||||
})
|
||||
}
|
||||
|
||||
const needsMaterialize =
|
||||
params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans)
|
||||
|
||||
const buildBase = (log: (typeof data)[number]) => {
|
||||
const result: any = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
deploymentVersionId: log.deploymentVersionId,
|
||||
level: log.level,
|
||||
trigger: log.trigger,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
endedAt: log.endedAt?.toISOString() || null,
|
||||
totalDurationMs: log.totalDurationMs,
|
||||
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
|
||||
files: log.files || null,
|
||||
}
|
||||
|
||||
if (params.details === 'full') {
|
||||
result.workflow = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName || 'Deleted Workflow',
|
||||
description: log.workflowDescription,
|
||||
deleted: !log.workflowName,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const formattedLogs = needsMaterialize
|
||||
? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => {
|
||||
const result = buildBase(log)
|
||||
if (log.executionData) {
|
||||
const execData = (await materializeExecutionData(
|
||||
log.executionData as Record<string, unknown> | null,
|
||||
{
|
||||
workspaceId: log.workspaceId,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
}
|
||||
)) as any
|
||||
if (params.includeFinalOutput && execData.finalOutput) {
|
||||
result.finalOutput = execData.finalOutput
|
||||
}
|
||||
if (params.includeTraceSpans && execData.traceSpans) {
|
||||
result.traceSpans = execData.traceSpans
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
: data.map(buildBase)
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
const response = createApiResponse(
|
||||
{
|
||||
data: formattedLogs,
|
||||
nextCursor,
|
||||
},
|
||||
limits,
|
||||
rateLimit // This is the API endpoint rate limit, not workflow execution limits
|
||||
)
|
||||
|
||||
return NextResponse.json(response.body, { headers: response.headers })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Logs fetch error`, { error: error.message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import type { SubscriptionPlan } from '@/lib/core/rate-limiter'
|
||||
import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
|
||||
import { authenticateV1Request } from '@/app/api/v1/auth'
|
||||
|
||||
const logger = createLogger('V1Middleware')
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
export type V1Endpoint =
|
||||
| 'logs'
|
||||
| 'logs-detail'
|
||||
| 'workflows'
|
||||
| 'workflow-detail'
|
||||
| 'workflow-deploy'
|
||||
| 'workflow-rollback'
|
||||
| 'audit-logs'
|
||||
| 'tables'
|
||||
| 'table-detail'
|
||||
| 'table-rows'
|
||||
| 'table-row-detail'
|
||||
| 'table-columns'
|
||||
| 'files'
|
||||
| 'file-detail'
|
||||
| 'knowledge'
|
||||
| 'knowledge-detail'
|
||||
| 'knowledge-search'
|
||||
| 'copilot-chat'
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
resetAt: Date
|
||||
limit: number
|
||||
retryAfterMs?: number
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AuthorizedRequest {
|
||||
requestId: string
|
||||
userId: string
|
||||
rateLimit: RateLimitResult
|
||||
}
|
||||
|
||||
export async function checkRateLimit(
|
||||
request: NextRequest,
|
||||
endpoint: V1Endpoint = 'logs'
|
||||
): Promise<RateLimitResult> {
|
||||
try {
|
||||
const auth = await authenticateV1Request(request)
|
||||
if (!auth.authenticated) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 10,
|
||||
resetAt: new Date(),
|
||||
error: auth.error,
|
||||
}
|
||||
}
|
||||
|
||||
const userId = auth.userId!
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
userId,
|
||||
subscription,
|
||||
'api-endpoint',
|
||||
false
|
||||
)
|
||||
|
||||
if (!result.allowed) {
|
||||
logger.warn(`Rate limit exceeded for user ${userId}`, {
|
||||
endpoint,
|
||||
remaining: result.remaining,
|
||||
resetAt: result.resetAt,
|
||||
})
|
||||
}
|
||||
|
||||
const plan = (subscription?.plan || 'free') as SubscriptionPlan
|
||||
const config = getRateLimit(plan, 'api-endpoint')
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
remaining: result.remaining,
|
||||
resetAt: result.resetAt,
|
||||
limit: config.refillRate,
|
||||
retryAfterMs: result.retryAfterMs,
|
||||
userId,
|
||||
workspaceId: auth.workspaceId,
|
||||
keyType: auth.keyType,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Rate limit check error', { error })
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 10,
|
||||
resetAt: new Date(Date.now() + 60000),
|
||||
error: 'Rate limit check failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates and rate-limits a v1 API request.
|
||||
* Returns NextResponse on failure, AuthorizedRequest on success.
|
||||
*/
|
||||
export async function authenticateRequest(
|
||||
request: NextRequest,
|
||||
endpoint: V1Endpoint
|
||||
): Promise<AuthorizedRequest | NextResponse> {
|
||||
const requestId = generateRequestId()
|
||||
const rateLimit = await checkRateLimit(request, endpoint)
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
return { requestId, userId: rateLimit.userId!, rateLimit }
|
||||
}
|
||||
|
||||
export function createRateLimitResponse(result: RateLimitResult): NextResponse {
|
||||
const headers = {
|
||||
'X-RateLimit-Limit': result.limit.toString(),
|
||||
'X-RateLimit-Remaining': result.remaining.toString(),
|
||||
'X-RateLimit-Reset': result.resetAt.toISOString(),
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401, headers })
|
||||
}
|
||||
|
||||
const retryAfterSeconds = result.retryAfterMs
|
||||
? Math.ceil(result.retryAfterMs / 1000)
|
||||
: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Rate limit exceeded',
|
||||
message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`,
|
||||
retryAfter: result.resetAt.getTime(),
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
...headers,
|
||||
'Retry-After': retryAfterSeconds.toString(),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the API key is allowed to access the requested workspace.
|
||||
*
|
||||
* Enforces two policies:
|
||||
* - A workspace-scoped key may only target its own workspace.
|
||||
* - A personal key is rejected when the workspace has disabled personal API
|
||||
* keys (`allowPersonalApiKeys = false`), matching the workflow-execution
|
||||
* surface in `app/api/workflows/middleware.ts`.
|
||||
*/
|
||||
export async function checkWorkspaceScope(
|
||||
rateLimit: RateLimitResult,
|
||||
requestedWorkspaceId: string
|
||||
): Promise<NextResponse | null> {
|
||||
if (
|
||||
rateLimit.keyType === 'workspace' &&
|
||||
rateLimit.workspaceId &&
|
||||
rateLimit.workspaceId !== requestedWorkspaceId
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is not authorized for this workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (rateLimit.keyType === 'personal') {
|
||||
const settings = await getWorkspaceBillingSettings(requestedWorkspaceId)
|
||||
if (!settings?.allowPersonalApiKeys) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Personal API keys are not allowed for this workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates workspace-scoped API key bounds and the user's workspace permission.
|
||||
* Returns null on success, NextResponse on failure.
|
||||
*/
|
||||
export async function validateWorkspaceAccess(
|
||||
rateLimit: RateLimitResult,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
level: PermissionType = 'read'
|
||||
): Promise<NextResponse | null> {
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permissionSatisfies(permission, level)) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1AddTableColumnContract,
|
||||
v1DeleteTableColumnContract,
|
||||
v1UpdateTableColumnContract,
|
||||
} from '@/lib/api/contracts/v1/tables'
|
||||
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
addTableColumn,
|
||||
deleteColumn,
|
||||
renameColumn,
|
||||
updateColumnConstraints,
|
||||
updateColumnType,
|
||||
} from '@/lib/table'
|
||||
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableColumnsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface ColumnsRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** POST /api/v1/tables/[tableId]/columns — Add a column to the table schema. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-columns')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
const parsed = await parseRequest(v1AddTableColumnContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId } = parsed.data.params
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updatedTable = await addTableColumn(tableId, validated.column, requestId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: validated.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.TABLE_UPDATED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: tableId,
|
||||
resourceName: table.name,
|
||||
description: `Added column "${validated.column.name}" to table "${table.name}"`,
|
||||
metadata: { column: validated.column },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
columns: updatedTable.schema.columns.map(normalizeColumn),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('already exists') || error.message.includes('maximum column')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
if (error.message === 'Table not found') {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error adding column to table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** PATCH /api/v1/tables/[tableId]/columns — Update a column (rename, type change, constraints). */
|
||||
export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-columns')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
const parsed = await parseRequest(v1UpdateTableColumnContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId } = parsed.data.params
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { updates } = validated
|
||||
let updatedTable = null
|
||||
|
||||
if (updates.name) {
|
||||
updatedTable = await renameColumn(
|
||||
{ tableId, oldName: validated.columnName, newName: updates.name },
|
||||
requestId
|
||||
)
|
||||
}
|
||||
|
||||
if (updates.type) {
|
||||
updatedTable = await updateColumnType(
|
||||
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
|
||||
requestId
|
||||
)
|
||||
}
|
||||
|
||||
if (updates.required !== undefined || updates.unique !== undefined) {
|
||||
updatedTable = await updateColumnConstraints(
|
||||
{
|
||||
tableId,
|
||||
columnName: updates.name ?? validated.columnName,
|
||||
...(updates.required !== undefined ? { required: updates.required } : {}),
|
||||
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
|
||||
},
|
||||
requestId
|
||||
)
|
||||
}
|
||||
|
||||
if (!updatedTable) {
|
||||
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: validated.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.TABLE_UPDATED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: tableId,
|
||||
resourceName: table.name,
|
||||
description: `Updated column "${validated.columnName}" in table "${table.name}"`,
|
||||
metadata: { columnName: validated.columnName, updates },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
columns: updatedTable.schema.columns.map(normalizeColumn),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message
|
||||
if (msg.includes('not found') || msg.includes('Table not found')) {
|
||||
return NextResponse.json({ error: msg }, { status: 404 })
|
||||
}
|
||||
if (
|
||||
msg.includes('already exists') ||
|
||||
msg.includes('Cannot delete the last column') ||
|
||||
msg.includes('Cannot set column') ||
|
||||
msg.includes('Invalid column') ||
|
||||
msg.includes('exceeds maximum') ||
|
||||
msg.includes('incompatible') ||
|
||||
msg.includes('duplicate')
|
||||
) {
|
||||
return NextResponse.json({ error: msg }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating column in table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/v1/tables/[tableId]/columns — Delete a column from the table schema. */
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: ColumnsRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-columns')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
const parsed = await parseRequest(v1DeleteTableColumnContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId } = parsed.data.params
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updatedTable = await deleteColumn(
|
||||
{ tableId, columnName: validated.columnName },
|
||||
requestId
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: validated.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.TABLE_UPDATED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: tableId,
|
||||
resourceName: table.name,
|
||||
description: `Deleted column "${validated.columnName}" from table "${table.name}"`,
|
||||
metadata: { columnName: validated.columnName },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
columns: updatedTable.schema.columns.map(normalizeColumn),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('not found') || error.message === 'Table not found') {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||
}
|
||||
if (error.message.includes('Cannot delete') || error.message.includes('last column')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting column from table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1DeleteTableContract, v1GetTableContract } from '@/lib/api/contracts/v1/tables'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { deleteTable, type TableSchema } from '@/lib/table'
|
||||
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableDetailAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface TableRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/tables/[tableId] — Get table details. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetTableContract, request, context, {
|
||||
validationErrorResponse: (error) => {
|
||||
const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId'))
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: hasInvalidTableId
|
||||
? 'Invalid table ID'
|
||||
: 'workspaceId query parameter is required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { tableId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const schemaData = table.schema as TableSchema
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
? table.createdAt.toISOString()
|
||||
: String(table.createdAt),
|
||||
updatedAt:
|
||||
table.updatedAt instanceof Date
|
||||
? table.updatedAt.toISOString()
|
||||
: String(table.updatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/v1/tables/[tableId] — Archive a table. */
|
||||
export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1DeleteTableContract, request, context, {
|
||||
validationErrorResponse: (error) => {
|
||||
const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId'))
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: hasInvalidTableId
|
||||
? 'Invalid table ID'
|
||||
: 'workspaceId query parameter is required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { tableId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
if (result.table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await deleteTable(tableId, requestId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.TABLE_DELETED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: tableId,
|
||||
resourceName: result.table.name,
|
||||
description: `Archived table "${result.table.name}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Table archived successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1DeleteTableRowContract,
|
||||
v1GetTableRowContract,
|
||||
v1UpdateTableRowContract,
|
||||
} from '@/lib/api/contracts/v1/tables'
|
||||
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
buildIdByName,
|
||||
buildNameById,
|
||||
rowDataIdToName,
|
||||
rowDataNameToId,
|
||||
updateRow,
|
||||
} from '@/lib/table'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableRowAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface RowRouteParams {
|
||||
params: Promise<{ tableId: string; rowId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/v1/tables/[tableId]/rows/[rowId] — Get a single row. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-row-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetTableRowContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId, rowId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
if (result.table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
position: userTableRows.position,
|
||||
createdAt: userTableRows.createdAt,
|
||||
updatedAt: userTableRows.updatedAt,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const nameById = buildNameById(result.table.schema as TableSchema)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: rowDataIdToName(row.data as RowData, nameById),
|
||||
position: row.position,
|
||||
createdAt:
|
||||
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
||||
updatedAt:
|
||||
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** PATCH /api/v1/tables/[tableId]/rows/[rowId] — Partial update a single row. */
|
||||
export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-row-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1UpdateTableRowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId, rowId } = parsed.data.params
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const idByName = buildIdByName(table.schema as TableSchema)
|
||||
const nameById = buildNameById(table.schema as TableSchema)
|
||||
const updatedRow = await updateRow(
|
||||
{
|
||||
tableId,
|
||||
rowId,
|
||||
data: rowDataNameToId(validated.data as RowData, idByName),
|
||||
workspaceId: validated.workspaceId,
|
||||
actorUserId: userId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
// No `cancellationGuard` is passed here, so `updateRow` can't return null
|
||||
// from this caller. Defensive narrowing for TypeScript.
|
||||
if (!updatedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
// Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new').
|
||||
// Firing a second mode: 'incomplete' dispatch here would race with it AND
|
||||
// bulk-clear sibling-group outputs.
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: updatedRow.id,
|
||||
data: rowDataIdToName(updatedRow.data, nameById),
|
||||
position: updatedRow.position,
|
||||
createdAt:
|
||||
updatedRow.createdAt instanceof Date
|
||||
? updatedRow.createdAt.toISOString()
|
||||
: updatedRow.createdAt,
|
||||
updatedAt:
|
||||
updatedRow.updatedAt instanceof Date
|
||||
? updatedRow.updatedAt.toISOString()
|
||||
: updatedRow.updatedAt,
|
||||
},
|
||||
message: 'Row updated successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
const errorMessage = toError(error).message
|
||||
|
||||
if (errorMessage === 'Row not found') {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 404 })
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes('Row size exceeds') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('must be unique') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('Cannot set unique column')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/v1/tables/[tableId]/rows/[rowId] — Delete a single row. */
|
||||
export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-row-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1DeleteTableRowContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId, rowId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
if (result.table.workspaceId !== workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [deletedRow] = await db
|
||||
.delete(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
.returning({ id: userTableRows.id })
|
||||
|
||||
if (!deletedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Row deleted successfully',
|
||||
deletedCount: 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,458 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
type V1BatchInsertTableRowsBody,
|
||||
v1CreateTableRowContract,
|
||||
v1DeleteTableRowsContract,
|
||||
v1ListTableRowsContract,
|
||||
v1UpdateRowsByFilterContract,
|
||||
} from '@/lib/api/contracts/v1/tables'
|
||||
import {
|
||||
parseRequest,
|
||||
validationErrorResponse,
|
||||
validationErrorResponseFromError,
|
||||
} from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { Filter, RowData, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
batchInsertRows,
|
||||
buildIdByName,
|
||||
buildNameById,
|
||||
deleteRowsByFilter,
|
||||
deleteRowsByIds,
|
||||
filterNamesToIds,
|
||||
insertRow,
|
||||
rowDataIdToName,
|
||||
rowDataNameToId,
|
||||
sortNamesToIds,
|
||||
updateRowsByFilter,
|
||||
validateBatchRows,
|
||||
validateRowData,
|
||||
validateRowSize,
|
||||
} from '@/lib/table'
|
||||
import { queryRows } from '@/lib/table/rows/service'
|
||||
import { TableQueryValidationError } from '@/lib/table/sql'
|
||||
import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableRowsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface TableRowsRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
async function handleBatchInsert(
|
||||
requestId: string,
|
||||
tableId: string,
|
||||
validated: V1BatchInsertTableRowsBody,
|
||||
userId: string
|
||||
): Promise<NextResponse> {
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// External callers key row data by column name; storage keys by id.
|
||||
const idByName = buildIdByName(table.schema as TableSchema)
|
||||
const nameById = buildNameById(table.schema as TableSchema)
|
||||
const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName))
|
||||
|
||||
const validation = await validateBatchRows({
|
||||
rows,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
try {
|
||||
const insertedRows = await batchInsertRows(
|
||||
{
|
||||
tableId,
|
||||
rows,
|
||||
workspaceId: validated.workspaceId,
|
||||
userId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: insertedRows.map((r) => ({
|
||||
id: r.id,
|
||||
data: rowDataIdToName(r.data, nameById),
|
||||
position: r.position,
|
||||
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
|
||||
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
|
||||
})),
|
||||
insertedCount: insertedRows.length,
|
||||
message: `Successfully inserted ${insertedRows.length} rows`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const response = rowWriteErrorResponse(error)
|
||||
if (response) return response
|
||||
|
||||
logger.error(`[${requestId}] Error batch inserting rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/v1/tables/[tableId]/rows — Query rows with filtering, sorting, pagination. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1ListTableRowsContract, request, context, {
|
||||
validationErrorResponse: (error) => {
|
||||
const hasJsonError = error.issues.some(
|
||||
(issue) =>
|
||||
issue.message === 'Invalid filter JSON' || issue.message === 'Invalid sort JSON'
|
||||
)
|
||||
if (hasJsonError) {
|
||||
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
|
||||
}
|
||||
return validationErrorResponse(error)
|
||||
},
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { tableId } = parsed.data.params
|
||||
const validated = parsed.data.query
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'read')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Translate name-keyed filter/sort fields → column ids; translate rows back.
|
||||
const idByName = buildIdByName(table.schema as TableSchema)
|
||||
const nameById = buildNameById(table.schema as TableSchema)
|
||||
const filter = validated.filter
|
||||
? filterNamesToIds(validated.filter as Filter, idByName)
|
||||
: undefined
|
||||
const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined
|
||||
|
||||
const result = await queryRows(
|
||||
table,
|
||||
{
|
||||
filter,
|
||||
sort,
|
||||
limit: validated.limit,
|
||||
offset: validated.offset,
|
||||
includeTotal: validated.includeTotal,
|
||||
withExecutions: false,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: result.rows.map((r) => ({
|
||||
id: r.id,
|
||||
data: rowDataIdToName(r.data, nameById),
|
||||
position: r.position,
|
||||
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
|
||||
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
|
||||
})),
|
||||
rowCount: result.rowCount,
|
||||
totalCount: result.totalCount,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof TableQueryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error querying rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/v1/tables/[tableId]/rows — Insert row(s). Supports single or batch. */
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: TableRowsRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1CreateTableRowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { tableId } = parsed.data.params
|
||||
if ('rows' in parsed.data.body) {
|
||||
const batchValidated = parsed.data.body
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
return handleBatchInsert(requestId, tableId, batchValidated, userId)
|
||||
}
|
||||
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const idByName = buildIdByName(table.schema as TableSchema)
|
||||
const nameById = buildNameById(table.schema as TableSchema)
|
||||
const rowData = rowDataNameToId(validated.data as RowData, idByName)
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const row = await insertRow(
|
||||
{
|
||||
tableId,
|
||||
data: rowData,
|
||||
workspaceId: validated.workspaceId,
|
||||
userId,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: rowDataIdToName(row.data, nameById),
|
||||
position: row.position,
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
|
||||
},
|
||||
message: 'Row inserted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
const response = rowWriteErrorResponse(error)
|
||||
if (response) return response
|
||||
|
||||
logger.error(`[${requestId}] Error inserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/** PUT /api/v1/tables/[tableId]/rows — Bulk update rows by filter. */
|
||||
export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1UpdateRowsByFilterContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId } = parsed.data.params
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const idByName = buildIdByName(table.schema as TableSchema)
|
||||
const patchData = rowDataNameToId(validated.data as RowData, idByName)
|
||||
|
||||
const sizeValidation = validateRowSize(patchData)
|
||||
if (!sizeValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: sizeValidation.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await updateRowsByFilter(
|
||||
table,
|
||||
{
|
||||
filter: filterNamesToIds(validated.filter as Filter, idByName),
|
||||
data: patchData,
|
||||
limit: validated.limit,
|
||||
actorUserId: userId,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
if (result.affectedCount === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'No rows matched the filter criteria',
|
||||
updatedCount: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows updated successfully',
|
||||
updatedCount: result.affectedCount,
|
||||
updatedRowIds: result.affectedRowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof TableQueryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = rowWriteErrorResponse(error)
|
||||
if (response) return response
|
||||
|
||||
logger.error(`[${requestId}] Error updating rows by filter:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/v1/tables/[tableId]/rows — Delete rows by filter or IDs. */
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: TableRowsRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1DeleteTableRowsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId } = parsed.data.params
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (validated.rowIds) {
|
||||
const result = await deleteRowsByIds(
|
||||
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message:
|
||||
result.deletedCount === 0
|
||||
? 'No matching rows found for the provided IDs'
|
||||
: 'Rows deleted successfully',
|
||||
deletedCount: result.deletedCount,
|
||||
deletedRowIds: result.deletedRowIds,
|
||||
requestedCount: result.requestedCount,
|
||||
...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const idByName = buildIdByName(table.schema as TableSchema)
|
||||
const result = await deleteRowsByFilter(
|
||||
table,
|
||||
{
|
||||
filter: filterNamesToIds(validated.filter as Filter, idByName),
|
||||
limit: validated.limit,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message:
|
||||
result.affectedCount === 0
|
||||
? 'No rows matched the filter criteria'
|
||||
: 'Rows deleted successfully',
|
||||
deletedCount: result.affectedCount,
|
||||
deletedRowIds: result.affectedRowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof TableQueryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = rowWriteErrorResponse(error)
|
||||
if (response) return response
|
||||
|
||||
logger.error(`[${requestId}] Error deleting rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables'
|
||||
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
buildIdByName,
|
||||
buildNameById,
|
||||
rowDataIdToName,
|
||||
rowDataNameToId,
|
||||
upsertRow,
|
||||
} from '@/lib/table'
|
||||
import { accessError, checkAccess } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkWorkspaceScope,
|
||||
createRateLimitResponse,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TableUpsertAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface UpsertRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** POST /api/v1/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'table-rows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1UpsertTableRowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { tableId } = parsed.data.params
|
||||
const validated = parsed.data.body
|
||||
|
||||
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const result = await checkAccess(tableId, userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
if (table.workspaceId !== validated.workspaceId) {
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const idByName = buildIdByName(table.schema as TableSchema)
|
||||
const nameById = buildNameById(table.schema as TableSchema)
|
||||
const upsertResult = await upsertRow(
|
||||
{
|
||||
tableId,
|
||||
workspaceId: validated.workspaceId,
|
||||
data: rowDataNameToId(validated.data as RowData, idByName),
|
||||
userId,
|
||||
conflictTarget: validated.conflictTarget,
|
||||
},
|
||||
table,
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: upsertResult.row.id,
|
||||
data: rowDataIdToName(upsertResult.row.data, nameById),
|
||||
createdAt:
|
||||
upsertResult.row.createdAt instanceof Date
|
||||
? upsertResult.row.createdAt.toISOString()
|
||||
: upsertResult.row.createdAt,
|
||||
updatedAt:
|
||||
upsertResult.row.updatedAt instanceof Date
|
||||
? upsertResult.row.updatedAt.toISOString()
|
||||
: upsertResult.row.updatedAt,
|
||||
},
|
||||
operation: upsertResult.operation,
|
||||
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
const errorMessage = toError(error).message
|
||||
|
||||
if (
|
||||
errorMessage.includes('unique column') ||
|
||||
errorMessage.includes('Unique constraint violation') ||
|
||||
errorMessage.includes('conflictTarget') ||
|
||||
errorMessage.includes('row limit') ||
|
||||
errorMessage.includes('Schema validation') ||
|
||||
errorMessage.includes('Upsert requires') ||
|
||||
errorMessage.includes('Row size exceeds')
|
||||
) {
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error upserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1CreateTableContract, v1ListTablesContract } from '@/lib/api/contracts/v1/tables'
|
||||
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table'
|
||||
import { normalizeColumn } from '@/app/api/table/utils'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1TablesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
/** GET /api/v1/tables — List all tables in a workspace. */
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'tables')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1ListTablesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId } = parsed.data.query
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
|
||||
if (accessError) return accessError
|
||||
|
||||
const tables = await listTables(workspaceId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tables: tables.map((t) => {
|
||||
const schemaData = t.schema as TableSchema
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: t.rowCount,
|
||||
maxRows: t.maxRows,
|
||||
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,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
logger.error(`[${requestId}] Error listing tables:`, error)
|
||||
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/v1/tables — Create a new table. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'tables')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
|
||||
const parsed = await parseRequest(v1CreateTableContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const accessError = await validateWorkspaceAccess(
|
||||
rateLimit,
|
||||
userId,
|
||||
params.workspaceId,
|
||||
'write'
|
||||
)
|
||||
if (accessError) return accessError
|
||||
|
||||
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
|
||||
|
||||
const normalizedSchema: TableSchema = {
|
||||
columns: params.schema.columns.map(normalizeColumn),
|
||||
}
|
||||
|
||||
const table = await createTable(
|
||||
{
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
schema: normalizedSchema,
|
||||
workspaceId: params.workspaceId,
|
||||
userId,
|
||||
maxTables: planLimits.maxTables,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: params.workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.TABLE_CREATED,
|
||||
resourceType: AuditResourceType.TABLE,
|
||||
resourceId: table.id,
|
||||
resourceName: table.name,
|
||||
description: `Created table "${table.name}" via API`,
|
||||
metadata: { columnCount: params.schema.columns.length },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: {
|
||||
columns: (table.schema as TableSchema).columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
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',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const validationResponse = validationErrorResponseFromError(error)
|
||||
if (validationResponse) return validationResponse
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('maximum table limit')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
if (
|
||||
error.message.includes('Invalid table name') ||
|
||||
error.message.includes('Invalid schema') ||
|
||||
error.message.includes('already exists')
|
||||
) {
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Tests for POST/DELETE /api/v1/workflows/[id]/deploy — verifies auth,
|
||||
* workspace admin permission enforcement, optional body handling, and the
|
||||
* mapping of orchestration results to v1 API responses.
|
||||
*/
|
||||
|
||||
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
|
||||
import { createMockRequest, workflowAuthzMockFns } from '@sim/testing'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckRateLimit,
|
||||
mockValidateWorkspaceAccess,
|
||||
mockPerformFullDeploy,
|
||||
mockPerformFullUndeploy,
|
||||
mockCaptureServerEvent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
mockValidateWorkspaceAccess: vi.fn(),
|
||||
mockPerformFullDeploy: vi.fn(),
|
||||
mockPerformFullUndeploy: vi.fn(),
|
||||
mockCaptureServerEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/middleware', () => ({
|
||||
checkRateLimit: mockCheckRateLimit,
|
||||
createRateLimitResponse: vi.fn(() =>
|
||||
NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
),
|
||||
validateWorkspaceAccess: mockValidateWorkspaceAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/orchestration', () => ({
|
||||
performFullDeploy: mockPerformFullDeploy,
|
||||
performFullUndeploy: mockPerformFullUndeploy,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/logs/meta', () => ({
|
||||
getUserLimits: vi.fn().mockResolvedValue({}),
|
||||
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: mockCaptureServerEvent,
|
||||
}))
|
||||
|
||||
import { DELETE, POST } from '@/app/api/v1/workflows/[id]/deploy/route'
|
||||
|
||||
const WORKFLOW_ID = 'wf-1'
|
||||
const WORKFLOW_RECORD = {
|
||||
id: WORKFLOW_ID,
|
||||
name: 'My Workflow',
|
||||
workspaceId: 'ws-1',
|
||||
isDeployed: true,
|
||||
}
|
||||
|
||||
function makeContext(id = WORKFLOW_ID) {
|
||||
return { params: Promise.resolve({ id }) }
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown) {
|
||||
return createMockRequest(
|
||||
method,
|
||||
body,
|
||||
{},
|
||||
`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy`
|
||||
)
|
||||
}
|
||||
|
||||
describe('POST /api/v1/workflows/[id]/deploy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(null)
|
||||
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
|
||||
mockPerformFullDeploy.mockResolvedValue({
|
||||
success: true,
|
||||
deployedAt: new Date('2026-06-12T00:00:00Z'),
|
||||
version: 4,
|
||||
warnings: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' })
|
||||
|
||||
const response = await POST(makeRequest('POST'), makeContext())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the workflow does not exist', async () => {
|
||||
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(makeRequest('POST'), makeContext())
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('masks missing admin permission as 404', async () => {
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(
|
||||
NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
)
|
||||
|
||||
const response = await POST(makeRequest('POST'), makeContext())
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowed: true }),
|
||||
'user-1',
|
||||
'ws-1',
|
||||
'admin'
|
||||
)
|
||||
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a malformed JSON body', async () => {
|
||||
const request = new NextRequest(
|
||||
new URL(`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
body: '{"name": "Release 4"',
|
||||
}
|
||||
)
|
||||
|
||||
const response = await POST(request, makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid version metadata', async () => {
|
||||
const response = await POST(makeRequest('POST', { name: '' }), makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deploys without a request body', async () => {
|
||||
const response = await POST(makeRequest('POST'), makeContext())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockPerformFullDeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowId: WORKFLOW_ID,
|
||||
userId: 'user-1',
|
||||
versionName: undefined,
|
||||
versionDescription: undefined,
|
||||
})
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data).toEqual({
|
||||
id: WORKFLOW_ID,
|
||||
isDeployed: true,
|
||||
deployedAt: '2026-06-12T00:00:00.000Z',
|
||||
version: 4,
|
||||
warnings: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('passes version metadata through to the deploy orchestration', async () => {
|
||||
const response = await POST(
|
||||
makeRequest('POST', { name: 'Release 4', description: 'Fixes the agent prompt' }),
|
||||
makeContext()
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockPerformFullDeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionName: 'Release 4',
|
||||
versionDescription: 'Fixes the agent prompt',
|
||||
})
|
||||
)
|
||||
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'workflow_deployed',
|
||||
expect.objectContaining({ workflow_id: WORKFLOW_ID }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('maps validation failures from the orchestration to 400', async () => {
|
||||
mockPerformFullDeploy.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Invalid schedule configuration',
|
||||
errorCode: 'validation',
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest('POST'), makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('Invalid schedule configuration')
|
||||
})
|
||||
|
||||
it('returns 423 when the workflow is locked', async () => {
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError())
|
||||
|
||||
const response = await POST(makeRequest('POST'), makeContext())
|
||||
|
||||
expect(response.status).toBe(423)
|
||||
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/v1/workflows/[id]/deploy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(null)
|
||||
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
|
||||
mockPerformFullUndeploy.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('returns 400 when the workflow is not deployed', async () => {
|
||||
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({
|
||||
...WORKFLOW_RECORD,
|
||||
isDeployed: false,
|
||||
})
|
||||
|
||||
const response = await DELETE(makeRequest('DELETE'), makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('Workflow is not deployed')
|
||||
expect(mockPerformFullUndeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('undeploys a deployed workflow', async () => {
|
||||
const response = await DELETE(makeRequest('DELETE'), makeContext())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockPerformFullUndeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowId: WORKFLOW_ID, userId: 'user-1' })
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data).toEqual({
|
||||
id: WORKFLOW_ID,
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
warnings: [],
|
||||
})
|
||||
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'workflow_undeployed',
|
||||
expect.objectContaining({ workflow_id: WORKFLOW_ID }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('masks missing admin permission as 404', async () => {
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(
|
||||
NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
)
|
||||
|
||||
const response = await DELETE(makeRequest('DELETE'), makeContext())
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockPerformFullUndeploy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1DeployWorkflowBodySchema,
|
||||
v1DeployWorkflowContract,
|
||||
v1UndeployWorkflowContract,
|
||||
} from '@/lib/api/contracts/v1/workflows'
|
||||
import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
|
||||
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||
import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
|
||||
|
||||
const logger = createLogger('V1WorkflowDeployAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 120
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'workflow-deploy')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1DeployWorkflowContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const rawBody = await parseOptionalJsonBody(request)
|
||||
if (!rawBody.success) return rawBody.response
|
||||
const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {})
|
||||
if (!body.success) {
|
||||
return validationErrorResponse(body.error)
|
||||
}
|
||||
|
||||
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
|
||||
if (!target.ok) return target.response
|
||||
const { workflow, workspaceId } = target
|
||||
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
logger.info(`[${requestId}] Deploying workflow ${id} via v1 API`, { userId })
|
||||
|
||||
const result = await performFullDeploy({
|
||||
workflowId: id,
|
||||
userId,
|
||||
workflowName: workflow.name || undefined,
|
||||
versionName: body.data.name,
|
||||
versionDescription: body.data.description ?? undefined,
|
||||
requestId,
|
||||
request,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to deploy workflow' },
|
||||
{ status: statusForOrchestrationError(result.errorCode) }
|
||||
)
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'workflow_deployed',
|
||||
{ workflow_id: id, workspace_id: workspaceId },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
const apiResponse = createApiResponse(
|
||||
{
|
||||
data: {
|
||||
id,
|
||||
isDeployed: true,
|
||||
deployedAt: result.deployedAt?.toISOString() ?? null,
|
||||
version: result.version,
|
||||
warnings: result.warnings ?? [],
|
||||
},
|
||||
},
|
||||
limits,
|
||||
rateLimit
|
||||
)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
logger.error(`[${requestId}] Workflow deploy error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'workflow-deploy')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1UndeployWorkflowContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
|
||||
if (!target.ok) return target.response
|
||||
const { workflow, workspaceId } = target
|
||||
|
||||
if (!workflow.isDeployed) {
|
||||
return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 })
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
logger.info(`[${requestId}] Undeploying workflow ${id} via v1 API`, { userId })
|
||||
|
||||
const result = await performFullUndeploy({ workflowId: id, userId, requestId })
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to undeploy workflow' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'workflow_undeployed',
|
||||
{ workflow_id: id, workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
const apiResponse = createApiResponse(
|
||||
{
|
||||
data: {
|
||||
id,
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
warnings: result.warnings ?? [],
|
||||
},
|
||||
},
|
||||
limits,
|
||||
rateLimit
|
||||
)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
logger.error(`[${requestId}] Workflow undeploy error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Tests for POST /api/v1/workflows/[id]/rollback — verifies target version
|
||||
* resolution (previous version by default, explicit version when provided)
|
||||
* and the mapping of activation results to v1 API responses.
|
||||
*/
|
||||
|
||||
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
|
||||
import { createMockRequest, workflowAuthzMockFns } from '@sim/testing'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckRateLimit,
|
||||
mockValidateWorkspaceAccess,
|
||||
mockPerformActivateVersion,
|
||||
mockFindPreviousDeploymentVersion,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
mockValidateWorkspaceAccess: vi.fn(),
|
||||
mockPerformActivateVersion: vi.fn(),
|
||||
mockFindPreviousDeploymentVersion: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
findPreviousDeploymentVersion: mockFindPreviousDeploymentVersion,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/middleware', () => ({
|
||||
checkRateLimit: mockCheckRateLimit,
|
||||
createRateLimitResponse: vi.fn(() =>
|
||||
NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
),
|
||||
validateWorkspaceAccess: mockValidateWorkspaceAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/orchestration', () => ({
|
||||
performActivateVersion: mockPerformActivateVersion,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/v1/logs/meta', () => ({
|
||||
getUserLimits: vi.fn().mockResolvedValue({}),
|
||||
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/v1/workflows/[id]/rollback/route'
|
||||
|
||||
const WORKFLOW_ID = 'wf-1'
|
||||
const WORKFLOW_RECORD = {
|
||||
id: WORKFLOW_ID,
|
||||
name: 'My Workflow',
|
||||
workspaceId: 'ws-1',
|
||||
isDeployed: true,
|
||||
}
|
||||
|
||||
function makeContext(id = WORKFLOW_ID) {
|
||||
return { params: Promise.resolve({ id }) }
|
||||
}
|
||||
|
||||
function makeRequest(body?: unknown) {
|
||||
return createMockRequest(
|
||||
'POST',
|
||||
body,
|
||||
{},
|
||||
`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/rollback`
|
||||
)
|
||||
}
|
||||
|
||||
describe('POST /api/v1/workflows/[id]/rollback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(null)
|
||||
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
|
||||
mockPerformActivateVersion.mockResolvedValue({
|
||||
success: true,
|
||||
deployedAt: new Date('2026-06-12T00:00:00Z'),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' })
|
||||
|
||||
const response = await POST(makeRequest(), makeContext())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the workflow does not exist', async () => {
|
||||
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(makeRequest(), makeContext())
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 423 when the workflow is locked', async () => {
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError())
|
||||
|
||||
const response = await POST(makeRequest(), makeContext())
|
||||
|
||||
expect(response.status).toBe(423)
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back to the previous version when no version is given', async () => {
|
||||
mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: true, version: 4 })
|
||||
|
||||
const response = await POST(makeRequest(), makeContext())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockPerformActivateVersion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowId: WORKFLOW_ID, version: 4, userId: 'user-1' })
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data).toEqual({
|
||||
id: WORKFLOW_ID,
|
||||
isDeployed: true,
|
||||
deployedAt: '2026-06-12T00:00:00.000Z',
|
||||
version: 4,
|
||||
warnings: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 when the workflow is not deployed, even with an explicit version', async () => {
|
||||
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({
|
||||
...WORKFLOW_RECORD,
|
||||
isDeployed: false,
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ version: 2 }), makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('Workflow is not deployed')
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back to an explicit version when provided', async () => {
|
||||
const response = await POST(makeRequest({ version: 2 }), makeContext())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockPerformActivateVersion).toHaveBeenCalledWith(expect.objectContaining({ version: 2 }))
|
||||
expect(mockFindPreviousDeploymentVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a non-integer version', async () => {
|
||||
const response = await POST(makeRequest({ version: 1.5 }), makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when there is no active deployment to roll back from', async () => {
|
||||
mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: false, reason: 'no_active_version' })
|
||||
|
||||
const response = await POST(makeRequest(), makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('Workflow has no active deployment to roll back from')
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when there is no previous version to roll back to', async () => {
|
||||
mockFindPreviousDeploymentVersion.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'no_previous_version',
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest(), makeContext())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('No previous deployment version to roll back to')
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a missing target version to 404', async () => {
|
||||
mockPerformActivateVersion.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Deployment version not found',
|
||||
errorCode: 'not_found',
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest({ version: 99 }), makeContext())
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('masks missing admin permission as 404', async () => {
|
||||
mockValidateWorkspaceAccess.mockResolvedValue(
|
||||
NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
)
|
||||
|
||||
const response = await POST(makeRequest(), makeContext())
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowed: true }),
|
||||
'user-1',
|
||||
'ws-1',
|
||||
'admin'
|
||||
)
|
||||
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
v1RollbackWorkflowBodySchema,
|
||||
v1RollbackWorkflowContract,
|
||||
} from '@/lib/api/contracts/v1/workflows'
|
||||
import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performActivateVersion } from '@/lib/workflows/orchestration'
|
||||
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
|
||||
import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||
import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
|
||||
|
||||
const logger = createLogger('V1WorkflowRollbackAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 120
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'workflow-rollback')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1RollbackWorkflowContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const rawBody = await parseOptionalJsonBody(request)
|
||||
if (!rawBody.success) return rawBody.response
|
||||
const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {})
|
||||
if (!body.success) {
|
||||
return validationErrorResponse(body.error)
|
||||
}
|
||||
|
||||
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
|
||||
if (!target.ok) return target.response
|
||||
const { workflow, workspaceId } = target
|
||||
|
||||
if (!workflow.isDeployed) {
|
||||
return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 })
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
let targetVersion = body.data.version
|
||||
if (targetVersion === undefined) {
|
||||
const previous = await findPreviousDeploymentVersion(id)
|
||||
if (!previous.ok) {
|
||||
const message =
|
||||
previous.reason === 'no_active_version'
|
||||
? 'Workflow has no active deployment to roll back from'
|
||||
: 'No previous deployment version to roll back to'
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
targetVersion = previous.version
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v1 API`,
|
||||
{ userId }
|
||||
)
|
||||
|
||||
const result = await performActivateVersion({
|
||||
workflowId: id,
|
||||
version: targetVersion,
|
||||
userId,
|
||||
workflow: workflow as Record<string, unknown>,
|
||||
requestId,
|
||||
request,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to roll back workflow' },
|
||||
{ status: statusForOrchestrationError(result.errorCode) }
|
||||
)
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'deployment_version_activated',
|
||||
{ workflow_id: id, workspace_id: workspaceId, version: targetVersion },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
const apiResponse = createApiResponse(
|
||||
{
|
||||
data: {
|
||||
id,
|
||||
isDeployed: true,
|
||||
deployedAt: result.deployedAt?.toISOString() ?? null,
|
||||
version: targetVersion,
|
||||
warnings: result.warnings ?? [],
|
||||
},
|
||||
},
|
||||
limits,
|
||||
rateLimit
|
||||
)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
logger.error(`[${requestId}] Workflow rollback error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowBlocks } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1GetWorkflowContract } from '@/lib/api/contracts/v1/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1WorkflowDetailsAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'workflow-detail')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(v1GetWorkflowContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
|
||||
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
|
||||
|
||||
const workflowData = await getActiveWorkflowRecord(id)
|
||||
if (!workflowData) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accessError = await validateWorkspaceAccess(
|
||||
rateLimit,
|
||||
userId,
|
||||
workflowData.workspaceId!
|
||||
)
|
||||
if (accessError) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const blockRows = await db
|
||||
.select({
|
||||
id: workflowBlocks.id,
|
||||
type: workflowBlocks.type,
|
||||
subBlocks: workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, id))
|
||||
|
||||
const blocksRecord = Object.fromEntries(
|
||||
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
|
||||
)
|
||||
const inputs = extractInputFieldsFromBlocks(blocksRecord)
|
||||
|
||||
const response = {
|
||||
id: workflowData.id,
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
folderId: workflowData.folderId,
|
||||
workspaceId: workflowData.workspaceId,
|
||||
isDeployed: workflowData.isDeployed,
|
||||
deployedAt: workflowData.deployedAt?.toISOString() || null,
|
||||
runCount: workflowData.runCount,
|
||||
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
|
||||
variables: workflowData.variables || {},
|
||||
inputs,
|
||||
createdAt: workflowData.createdAt.toISOString(),
|
||||
updatedAt: workflowData.updatedAt.toISOString(),
|
||||
}
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
|
||||
|
||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, asc, eq, gt, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v1ListWorkflowsContract } from '@/lib/api/contracts/v1/workflows'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
||||
import {
|
||||
checkRateLimit,
|
||||
createRateLimitResponse,
|
||||
validateWorkspaceAccess,
|
||||
} from '@/app/api/v1/middleware'
|
||||
|
||||
const logger = createLogger('V1WorkflowsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface CursorData {
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function encodeCursor(data: CursorData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): CursorData | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const rateLimit = await checkRateLimit(request, 'workflows')
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit)
|
||||
}
|
||||
|
||||
const userId = rateLimit.userId!
|
||||
const parsed = await parseRequest(
|
||||
v1ListWorkflowsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: getValidationErrorMessage(error, 'Invalid parameters'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const params = parsed.data.query
|
||||
|
||||
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
|
||||
userId,
|
||||
filters: {
|
||||
folderId: params.folderId,
|
||||
deployedOnly: params.deployedOnly,
|
||||
},
|
||||
})
|
||||
|
||||
const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId)
|
||||
if (accessError) return accessError
|
||||
|
||||
const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)]
|
||||
|
||||
if (params.folderId) {
|
||||
conditions.push(eq(workflow.folderId, params.folderId))
|
||||
}
|
||||
|
||||
if (params.deployedOnly) {
|
||||
conditions.push(eq(workflow.isDeployed, true))
|
||||
}
|
||||
|
||||
if (params.cursor) {
|
||||
const cursorData = decodeCursor(params.cursor)
|
||||
if (cursorData) {
|
||||
const cursorCondition = or(
|
||||
gt(workflow.sortOrder, cursorData.sortOrder),
|
||||
and(
|
||||
eq(workflow.sortOrder, cursorData.sortOrder),
|
||||
gt(workflow.createdAt, new Date(cursorData.createdAt))
|
||||
),
|
||||
and(
|
||||
eq(workflow.sortOrder, cursorData.sortOrder),
|
||||
eq(workflow.createdAt, new Date(cursorData.createdAt)),
|
||||
gt(workflow.id, cursorData.id)
|
||||
)
|
||||
)
|
||||
if (cursorCondition) {
|
||||
conditions.push(cursorCondition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
folderId: workflow.folderId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
deployedAt: workflow.deployedAt,
|
||||
runCount: workflow.runCount,
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
sortOrder: workflow.sortOrder,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(...conditions))
|
||||
.orderBy(...orderByClause)
|
||||
.limit(params.limit + 1)
|
||||
|
||||
const hasMore = rows.length > params.limit
|
||||
const data = rows.slice(0, params.limit)
|
||||
|
||||
let nextCursor: string | undefined
|
||||
if (hasMore && data.length > 0) {
|
||||
const lastWorkflow = data[data.length - 1]
|
||||
nextCursor = encodeCursor({
|
||||
sortOrder: lastWorkflow.sortOrder,
|
||||
createdAt: lastWorkflow.createdAt.toISOString(),
|
||||
id: lastWorkflow.id,
|
||||
})
|
||||
}
|
||||
|
||||
const formattedWorkflows = data.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
folderId: w.folderId,
|
||||
workspaceId: w.workspaceId,
|
||||
isDeployed: w.isDeployed,
|
||||
deployedAt: w.deployedAt?.toISOString() || null,
|
||||
runCount: w.runCount,
|
||||
lastRunAt: w.lastRunAt?.toISOString() || null,
|
||||
createdAt: w.createdAt.toISOString(),
|
||||
updatedAt: w.updatedAt.toISOString(),
|
||||
}))
|
||||
|
||||
const limits = await getUserLimits(userId)
|
||||
|
||||
const response = createApiResponse(
|
||||
{
|
||||
data: formattedWorkflows,
|
||||
nextCursor,
|
||||
},
|
||||
limits,
|
||||
rateLimit
|
||||
)
|
||||
|
||||
return NextResponse.json(response.body, { headers: response.headers })
|
||||
} catch (error: unknown) {
|
||||
const message = getErrorMessage(error, 'Unknown error')
|
||||
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware'
|
||||
|
||||
function workflowNotFoundResponse(): NextResponse {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the target workflow for a v1 deployment mutation: loads the active
|
||||
* record and verifies the caller's admin permission on its workspace. Access
|
||||
* failures are masked as 404, matching the v1 workflow read surface so
|
||||
* unauthorized callers cannot probe workflow existence.
|
||||
*/
|
||||
export async function resolveV1DeploymentWorkflow(
|
||||
rateLimit: RateLimitResult,
|
||||
userId: string,
|
||||
workflowId: string
|
||||
): Promise<
|
||||
| { ok: true; workflow: ActiveWorkflowRecord; workspaceId: string }
|
||||
| { ok: false; response: NextResponse }
|
||||
> {
|
||||
const workflow = await getActiveWorkflowRecord(workflowId)
|
||||
if (!workflow?.workspaceId) {
|
||||
return { ok: false, response: workflowNotFoundResponse() }
|
||||
}
|
||||
|
||||
const accessError = await validateWorkspaceAccess(
|
||||
rateLimit,
|
||||
userId,
|
||||
workflow.workspaceId,
|
||||
'admin'
|
||||
)
|
||||
if (accessError) {
|
||||
return { ok: false, response: workflowNotFoundResponse() }
|
||||
}
|
||||
|
||||
return { ok: true, workflow, workspaceId: workflow.workspaceId }
|
||||
}
|
||||
Reference in New Issue
Block a user