sysrot-hub
Version:
CLI de nueva generación para proyectos Next.js 14+ con IA multi-modelo, Web3 integration, internacionalización completa y roadmap realista 2025-2026
839 lines (731 loc) • 22.4 kB
text/typescript
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
import { getSession } from 'next-auth/react';
import { z } from 'zod';
const prisma = new PrismaClient();
// Validation schemas
const sendMessageSchema = z.object({
roomId: z.string().min(1, 'Room ID is required'),
content: z.string().min(1, 'Message content is required').max(4000, 'Message too long'),
type: z.enum(['TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'FILE', 'STICKER', 'GIF']).default('TEXT'),
parentId: z.string().optional(), // For threading
metadata: z.record(z.any()).optional() // For mentions, formatting, etc.
});
const editMessageSchema = z.object({
content: z.string().min(1, 'Message content is required').max(4000, 'Message too long')
});
const reactionSchema = z.object({
emoji: z.string().min(1, 'Emoji is required').max(50, 'Emoji too long')
});
const typingSchema = z.object({
roomId: z.string().min(1, 'Room ID is required'),
isTyping: z.boolean()
});
async function hasRoomAccess(userId: string, roomId: string) {
const participant = await prisma.chatParticipant.findUnique({
where: {
roomId_userId: {
roomId,
userId
}
}
});
return participant && !participant.isBlocked;
}
async function canModifyMessage(userId: string, messageId: string) {
const message = await prisma.chatMessage.findUnique({
where: { id: messageId },
include: {
room: {
include: {
participants: {
where: { userId }
}
}
}
}
});
if (!message || !message.userId) return false;
const participant = message.room.participants[0];
if (!participant || participant.isBlocked) return false;
// User can modify their own messages or admins can modify any message
return message.userId === userId || ['OWNER', 'ADMIN', 'MODERATOR'].includes(participant.role);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const session = await getSession({ req });
if (!session?.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { method, query } = req;
const { id, action } = query;
// Handle specific actions
if (action === 'reaction' && method === 'POST') {
return await addReaction(req, res);
} else if (action === 'reaction' && method === 'DELETE') {
return await removeReaction(req, res);
} else if (action === 'read' && method === 'POST') {
return await markAsRead(req, res);
} else if (action === 'typing' && method === 'POST') {
return await updateTypingStatus(req, res);
}
switch (method) {
case 'GET':
if (id) {
return await getMessage(req, res);
} else {
return await getRoomMessages(req, res);
}
case 'POST':
return await sendMessage(req, res);
case 'PUT':
return await editMessage(req, res);
case 'DELETE':
return await deleteMessage(req, res);
default:
res.setHeader('Allow', ['GET', 'POST', 'PUT', 'DELETE']);
return res.status(405).end('Method Not Allowed');
}
} catch (error) {
console.error('Chat messages API error:', error);
return res.status(500).json({ error: 'Internal server error' });
} finally {
await prisma.$disconnect();
}
}
async function getRoomMessages(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const { roomId, limit = '50', before, after } = req.query;
if (!roomId || typeof roomId !== 'string') {
return res.status(400).json({ error: 'Room ID is required' });
}
try {
// Check room access
const hasAccess = await hasRoomAccess(session.user.id, roomId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
// Build pagination where clause
const whereClause: any = {
roomId,
deletedAt: null
};
if (before && typeof before === 'string') {
whereClause.createdAt = { lt: new Date(before) };
} else if (after && typeof after === 'string') {
whereClause.createdAt = { gt: new Date(after) };
}
const messages = await prisma.chatMessage.findMany({
where: whereClause,
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
},
attachments: true,
reactions: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
},
readReceipts: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
},
orderBy: {
readAt: 'desc'
}
},
parent: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
},
_count: {
select: {
replies: true
}
}
},
orderBy: {
createdAt: before ? 'desc' : 'asc'
},
take: parseInt(limit as string)
});
// If using 'before' pagination, reverse the results to maintain chronological order
if (before) {
messages.reverse();
}
// Group reactions by emoji
const messagesWithGroupedReactions = messages.map(message => ({
...message,
reactions: groupReactionsByEmoji(message.reactions)
}));
res.status(200).json({
messages: messagesWithGroupedReactions,
hasMore: messages.length === parseInt(limit as string)
});
} catch (error) {
console.error('Error fetching messages:', error);
res.status(500).json({ error: 'Failed to fetch messages' });
}
}
async function getMessage(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const { id } = req.query;
if (!id || typeof id !== 'string') {
return res.status(400).json({ error: 'Message ID is required' });
}
try {
const message = await prisma.chatMessage.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
},
room: {
select: {
id: true,
name: true,
type: true
}
},
attachments: true,
reactions: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
},
readReceipts: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
},
parent: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
},
replies: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
},
reactions: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
}
},
orderBy: {
createdAt: 'asc'
}
}
}
});
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
// Check room access
const hasAccess = await hasRoomAccess(session.user.id, message.roomId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
// Group reactions by emoji
const messageWithGroupedReactions = {
...message,
reactions: groupReactionsByEmoji(message.reactions),
replies: message.replies.map(reply => ({
...reply,
reactions: groupReactionsByEmoji(reply.reactions)
}))
};
res.status(200).json({ message: messageWithGroupedReactions });
} catch (error) {
console.error('Error fetching message:', error);
res.status(500).json({ error: 'Failed to fetch message' });
}
}
async function sendMessage(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
try {
const validatedData = sendMessageSchema.parse(req.body);
const { roomId, content, type, parentId, metadata } = validatedData;
// Check room access
const hasAccess = await hasRoomAccess(session.user.id, roomId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied to this room' });
}
// If replying to a message, verify parent exists and is in same room
if (parentId) {
const parentMessage = await prisma.chatMessage.findUnique({
where: { id: parentId },
select: { roomId: true }
});
if (!parentMessage || parentMessage.roomId !== roomId) {
return res.status(400).json({ error: 'Invalid parent message' });
}
}
// Create the message
const message = await prisma.chatMessage.create({
data: {
roomId,
userId: session.user.id,
content,
type,
parentId,
metadata
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
},
parent: parentId ? {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
} : false,
attachments: true,
reactions: true,
readReceipts: true
}
});
// Update room's updatedAt timestamp
await prisma.chatRoom.update({
where: { id: roomId },
data: { updatedAt: new Date() }
});
// Clear typing indicator for this user
await prisma.chatTypingIndicator.deleteMany({
where: {
roomId,
userId: session.user.id
}
});
// Auto-mark as read for sender
await prisma.chatReadReceipt.create({
data: {
messageId: message.id,
userId: session.user.id
}
});
// TODO: Send real-time notification to room participants
// This would be handled by WebSocket server
res.status(201).json({
message: {
...message,
reactions: []
},
timestamp: new Date().toISOString()
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
console.error('Error sending message:', error);
res.status(500).json({ error: 'Failed to send message' });
}
}
async function editMessage(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const { id } = req.query;
if (!id || typeof id !== 'string') {
return res.status(400).json({ error: 'Message ID is required' });
}
try {
const validatedData = editMessageSchema.parse(req.body);
const { content } = validatedData;
// Check if user can modify this message
const canModify = await canModifyMessage(session.user.id, id);
if (!canModify) {
return res.status(403).json({ error: 'Cannot edit this message' });
}
// Check message age (can only edit messages within 24 hours)
const message = await prisma.chatMessage.findUnique({
where: { id },
select: { createdAt: true, type: true }
});
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const hoursSinceCreated = (Date.now() - message.createdAt.getTime()) / (1000 * 60 * 60);
if (hoursSinceCreated > 24) {
return res.status(400).json({ error: 'Cannot edit messages older than 24 hours' });
}
// Cannot edit system messages
if (message.type === 'SYSTEM') {
return res.status(400).json({ error: 'Cannot edit system messages' });
}
// Update the message
const updatedMessage = await prisma.chatMessage.update({
where: { id },
data: {
content,
editedAt: new Date()
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
},
attachments: true,
reactions: {
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
}
}
});
res.status(200).json({
message: {
...updatedMessage,
reactions: groupReactionsByEmoji(updatedMessage.reactions)
}
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
console.error('Error editing message:', error);
res.status(500).json({ error: 'Failed to edit message' });
}
}
async function deleteMessage(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const { id } = req.query;
if (!id || typeof id !== 'string') {
return res.status(400).json({ error: 'Message ID is required' });
}
try {
// Check if user can modify this message
const canModify = await canModifyMessage(session.user.id, id);
if (!canModify) {
return res.status(403).json({ error: 'Cannot delete this message' });
}
const message = await prisma.chatMessage.findUnique({
where: { id },
select: { type: true, createdAt: true }
});
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
// Cannot delete system messages
if (message.type === 'SYSTEM') {
return res.status(400).json({ error: 'Cannot delete system messages' });
}
// Soft delete - mark as deleted instead of actually deleting
await prisma.chatMessage.update({
where: { id },
data: {
content: '[Message deleted]',
deletedAt: new Date(),
metadata: { deleted: true, deletedBy: session.user.id }
}
});
res.status(200).json({
message: 'Message deleted successfully'
});
} catch (error) {
console.error('Error deleting message:', error);
res.status(500).json({ error: 'Failed to delete message' });
}
}
async function addReaction(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const { id } = req.query; // message ID
if (!id || typeof id !== 'string') {
return res.status(400).json({ error: 'Message ID is required' });
}
try {
const validatedData = reactionSchema.parse(req.body);
const { emoji } = validatedData;
// Get message and check room access
const message = await prisma.chatMessage.findUnique({
where: { id },
select: { roomId: true }
});
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const hasAccess = await hasRoomAccess(session.user.id, message.roomId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
// Add or update reaction
const reaction = await prisma.chatReaction.upsert({
where: {
messageId_userId_emoji: {
messageId: id,
userId: session.user.id,
emoji
}
},
update: {
createdAt: new Date() // Update timestamp if re-reacting
},
create: {
messageId: id,
userId: session.user.id,
emoji
},
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
});
res.status(200).json({ reaction });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
console.error('Error adding reaction:', error);
res.status(500).json({ error: 'Failed to add reaction' });
}
}
async function removeReaction(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const { id, emoji } = req.query; // message ID and emoji
if (!id || typeof id !== 'string' || !emoji || typeof emoji !== 'string') {
return res.status(400).json({ error: 'Message ID and emoji are required' });
}
try {
// Get message and check room access
const message = await prisma.chatMessage.findUnique({
where: { id },
select: { roomId: true }
});
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const hasAccess = await hasRoomAccess(session.user.id, message.roomId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
// Remove reaction
await prisma.chatReaction.delete({
where: {
messageId_userId_emoji: {
messageId: id,
userId: session.user.id,
emoji
}
}
});
res.status(200).json({ message: 'Reaction removed' });
} catch (error) {
console.error('Error removing reaction:', error);
res.status(500).json({ error: 'Failed to remove reaction' });
}
}
async function markAsRead(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const { id } = req.query; // message ID
if (!id || typeof id !== 'string') {
return res.status(400).json({ error: 'Message ID is required' });
}
try {
// Get message and check room access
const message = await prisma.chatMessage.findUnique({
where: { id },
select: { roomId: true, userId: true }
});
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
const hasAccess = await hasRoomAccess(session.user.id, message.roomId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
// Don't create read receipt for own messages
if (message.userId === session.user.id) {
return res.status(200).json({ message: 'Cannot mark own message as read' });
}
// Create read receipt
const readReceipt = await prisma.chatReadReceipt.upsert({
where: {
messageId_userId: {
messageId: id,
userId: session.user.id
}
},
update: {
readAt: new Date()
},
create: {
messageId: id,
userId: session.user.id
},
include: {
user: {
select: {
id: true,
name: true,
image: true
}
}
}
});
res.status(200).json({ readReceipt });
} catch (error) {
console.error('Error marking message as read:', error);
res.status(500).json({ error: 'Failed to mark message as read' });
}
}
async function updateTypingStatus(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
try {
const validatedData = typingSchema.parse(req.body);
const { roomId, isTyping } = validatedData;
// Check room access
const hasAccess = await hasRoomAccess(session.user.id, roomId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
if (isTyping) {
// Create or update typing indicator
await prisma.chatTypingIndicator.upsert({
where: {
roomId_userId: {
roomId,
userId: session.user.id
}
},
update: {
lastTypedAt: new Date(),
isTyping: true
},
create: {
roomId,
userId: session.user.id,
isTyping: true
}
});
} else {
// Remove typing indicator
await prisma.chatTypingIndicator.deleteMany({
where: {
roomId,
userId: session.user.id
}
});
}
res.status(200).json({ message: 'Typing status updated' });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors
});
}
console.error('Error updating typing status:', error);
res.status(500).json({ error: 'Failed to update typing status' });
}
}
// Helper function to group reactions by emoji
function groupReactionsByEmoji(reactions: any[]) {
const grouped = reactions.reduce((acc, reaction) => {
const emoji = reaction.emoji;
if (!acc[emoji]) {
acc[emoji] = {
emoji,
count: 0,
users: [],
hasReacted: false
};
}
acc[emoji].count++;
acc[emoji].users.push(reaction.user);
return acc;
}, {});
return Object.values(grouped);
}