chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
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')
|
||||
}
|
||||
})
|
||||
)
|
||||
Reference in New Issue
Block a user