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:
parent
1b0cff7332
commit
e1bbd80f9b
26 changed files with 1448 additions and 146 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
115
apps/web/src/app/api/sync/cleanup-activity/route.ts
Normal file
115
apps/web/src/app/api/sync/cleanup-activity/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
109
apps/web/src/app/api/user/activity/route.ts
Normal file
109
apps/web/src/app/api/user/activity/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
237
apps/web/src/components/DashboardActivityStream.tsx
Normal file
237
apps/web/src/components/DashboardActivityStream.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/web/src/lib/activity.ts
Normal file
106
apps/web/src/lib/activity.ts
Normal 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',
|
||||
};
|
||||
203
apps/web/src/lib/api-response.ts
Normal file
203
apps/web/src/lib/api-response.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@
|
|||
{
|
||||
"path": "/api/sync/stats-snapshot",
|
||||
"schedule": "0 0 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/cleanup-activity",
|
||||
"schedule": "0 3 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue