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,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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
Reference in New Issue
Block a user