Skip to main content

T11: Social API

Implement favorites, comments, and ratings API routes.


Overview​

AttributeValue
Task IDT11
DependenciesT5 (MCP API), T6 (Skills API)
Effort3 points
PriorityP1

Objectives​

  1. Implement favorites toggle
  2. Implement comments CRUD
  3. Implement ratings
  4. Update aggregate counts

API Endpoints​

MethodEndpointDescriptionAuth
POST/api/mcp/[id]/favoriteToggle favoriteYes
GET/api/mcp/[id]/commentsList commentsNo
POST/api/mcp/[id]/commentsAdd commentYes
DELETE/api/mcp/[id]/comments/[commentId]Delete commentYes
POST/api/mcp/[id]/ratingAdd/update ratingYes

Same endpoints for /api/skill/[id]/...


Deliverables​

1. Favorites (apps/web/app/api/mcp/[id]/favorite/route.ts)​

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { db, favorites, mcpPackages } from '@/lib/db';
import { getSession } from '@/lib/auth/cookies';
import { generateId } from '@/lib/utils';
import { eq, and } from 'drizzle-orm';

export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id } = params;
const entityType = 'mcp';

// Check if already favorited
const existing = await db.query.favorites.findFirst({
where: and(
eq(favorites.userId, session.userId),
eq(favorites.entityType, entityType),
eq(favorites.entityId, id)
),
});

let isFavorited: boolean;

if (existing) {
// Remove favorite
await db.delete(favorites).where(
and(
eq(favorites.userId, session.userId),
eq(favorites.entityType, entityType),
eq(favorites.entityId, id)
)
);
isFavorited = false;

// Decrement count
const pkg = await db.query.mcpPackages.findFirst({
where: eq(mcpPackages.id, id),
});
if (pkg) {
await db
.update(mcpPackages)
.set({ favoritesCount: Math.max(0, pkg.favoritesCount - 1) })
.where(eq(mcpPackages.id, id));
}
} else {
// Add favorite
await db.insert(favorites).values({
userId: session.userId,
entityType,
entityId: id,
});
isFavorited = true;

// Increment count
const pkg = await db.query.mcpPackages.findFirst({
where: eq(mcpPackages.id, id),
});
if (pkg) {
await db
.update(mcpPackages)
.set({ favoritesCount: pkg.favoritesCount + 1 })
.where(eq(mcpPackages.id, id));
}
}

return NextResponse.json({ isFavorited });
}

// GET - Check if favorited
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ isFavorited: false });
}

const { id } = params;

const existing = await db.query.favorites.findFirst({
where: and(
eq(favorites.userId, session.userId),
eq(favorites.entityType, 'mcp'),
eq(favorites.entityId, id)
),
});

return NextResponse.json({ isFavorited: !!existing });
}

2. Comments (apps/web/app/api/mcp/[id]/comments/route.ts)​

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
import { db, comments, users } from '@/lib/db';
import { getSession } from '@/lib/auth/cookies';
import { generateId } from '@/lib/utils';
import { eq, and, isNull, desc } from 'drizzle-orm';

// GET - List comments
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { id } = params;

const allComments = await db
.select({
id: comments.id,
content: comments.content,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
author: {
id: users.id,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.leftJoin(users, eq(comments.userId, users.id))
.where(
and(
eq(comments.entityType, 'mcp'),
eq(comments.entityId, id)
)
)
.orderBy(desc(comments.createdAt));

// Build tree structure
const commentMap = new Map();
const rootComments: any[] = [];

allComments.forEach((comment) => {
commentMap.set(comment.id, { ...comment, replies: [] });
});

allComments.forEach((comment) => {
if (comment.parentId) {
const parent = commentMap.get(comment.parentId);
if (parent) {
parent.replies.push(commentMap.get(comment.id));
}
} else {
rootComments.push(commentMap.get(comment.id));
}
});

return NextResponse.json({ comments: rootComments });
}

const createCommentSchema = z.object({
content: z.string().min(1).max(2000),
parentId: z.string().optional(),
});

// POST - Add comment
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id } = params;
const body = await request.json();
const { content, parentId } = createCommentSchema.parse(body);

const commentId = generateId();

await db.insert(comments).values({
id: commentId,
entityType: 'mcp',
entityId: id,
userId: session.userId,
content,
parentId: parentId || null,
});

// Fetch the created comment with author
const [comment] = await db
.select({
id: comments.id,
content: comments.content,
parentId: comments.parentId,
createdAt: comments.createdAt,
author: {
id: users.id,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.leftJoin(users, eq(comments.userId, users.id))
.where(eq(comments.id, commentId));

return NextResponse.json({ comment });
}

3. Delete Comment (apps/web/app/api/mcp/[id]/comments/[commentId]/route.ts)​

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { db, comments } from '@/lib/db';
import { getSession } from '@/lib/auth/cookies';
import { eq } from 'drizzle-orm';

export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string; commentId: string } }
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { commentId } = params;

const comment = await db.query.comments.findFirst({
where: eq(comments.id, commentId),
});

if (!comment) {
return NextResponse.json({ error: 'Comment not found' }, { status: 404 });
}

// Only author or admin can delete
if (comment.userId !== session.userId && session.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

// Delete comment (cascades to replies)
await db.delete(comments).where(eq(comments.id, commentId));

return NextResponse.json({ success: true });
}

4. Ratings (apps/web/app/api/mcp/[id]/rating/route.ts)​

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
import { db, ratings, mcpPackages } from '@/lib/db';
import { getSession } from '@/lib/auth/cookies';
import { eq, and, avg, count } from 'drizzle-orm';

const ratingSchema = z.object({
score: z.number().int().min(1).max(5),
});

export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id } = params;
const body = await request.json();
const { score } = ratingSchema.parse(body);

// Upsert rating
const existing = await db.query.ratings.findFirst({
where: and(
eq(ratings.userId, session.userId),
eq(ratings.entityType, 'mcp'),
eq(ratings.entityId, id)
),
});

if (existing) {
await db
.update(ratings)
.set({ score })
.where(
and(
eq(ratings.userId, session.userId),
eq(ratings.entityType, 'mcp'),
eq(ratings.entityId, id)
)
);
} else {
await db.insert(ratings).values({
userId: session.userId,
entityType: 'mcp',
entityId: id,
score,
});
}

// Update aggregate on package
const [stats] = await db
.select({
avgRating: avg(ratings.score),
ratingCount: count(ratings.score),
})
.from(ratings)
.where(
and(
eq(ratings.entityType, 'mcp'),
eq(ratings.entityId, id)
)
);

await db
.update(mcpPackages)
.set({
ratingAvg: Number(stats.avgRating) || 0,
ratingCount: Number(stats.ratingCount) || 0,
})
.where(eq(mcpPackages.id, id));

return NextResponse.json({
success: true,
ratingAvg: Number(stats.avgRating) || 0,
ratingCount: Number(stats.ratingCount) || 0,
});
}

// GET - Get user's rating
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getSession();
if (!session) {
return NextResponse.json({ userRating: null });
}

const { id } = params;

const rating = await db.query.ratings.findFirst({
where: and(
eq(ratings.userId, session.userId),
eq(ratings.entityType, 'mcp'),
eq(ratings.entityId, id)
),
});

return NextResponse.json({ userRating: rating?.score || null });
}

Shared Service (apps/web/lib/services/social.ts)​

import { db, favorites, comments, ratings } from '@/lib/db';
import { eq, and } from 'drizzle-orm';

export type EntityType = 'mcp' | 'skill' | 'datasource';

export async function toggleFavorite(
userId: string,
entityType: EntityType,
entityId: string
): Promise<boolean> {
const existing = await db.query.favorites.findFirst({
where: and(
eq(favorites.userId, userId),
eq(favorites.entityType, entityType),
eq(favorites.entityId, entityId)
),
});

if (existing) {
await db.delete(favorites).where(
and(
eq(favorites.userId, userId),
eq(favorites.entityType, entityType),
eq(favorites.entityId, entityId)
)
);
return false;
} else {
await db.insert(favorites).values({
userId,
entityType,
entityId,
});
return true;
}
}

export async function getUserFavorites(
userId: string,
entityType: EntityType
) {
return db.query.favorites.findMany({
where: and(
eq(favorites.userId, userId),
eq(favorites.entityType, entityType)
),
});
}

Response Shapes​

Comment​

interface Comment {
id: string;
content: string;
parentId: string | null;
createdAt: string;
updatedAt: string;
author: {
id: string;
username: string;
displayName: string;
avatarUrl: string | null;
};
replies: Comment[];
}

Acceptance Criteria​

  • Toggle favorites (add/remove)
  • Get favorite status
  • List comments with nested replies
  • Add comment (top-level or reply)
  • Delete comment (owner only)
  • Add/update rating (1-5)
  • Get user's rating
  • Aggregate counts updated correctly

Notes​

  • Comments support one level of nesting (replies)
  • Ratings are upserted (one per user per entity)
  • Aggregate counts updated on each action
  • Same pattern used for Skills API