import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { db, conversations, conversationReads, messages, eq, and, desc, lt } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; import { isParticipant, otherParticipantId } from "@/lib/messaging"; import { isBlockedEitherWay } from "@/lib/blocks"; import { randomUUID } from "crypto"; interface RouteContext { params: Promise<{ id: string }>; } const Schema = z.object({ content: z.string().min(1).max(4000) }); const PAGE_SIZE = 50; export async function GET(req: NextRequest, { params }: RouteContext) { const { session, response } = await requireSession(); if (response) return response; const { id } = await params; const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) }); if (!conversation || !isParticipant(conversation, session!.user.id)) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Cursor-based pagination: fetch the page ending just before `before` // (an ISO createdAt timestamp), newest-first, then reverse so the client // still receives oldest-first order for the page. This avoids the old // `asc + limit(200)` bug, which permanently hid every message past the // 200th once a conversation grew beyond that. const before = req.nextUrl.searchParams.get("before"); const beforeDate = before ? new Date(before) : null; const validBefore = beforeDate && !isNaN(beforeDate.getTime()) ? beforeDate : null; const rows = await db .select() .from(messages) .where( validBefore ? and(eq(messages.conversationId, id), lt(messages.createdAt, validBefore)) : eq(messages.conversationId, id) ) .orderBy(desc(messages.createdAt)) .limit(PAGE_SIZE); const ordered = rows.slice().reverse(); const nextCursor = rows.length === PAGE_SIZE ? rows[rows.length - 1]!.createdAt.toISOString() : null; // Only mark the conversation read when loading the latest page, not when // paging through history. if (!validBefore) { await db .insert(conversationReads) .values({ conversationId: id, userId: session!.user.id, lastReadAt: new Date() }) .onConflictDoUpdate({ target: [conversationReads.conversationId, conversationReads.userId], set: { lastReadAt: new Date() }, }); } return NextResponse.json({ messages: ordered, nextCursor }); } export async function POST(req: NextRequest, { params }: RouteContext) { const { session, response } = await requireSession(); if (response) return response; const { id } = await params; const limited = await applyRateLimit(`rl:message:${session!.user.id}`, 30, 60); if (limited) return limited; const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) }); if (!conversation || !isParticipant(conversation, session!.user.id)) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const otherId = otherParticipantId(conversation, session!.user.id); if (await isBlockedEitherWay(session!.user.id, otherId)) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const body = await req.json() as unknown; const parsed = Schema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); const messageId = randomUUID(); await db.insert(messages).values({ id: messageId, conversationId: id, senderId: session!.user.id, content: parsed.data.content, }); await db.update(conversations).set({ lastMessageAt: new Date() }).where(eq(conversations.id, id)); return NextResponse.json({ id: messageId }, { status: 201 }); }