feat: implement user activity stream and multiple API improvements

Activity Stream:
- Add UserActivity model with ActivityType enum to track user actions
- Create activity logging service with fire-and-forget pattern
- Add /api/user/activity endpoint with cursor pagination
- Add cleanup cron job for 90-day activity retention
- Integrate activity logging into 18 mutation API routes
- Add DashboardActivityStream component with virtualized rendering

API Improvements:
- Add distributed rate limiting via Vercel KV (with in-memory fallback)
- Add rate limiting to chat endpoint (30 req/min)
- Optimize BM25 search with database-level pre-filtering
- Fix JSON parse crash in search endpoint
- Add pagination to collection tools response (toolsLimit/toolsOffset)
- Add take limits to agent detail query to prevent excessive data fetch
- Fix hardcoded tool count calculation in agents dashboard

Schema Extraction:
- Add schemaExtractionAttemptAt and schemaExtractionError fields
- Separate rate limiting for failed (1 min) vs successful (1 hour) attempts
- Allow retry of failed extractions

Standardization:
- Create api-response.ts with standardized response helpers
- Add apiSuccess, apiError, apiNotFound, apiForbidden, etc.
- Update agents routes to use standardized format

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-08 04:11:01 +10:00
parent 9f254a5ae8
commit d6de3e025a
26 changed files with 1448 additions and 146 deletions

View file

@ -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 },

View file

@ -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,

View file

@ -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<Response> {
// 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 {

View file

@ -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: {

View file

@ -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<NextResponse> {
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<NextResponse> {
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<NextResponse> {
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);
}
}

View file

@ -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 },

View file

@ -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,

View file

@ -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<NextResponse> {
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,

View file

@ -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: {

View file

@ -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<NextResponse<ApiResponse>> {
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 },

View file

@ -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 },

View file

@ -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,

View file

@ -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<NextResponse<ApiRespon
return newCollection;
});
// Log activity (fire-and-forget)
logActivity({
userId: session.user.id,
type: 'COLLECTION_CREATED',
targetName: collection.name,
targetType: 'collection',
collectionId: collection.id,
});
return NextResponse.json(
{
success: true,

View file

@ -0,0 +1,115 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { env } from '~/env';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes max for cron jobs
/**
* POST /api/sync/cleanup-activity
* Delete activity records older than 90 days
*
* This endpoint is called by Vercel Cron (daily at 3 AM UTC)
* Requires Authorization: Bearer <CRON_SECRET>
*/
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 }
);
}
}

View file

@ -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: {

View file

@ -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);

View file

@ -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`);

View file

@ -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<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
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<NextResponse<ApiResponse>> {
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 }
);
}
}

View file

@ -247,7 +247,11 @@ export default function AgentsPage(): React.ReactElement {
</TableCell>
<TableCell>
<span className="text-foreground-secondary">
{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}
</span>
</TableCell>
<TableCell>

View file

@ -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 {
</Link>
</div>
{/* Profile Section */}
<div className="bg-background border border-border rounded-lg p-6">
<h2 className="text-lg font-medium text-foreground mb-4">Profile</h2>
{/* Two-column layout for Profile and Activity */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Profile Section */}
<div className="bg-background border border-border rounded-lg p-6">
<h2 className="text-lg font-medium text-foreground mb-4">Profile</h2>
<div className="space-y-4">
<div>
<span className="block text-sm text-foreground-secondary">Name</span>
<p className="text-foreground font-medium">{session?.user?.name || 'User'}</p>
</div>
<div className="space-y-4">
<div>
<span className="block text-sm text-foreground-secondary">Name</span>
<p className="text-foreground font-medium">{session?.user?.name || 'User'}</p>
</div>
<div>
<span className="block text-sm text-foreground-secondary">Email</span>
<p className="text-foreground font-medium">{session?.user?.email}</p>
</div>
<div>
<span className="block text-sm text-foreground-secondary">Email</span>
<p className="text-foreground font-medium">{session?.user?.email}</p>
</div>
<div>
<span className="block text-sm text-foreground-secondary">Email Verified</span>
<p className="text-foreground font-medium">
{session?.user?.emailVerified ? (
<span className="text-success">Verified</span>
) : (
<span className="text-warning">Not verified</span>
)}
</p>
<div>
<span className="block text-sm text-foreground-secondary">Email Verified</span>
<p className="text-foreground font-medium">
{session?.user?.emailVerified ? (
<span className="text-success">Verified</span>
) : (
<span className="text-warning">Not verified</span>
)}
</p>
</div>
</div>
</div>
{/* Activity Stream Section */}
<div className="bg-background border border-border rounded-lg p-6">
<h2 className="text-lg font-medium text-foreground mb-4">Recent Activity</h2>
<DashboardActivityStream autoRefreshInterval={30000} />
</div>
</div>
</DashboardLayout>
);

View file

@ -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<string, unknown> | 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<string, IconName> = {
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<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const nextCursorRef = useRef<string | null>(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 (
<div className={`flex items-center justify-center py-12 ${className}`}>
<Spinner size="md" />
<span className="ml-2 text-foreground-secondary text-sm">Loading activity...</span>
</div>
);
}
if (error) {
return (
<div className={`text-center py-8 ${className}`}>
<Icon icon="alertCircle" size="lg" className="text-foreground-tertiary mx-auto mb-2" />
<p className="text-foreground-secondary text-sm">{error}</p>
</div>
);
}
if (activities.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<Icon icon="clock" size="lg" className="text-foreground-tertiary mx-auto mb-2" />
<p className="text-foreground-secondary text-sm">No activity yet</p>
<p className="text-foreground-tertiary text-xs mt-1">
Your activity will appear here when you create or modify agents, collections, or tools.
</p>
</div>
);
}
return (
<div className={`${className}`}>
<Virtuoso
style={{ height: '400px' }}
data={activities}
endReached={loadMore}
overscan={10}
itemContent={(_, activity) => (
<div className="flex items-start gap-3 py-3 border-b border-border last:border-0">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-surface flex items-center justify-center">
<Icon
icon={getActivityIcon(activity.type)}
size="sm"
className="text-foreground-secondary"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">{getActivityMessage(activity)}</p>
<p className="text-xs text-foreground-tertiary mt-0.5">
{formatRelativeTime(activity.createdAt)}
</p>
</div>
</div>
)}
components={{
Footer: () =>
loadingMore ? (
<div className="flex items-center justify-center py-4">
<Spinner size="sm" />
</div>
) : null,
}}
/>
</div>
);
}

View file

@ -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<string, unknown>;
}
/**
* 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<void> {
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, unknown>) => 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<ActivityType, string> = {
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',
};

View file

@ -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<string, unknown>;
}
export interface ApiMeta {
version: string;
timestamp: string;
requestId: string;
}
export interface ApiSuccessResponse<T = unknown> {
success: true;
data: T;
meta: ApiMeta;
pagination?: Record<string, unknown>;
}
export interface ApiErrorResponse {
success: false;
error: ApiError;
meta: ApiMeta;
}
export type ApiResponse<T = unknown> = ApiSuccessResponse<T> | 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<T>(
data: T,
options?: {
requestId?: string;
pagination?: Record<string, unknown>;
status?: number;
}
): NextResponse<ApiSuccessResponse<T>> {
const response: ApiSuccessResponse<T> = {
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<string, unknown>;
status?: number;
headers?: Record<string, string>;
}
): NextResponse<ApiErrorResponse> {
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<ApiErrorResponse> {
return apiError(ErrorCodes.UNAUTHORIZED, message, { status: 401, requestId });
}
export function apiForbidden(
message = 'Access denied',
requestId?: string
): NextResponse<ApiErrorResponse> {
return apiError(ErrorCodes.FORBIDDEN, message, { status: 403, requestId });
}
export function apiNotFound(resource: string, requestId?: string): NextResponse<ApiErrorResponse> {
return apiError(ErrorCodes.NOT_FOUND, `${resource} not found`, { status: 404, requestId });
}
export function apiValidationError(
message: string,
details?: Record<string, unknown>,
requestId?: string
): NextResponse<ApiErrorResponse> {
return apiError(ErrorCodes.VALIDATION_ERROR, message, { status: 400, details, requestId });
}
export function apiConflict(message: string, requestId?: string): NextResponse<ApiErrorResponse> {
return apiError(ErrorCodes.CONFLICT, message, { status: 409, requestId });
}
export function apiRateLimited(
retryAfterSeconds: number,
requestId?: string
): NextResponse<ApiErrorResponse> {
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<ApiErrorResponse> {
return apiError(ErrorCodes.INTERNAL_ERROR, message, { status: 500, requestId });
}

View file

@ -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<string, RateLimitEntry>();
// In-memory fallback store (used when Vercel KV is not available)
const memoryStore = new Map<string, RateLimitEntry>();
// 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<RateLimitEntry | null> {
try {
return await kv.get<RateLimitEntry>(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<void> {
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<NextResponse | null> {
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<NextResponse | null> {
// 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,
};
}

View file

@ -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")
}

View file

@ -56,6 +56,10 @@
{
"path": "/api/sync/stats-snapshot",
"schedule": "0 0 * * *"
},
{
"path": "/api/sync/cleanup-activity",
"schedule": "0 3 * * *"
}
]
}