diff --git a/apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts b/apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts index c35bc2d..62c2e0c 100644 --- a/apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/collections/[collectionId]/route.ts @@ -2,6 +2,7 @@ import { prisma } from '@tpmjs/db'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -24,10 +25,10 @@ export async function DELETE(_request: NextRequest, context: RouteContext): Prom const { id, collectionId } = await context.params; - // Check agent ownership + // Check agent ownership and get agent name for activity log const agent = await prisma.agent.findUnique({ where: { id }, - select: { userId: true }, + select: { userId: true, name: true }, }); if (!agent) { return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); @@ -36,11 +37,28 @@ export async function DELETE(_request: NextRequest, context: RouteContext): Prom return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 }); } + // Get collection name for activity log + const collection = await prisma.collection.findUnique({ + where: { id: collectionId }, + select: { name: true }, + }); + // Delete the agent-collection link await prisma.agentCollection.deleteMany({ where: { agentId: id, collectionId }, }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'AGENT_COLLECTION_REMOVED', + targetName: agent.name, + targetType: 'agent', + agentId: id, + collectionId, + metadata: collection ? { collectionName: collection.name } : undefined, + }); + return NextResponse.json({ success: true, data: { removed: true }, diff --git a/apps/web/src/app/api/agents/[id]/collections/route.ts b/apps/web/src/app/api/agents/[id]/collections/route.ts index ea9e67a..4acd1ce 100644 --- a/apps/web/src/app/api/agents/[id]/collections/route.ts +++ b/apps/web/src/app/api/agents/[id]/collections/route.ts @@ -3,6 +3,7 @@ import { AGENT_LIMITS, AddCollectionToAgentSchema } from '@tpmjs/types/agent'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -98,7 +99,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise // Check agent ownership const agent = await prisma.agent.findUnique({ where: { id }, - select: { userId: true, _count: { select: { collections: true } } }, + select: { userId: true, name: true, _count: { select: { collections: true } } }, }); if (!agent) { return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); @@ -176,6 +177,17 @@ export async function POST(request: NextRequest, context: RouteContext): Promise }, }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'AGENT_COLLECTION_ADDED', + targetName: agent.name, + targetType: 'agent', + agentId: id, + collectionId: agentCollection.collectionId, + metadata: { collectionName: agentCollection.collection.name }, + }); + return NextResponse.json( { success: true, diff --git a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts index 8208607..3529b13 100644 --- a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts @@ -12,6 +12,16 @@ import type { AIProvider } from '@tpmjs/types/agent'; import { SendMessageSchema } from '@tpmjs/types/agent'; import type { LanguageModel, ModelMessage } from 'ai'; import { type NextRequest, NextResponse } from 'next/server'; +import { type RateLimitConfig, checkRateLimitDistributed } from '~/lib/rate-limit'; + +/** + * Rate limit for chat messages: 30 requests per minute + * This is stricter than default because chat involves expensive LLM calls + */ +const CHAT_RATE_LIMIT: RateLimitConfig = { + limit: 30, + windowSeconds: 60, +}; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -61,6 +71,12 @@ async function getProviderModel( * Accepts either agent id (cuid) or uid */ export async function POST(request: NextRequest, context: RouteContext): Promise { + // Check rate limit first to prevent expensive LLM calls (uses distributed KV when available) + const rateLimitResponse = await checkRateLimitDistributed(request, CHAT_RATE_LIMIT); + if (rateLimitResponse) { + return rateLimitResponse; + } + const { id: idOrUid, conversationId } = await context.params; try { diff --git a/apps/web/src/app/api/agents/[id]/like/route.ts b/apps/web/src/app/api/agents/[id]/like/route.ts index a542ef0..8cac433 100644 --- a/apps/web/src/app/api/agents/[id]/like/route.ts +++ b/apps/web/src/app/api/agents/[id]/like/route.ts @@ -1,6 +1,7 @@ import { prisma } from '@tpmjs/db'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -117,9 +118,10 @@ export async function POST( ); } - // Check agent exists + // Check agent exists and get name for activity log const agent = await prisma.agent.findUnique({ where: { id }, + select: { id: true, name: true, likeCount: true }, }); if (!agent) { @@ -168,6 +170,15 @@ export async function POST( }), ]); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'AGENT_LIKED', + targetName: agent.name, + targetType: 'agent', + agentId: id, + }); + return NextResponse.json({ success: true, data: { @@ -216,7 +227,7 @@ export async function DELETE( ); } - // Check if liked + // Check if liked and get agent info for activity log const existingLike = await prisma.agentLike.findUnique({ where: { userId_agentId: { @@ -242,6 +253,12 @@ export async function DELETE( }); } + // Get agent name for activity log + const agent = await prisma.agent.findUnique({ + where: { id }, + select: { name: true }, + }); + // Delete like and decrement count atomically const [, updatedAgent] = await prisma.$transaction([ prisma.agentLike.delete({ @@ -258,6 +275,17 @@ export async function DELETE( }), ]); + // Log activity (fire-and-forget) + if (agent) { + logActivity({ + userId: session.user.id, + type: 'AGENT_UNLIKED', + targetName: agent.name, + targetType: 'agent', + agentId: id, + }); + } + return NextResponse.json({ success: true, data: { diff --git a/apps/web/src/app/api/agents/[id]/route.ts b/apps/web/src/app/api/agents/[id]/route.ts index 4d60506..5c94cce 100644 --- a/apps/web/src/app/api/agents/[id]/route.ts +++ b/apps/web/src/app/api/agents/[id]/route.ts @@ -1,8 +1,18 @@ import { prisma } from '@tpmjs/db'; import { UpdateAgentSchema } from '@tpmjs/types/agent'; import { headers } from 'next/headers'; -import { type NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { logActivity } from '~/lib/activity'; +import { + apiConflict, + apiForbidden, + apiInternalError, + apiNotFound, + apiSuccess, + apiUnauthorized, + apiValidationError, +} from '~/lib/api-response'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -16,7 +26,9 @@ type RouteContext = { * GET /api/agents/[id] * Get a single agent's details */ -export async function GET(_request: NextRequest, context: RouteContext): Promise { +export async function GET(_request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + try { const session = await auth.api.getSession({ headers: await headers() }); const { id } = await context.params; @@ -35,6 +47,7 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise }, }, orderBy: { position: 'asc' }, + take: 50, // Limit to prevent excessive data fetch }, tools: { include: { @@ -53,6 +66,7 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise }, }, orderBy: { position: 'asc' }, + take: 100, // Limit to prevent excessive data fetch }, _count: { select: { @@ -65,18 +79,17 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise }); if (!agent) { - return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); + return apiNotFound('Agent', requestId); } // Check access - owner or public const isOwner = session?.user?.id === agent.userId; if (!isOwner && !agent.isPublic) { - return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 }); + return apiForbidden('Access denied', requestId); } - return NextResponse.json({ - success: true, - data: { + return apiSuccess( + { ...agent, isOwner, toolCount: agent._count.tools, @@ -101,10 +114,11 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise })), _count: undefined, }, - }); + { requestId } + ); } catch (error) { console.error('Failed to get agent:', error); - return NextResponse.json({ success: false, error: 'Failed to get agent' }, { status: 500 }); + return apiInternalError('Failed to get agent', requestId); } } @@ -112,20 +126,23 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise * PATCH /api/agents/[id] * Update an agent's configuration */ -export async function PATCH(request: NextRequest, context: RouteContext): Promise { +export async function PATCH(request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + try { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + return apiUnauthorized('Authentication required', requestId); } const { id } = await context.params; const body = await request.json(); const parsed = UpdateAgentSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { success: false, error: 'Invalid request', details: parsed.error.flatten() }, - { status: 400 } + return apiValidationError( + 'Invalid request body', + { errors: parsed.error.flatten().fieldErrors }, + requestId ); } @@ -135,10 +152,10 @@ export async function PATCH(request: NextRequest, context: RouteContext): Promis select: { userId: true }, }); if (!existing) { - return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); + return apiNotFound('Agent', requestId); } if (existing.userId !== session.user.id) { - return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 }); + return apiForbidden('Access denied', requestId); } // Check UID uniqueness if being changed @@ -147,7 +164,7 @@ export async function PATCH(request: NextRequest, context: RouteContext): Promis where: { uid: parsed.data.uid, id: { not: id } }, }); if (existingByUid) { - return NextResponse.json({ success: false, error: 'UID already in use' }, { status: 409 }); + return apiConflict('UID already in use', requestId); } } @@ -157,10 +174,7 @@ export async function PATCH(request: NextRequest, context: RouteContext): Promis where: { userId: session.user.id, name: parsed.data.name, id: { not: id } }, }); if (existingByName) { - return NextResponse.json( - { success: false, error: 'An agent with this name already exists' }, - { status: 409 } - ); + return apiConflict('An agent with this name already exists', requestId); } } @@ -190,18 +204,27 @@ export async function PATCH(request: NextRequest, context: RouteContext): Promis }, }); - return NextResponse.json({ - success: true, - data: { + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'AGENT_UPDATED', + targetName: agent.name, + targetType: 'agent', + agentId: agent.id, + }); + + return apiSuccess( + { ...agent, toolCount: agent._count.tools, collectionCount: agent._count.collections, _count: undefined, }, - }); + { requestId } + ); } catch (error) { console.error('Failed to update agent:', error); - return NextResponse.json({ success: false, error: 'Failed to update agent' }, { status: 500 }); + return apiInternalError('Failed to update agent', requestId); } } @@ -209,35 +232,42 @@ export async function PATCH(request: NextRequest, context: RouteContext): Promis * DELETE /api/agents/[id] * Delete an agent and all its conversations */ -export async function DELETE(_request: NextRequest, context: RouteContext): Promise { +export async function DELETE(_request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + try { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + return apiUnauthorized('Authentication required', requestId); } const { id } = await context.params; - // Check ownership + // Check ownership and get name for activity log const existing = await prisma.agent.findUnique({ where: { id }, - select: { userId: true }, + select: { userId: true, name: true }, }); if (!existing) { - return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); + return apiNotFound('Agent', requestId); } if (existing.userId !== session.user.id) { - return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 }); + return apiForbidden('Access denied', requestId); } await prisma.agent.delete({ where: { id } }); - return NextResponse.json({ - success: true, - data: { deleted: true }, + // Log activity (fire-and-forget) - note: agentId is not included since agent is deleted + logActivity({ + userId: session.user.id, + type: 'AGENT_DELETED', + targetName: existing.name, + targetType: 'agent', }); + + return apiSuccess({ deleted: true }, { requestId }); } catch (error) { console.error('Failed to delete agent:', error); - return NextResponse.json({ success: false, error: 'Failed to delete agent' }, { status: 500 }); + return apiInternalError('Failed to delete agent', requestId); } } diff --git a/apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts b/apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts index e3e04fa..8a541f2 100644 --- a/apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/tools/[toolId]/route.ts @@ -2,6 +2,7 @@ import { prisma } from '@tpmjs/db'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -24,10 +25,10 @@ export async function DELETE(_request: NextRequest, context: RouteContext): Prom const { id, toolId } = await context.params; - // Check agent ownership + // Check agent ownership and get agent name for activity log const agent = await prisma.agent.findUnique({ where: { id }, - select: { userId: true }, + select: { userId: true, name: true }, }); if (!agent) { return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); @@ -36,11 +37,28 @@ export async function DELETE(_request: NextRequest, context: RouteContext): Prom return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 }); } + // Get tool name for activity log + const tool = await prisma.tool.findUnique({ + where: { id: toolId }, + select: { name: true }, + }); + // Delete the agent-tool link await prisma.agentTool.deleteMany({ where: { agentId: id, toolId }, }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'AGENT_TOOL_REMOVED', + targetName: agent.name, + targetType: 'agent', + agentId: id, + toolId, + metadata: tool ? { toolName: tool.name } : undefined, + }); + return NextResponse.json({ success: true, data: { removed: true }, diff --git a/apps/web/src/app/api/agents/[id]/tools/route.ts b/apps/web/src/app/api/agents/[id]/tools/route.ts index f27455c..07fde8f 100644 --- a/apps/web/src/app/api/agents/[id]/tools/route.ts +++ b/apps/web/src/app/api/agents/[id]/tools/route.ts @@ -3,6 +3,7 @@ import { AGENT_LIMITS, AddToolToAgentSchema } from '@tpmjs/types/agent'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -102,7 +103,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise // Check agent ownership const agent = await prisma.agent.findUnique({ where: { id }, - select: { userId: true, _count: { select: { tools: true } } }, + select: { userId: true, name: true, _count: { select: { tools: true } } }, }); if (!agent) { return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); @@ -171,6 +172,17 @@ export async function POST(request: NextRequest, context: RouteContext): Promise }, }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'AGENT_TOOL_ADDED', + targetName: agent.name, + targetType: 'agent', + agentId: id, + toolId: agentTool.toolId, + metadata: { toolName: agentTool.tool.name }, + }); + return NextResponse.json( { success: true, diff --git a/apps/web/src/app/api/agents/route.ts b/apps/web/src/app/api/agents/route.ts index 7100849..52fbafa 100644 --- a/apps/web/src/app/api/agents/route.ts +++ b/apps/web/src/app/api/agents/route.ts @@ -3,6 +3,7 @@ import { AGENT_LIMITS, CreateAgentSchema } from '@tpmjs/types/agent'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -221,6 +222,15 @@ export async function POST(request: NextRequest): Promise { return newAgent; }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'AGENT_CREATED', + targetName: agent.name, + targetType: 'agent', + agentId: agent.id, + }); + return NextResponse.json( { success: true, diff --git a/apps/web/src/app/api/collections/[id]/like/route.ts b/apps/web/src/app/api/collections/[id]/like/route.ts index 72053f9..7cdc993 100644 --- a/apps/web/src/app/api/collections/[id]/like/route.ts +++ b/apps/web/src/app/api/collections/[id]/like/route.ts @@ -1,6 +1,7 @@ import { prisma } from '@tpmjs/db'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -168,6 +169,15 @@ export async function POST( }), ]); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'COLLECTION_LIKED', + targetName: collection.name, + targetType: 'collection', + collectionId: id, + }); + return NextResponse.json({ success: true, data: { @@ -242,6 +252,12 @@ export async function DELETE( }); } + // Get collection name for activity log + const collection = await prisma.collection.findUnique({ + where: { id }, + select: { name: true }, + }); + // Delete like and decrement count atomically const [, updatedCollection] = await prisma.$transaction([ prisma.collectionLike.delete({ @@ -258,6 +274,17 @@ export async function DELETE( }), ]); + // Log activity (fire-and-forget) + if (collection) { + logActivity({ + userId: session.user.id, + type: 'COLLECTION_UNLIKED', + targetName: collection.name, + targetType: 'collection', + collectionId: id, + }); + } + return NextResponse.json({ success: true, data: { diff --git a/apps/web/src/app/api/collections/[id]/route.ts b/apps/web/src/app/api/collections/[id]/route.ts index 093a7ca..8817893 100644 --- a/apps/web/src/app/api/collections/[id]/route.ts +++ b/apps/web/src/app/api/collections/[id]/route.ts @@ -2,6 +2,7 @@ import { prisma } from '@tpmjs/db'; import { UpdateCollectionSchema } from '@tpmjs/types/collection'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -32,14 +33,26 @@ interface RouteContext { /** * GET /api/collections/[id] * Get a single collection with its tools + * + * Query params: + * - toolsLimit: Max tools to return (default: 50, max: 100) + * - toolsOffset: Offset for tools pagination (default: 0) */ export async function GET( - _request: NextRequest, + request: NextRequest, context: RouteContext ): Promise> { const requestId = crypto.randomUUID(); const { id } = await context.params; + // Parse pagination params for tools + const { searchParams } = new URL(request.url); + const toolsLimit = Math.min( + Math.max(Number.parseInt(searchParams.get('toolsLimit') || '50', 10), 1), + 100 + ); + const toolsOffset = Math.max(Number.parseInt(searchParams.get('toolsOffset') || '0', 10), 0); + try { // Check authentication const session = await auth.api.getSession({ @@ -57,7 +70,7 @@ export async function GET( ); } - // Fetch collection with tools + // Fetch collection with paginated tools const collection = await prisma.collection.findUnique({ where: { id }, include: { @@ -76,6 +89,8 @@ export async function GET( }, }, orderBy: { position: 'asc' }, + take: toolsLimit + 1, // Fetch one extra to check hasMore + skip: toolsOffset, }, _count: { select: { tools: true } }, }, @@ -104,6 +119,10 @@ export async function GET( ); } + // Check if there are more tools + const hasMoreTools = collection.tools.length > toolsLimit; + const paginatedTools = hasMoreTools ? collection.tools.slice(0, toolsLimit) : collection.tools; + return NextResponse.json({ success: true, data: { @@ -115,7 +134,7 @@ export async function GET( createdAt: collection.createdAt, updatedAt: collection.updatedAt, isOwner: collection.userId === session.user.id, - tools: collection.tools.map((ct) => ({ + tools: paginatedTools.map((ct) => ({ id: ct.id, toolId: ct.toolId, position: ct.position, @@ -130,6 +149,12 @@ export async function GET( })), }, meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + pagination: { + toolsLimit, + toolsOffset, + toolsReturned: paginatedTools.length, + hasMoreTools, + }, }); } catch (error) { console.error('[API Error] GET /api/collections/[id]:', error); @@ -258,6 +283,15 @@ export async function PATCH( }, }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'COLLECTION_UPDATED', + targetName: collection.name, + targetType: 'collection', + collectionId: collection.id, + }); + return NextResponse.json({ success: true, data: { @@ -339,11 +373,22 @@ export async function DELETE( ); } + // Store name for activity log before deletion + const collectionName = collection.name; + // Delete collection (cascade will delete CollectionTools) await prisma.collection.delete({ where: { id }, }); + // Log activity (fire-and-forget) - note: collectionId not included since it's deleted + logActivity({ + userId: session.user.id, + type: 'COLLECTION_DELETED', + targetName: collectionName, + targetType: 'collection', + }); + return NextResponse.json({ success: true, data: { deleted: true }, diff --git a/apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts b/apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts index 7d59687..ee682ab 100644 --- a/apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts +++ b/apps/web/src/app/api/collections/[id]/tools/[toolId]/route.ts @@ -1,6 +1,7 @@ import { prisma } from '@tpmjs/db'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -83,7 +84,7 @@ export async function DELETE( ); } - // Find the collection-tool entry + // Find the collection-tool entry with tool info for activity log const collectionTool = await prisma.collectionTool.findUnique({ where: { collectionId_toolId: { @@ -91,6 +92,11 @@ export async function DELETE( toolId, }, }, + include: { + tool: { + select: { name: true }, + }, + }, }); if (!collectionTool) { @@ -109,6 +115,17 @@ export async function DELETE( where: { id: collectionTool.id }, }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'COLLECTION_TOOL_REMOVED', + targetName: collection.name, + targetType: 'collection', + collectionId, + toolId, + metadata: { toolName: collectionTool.tool.name }, + }); + return NextResponse.json({ success: true, data: { deleted: true }, diff --git a/apps/web/src/app/api/collections/[id]/tools/route.ts b/apps/web/src/app/api/collections/[id]/tools/route.ts index 270a03c..402ba11 100644 --- a/apps/web/src/app/api/collections/[id]/tools/route.ts +++ b/apps/web/src/app/api/collections/[id]/tools/route.ts @@ -2,6 +2,7 @@ import { prisma } from '@tpmjs/db'; import { AddToolToCollectionSchema, COLLECTION_LIMITS } from '@tpmjs/types/collection'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -184,6 +185,17 @@ export async function POST( }, }); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'COLLECTION_TOOL_ADDED', + targetName: collection.name, + targetType: 'collection', + collectionId, + toolId, + metadata: { toolName: tool.name }, + }); + return NextResponse.json( { success: true, diff --git a/apps/web/src/app/api/collections/route.ts b/apps/web/src/app/api/collections/route.ts index d35dd0a..9e7a54c 100644 --- a/apps/web/src/app/api/collections/route.ts +++ b/apps/web/src/app/api/collections/route.ts @@ -2,6 +2,7 @@ import { prisma } from '@tpmjs/db'; import { COLLECTION_LIMITS, CreateCollectionSchema } from '@tpmjs/types/collection'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -222,6 +223,15 @@ export async function POST(request: NextRequest): Promise + */ +export async function POST(request: NextRequest) { + // Verify cron secret for security + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (env.CRON_SECRET && token !== env.CRON_SECRET) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + const startTime = Date.now(); + + try { + // Calculate the cutoff date (90 days ago) + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 90); + + // Delete activities older than 90 days + const result = await prisma.userActivity.deleteMany({ + where: { + createdAt: { + lt: cutoffDate, + }, + }, + }); + + const durationMs = Date.now() - startTime; + + // Log the cleanup to syncLog + await prisma.syncLog.create({ + data: { + source: 'cleanup-activity', + status: 'success', + processed: result.count, + skipped: 0, + errors: 0, + message: `Deleted ${result.count} activities older than 90 days`, + metadata: { + durationMs, + cutoffDate: cutoffDate.toISOString(), + }, + }, + }); + + // Update checkpoint with last run timestamp + await prisma.syncCheckpoint.upsert({ + where: { source: 'cleanup-activity' }, + create: { + source: 'cleanup-activity', + checkpoint: { + lastRun: new Date().toISOString(), + deletedCount: result.count, + }, + }, + update: { + checkpoint: { + lastRun: new Date().toISOString(), + deletedCount: result.count, + }, + }, + }); + + return NextResponse.json({ + success: true, + data: { + deleted: result.count, + cutoffDate: cutoffDate.toISOString(), + durationMs, + }, + }); + } catch (error) { + const durationMs = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Log the error to syncLog + await prisma.syncLog + .create({ + data: { + source: 'cleanup-activity', + status: 'error', + processed: 0, + skipped: 0, + errors: 1, + message: errorMessage, + metadata: { durationMs }, + }, + }) + .catch(console.error); + + console.error('[Cleanup Activity Error]', error); + + return NextResponse.json( + { + success: false, + error: errorMessage, + data: { durationMs }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/[id]/like/route.ts b/apps/web/src/app/api/tools/[id]/like/route.ts index c9717a3..f4502fd 100644 --- a/apps/web/src/app/api/tools/[id]/like/route.ts +++ b/apps/web/src/app/api/tools/[id]/like/route.ts @@ -1,6 +1,7 @@ import { prisma } from '@tpmjs/db'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; +import { logActivity } from '~/lib/activity'; import { auth } from '~/lib/auth'; export const runtime = 'nodejs'; @@ -168,6 +169,15 @@ export async function POST( }), ]); + // Log activity (fire-and-forget) + logActivity({ + userId: session.user.id, + type: 'TOOL_LIKED', + targetName: tool.name, + targetType: 'tool', + toolId: id, + }); + return NextResponse.json({ success: true, data: { @@ -242,6 +252,12 @@ export async function DELETE( }); } + // Get tool name for activity log + const tool = await prisma.tool.findUnique({ + where: { id }, + select: { name: true }, + }); + // Delete like and decrement count atomically const [, updatedTool] = await prisma.$transaction([ prisma.toolLike.delete({ @@ -258,6 +274,17 @@ export async function DELETE( }), ]); + // Log activity (fire-and-forget) + if (tool) { + logActivity({ + userId: session.user.id, + type: 'TOOL_UNLIKED', + targetName: tool.name, + targetType: 'tool', + toolId: id, + }); + } + return NextResponse.json({ success: true, data: { diff --git a/apps/web/src/app/api/tools/extract-schema/route.ts b/apps/web/src/app/api/tools/extract-schema/route.ts index cc0a439..ee408d5 100644 --- a/apps/web/src/app/api/tools/extract-schema/route.ts +++ b/apps/web/src/app/api/tools/extract-schema/route.ts @@ -53,19 +53,26 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Tool not found' }, { status: 404 }); } - // Rate limit: 1 minute cooldown - if (tool.schemaExtractedAt) { - const timeSinceLastExtraction = Date.now() - tool.schemaExtractedAt.getTime(); - const cooldownMs = 60000; // 1 minute + // Rate limiting based on last attempt + // - If last attempt succeeded: 1 hour cooldown (re-extraction rarely needed) + // - If last attempt failed: 1 minute cooldown (allow retry) + // - If no previous attempt: no cooldown + if (tool.schemaExtractionAttemptAt) { + const timeSinceLastAttempt = Date.now() - tool.schemaExtractionAttemptAt.getTime(); + const lastAttemptFailed = !!tool.schemaExtractionError; + const cooldownMs = lastAttemptFailed ? 60_000 : 3600_000; // 1 min if failed, 1 hour if succeeded - if (timeSinceLastExtraction < cooldownMs) { - const retryAfter = Math.ceil((cooldownMs - timeSinceLastExtraction) / 1000); + if (timeSinceLastAttempt < cooldownMs) { + const retryAfter = Math.ceil((cooldownMs - timeSinceLastAttempt) / 1000); return NextResponse.json( { success: false, error: 'Rate limited', - message: `Please wait ${retryAfter} seconds before trying again`, + message: lastAttemptFailed + ? `Please wait ${retryAfter} seconds before retrying failed extraction` + : `Schema was recently extracted. Please wait ${Math.ceil(retryAfter / 60)} minutes before re-extracting`, retryAfter, + lastAttemptFailed, }, { status: 429 } ); @@ -88,6 +95,7 @@ export async function POST(request: NextRequest) { if (schemaResult.success) { // Update tool with extracted schema + const now = new Date(); const updatedTool = await prisma.tool.update({ where: { id: tool.id }, data: { @@ -97,7 +105,9 @@ export async function POST(request: NextRequest) { // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any, schemaSource: 'extracted', - schemaExtractedAt: new Date(), + schemaExtractedAt: now, + schemaExtractionAttemptAt: now, // Track attempt for rate limiting + schemaExtractionError: null, // Clear any previous error on success }, select: { id: true, @@ -130,11 +140,13 @@ export async function POST(request: NextRequest) { error: schemaResult.error, }); - // Update tool to mark extraction attempt + // Update tool to mark extraction attempt and store error (allows retry after 1 min cooldown) await prisma.tool.update({ where: { id: tool.id }, data: { - schemaExtractedAt: new Date(), // Update timestamp even on failure for rate limiting + schemaExtractionAttemptAt: new Date(), // Track attempt for rate limiting + schemaExtractionError: schemaResult.error || 'Unknown extraction error', // Store error to enable retry + // Note: schemaExtractedAt is NOT updated - it only tracks successful extractions }, }); @@ -143,6 +155,7 @@ export async function POST(request: NextRequest) { error: 'Schema extraction failed', message: schemaResult.error, schemaSource: tool.schemaSource, + canRetryAfter: 60, // Inform client they can retry after 1 minute }); } catch (error) { console.error('[Extract Schema] Error:', error); diff --git a/apps/web/src/app/api/tools/search/route.ts b/apps/web/src/app/api/tools/search/route.ts index 42ae698..8915fb1 100644 --- a/apps/web/src/app/api/tools/search/route.ts +++ b/apps/web/src/app/api/tools/search/route.ts @@ -87,21 +87,59 @@ export async function GET(request: NextRequest) { const limit = Math.min(Number.parseInt(searchParams.get('limit') || '10'), 50); // Get recent messages for context (passed as JSON in 'messages' param) + // Wrap in try-catch to handle malformed JSON gracefully const messagesParam = searchParams.get('messages'); - const recentMessages = messagesParam ? JSON.parse(messagesParam) : []; + let recentMessages: string[] = []; + if (messagesParam) { + try { + const parsed = JSON.parse(messagesParam); + if (Array.isArray(parsed)) { + recentMessages = parsed.filter((m): m is string => typeof m === 'string'); + } + } catch { + console.warn('[SEARCH API] Failed to parse messages param, ignoring'); + } + } console.log( `🔎 [SEARCH API] Query: "${query}", Category: ${category}, Limit: ${limit}, Messages: ${recentMessages.length}` ); - // Fetch all tools with package info + // Extract search tokens for database-level pre-filtering + const searchTokens = tokenize(query).filter((t) => t.length >= 2); + const hasSearchQuery = searchTokens.length > 0; + + // Build database filter - pre-filter at DB level to reduce in-memory processing + // Use OR conditions to find tools that match ANY search token + const dbFilter = { + ...(category && { package: { category } }), + ...(hasSearchQuery && { + OR: [ + // Match tool name + { name: { contains: query, mode: 'insensitive' as const } }, + // Match tool description + { description: { contains: query, mode: 'insensitive' as const } }, + // Match package name + { package: { npmPackageName: { contains: query, mode: 'insensitive' as const } } }, + // Match package description + { package: { npmDescription: { contains: query, mode: 'insensitive' as const } } }, + // Also try individual tokens for partial matches + ...searchTokens + .slice(0, 3) + .flatMap((token) => [ + { name: { contains: token, mode: 'insensitive' as const } }, + { description: { contains: token, mode: 'insensitive' as const } }, + ]), + ], + }), + }; + + // Fetch filtered tools with package info (max 500 for BM25 scoring) const tools = await prisma.tool.findMany({ include: { package: true }, - where: category - ? { - package: { category }, - } - : undefined, + where: dbFilter, + take: hasSearchQuery ? 500 : 100, // Limit results for performance + orderBy: hasSearchQuery ? undefined : { qualityScore: 'desc' }, }); console.log(`📊 [SEARCH API] Found ${tools.length} tools in database`); diff --git a/apps/web/src/app/api/user/activity/route.ts b/apps/web/src/app/api/user/activity/route.ts new file mode 100644 index 0000000..c8b2b26 --- /dev/null +++ b/apps/web/src/app/api/user/activity/route.ts @@ -0,0 +1,109 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const API_VERSION = '1.0.0'; + +interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + meta: { + version: string; + timestamp: string; + requestId?: string; + }; + pagination?: { + limit: number; + hasMore: boolean; + nextCursor?: string | null; + }; +} + +/** + * GET /api/user/activity + * Get activity stream for the current user + * + * Query params: + * - limit: number (1-50, default 20) + * - cursor: string (activity ID for cursor-based pagination) + */ +export async function GET(request: NextRequest): Promise> { + const requestId = crypto.randomUUID(); + + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return NextResponse.json( + { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const limit = Math.min(Math.max(Number.parseInt(searchParams.get('limit') || '20', 10), 1), 50); + const cursor = searchParams.get('cursor'); + + const activities = await prisma.userActivity.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(cursor && { + cursor: { id: cursor }, + skip: 1, // Skip the cursor item itself + }), + select: { + id: true, + type: true, + targetName: true, + targetType: true, + agentId: true, + collectionId: true, + toolId: true, + metadata: true, + createdAt: true, + }, + }); + + const hasMore = activities.length > limit; + const data = hasMore ? activities.slice(0, limit) : activities; + const nextCursor = hasMore && data.length > 0 ? data[data.length - 1]?.id : null; + + return NextResponse.json({ + success: true, + data, + pagination: { + limit, + hasMore, + nextCursor, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] GET /api/user/activity:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch activity' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/dashboard/agents/page.tsx b/apps/web/src/app/dashboard/agents/page.tsx index 0146179..e5564ad 100644 --- a/apps/web/src/app/dashboard/agents/page.tsx +++ b/apps/web/src/app/dashboard/agents/page.tsx @@ -247,7 +247,11 @@ export default function AgentsPage(): React.ReactElement { - {agent.toolCount + agent.collectionCount * 5} + {agent.toolCount > 0 && agent.collectionCount > 0 + ? `${agent.toolCount} + ${agent.collectionCount} collections` + : agent.collectionCount > 0 + ? `${agent.collectionCount} collection${agent.collectionCount !== 1 ? 's' : ''}` + : agent.toolCount} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 791e08c..f3098ef 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -3,6 +3,7 @@ import { useSession } from '@/lib/auth-client'; import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; +import { DashboardActivityStream } from '~/components/DashboardActivityStream'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; export default function DashboardPage(): React.ReactElement { @@ -59,32 +60,41 @@ export default function DashboardPage(): React.ReactElement { - {/* Profile Section */} -
-

Profile

+ {/* Two-column layout for Profile and Activity */} +
+ {/* Profile Section */} +
+

Profile

-
-
- Name -

{session?.user?.name || 'User'}

-
+
+
+ Name +

{session?.user?.name || 'User'}

+
-
- Email -

{session?.user?.email}

-
+
+ Email +

{session?.user?.email}

+
-
- Email Verified -

- {session?.user?.emailVerified ? ( - Verified - ) : ( - Not verified - )} -

+
+ Email Verified +

+ {session?.user?.emailVerified ? ( + Verified + ) : ( + Not verified + )} +

+
+ + {/* Activity Stream Section */} +
+

Recent Activity

+ +
); diff --git a/apps/web/src/components/DashboardActivityStream.tsx b/apps/web/src/components/DashboardActivityStream.tsx new file mode 100644 index 0000000..a31fbf1 --- /dev/null +++ b/apps/web/src/components/DashboardActivityStream.tsx @@ -0,0 +1,237 @@ +'use client'; + +import type { ActivityType } from '@prisma/client'; +import { Icon, type IconName } from '@tpmjs/ui/Icon/Icon'; +import { Spinner } from '@tpmjs/ui/Spinner/Spinner'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { ACTIVITY_ICONS, ACTIVITY_MESSAGES } from '~/lib/activity'; + +interface Activity { + id: string; + type: ActivityType; + targetName: string; + targetType: string; + agentId: string | null; + collectionId: string | null; + toolId: string | null; + metadata: Record | null; + createdAt: string; +} + +interface ActivityResponse { + success: boolean; + data: Activity[]; + pagination: { + limit: number; + hasMore: boolean; + nextCursor: string | null; + }; +} + +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +// Map activity icons to available IconName values +const iconMapping: Record = { + plus: 'plus', + pencil: 'edit', + trash: 'trash', + link: 'link', + unlink: 'x', + folderPlus: 'folder', + folderMinus: 'folder', + heart: 'heart', + heartOff: 'heart', +}; + +function getActivityIcon(type: ActivityType): IconName { + const iconKey = ACTIVITY_ICONS[type]; + return iconMapping[iconKey] || 'info'; +} + +function getActivityMessage(activity: Activity): string { + const messageGetter = ACTIVITY_MESSAGES[activity.type]; + if (!messageGetter) return `Unknown activity: ${activity.type}`; + return messageGetter(activity.targetName, activity.metadata ?? undefined); +} + +interface DashboardActivityStreamProps { + className?: string; + autoRefreshInterval?: number; // milliseconds, 0 to disable +} + +export function DashboardActivityStream({ + className = '', + autoRefreshInterval = 30000, +}: DashboardActivityStreamProps): React.ReactElement { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(null); + const nextCursorRef = useRef(null); + + const fetchActivities = useCallback(async (cursor?: string | null) => { + try { + const params = new URLSearchParams({ limit: '20' }); + if (cursor) params.set('cursor', cursor); + + const response = await fetch(`/api/user/activity?${params.toString()}`); + const data: ActivityResponse = await response.json(); + + if (!data.success) { + throw new Error('Failed to fetch activities'); + } + + return data; + } catch (err) { + throw err instanceof Error ? err : new Error('Unknown error'); + } + }, []); + + // Initial load + useEffect(() => { + let cancelled = false; + + async function load() { + try { + setLoading(true); + setError(null); + const data = await fetchActivities(); + if (!cancelled) { + setActivities(data.data); + setHasMore(data.pagination.hasMore); + nextCursorRef.current = data.pagination.nextCursor; + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load activities'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + load(); + return () => { + cancelled = true; + }; + }, [fetchActivities]); + + // Auto-refresh + useEffect(() => { + if (autoRefreshInterval <= 0) return; + + const interval = setInterval(async () => { + try { + const data = await fetchActivities(); + setActivities(data.data); + setHasMore(data.pagination.hasMore); + nextCursorRef.current = data.pagination.nextCursor; + } catch { + // Silent fail on auto-refresh + } + }, autoRefreshInterval); + + return () => clearInterval(interval); + }, [autoRefreshInterval, fetchActivities]); + + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore || !nextCursorRef.current) return; + + try { + setLoadingMore(true); + const data = await fetchActivities(nextCursorRef.current); + setActivities((prev) => [...prev, ...data.data]); + setHasMore(data.pagination.hasMore); + nextCursorRef.current = data.pagination.nextCursor; + } catch { + // Silent fail on load more + } finally { + setLoadingMore(false); + } + }, [loadingMore, hasMore, fetchActivities]); + + if (loading) { + return ( +
+ + Loading activity... +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + if (activities.length === 0) { + return ( +
+ +

No activity yet

+

+ Your activity will appear here when you create or modify agents, collections, or tools. +

+
+ ); + } + + return ( +
+ ( +
+
+ +
+
+

{getActivityMessage(activity)}

+

+ {formatRelativeTime(activity.createdAt)} +

+
+
+ )} + components={{ + Footer: () => + loadingMore ? ( +
+ +
+ ) : null, + }} + /> +
+ ); +} diff --git a/apps/web/src/lib/activity.ts b/apps/web/src/lib/activity.ts new file mode 100644 index 0000000..044ff1c --- /dev/null +++ b/apps/web/src/lib/activity.ts @@ -0,0 +1,106 @@ +import type { ActivityType, Prisma } from '@prisma/client'; +import { prisma } from '@tpmjs/db'; + +export interface LogActivityParams { + userId: string; + type: ActivityType; + targetName: string; + targetType: 'agent' | 'collection' | 'tool'; + agentId?: string; + collectionId?: string; + toolId?: string; + metadata?: Record; +} + +/** + * Log user activity (fire-and-forget pattern) + * Never throws - failures are logged but don't break the main operation + */ +export async function logActivity(params: LogActivityParams): Promise { + try { + await prisma.userActivity.create({ + data: { + userId: params.userId, + type: params.type, + targetName: params.targetName, + targetType: params.targetType, + agentId: params.agentId, + collectionId: params.collectionId, + toolId: params.toolId, + metadata: params.metadata as Prisma.InputJsonValue | undefined, + }, + }); + } catch (error) { + // Never throw - activity logging should never break main operations + console.error('Failed to log activity:', error); + } +} + +/** + * Activity type to human-readable message mapping + */ +export const ACTIVITY_MESSAGES: Record< + ActivityType, + (targetName: string, metadata?: Record) => string +> = { + AGENT_CREATED: (name) => `Created agent "${name}"`, + AGENT_UPDATED: (name) => `Updated agent "${name}"`, + AGENT_DELETED: (name) => `Deleted agent "${name}"`, + AGENT_TOOL_ADDED: (name, meta) => + meta?.toolName + ? `Added tool "${meta.toolName}" to agent "${name}"` + : `Added tool to agent "${name}"`, + AGENT_TOOL_REMOVED: (name, meta) => + meta?.toolName + ? `Removed tool "${meta.toolName}" from agent "${name}"` + : `Removed tool from agent "${name}"`, + AGENT_COLLECTION_ADDED: (name, meta) => + meta?.collectionName + ? `Added collection "${meta.collectionName}" to agent "${name}"` + : `Added collection to agent "${name}"`, + AGENT_COLLECTION_REMOVED: (name, meta) => + meta?.collectionName + ? `Removed collection "${meta.collectionName}" from agent "${name}"` + : `Removed collection from agent "${name}"`, + COLLECTION_CREATED: (name) => `Created collection "${name}"`, + COLLECTION_UPDATED: (name) => `Updated collection "${name}"`, + COLLECTION_DELETED: (name) => `Deleted collection "${name}"`, + COLLECTION_TOOL_ADDED: (name, meta) => + meta?.toolName + ? `Added tool "${meta.toolName}" to collection "${name}"` + : `Added tool to collection "${name}"`, + COLLECTION_TOOL_REMOVED: (name, meta) => + meta?.toolName + ? `Removed tool "${meta.toolName}" from collection "${name}"` + : `Removed tool from collection "${name}"`, + TOOL_LIKED: (name) => `Liked tool "${name}"`, + TOOL_UNLIKED: (name) => `Unliked tool "${name}"`, + COLLECTION_LIKED: (name) => `Liked collection "${name}"`, + COLLECTION_UNLIKED: (name) => `Unliked collection "${name}"`, + AGENT_LIKED: (name) => `Liked agent "${name}"`, + AGENT_UNLIKED: (name) => `Unliked agent "${name}"`, +}; + +/** + * Activity type to icon name mapping (for UI) + */ +export const ACTIVITY_ICONS: Record = { + AGENT_CREATED: 'plus', + AGENT_UPDATED: 'pencil', + AGENT_DELETED: 'trash', + AGENT_TOOL_ADDED: 'link', + AGENT_TOOL_REMOVED: 'unlink', + AGENT_COLLECTION_ADDED: 'folderPlus', + AGENT_COLLECTION_REMOVED: 'folderMinus', + COLLECTION_CREATED: 'folderPlus', + COLLECTION_UPDATED: 'pencil', + COLLECTION_DELETED: 'trash', + COLLECTION_TOOL_ADDED: 'link', + COLLECTION_TOOL_REMOVED: 'unlink', + TOOL_LIKED: 'heart', + TOOL_UNLIKED: 'heartOff', + COLLECTION_LIKED: 'heart', + COLLECTION_UNLIKED: 'heartOff', + AGENT_LIKED: 'heart', + AGENT_UNLIKED: 'heartOff', +}; diff --git a/apps/web/src/lib/api-response.ts b/apps/web/src/lib/api-response.ts new file mode 100644 index 0000000..768096d --- /dev/null +++ b/apps/web/src/lib/api-response.ts @@ -0,0 +1,203 @@ +import { NextResponse } from 'next/server'; + +/** + * Standardized API Response Format + * + * All API endpoints should use this helper for consistent response formatting. + * + * Success response: + * { + * success: true, + * data: T, + * meta: { version, timestamp, requestId }, + * pagination?: { ... } + * } + * + * Error response: + * { + * success: false, + * error: { code, message, details? }, + * meta: { version, timestamp, requestId } + * } + */ + +const API_VERSION = '1.0.0'; + +export interface ApiError { + code: string; + message: string; + details?: Record; +} + +export interface ApiMeta { + version: string; + timestamp: string; + requestId: string; +} + +export interface ApiSuccessResponse { + success: true; + data: T; + meta: ApiMeta; + pagination?: Record; +} + +export interface ApiErrorResponse { + success: false; + error: ApiError; + meta: ApiMeta; +} + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +/** + * Common error codes for API responses + */ +export const ErrorCodes = { + // Authentication & Authorization + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + + // Validation + VALIDATION_ERROR: 'VALIDATION_ERROR', + INVALID_INPUT: 'INVALID_INPUT', + + // Resource errors + NOT_FOUND: 'NOT_FOUND', + DUPLICATE: 'DUPLICATE', + CONFLICT: 'CONFLICT', + + // Rate limiting + RATE_LIMITED: 'RATE_LIMITED', + + // Server errors + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +/** + * Create API metadata with version and timestamp + */ +function createMeta(requestId?: string): ApiMeta { + return { + version: API_VERSION, + timestamp: new Date().toISOString(), + requestId: requestId ?? crypto.randomUUID(), + }; +} + +/** + * Create a successful API response + */ +export function apiSuccess( + data: T, + options?: { + requestId?: string; + pagination?: Record; + status?: number; + } +): NextResponse> { + const response: ApiSuccessResponse = { + success: true, + data, + meta: createMeta(options?.requestId), + }; + + if (options?.pagination) { + response.pagination = options.pagination; + } + + return NextResponse.json(response, { status: options?.status ?? 200 }); +} + +/** + * Create an error API response + */ +export function apiError( + code: ErrorCode | string, + message: string, + options?: { + requestId?: string; + details?: Record; + status?: number; + headers?: Record; + } +): NextResponse { + const error: ApiError = { + code, + message, + }; + + if (options?.details) { + error.details = options.details; + } + + const response: ApiErrorResponse = { + success: false, + error, + meta: createMeta(options?.requestId), + }; + + return NextResponse.json(response, { + status: options?.status ?? 500, + headers: options?.headers, + }); +} + +// Common error response helpers + +export function apiUnauthorized( + message = 'Authentication required', + requestId?: string +): NextResponse { + return apiError(ErrorCodes.UNAUTHORIZED, message, { status: 401, requestId }); +} + +export function apiForbidden( + message = 'Access denied', + requestId?: string +): NextResponse { + return apiError(ErrorCodes.FORBIDDEN, message, { status: 403, requestId }); +} + +export function apiNotFound(resource: string, requestId?: string): NextResponse { + return apiError(ErrorCodes.NOT_FOUND, `${resource} not found`, { status: 404, requestId }); +} + +export function apiValidationError( + message: string, + details?: Record, + requestId?: string +): NextResponse { + return apiError(ErrorCodes.VALIDATION_ERROR, message, { status: 400, details, requestId }); +} + +export function apiConflict(message: string, requestId?: string): NextResponse { + return apiError(ErrorCodes.CONFLICT, message, { status: 409, requestId }); +} + +export function apiRateLimited( + retryAfterSeconds: number, + requestId?: string +): NextResponse { + return apiError( + ErrorCodes.RATE_LIMITED, + `Rate limit exceeded. Retry after ${retryAfterSeconds} seconds.`, + { + status: 429, + requestId, + details: { retryAfter: retryAfterSeconds }, + headers: { + 'Retry-After': retryAfterSeconds.toString(), + }, + } + ); +} + +export function apiInternalError( + message = 'Internal server error', + requestId?: string +): NextResponse { + return apiError(ErrorCodes.INTERNAL_ERROR, message, { status: 500, requestId }); +} diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index 9c2d0bd..f0d69bb 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -1,63 +1,66 @@ +import { kv } from '@vercel/kv'; import { type NextRequest, NextResponse } from 'next/server'; import { env } from '~/env'; /** - * Simple in-memory rate limiter using sliding window + * Distributed rate limiter using Vercel KV (with in-memory fallback) * - * Note: This is suitable for moderate traffic. For high-traffic production, - * consider using a distributed solution like Upstash Redis or Vercel KV. + * Uses Vercel KV in production for accurate rate limiting across serverless instances. + * Falls back to in-memory store when KV is not available (development). */ interface RateLimitEntry { timestamps: number[]; } -// Store rate limit data in memory (per serverless instance) -const rateLimitStore = new Map(); +// In-memory fallback store (used when Vercel KV is not available) +const memoryStore = new Map(); -// Cleanup interval to prevent memory leaks +// Check if Vercel KV is available +const isKVAvailable = !!process.env.KV_REST_API_URL; + +// Cleanup interval for in-memory fallback const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes -const MAX_STORE_SIZE = 10000; // Prevent unbounded growth - +const MAX_STORE_SIZE = 10000; let lastCleanup = Date.now(); /** - * Clean up old entries from the rate limit store + * Clean up old entries from the in-memory fallback store */ -function cleanup() { +function cleanupMemoryStore() { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; - const cutoff = now - 60 * 1000; // Remove entries older than 1 minute + const cutoff = now - 60 * 1000; let removedCount = 0; - for (const [key, entry] of rateLimitStore.entries()) { + for (const [key, entry] of memoryStore.entries()) { entry.timestamps = entry.timestamps.filter((ts) => ts > cutoff); if (entry.timestamps.length === 0) { - rateLimitStore.delete(key); + memoryStore.delete(key); removedCount++; } } - // If store is still too large, remove oldest entries - if (rateLimitStore.size > MAX_STORE_SIZE) { - const entries = Array.from(rateLimitStore.entries()); + // Prevent unbounded growth + if (memoryStore.size > MAX_STORE_SIZE) { + const entries = Array.from(memoryStore.entries()); entries.sort((a, b) => { - const aLatest = Math.max(...a[1].timestamps); - const bLatest = Math.max(...b[1].timestamps); + const aLatest = a[1].timestamps.length > 0 ? Math.max(...a[1].timestamps) : 0; + const bLatest = b[1].timestamps.length > 0 ? Math.max(...b[1].timestamps) : 0; return aLatest - bLatest; }); const toRemove = entries.slice(0, Math.floor(MAX_STORE_SIZE * 0.2)); for (const [key] of toRemove) { - rateLimitStore.delete(key); + memoryStore.delete(key); removedCount++; } } lastCleanup = now; if (removedCount > 0) { - console.log(`[Rate Limit] Cleaned up ${removedCount} entries`); + console.log(`[Rate Limit] Cleaned up ${removedCount} in-memory entries`); } } @@ -65,7 +68,6 @@ function cleanup() { * Get client identifier from request (IP address) */ function getClientId(request: NextRequest): string { - // Try to get real IP from headers (Vercel sets these) const forwarded = request.headers.get('x-forwarded-for'); const realIp = request.headers.get('x-real-ip'); @@ -76,7 +78,6 @@ function getClientId(request: NextRequest): string { return realIp; } - // Fallback to connection info (less reliable in serverless) return 'unknown'; } @@ -84,64 +85,124 @@ function getClientId(request: NextRequest): string { * Rate limit configuration */ export interface RateLimitConfig { - /** - * Maximum requests allowed in the window - */ + /** Maximum requests allowed in the window */ limit: number; - - /** - * Time window in seconds - */ + /** Time window in seconds */ windowSeconds: number; + /** Optional key prefix for namespacing */ + prefix?: string; } -/** - * Default rate limit: 100 requests per minute - */ +/** Default rate limit: 100 requests per minute */ export const DEFAULT_RATE_LIMIT: RateLimitConfig = { limit: 100, windowSeconds: 60, }; -/** - * Strict rate limit for expensive operations: 20 requests per minute - */ +/** Strict rate limit for expensive operations: 20 requests per minute */ export const STRICT_RATE_LIMIT: RateLimitConfig = { limit: 20, windowSeconds: 60, }; /** - * Check if a request should be rate limited - * - * @param request - Next.js request object - * @param config - Rate limit configuration - * @returns null if allowed, NextResponse with 429 if rate limited + * Get rate limit entry from Vercel KV */ -export function checkRateLimit( - request: NextRequest, - config: RateLimitConfig = DEFAULT_RATE_LIMIT -): NextResponse | null { - // Skip rate limiting for cron jobs (authenticated with CRON_SECRET) - const authHeader = request.headers.get('authorization'); - const token = authHeader?.replace('Bearer ', ''); - if (env.CRON_SECRET && token === env.CRON_SECRET) { - return null; // Allow cron jobs to bypass rate limiting +async function getKVEntry(key: string): Promise { + try { + return await kv.get(key); + } catch (error) { + console.error('[Rate Limit] KV get error:', error); + return null; } +} - // Periodic cleanup - cleanup(); +/** + * Set rate limit entry in Vercel KV + */ +async function setKVEntry(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise { + try { + await kv.set(key, entry, { ex: ttlSeconds }); + } catch (error) { + console.error('[Rate Limit] KV set error:', error); + } +} +/** + * Check if a request should be rate limited (async version for KV) + */ +async function checkRateLimitAsync( + request: NextRequest, + config: RateLimitConfig +): Promise { const clientId = getClientId(request); + const prefix = config.prefix || 'ratelimit'; + const key = `${prefix}:${clientId}`; const now = Date.now(); const windowMs = config.windowSeconds * 1000; const cutoff = now - windowMs; - // Get or create rate limit entry - let entry = rateLimitStore.get(clientId); + // Get or create entry from KV + let entry = await getKVEntry(key); if (!entry) { entry = { timestamps: [] }; - rateLimitStore.set(clientId, entry); + } + + // Remove timestamps outside the current window + entry.timestamps = entry.timestamps.filter((ts) => ts > cutoff); + + // Check if limit exceeded + if (entry.timestamps.length >= config.limit) { + const oldestInWindow = entry.timestamps[0] || now; + const resetTime = oldestInWindow + windowMs; + const retryAfterSeconds = Math.ceil((resetTime - now) / 1000); + + return NextResponse.json( + { + success: false, + error: 'Rate limit exceeded', + message: `Too many requests. Please try again in ${retryAfterSeconds} seconds.`, + retryAfter: retryAfterSeconds, + limit: config.limit, + window: config.windowSeconds, + }, + { + status: 429, + headers: { + 'Retry-After': retryAfterSeconds.toString(), + 'X-RateLimit-Limit': config.limit.toString(), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': Math.ceil(resetTime / 1000).toString(), + }, + } + ); + } + + // Add current timestamp and save + entry.timestamps.push(now); + await setKVEntry(key, entry, config.windowSeconds + 10); // TTL slightly longer than window + + return null; +} + +/** + * Check if a request should be rate limited (sync version for in-memory) + */ +function checkRateLimitSync(request: NextRequest, config: RateLimitConfig): NextResponse | null { + cleanupMemoryStore(); + + const clientId = getClientId(request); + const prefix = config.prefix || 'ratelimit'; + const key = `${prefix}:${clientId}`; + const now = Date.now(); + const windowMs = config.windowSeconds * 1000; + const cutoff = now - windowMs; + + // Get or create entry + let entry = memoryStore.get(key); + if (!entry) { + entry = { timestamps: [] }; + memoryStore.set(key, entry); } // Remove timestamps outside the current window @@ -177,10 +238,65 @@ export function checkRateLimit( // Add current timestamp entry.timestamps.push(now); - // Request is allowed return null; } +/** + * Check if a request should be rate limited + * + * Uses Vercel KV in production for distributed rate limiting, + * falls back to in-memory store in development. + * + * @param request - Next.js request object + * @param config - Rate limit configuration + * @returns null if allowed, NextResponse with 429 if rate limited + */ +export function checkRateLimit( + request: NextRequest, + config: RateLimitConfig = DEFAULT_RATE_LIMIT +): NextResponse | null { + // Skip rate limiting for cron jobs (authenticated with CRON_SECRET) + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + if (env.CRON_SECRET && token === env.CRON_SECRET) { + return null; + } + + // Use sync in-memory check for immediate response + // Note: KV would require async, but checkRateLimit is called synchronously + // This is a limitation - for truly distributed rate limiting, consider + // using middleware or making the rate limit check async + return checkRateLimitSync(request, config); +} + +/** + * Check if a request should be rate limited (async version) + * + * Use this when you can await the rate limit check for true distributed limiting. + * + * @param request - Next.js request object + * @param config - Rate limit configuration + * @returns null if allowed, NextResponse with 429 if rate limited + */ +export async function checkRateLimitDistributed( + request: NextRequest, + config: RateLimitConfig = DEFAULT_RATE_LIMIT +): Promise { + // Skip rate limiting for cron jobs + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + if (env.CRON_SECRET && token === env.CRON_SECRET) { + return null; + } + + // Use KV if available, otherwise fall back to in-memory + if (isKVAvailable) { + return checkRateLimitAsync(request, config); + } + + return checkRateLimitSync(request, config); +} + /** * Get current rate limit status for debugging */ @@ -189,7 +305,9 @@ export function getRateLimitStatus( config: RateLimitConfig = DEFAULT_RATE_LIMIT ) { const clientId = getClientId(request); - const entry = rateLimitStore.get(clientId); + const prefix = config.prefix || 'ratelimit'; + const key = `${prefix}:${clientId}`; + const entry = memoryStore.get(key); const now = Date.now(); const windowMs = config.windowSeconds * 1000; const cutoff = now - windowMs; @@ -203,5 +321,6 @@ export function getRateLimitStatus( remaining, used: recentRequests, resetAt: new Date(now + windowMs), + isDistributed: isKVAvailable, }; } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 13b2c26..e8eda3d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -72,9 +72,11 @@ model Tool { aiAgent Json? @map("ai_agent") @db.JsonB // @deprecated - will be auto-extracted in future // Schema Extraction Fields - inputSchema Json? @map("input_schema") @db.JsonB // Full JSON Schema from executor - schemaSource String? @map("schema_source") @db.VarChar(20) // 'extracted' | 'author' | null - schemaExtractedAt DateTime? @map("schema_extracted_at") + inputSchema Json? @map("input_schema") @db.JsonB // Full JSON Schema from executor + schemaSource String? @map("schema_source") @db.VarChar(20) // 'extracted' | 'author' | null + schemaExtractedAt DateTime? @map("schema_extracted_at") // Only updated on successful extraction + schemaExtractionAttemptAt DateTime? @map("schema_extraction_attempt_at") // Updated on every attempt (for rate limiting) + schemaExtractionError String? @map("schema_extraction_error") @db.Text // Error message from last failed attempt // Tool Discovery Fields toolDiscoverySource String? @map("tool_discovery_source") @db.VarChar(20) // 'auto' | 'manual' | null @@ -342,6 +344,7 @@ model User { toolLikes ToolLike[] collectionLikes CollectionLike[] agentLikes AgentLike[] + activities UserActivity[] @@map("users") } @@ -713,3 +716,62 @@ model AgentLike { @@index([userId]) @@map("agent_likes") } + +// ============================================================================ +// Activity Stream Models +// ============================================================================ + +/// Activity type enum - types of user activities tracked +enum ActivityType { + AGENT_CREATED + AGENT_UPDATED + AGENT_DELETED + AGENT_TOOL_ADDED + AGENT_TOOL_REMOVED + AGENT_COLLECTION_ADDED + AGENT_COLLECTION_REMOVED + COLLECTION_CREATED + COLLECTION_UPDATED + COLLECTION_DELETED + COLLECTION_TOOL_ADDED + COLLECTION_TOOL_REMOVED + TOOL_LIKED + TOOL_UNLIKED + COLLECTION_LIKED + COLLECTION_UNLIKED + AGENT_LIKED + AGENT_UNLIKED +} + +/// UserActivity - tracks user actions for activity stream +model UserActivity { + id String @id @default(cuid()) + + // Owner relationship + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Activity type + type ActivityType + + // Optional entity references (for linking to entities if they still exist) + agentId String? @map("agent_id") + collectionId String? @map("collection_id") + toolId String? @map("tool_id") + + // Denormalized fields (stored at creation time for display even after entity deletion) + targetName String @map("target_name") @db.VarChar(200) + targetType String @map("target_type") @db.VarChar(50) // 'agent' | 'collection' | 'tool' + + // Additional context (e.g., toolName when adding to collection) + metadata Json? @db.JsonB + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + + @@index([userId]) + @@index([userId, createdAt]) + @@index([type]) + @@index([createdAt]) + @@map("user_activities") +} diff --git a/vercel.json b/vercel.json index d36f235..09a845f 100644 --- a/vercel.json +++ b/vercel.json @@ -56,6 +56,10 @@ { "path": "/api/sync/stats-snapshot", "schedule": "0 0 * * *" + }, + { + "path": "/api/sync/cleanup-activity", + "schedule": "0 3 * * *" } ] }