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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,74 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { apiKeyIdParamsSchema } from '@/lib/api/contracts'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('ApiKeyAPI')
// DELETE /api/users/me/api-keys/[id] - Delete an API key
export const DELETE = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const parsedParams = apiKeyIdParamsSchema.safeParse(await params)
if (!parsedParams.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(parsedParams.error) },
{ status: 400 }
)
}
const { id } = parsedParams.data
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const keyId = id
// Delete the API key, ensuring it belongs to the current user
const result = await db
.delete(apiKey)
.where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId), eq(apiKey.type, 'personal')))
.returning({ id: apiKey.id, name: apiKey.name })
if (!result.length) {
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
}
const deletedKey = result[0]
recordAudit({
workspaceId: null,
actorId: userId,
action: AuditAction.PERSONAL_API_KEY_REVOKED,
resourceType: AuditResourceType.API_KEY,
resourceId: keyId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: deletedKey.name,
description: `Revoked personal API key: ${deletedKey.name}`,
request,
})
captureServerEvent(userId, 'api_key_revoked', {
key_name: deletedKey.name,
scope: 'personal',
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to delete API key', { error })
return NextResponse.json({ error: 'Failed to delete API key' }, { status: 500 })
}
}
)
+141
View File
@@ -0,0 +1,141 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createPersonalApiKeyContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { hashApiKey } from '@/lib/api-key/crypto'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('ApiKeysAPI')
// GET /api/users/me/api-keys - Get all API keys for the current user
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const keys = await db
.select({
id: apiKey.id,
name: apiKey.name,
key: apiKey.key,
createdAt: apiKey.createdAt,
lastUsed: apiKey.lastUsed,
expiresAt: apiKey.expiresAt,
})
.from(apiKey)
.where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal')))
.orderBy(apiKey.createdAt)
const maskedKeys = await Promise.all(
keys.map(async (key) => {
const displayFormat = await getApiKeyDisplayFormat(key.key)
return {
...key,
key: key.key,
displayKey: displayFormat,
}
})
)
return NextResponse.json({ keys: maskedKeys })
} catch (error) {
logger.error('Failed to fetch API keys', { error })
return NextResponse.json({ error: 'Failed to fetch API keys' }, { status: 500 })
}
})
// POST /api/users/me/api-keys - Create a new API key
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const parsed = await parseRequest(createPersonalApiKeyContract, request, {})
if (!parsed.success) return parsed.response
const { name } = parsed.data.body
const existingKey = await db
.select()
.from(apiKey)
.where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal')))
.limit(1)
if (existingKey.length > 0) {
return NextResponse.json(
{
error: `A personal API key named "${name}" already exists. Please choose a different name.`,
},
{ status: 409 }
)
}
const { key: plainKey, encryptedKey } = await createApiKey(true)
if (!encryptedKey) {
throw new Error('Failed to encrypt API key for storage')
}
const [newKey] = await db
.insert(apiKey)
.values({
id: generateShortId(),
userId,
workspaceId: null,
name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({
id: apiKey.id,
name: apiKey.name,
createdAt: apiKey.createdAt,
})
recordAudit({
workspaceId: null,
actorId: userId,
action: AuditAction.PERSONAL_API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
resourceId: newKey.id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Created personal API key: ${name}`,
request,
})
captureServerEvent(userId, 'api_key_created', {
key_name: name,
scope: 'personal',
})
return NextResponse.json({
key: {
...newKey,
key: plainKey,
},
})
} catch (error) {
logger.error('Failed to create API key', { error })
return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 })
}
})
+101
View File
@@ -0,0 +1,101 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateUserProfileContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserProfile } from '@/lib/users/queries'
const logger = createLogger('UpdateUserProfileAPI')
interface UpdateData {
updatedAt: Date
name?: string
image?: string | null
}
export const dynamic = 'force-dynamic'
export const PATCH = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const parsed = await parseRequest(updateUserProfileContract, request, {})
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const updateData: UpdateData = { updatedAt: new Date() }
if (validatedData.name !== undefined) updateData.name = validatedData.name
if (validatedData.image !== undefined) updateData.image = validatedData.image
const [updatedUser] = await db
.update(user)
.set(updateData)
.where(eq(user.id, userId))
.returning()
if (!updatedUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
logger.info(`[${requestId}] User profile updated`, {
userId,
updatedFields: Object.keys(validatedData),
})
return NextResponse.json({
success: true,
user: {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
image: updatedUser.image,
},
})
} catch (error: any) {
logger.error(`[${requestId}] Profile update error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
// GET endpoint to fetch current user profile
export const GET = withRouteHandler(async () => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const userRecord = await getUserProfile(userId)
if (!userRecord) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
user: userRecord,
})
} catch (error: any) {
logger.error(`[${requestId}] Profile fetch error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,79 @@
import { db } from '@sim/db'
import { settings } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { updateUserSettingsContract } from '@/lib/api/contracts'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { defaultUserSettings, getUserSettings } from '@/lib/users/queries'
const logger = createLogger('UserSettingsAPI')
export const GET = withRouteHandler(async () => {
const requestId = generateRequestId()
try {
const session = await getSession()
const data = await getUserSettings(session?.user?.id ?? null)
return NextResponse.json({ data }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Settings fetch error`, error)
return NextResponse.json({ data: defaultUserSettings }, { status: 200 })
}
})
export const PATCH = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.info(
`[${requestId}] Settings update attempted by unauthenticated user - acknowledged without saving`
)
return NextResponse.json({ success: true }, { status: 200 })
}
const userId = session.user.id
const parsed = await parseRequest(
updateUserSettingsContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid settings data`, { errors: error.issues })
return validationErrorResponse(error, 'Invalid settings data')
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
await db
.insert(settings)
.values({
id: generateShortId(),
userId,
...validatedData,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [settings.userId],
set: {
...validatedData,
updatedAt: new Date(),
},
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Settings update error`, error)
return NextResponse.json({ success: true }, { status: 200 })
}
})
@@ -0,0 +1,194 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
unsubscribeFormContract,
unsubscribeGetContract,
unsubscribePostContract,
} from '@/lib/api/contracts/user'
import { parseRequest } from '@/lib/api/server'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { EmailType } from '@/lib/messaging/email/mailer'
import {
getEmailPreferences,
isTransactionalEmail,
unsubscribeFromAll,
updateEmailPreferences,
verifyUnsubscribeToken,
} from '@/lib/messaging/email/unsubscribe'
const logger = createLogger('UnsubscribeAPI')
const UNSUBSCRIBE_RATE_LIMIT = {
maxTokens: 10,
refillRate: 10,
refillIntervalMs: 60_000,
}
export const GET = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
const rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT)
if (rateLimited) return rateLimited
try {
const parsed = await parseRequest(
unsubscribeGetContract,
req,
{},
{
validationErrorResponse: () =>
NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }),
}
)
if (!parsed.success) {
logger.warn(`[${requestId}] Missing email or token in GET request`)
return parsed.response
}
const { email, token } = parsed.data.query
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
}
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
const preferences = await getEmailPreferences(email)
logger.info(
`[${requestId}] Valid unsubscribe GET request for email: ${email}, type: ${emailType}`
)
return NextResponse.json({
success: true,
email,
token,
emailType,
isTransactional,
currentPreferences: preferences || {},
})
} catch (error) {
logger.error(`[${requestId}] Error processing unsubscribe GET request:`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
const rateLimited = await enforceIpRateLimit('unsubscribe', req, UNSUBSCRIBE_RATE_LIMIT)
if (rateLimited) return rateLimited
try {
const contentType = req.headers.get('content-type') || ''
let email: string
let token: string
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
if (contentType.includes('application/x-www-form-urlencoded')) {
const parsed = await parseRequest(
unsubscribeFormContract,
req,
{},
{
validationErrorResponse: () =>
NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 }),
}
)
if (!parsed.success) {
logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
return parsed.response
}
email = parsed.data.query.email
token = parsed.data.query.token
logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
} else {
const parsed = await parseRequest(
unsubscribePostContract,
req,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{ error: 'Invalid request data', details: error.issues },
{ status: 400 }
),
}
)
if (!parsed.success) {
logger.warn(`[${requestId}] Invalid unsubscribe POST data`)
return parsed.response
}
email = parsed.data.body.email
token = parsed.data.body.token
type = parsed.data.body.type
}
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
}
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
if (isTransactional) {
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
return NextResponse.json(
{
error: 'Cannot unsubscribe from transactional emails',
isTransactional: true,
message:
'Transactional emails cannot be unsubscribed from as they contain important account information.',
},
{ status: 400 }
)
}
let success = false
switch (type) {
case 'all':
success = await unsubscribeFromAll(email)
break
case 'marketing':
success = await updateEmailPreferences(email, { unsubscribeMarketing: true })
break
case 'updates':
success = await updateEmailPreferences(email, { unsubscribeUpdates: true })
break
case 'notifications':
success = await updateEmailPreferences(email, { unsubscribeNotifications: true })
break
}
if (!success) {
logger.error(`[${requestId}] Failed to update unsubscribe preferences for: ${email}`)
return NextResponse.json({ error: 'Failed to process unsubscribe request' }, { status: 500 })
}
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
return NextResponse.json(
{
success: true,
message: `Successfully unsubscribed from ${type} emails`,
email,
type,
emailType,
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Error processing unsubscribe POST request:`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,140 @@
/**
* @vitest-environment node
*/
import {
authMock,
authMockFns,
createMockRequest,
createSession,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/auth', () => authMock)
vi.mock('@/lib/billing/plan-helpers', () => ({
isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise',
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'],
hasPaidSubscriptionStatus: (status: string) => status === 'active' || status === 'past_due',
}))
import { POST } from '@/app/api/users/me/subscription/[id]/transfer/route'
function makeRequest(body: unknown, id = 'sub-1') {
return POST(
createMockRequest(
'POST',
body,
{},
`http://localhost/api/users/me/subscription/${id}/transfer`
),
{ params: Promise.resolve({ id }) }
)
}
describe('POST /api/users/me/subscription/[id]/transfer', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue(
createSession({
userId: 'user-1',
email: 'owner@example.com',
name: 'Owner',
})
)
})
it('rejects transfers for non-organization subscriptions', async () => {
dbChainMockFns.for.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'user-1', plan: 'pro', status: 'active' },
])
const response = await makeRequest({ organizationId: 'org-1' })
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
error: 'Only active Team or Enterprise subscriptions can be transferred to an organization.',
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('transfers an active organization subscription to an admin-owned organization', async () => {
dbChainMockFns.for
.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'owner' }]).mockResolvedValueOnce([])
const response = await makeRequest({ organizationId: 'org-1' })
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
message: 'Subscription transferred successfully',
})
expect(dbChainMockFns.update).toHaveBeenCalled()
expect(dbChainMockFns.set).toHaveBeenCalledWith({ referenceId: 'org-1' })
})
it('treats an already-transferred organization subscription as a successful no-op', async () => {
dbChainMockFns.for
.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'owner' }])
const response = await makeRequest({ organizationId: 'org-1' })
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
message: 'Subscription already belongs to this organization',
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('rejects the noop probe when the requester is not a member of the target organization', async () => {
dbChainMockFns.for
.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit.mockResolvedValueOnce([])
const response = await makeRequest({ organizationId: 'org-1' })
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({
error: 'Unauthorized - user is not admin of organization',
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('rejects the transfer when the target organization already has an active subscription', async () => {
dbChainMockFns.for
.mockResolvedValueOnce([
{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' },
])
.mockResolvedValueOnce([{ id: 'org-1' }])
dbChainMockFns.limit
.mockResolvedValueOnce([{ role: 'owner' }])
.mockResolvedValueOnce([{ id: 'existing-sub' }])
const response = await makeRequest({ organizationId: 'org-1' })
expect(response.status).toBe(409)
await expect(response.json()).resolves.toEqual({
error: 'Organization already has an active subscription',
})
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,167 @@
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 { toError } from '@sim/utils/errors'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { subscriptionTransferContract } from '@/lib/api/contracts/user'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
hasPaidSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('SubscriptionTransferAPI')
type TransferOutcome =
| { kind: 'error'; status: number; error: string }
| { kind: 'noop'; message: string }
| { kind: 'success'; message: string }
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized subscription transfer attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(subscriptionTransferContract, request, context)
if (!parsed.success) return parsed.response
const subscriptionId = parsed.data.params.id
const { organizationId } = parsed.data.body
const userId = session.user.id
logger.info('Processing subscription transfer', { subscriptionId, organizationId })
const outcome = await db.transaction(async (tx): Promise<TransferOutcome> => {
const [sub] = await tx
.select()
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.for('update')
if (!sub) {
return { kind: 'error', status: 404, error: 'Subscription not found' }
}
if (!isOrgPlan(sub.plan) || !hasPaidSubscriptionStatus(sub.status)) {
return {
kind: 'error',
status: 400,
error:
'Only active Team or Enterprise subscriptions can be transferred to an organization.',
}
}
const [org] = await tx
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.for('update')
if (!org) {
return { kind: 'error', status: 404, error: 'Organization not found' }
}
const [mem] = await tx
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
if (!mem || (mem.role !== 'owner' && mem.role !== 'admin')) {
return {
kind: 'error',
status: 403,
error: 'Unauthorized - user is not admin of organization',
}
}
if (sub.referenceId === organizationId) {
return { kind: 'noop', message: 'Subscription already belongs to this organization' }
}
if (sub.referenceId !== userId) {
return {
kind: 'error',
status: 403,
error: 'Unauthorized - subscription does not belong to user',
}
}
const [existingOrgSub] = await tx
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
if (existingOrgSub) {
return {
kind: 'error',
status: 409,
error: 'Organization already has an active subscription',
}
}
await tx
.update(subscription)
.set({ referenceId: organizationId })
.where(eq(subscription.id, subscriptionId))
return { kind: 'success', message: 'Subscription transferred successfully' }
})
if (outcome.kind === 'error') {
return NextResponse.json({ error: outcome.error }, { status: outcome.status })
}
if (outcome.kind === 'success') {
logger.info('Subscription transfer completed', {
subscriptionId,
organizationId,
userId,
})
recordAudit({
actorId: userId,
action: AuditAction.SUBSCRIPTION_TRANSFERRED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscriptionId,
description: `Subscription transferred to organization ${organizationId}`,
metadata: {
subscriptionId,
organizationId,
fromEntity: 'user',
toEntity: 'organization',
},
request,
})
captureServerEvent(userId, 'subscription_transferred', {
subscription_id: subscriptionId,
from_entity: 'user',
to_entity: 'organization',
})
}
return NextResponse.json({ success: true, message: outcome.message })
} catch (error) {
logger.error('Error transferring subscription', {
error: toError(error).message,
})
return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 })
}
}
)
@@ -0,0 +1,87 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse } from '@/app/api/workflows/utils'
const logger = createLogger('UsageLimitsAPI')
export const GET = withRouteHandler(async (request: NextRequest) => {
usageLimitsRequestSchema.parse({})
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return createErrorResponse('Authentication required', 401)
}
const authenticatedUserId = auth.userId
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
const rateLimiter = new RateLimiter()
const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual'
const [syncStatus, asyncStatus] = await Promise.all([
rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
false
),
rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
true
),
])
const [usageCheck, storageUsage, storageLimit] = await Promise.all([
checkServerSideUsageLimits(authenticatedUserId),
getUserStorageUsage(authenticatedUserId),
getUserStorageLimit(authenticatedUserId),
])
// Same computation as `limit` (one source, one tier) — the pair can never
// disagree under replication lag or mixed baseline/ledger tiers.
const currentPeriodCost = usageCheck.currentUsage
return NextResponse.json({
success: true,
rateLimit: {
sync: {
isLimited: syncStatus.remaining === 0,
requestsPerMinute: syncStatus.requestsPerMinute,
maxBurst: syncStatus.maxBurst,
remaining: syncStatus.remaining,
resetAt: syncStatus.resetAt,
},
async: {
isLimited: asyncStatus.remaining === 0,
requestsPerMinute: asyncStatus.requestsPerMinute,
maxBurst: asyncStatus.maxBurst,
remaining: asyncStatus.remaining,
resetAt: asyncStatus.resetAt,
},
authType: triggerType,
},
usage: {
currentPeriodCost,
limit: usageCheck.limit,
plan: userSubscription?.plan || 'free',
},
storage: {
usedBytes: storageUsage,
limitBytes: storageLimit,
percentUsed: storageLimit > 0 ? (storageUsage / storageLimit) * 100 : 0,
},
})
} catch (error) {
logger.error('Error checking usage limits:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to check usage limits'), 500)
}
})
@@ -0,0 +1,242 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apportionCredits } from '@/lib/billing/credits/conversion'
const { mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({
mockGetUserUsageLogs: vi.fn(),
mockGetUsageCreditsByLogId: vi.fn(),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getUserUsageLogs: mockGetUserUsageLogs,
getUsageCreditsByLogId: mockGetUsageCreditsByLogId,
}))
import { GET } from '@/app/api/users/me/usage-logs/export/route'
describe('GET /api/users/me/usage-logs/export', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockGetUsageCreditsByLogId.mockResolvedValue({})
})
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const response = await GET(createMockRequest('GET'))
expect(response.status).toBe(401)
})
it('returns a CSV with the header row and one line per log', async () => {
mockGetUserUsageLogs.mockResolvedValueOnce({
logs: [
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
category: 'model',
source: 'copilot',
description: 'claude-opus-4.8',
cost: 0.5,
},
],
summary: { totalCost: 0.5, bySource: { copilot: 0.5 } },
pagination: { hasMore: false },
})
mockGetUsageCreditsByLogId.mockResolvedValue(apportionCredits([{ key: 'log-1', dollars: 0.5 }]))
const response = await GET(createMockRequest('GET'))
const csv = await response.text()
const [header, row] = csv.split('\n')
expect(response.headers.get('Content-Type')).toBe('text/csv; charset=utf-8')
expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=')
expect(response.headers.get('X-Export-Truncated')).toBe('0')
expect(header).toBe('Date,Type,Credits')
expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100')
})
it('sets X-Export-Truncated when the safety cap is hit with more data remaining', async () => {
mockGetUserUsageLogs.mockResolvedValueOnce({
logs: Array.from({ length: 50000 }, (_, i) => ({
id: `log-${i}`,
createdAt: '2026-07-01T00:00:00.000Z',
source: 'copilot',
cost: 0.1,
})),
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: true, nextCursor: 'log-49999' },
})
const response = await GET(createMockRequest('GET'))
expect(response.headers.get('X-Export-Truncated')).toBe('1')
})
it('does not request the summary aggregate — the export never reads it', async () => {
mockGetUserUsageLogs.mockResolvedValueOnce({
logs: [],
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: false },
})
await GET(createMockRequest('GET'))
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ includeSummary: false })
)
})
it('names the specific workflow for workflow-sourced rows', async () => {
mockGetUserUsageLogs.mockResolvedValueOnce({
logs: [
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
category: 'fixed',
source: 'workflow',
description: 'execution_fee',
cost: 0.01,
workflowId: 'wf-1',
workflowName: 'ITSM_Prod_main',
},
],
summary: { totalCost: 0.01, bySource: { workflow: 0.01 } },
pagination: { hasMore: false },
})
const response = await GET(createMockRequest('GET'))
const csv = await response.text()
expect(csv).toContain('Workflow: ITSM_Prod_main')
})
it('quotes a Type field that contains a comma', async () => {
mockGetUserUsageLogs.mockResolvedValueOnce({
logs: [
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
category: 'fixed',
source: 'workflow',
description: 'execution_fee',
cost: 0.01,
workflowId: 'wf-1',
workflowName: 'Prod, main',
},
],
summary: { totalCost: 0.01, bySource: { workflow: 0.01 } },
pagination: { hasMore: false },
})
const response = await GET(createMockRequest('GET'))
const csv = await response.text()
expect(csv).toContain('"Workflow: Prod, main"')
})
it('paginates through getUserUsageLogs until hasMore is false', async () => {
mockGetUserUsageLogs
.mockResolvedValueOnce({
logs: [
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
category: 'model',
source: 'copilot',
description: 'claude-opus-4.8',
cost: 0.1,
},
],
summary: { totalCost: 0.2, bySource: { copilot: 0.2 } },
pagination: { hasMore: true, nextCursor: 'log-1' },
})
.mockResolvedValueOnce({
logs: [
{
id: 'log-2',
createdAt: '2026-06-30T00:00:00.000Z',
category: 'model',
source: 'copilot',
description: 'claude-opus-4.8',
cost: 0.1,
},
],
summary: { totalCost: 0.2, bySource: { copilot: 0.2 } },
pagination: { hasMore: false },
})
const response = await GET(createMockRequest('GET'))
const csv = await response.text()
expect(mockGetUserUsageLogs).toHaveBeenCalledTimes(2)
expect(mockGetUserUsageLogs).toHaveBeenNthCalledWith(
2,
'user-1',
expect.objectContaining({
cursor: 'log-1',
cursorCreatedAt: new Date('2026-07-01T00:00:00.000Z'),
})
)
expect(csv.split('\n')).toHaveLength(3)
})
it('apportions credits once over the whole filtered set, not per page', async () => {
mockGetUserUsageLogs
.mockResolvedValueOnce({
logs: [
{ id: 'log-1', createdAt: '2026-07-01T00:00:00.000Z', source: 'copilot', cost: 0.002 },
],
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: true, nextCursor: 'log-1' },
})
.mockResolvedValueOnce({
logs: [
{ id: 'log-2', createdAt: '2026-06-30T00:00:00.000Z', source: 'copilot', cost: 0.002 },
],
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: false },
})
mockGetUsageCreditsByLogId.mockResolvedValue(
apportionCredits([
{ key: 'log-1', dollars: 0.002 },
{ key: 'log-2', dollars: 0.002 },
])
)
await GET(createMockRequest('GET'))
expect(mockGetUsageCreditsByLogId).toHaveBeenCalledTimes(1)
})
it('stops at exactly the safety cap without an extra wasted page fetch', async () => {
mockGetUserUsageLogs.mockResolvedValueOnce({
logs: Array.from({ length: 50000 }, (_, i) => ({
id: `log-${i}`,
createdAt: '2026-07-01T00:00:00.000Z',
source: 'copilot',
cost: 0.1,
})),
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: true, nextCursor: 'log-49999' },
})
await GET(createMockRequest('GET'))
expect(mockGetUserUsageLogs).toHaveBeenCalledTimes(1)
})
it('rejects "custom" period without a startDate', async () => {
const response = await GET(
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=custom')
)
expect(response.status).toBe(400)
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,109 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { exportUsageLogsContract } from '@/lib/api/contracts/user'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
getUsageCreditsByLogId,
getUserUsageLogs,
type UsageLogSource,
} from '@/lib/billing/core/usage-log'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels'
const logger = createLogger('UsageLogsExportAPI')
/**
* Circuit breaker, not a UX boundary — a personal credit ledger is bounded by
* the user's own usage history and should never realistically approach this.
* Exists only to keep a pathological account (or a bug upstream) from paging
* forever; hitting it is worth alerting on, not a normal truncation case.
*/
const EXPORT_SAFETY_CAP = 50000
const EXPORT_PAGE_SIZE = 1000
const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits'])
/**
* Downloads every usage log matching the current filter as CSV — unlike the
* paginated list route, this fetches every matching row in one response
* rather than a single page, since a user's own credit ledger is bounded
* (unlike, say, a workspace's full execution history).
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(exportUsageLogsContract, request, {})
if (!parsed.success) return parsed.response
const { source, workspaceId, period, startDate, endDate } = parsed.data.query
const dateRange = resolveDateRange(period, startDate, endDate)
const filter = {
source: source as UsageLogSource | undefined,
workspaceId,
startDate: dateRange.startDate,
endDate: dateRange.endDate,
}
const rows: Awaited<ReturnType<typeof getUserUsageLogs>>['logs'] = []
let cursor: string | undefined
let cursorCreatedAt: Date | undefined
let truncated = false
while (rows.length < EXPORT_SAFETY_CAP) {
const page = await getUserUsageLogs(auth.userId, {
...filter,
limit: Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length),
cursor,
cursorCreatedAt,
includeSummary: false,
})
rows.push(...page.logs)
if (!page.pagination.hasMore) break
truncated = rows.length >= EXPORT_SAFETY_CAP
cursor = page.pagination.nextCursor
const lastRow = page.logs[page.logs.length - 1]
cursorCreatedAt = lastRow ? new Date(lastRow.createdAt) : undefined
}
const creditsByLogId = await getUsageCreditsByLogId(auth.userId, filter)
if (truncated) {
logger.error('Usage log export hit the safety cap — investigate this account', {
userId: auth.userId,
period,
cap: EXPORT_SAFETY_CAP,
})
}
const csvLines = rows.map((log) => {
const type =
log.source === 'workflow' && log.workflowName
? `Workflow: ${log.workflowName}`
: USAGE_LOG_SOURCE_LABELS[log.source]
return toCsvRow([
formatCsvValue(log.createdAt),
formatCsvValue(type),
formatCsvValue(creditsByLogId[log.id]),
])
})
const csv = [CSV_HEADER, ...csvLines].join('\n')
const filename = `credit-usage-${period}-${new Date().toISOString().slice(0, 10)}.csv`
logger.info('Exported usage logs', { userId: auth.userId, period, rowCount: rows.length })
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-cache',
'X-Export-Truncated': truncated ? '1' : '0',
},
})
})
@@ -0,0 +1,186 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apportionCredits } from '@/lib/billing/credits/conversion'
const { mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({
mockGetUserUsageLogs: vi.fn(),
mockGetUsageCreditsByLogId: vi.fn(),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getUserUsageLogs: mockGetUserUsageLogs,
getUsageCreditsByLogId: mockGetUsageCreditsByLogId,
}))
import { GET } from '@/app/api/users/me/usage-logs/route'
describe('GET /api/users/me/usage-logs', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockGetUserUsageLogs.mockResolvedValue({
logs: [
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
category: 'model',
source: 'workflow',
description: 'gpt-4o',
cost: 0.5,
},
],
summary: { totalCost: 0.5, bySource: { workflow: 0.5 } },
pagination: { hasMore: false },
})
mockGetUsageCreditsByLogId.mockResolvedValue(apportionCredits([{ key: 'log-1', dollars: 0.5 }]))
})
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const response = await GET(createMockRequest('GET'))
expect(response.status).toBe(401)
})
it('converts dollar costs to credits in the logs and summary', async () => {
const response = await GET(createMockRequest('GET'))
const body = await response.json()
expect(body.logs).toEqual([
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
source: 'workflow',
workflowName: null,
creditCost: 100,
dollarCost: 0.5,
},
])
expect(body.summary).toEqual({
totalCredits: 100,
bySourceCredits: { workflow: 100 },
})
})
it('passes through the workflow name for workflow-sourced rows', async () => {
mockGetUserUsageLogs.mockResolvedValue({
logs: [
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
category: 'fixed',
source: 'workflow',
description: 'execution_fee',
cost: 0.01,
workflowId: 'wf-1',
workflowName: 'ITSM_Prod_main',
},
],
summary: { totalCost: 0.01, bySource: { workflow: 0.01 } },
pagination: { hasMore: false },
})
mockGetUsageCreditsByLogId.mockResolvedValue(
apportionCredits([{ key: 'log-1', dollars: 0.01 }])
)
const response = await GET(createMockRequest('GET'))
const body = await response.json()
expect(body.logs[0].workflowName).toBe('ITSM_Prod_main')
})
it('rejects "custom" period without a startDate', async () => {
const response = await GET(
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=custom')
)
expect(response.status).toBe(400)
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
})
it('apportions row credits so they sum exactly to the page total, instead of rounding each row independently', async () => {
mockGetUserUsageLogs.mockResolvedValue({
logs: [
{ id: 'log-a', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', cost: 0.002 },
{ id: 'log-b', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', cost: 0.002 },
{ id: 'log-c', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', cost: 0.002 },
].map((log) => ({ ...log, category: 'model', description: 'gpt-4o' })),
summary: { totalCost: 0.006, bySource: { workflow: 0.006 } },
pagination: { hasMore: false },
})
mockGetUsageCreditsByLogId.mockResolvedValue(
apportionCredits([
{ key: 'log-a', dollars: 0.002 },
{ key: 'log-b', dollars: 0.002 },
{ key: 'log-c', dollars: 0.002 },
])
)
const response = await GET(createMockRequest('GET'))
const body = await response.json()
const rowCreditSum = body.logs.reduce(
(sum: number, log: { creditCost: number }) => sum + log.creditCost,
0
)
expect(rowCreditSum).toBe(body.summary.totalCredits)
})
it('rejects an invalid period', async () => {
const response = await GET(
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=1y')
)
expect(response.status).toBe(400)
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
})
it('skips the whole-filter credit apportionment scan when includeCredits=false', async () => {
await GET(
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/test?limit=1&includeCredits=false'
)
)
expect(mockGetUsageCreditsByLogId).not.toHaveBeenCalled()
})
it('defaults creditCost to 0 (not undefined) when credits were skipped', async () => {
const response = await GET(
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/test?limit=1&includeCredits=false'
)
)
const body = await response.json()
expect(body.logs[0].creditCost).toBe(0)
})
it('resolves the start date from the period filter', async () => {
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=7d'))
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ startDate: expect.any(Date) })
)
})
it('omits the start date for the "all" period', async () => {
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=all'))
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ startDate: undefined })
)
})
})
@@ -0,0 +1,81 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getUsageLogsContract } from '@/lib/api/contracts/user'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
getUsageCreditsByLogId,
getUserUsageLogs,
type UsageLogSource,
} from '@/lib/billing/core/usage-log'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
const logger = createLogger('UsageLogsAPI')
/**
* Lists the authenticated user's credit-consuming usage events (model, tool,
* and fixed charges), converted to credits for display in Billing settings.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(getUsageLogsContract, request, {})
if (!parsed.success) return parsed.response
const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } =
parsed.data.query
const dateRange = resolveDateRange(period, startDate, endDate)
const filter = {
source: source as UsageLogSource | undefined,
workspaceId,
startDate: dateRange.startDate,
endDate: dateRange.endDate,
}
const [result, creditsByLogId] = await Promise.all([
getUserUsageLogs(auth.userId, { ...filter, limit, cursor }),
includeCredits
? getUsageCreditsByLogId(auth.userId, filter)
: Promise.resolve<Record<string, number>>({}),
])
const logs = result.logs.map((log) => ({
id: log.id,
createdAt: log.createdAt,
source: log.source,
workflowName: log.workflowName ?? null,
creditCost: creditsByLogId[log.id] ?? 0,
dollarCost: log.cost,
}))
const bySourceCredits = Object.fromEntries(
Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [
sourceKey,
dollarsToCredits(cost),
])
)
logger.debug('Retrieved usage logs', {
userId: auth.userId,
source,
period,
logCount: logs.length,
hasMore: result.pagination.hasMore,
})
return NextResponse.json({
success: true,
logs,
summary: {
totalCredits: dollarsToCredits(result.summary.totalCost),
bySourceCredits,
},
pagination: result.pagination,
})
})
@@ -0,0 +1,41 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
describe('resolveDateRange', () => {
it('throws when period is "custom" without a startDate', () => {
expect(() => resolveDateRange('custom', undefined, undefined)).toThrow(
'startDate is required when period is "custom"'
)
})
it('defaults endDate to now when omitted for a custom period', () => {
const range = resolveDateRange('custom', '2026-01-01T00:00', undefined)
expect(range.startDate).toEqual(new Date('2026-01-01T00:00'))
expect(range.endDate.getTime()).toBeCloseTo(Date.now(), -3)
})
it('uses both bounds when provided for a custom period', () => {
const range = resolveDateRange('custom', '2026-01-01T00:00', '2026-01-31T00:00')
expect(range.startDate).toEqual(new Date('2026-01-01T00:00'))
expect(range.endDate).toEqual(new Date('2026-01-31T00:00'))
})
it('omits startDate for the "all" period', () => {
const range = resolveDateRange('all', undefined, undefined)
expect(range.startDate).toBeUndefined()
})
it('resolves a startDate N days back for a preset period', () => {
const range = resolveDateRange('7d', undefined, undefined)
const expected = new Date()
expected.setDate(expected.getDate() - 7)
expect(range.startDate?.toDateString()).toBe(expected.toDateString())
})
})
@@ -0,0 +1,28 @@
import type { UsageLogPeriod } from '@/lib/api/contracts/user'
const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 }
interface ResolvedDateRange {
startDate: Date | undefined
endDate: Date
}
/** Shared by the list and export routes so their date-filtering can never drift. */
export function resolveDateRange(
period: UsageLogPeriod,
customStartDate: string | undefined,
customEndDate: string | undefined
): ResolvedDateRange {
if (period === 'custom') {
if (!customStartDate) throw new Error('startDate is required when period is "custom"')
return {
startDate: new Date(customStartDate),
endDate: customEndDate ? new Date(customEndDate) : new Date(),
}
}
if (period === 'all') return { startDate: undefined, endDate: new Date() }
const startDate = new Date()
startDate.setDate(startDate.getDate() - PERIOD_TO_DAYS[period])
return { startDate, endDate: new Date() }
}
@@ -0,0 +1,20 @@
import type { UsageLogSource } from '@/lib/api/contracts/user'
/**
* Humanized labels for `usage_log.source`, shared by the Credit usage page's
* row rendering and the CSV export so both read identically. Avoids the
* internal "copilot" / "mothership" naming — the agent is always "Sim", the
* surface is "Chat". Pure data, no server-only imports, so it's safe from
* both the client page and the export route.
*/
export const USAGE_LOG_SOURCE_LABELS: Record<UsageLogSource, string> = {
workflow: 'Workflow',
wand: 'Wand',
copilot: 'Chat',
'workspace-chat': 'Chat',
mcp_copilot: 'Chat (MCP)',
mothership_block: 'Agent block',
'knowledge-base': 'Knowledge Base',
'voice-input': 'Voice input',
enrichment: 'Enrichment',
}