diff --git a/.env.example b/.env.example index b3b1b5d..1d4b317 100644 --- a/.env.example +++ b/.env.example @@ -25,13 +25,6 @@ NODE_MAILER_USER= NODE_MAILER_PASS= NODE_MAILER_SECURE=true -# Global chat conversation ID (seed with a cuid, e.g. from `npx cuid`) -GLOBAL_CONVERSATION_ID= -NEXT_PUBLIC_GLOBAL_CONVERSATION_ID= - -# Cron job secret (protects POST /api/cron/cleanup-messages) -CRON_SECRET= - # Norton Safe Web NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION= diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index e3ee248..107ab8e 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -17,7 +17,7 @@ export async function GET() { return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } - const [topUserStats, threads, messages, leaderboards, flexes] = + const [topUserStats, leaderboards, flexes] = await Promise.all([ prisma.userStats.findMany({ select: { @@ -35,8 +35,8 @@ export async function GET() { }, }, }), - prisma.conversation.count(), - prisma.message.count({ where: { expires_at: { gt: new Date() } } }), + // removed prisma.conversation.count here + // also removed prisma.message.count toooo prisma.leaderboard.count(), prisma.userFlex.count({ where: { expires_at: { gt: new Date() } } }), ]); @@ -54,8 +54,7 @@ export async function GET() { return NextResponse.json({ users, - totalThreads: threads, - totalMessages: messages, + // removed excess code from here too totalLeaderboards: leaderboards, totalFlexes: flexes, }); diff --git a/app/api/conversations/[id]/presence/route.ts b/app/api/conversations/[id]/presence/route.ts deleted file mode 100644 index 8259408..0000000 --- a/app/api/conversations/[id]/presence/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; - -export async function PATCH( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id: conversationId } = await params; - const { mark_read } = await req.json(); - - const timestamp = new Date(); - - const data: { last_seen_at: Date; last_read_at?: Date } = { - last_seen_at: timestamp, - }; - if (mark_read) { - data.last_read_at = timestamp; - } - - await prisma.conversationParticipant.updateMany({ - where: { conversation_id: conversationId, user_id: session.user.id }, - data, - }); - - return NextResponse.json({ success: true }); -} diff --git a/app/api/conversations/[id]/route.ts b/app/api/conversations/[id]/route.ts deleted file mode 100644 index e2cd72f..0000000 --- a/app/api/conversations/[id]/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; - -export async function DELETE( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - - const participant = await prisma.conversationParticipant.findUnique({ - where: { - conversation_id_user_id: { conversation_id: id, user_id: session.user.id }, - }, - }); - - if (!participant) { - return NextResponse.json({ error: "Not found." }, { status: 404 }); - } - - await prisma.conversation.delete({ where: { id } }); - - return NextResponse.json({ success: true }); -} diff --git a/app/api/conversations/[id]/unread/route.ts b/app/api/conversations/[id]/unread/route.ts deleted file mode 100644 index 1c5fb06..0000000 --- a/app/api/conversations/[id]/unread/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; - -export async function GET( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id: conversationId } = await params; - - const participant = await prisma.conversationParticipant.findUnique({ - where: { - conversation_id_user_id: { - conversation_id: conversationId, - user_id: session.user.id, - }, - }, - select: { last_read_at: true }, - }); - - if (!participant) { - return NextResponse.json({ count: 0 }); - } - - const count = await prisma.message.count({ - where: { - conversation_id: conversationId, - sender_id: { not: session.user.id }, - created_at: { gt: participant.last_read_at }, - expires_at: { gt: new Date() }, - }, - }); - - return NextResponse.json({ count }); -} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts deleted file mode 100644 index ca8395a..0000000 --- a/app/api/conversations/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; - -export async function GET() { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const participantRows = await prisma.conversationParticipant.findMany({ - where: { user_id: session.user.id }, - include: { - conversation: { - include: { - participants: { - select: { - user_id: true, - email: true, - last_seen_at: true, - last_read_at: true, - }, - }, - }, - }, - }, - }); - - const conversations = participantRows.map((row) => ({ - id: row.conversation_id, - type: row.conversation.type.toLowerCase(), - created_at: row.conversation.created_at.toISOString(), - last_read_at: row.last_read_at.toISOString(), - users: row.conversation.participants.map((p) => ({ - id: p.user_id, - email: p.email, - last_seen_at: p.last_seen_at.toISOString(), - })), - })); - - return NextResponse.json(conversations); -} - -export async function POST(req: Request) { - const session = await auth(); - if (!session?.user?.id || !session.user.email) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { other_user_id, other_user_email } = await req.json(); - - if (!other_user_id) { - return NextResponse.json( - { error: "other_user_id is required." }, - { status: 400 }, - ); - } - - const timestamp = new Date(); - const EPOCH = new Date("1970-01-01T00:00:00.000Z"); - - const conversation = await prisma.conversation.create({ - data: { - type: "PRIVATE", - participants: { - create: [ - { - user_id: session.user.id, - email: session.user.email, - last_seen_at: timestamp, - last_read_at: timestamp, - }, - { - user_id: other_user_id, - email: other_user_email ?? "", - last_seen_at: EPOCH, - last_read_at: EPOCH, - }, - ], - }, - }, - include: { - participants: { - select: { user_id: true, email: true, last_seen_at: true }, - }, - }, - }); - - return NextResponse.json( - { - id: conversation.id, - type: "private", - created_at: conversation.created_at.toISOString(), - last_read_at: timestamp.toISOString(), - users: conversation.participants.map((p) => ({ - id: p.user_id, - email: p.email, - last_seen_at: p.last_seen_at.toISOString(), - })), - }, - { status: 201 }, - ); -} diff --git a/app/api/cron/cleanup-messages/route.ts b/app/api/cron/cleanup-messages/route.ts deleted file mode 100644 index 8c62adc..0000000 --- a/app/api/cron/cleanup-messages/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/app/lib/prisma"; - -export async function POST(req: Request) { - const secret = req.headers.get("x-cron-secret"); - if (!secret || secret !== process.env.CRON_SECRET) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { count } = await prisma.message.deleteMany({ - where: { expires_at: { lt: new Date() } }, - }); - - return NextResponse.json({ deleted: count }); -} diff --git a/app/api/kanban/data/route.ts b/app/api/kanban/data/route.ts deleted file mode 100644 index 031c692..0000000 --- a/app/api/kanban/data/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { getKanbanData } from "@/app/lib/kanban"; - -export async function GET() { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - return NextResponse.json(await getKanbanData(session.user.id)); -} diff --git a/app/api/kanban/issues/[id]/route.ts b/app/api/kanban/issues/[id]/route.ts deleted file mode 100644 index 2eaabbb..0000000 --- a/app/api/kanban/issues/[id]/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; -import { emitter } from "@/app/lib/emitter"; -import { getColumnAccess, getIssueAccess } from "@/app/lib/kanban"; - -export async function PATCH( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - const body = await req.json(); - const { column_id, position } = body as { - column_id?: string; - position?: number; - }; - - const issueAccess = await getIssueAccess(session.user.id, id); - if (!issueAccess) { - return NextResponse.json({ error: "Issue not found." }, { status: 404 }); - } - - if (column_id !== undefined) { - const columnAccess = await getColumnAccess(session.user.id, column_id); - if (!columnAccess || columnAccess.project_id !== issueAccess.project_id) { - return NextResponse.json( - { error: "Cannot move issue to that column." }, - { status: 400 }, - ); - } - } - - const data: Record = {}; - if (column_id !== undefined) data.column_id = column_id; - if (position !== undefined) data.position = position; - - if (Object.keys(data).length === 0) { - return NextResponse.json({ error: "Nothing to update." }, { status: 400 }); - } - - const issue = await prisma.issue.update({ - where: { id }, - data: { ...data, updated_at: new Date() }, - }); - - const payload = { - type: "issue_updated", - data: { - id: issue.id, - column_id: issue.column_id, - position: issue.position, - }, - }; - emitter.emit("kanban", payload); - - return NextResponse.json({ - id: issue.id, - column_id: issue.column_id, - position: issue.position, - }); -} diff --git a/app/api/kanban/issues/route.ts b/app/api/kanban/issues/route.ts deleted file mode 100644 index 67357cb..0000000 --- a/app/api/kanban/issues/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; -import { emitter } from "@/app/lib/emitter"; -import { IssueType, IssuePriority } from "@prisma/client"; -import { getColumnAccess, getNextIssueKey } from "@/app/lib/kanban"; - -const TYPE_MAP: Record = { - bug: "BUG", - feature: "FEATURE", - chore: "CHORE", -}; - -const PRIORITY_MAP: Record = { - p0: "P0", - p1: "P1", - p2: "P2", -}; - -export async function POST(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { column_id, title, tag, type, priority, issue_key, position } = - await req.json(); - - if (!column_id || !title?.trim()) { - return NextResponse.json( - { error: "columnId and title are required." }, - { status: 400 }, - ); - } - - const columnAccess = await getColumnAccess(session.user.id, column_id); - if (!columnAccess) { - return NextResponse.json({ error: "Column not found." }, { status: 404 }); - } - - const resolvedIssueKey = - typeof issue_key === "string" && issue_key.trim().length > 0 - ? issue_key.trim() - : await getNextIssueKey(columnAccess.project_id, columnAccess.project_name); - - const issue = await prisma.issue.create({ - data: { - column_id: column_id, - issue_key: resolvedIssueKey, - title: title.trim(), - tag: tag ?? "", - type: TYPE_MAP[type] ?? "FEATURE", - priority: PRIORITY_MAP[priority] ?? "P2", - position: position ?? 0, - }, - }); - - const payload = { - id: issue.id, - column_id: issue.column_id, - issue_key: issue.issue_key, - title: issue.title, - tag: issue.tag ?? "", - type: issue.type.toLowerCase(), - priority: issue.priority.toLowerCase(), - position: issue.position, - created_at: issue.created_at.toISOString(), - }; - - emitter.emit("kanban", { type: "issue_created", data: payload }); - - return NextResponse.json(payload, { status: 201 }); -} diff --git a/app/api/kanban/projects/route.ts b/app/api/kanban/projects/route.ts deleted file mode 100644 index 3c14a74..0000000 --- a/app/api/kanban/projects/route.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; -import { getKanbanData } from "@/app/lib/kanban"; - -const DEFAULT_COLUMNS = ["Backlog", "In Progress", "Done"]; - -export async function GET() { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const data = await getKanbanData(session.user.id); - return NextResponse.json({ - projects: data.projects, - wakatime_projects: data.wakatime_projects, - }); -} - -export async function POST(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = (await req.json()) as { - name?: string; - description?: string; - wakatime_project_name?: string; - }; - - const name = body.name?.trim(); - if (!name) { - return NextResponse.json( - { error: "Project name is required." }, - { status: 400 }, - ); - } - - const existing = await prisma.$queryRaw>` - SELECT id - FROM projects - WHERE user_id = ${session.user.id} - AND LOWER(name) = LOWER(${name}) - LIMIT 1 - `; - - if (existing.length > 0) { - return NextResponse.json( - { error: "A Kanban project with that name already exists." }, - { status: 409 }, - ); - } - - const projectId = crypto.randomUUID(); - const boardId = crypto.randomUUID(); - const safeDescription = body.description?.trim() || null; - const safeWakaName = body.wakatime_project_name?.trim() || null; - const now = new Date(); - - await prisma.$transaction(async (tx) => { - await tx.$executeRaw` - INSERT INTO projects ( - id, - user_id, - name, - description, - wakatime_project_name, - created_at - ) VALUES ( - ${projectId}, - ${session.user.id}, - ${name}, - ${safeDescription}, - ${safeWakaName}, - ${now} - ) - `; - - await tx.$executeRaw` - INSERT INTO boards ( - id, - project_id, - title, - description, - created_at - ) VALUES ( - ${boardId}, - ${projectId}, - ${`${name} Board`}, - ${safeDescription}, - ${now} - ) - `; - - for (const [index, columnTitle] of DEFAULT_COLUMNS.entries()) { - await tx.$executeRaw` - INSERT INTO columns ( - id, - board_id, - title, - position, - created_at - ) VALUES ( - ${crypto.randomUUID()}, - ${boardId}, - ${columnTitle}, - ${index}, - ${now} - ) - `; - } - }); - - const data = await getKanbanData(session.user.id); - const project = data.projects.find((entry) => entry.id === projectId); - - return NextResponse.json(project, { status: 201 }); -} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts deleted file mode 100644 index 66b3955..0000000 --- a/app/api/messages/route.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; -import { emitter } from "@/app/lib/emitter"; - -export async function GET(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { searchParams } = new URL(req.url); - const conversationId = searchParams.get("conversationId"); - - if (!conversationId) { - return NextResponse.json( - { error: "conversationId is required." }, - { status: 400 }, - ); - } - - const participant = await prisma.conversationParticipant.findUnique({ - where: { - conversation_id_user_id: { - conversation_id: conversationId, - user_id: session.user.id, - }, - }, - }); - - if (!participant) { - return NextResponse.json({ error: "Forbidden." }, { status: 403 }); - } - - const messages = await prisma.message.findMany({ - where: { - conversation_id: conversationId, - expires_at: { gt: new Date() }, - }, - orderBy: { created_at: "asc" }, - }); - - return NextResponse.json( - messages.map((m) => ({ - id: m.id, - conversation_id: m.conversation_id, - sender_id: m.sender_id, - text: m.text, - attachments: m.attachments, - created_at: m.created_at.toISOString(), - })), - ); -} - -export async function POST(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { conversation_id, text, attachments } = await req.json(); - - if ( - !conversation_id || - (!text?.trim() && (!attachments || attachments.length === 0)) - ) { - return NextResponse.json( - { error: "conversationId and text are required." }, - { status: 400 }, - ); - } - - const participant = await prisma.conversationParticipant.findUnique({ - where: { - conversation_id_user_id: { - conversation_id, - user_id: session.user.id, - }, - }, - }); - - if (!participant) { - return NextResponse.json({ error: "Forbidden." }, { status: 403 }); - } - - const message = await prisma.message.create({ - data: { - conversation_id, - sender_id: session.user.id, - text: text?.trim() ?? "", - attachments: attachments ?? [], - }, - }); - - const payload = { - id: message.id, - conversation_id: message.conversation_id, - sender_id: message.sender_id, - text: message.text, - attachments: message.attachments, - created_at: message.created_at.toISOString(), - }; - - emitter.emit(`chat:${conversation_id}`, { type: "message", data: payload }); - - const participants = await prisma.conversationParticipant.findMany({ - where: { - conversation_id, - user_id: { not: session.user.id }, - }, - select: { user_id: true }, - }); - - for (const p of participants) { - emitter.emit(`user:${p.user_id}`, { - type: "new_message", - data: { conversation_id, sender_id: session.user.id }, - }); - } - - return NextResponse.json(payload, { status: 201 }); -} diff --git a/app/api/presence/route.ts b/app/api/presence/route.ts deleted file mode 100644 index 1d2e51f..0000000 --- a/app/api/presence/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; - -export async function PATCH() { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const timestamp = new Date(); - - await prisma.conversationParticipant.updateMany({ - where: { user_id: session.user.id }, - data: { last_seen_at: timestamp }, - }); - - return NextResponse.json({ success: true }); -} diff --git a/app/api/sse/chat/[conversationId]/route.ts b/app/api/sse/chat/[conversationId]/route.ts deleted file mode 100644 index ab7c5eb..0000000 --- a/app/api/sse/chat/[conversationId]/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { auth } from "@/app/lib/auth"; -import { emitter } from "@/app/lib/emitter"; -import { prisma } from "@/app/lib/prisma"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET( - req: Request, - { params }: { params: Promise<{ conversationId: string }> }, -) { - const session = await auth(); - if (!session?.user?.id) { - return new Response("Unauthorized", { status: 401 }); - } - - const { conversationId } = await params; - - const participant = await prisma.conversationParticipant.findUnique({ - where: { - conversation_id_user_id: { - conversation_id: conversationId, - user_id: session.user.id, - }, - }, - }); - - if (!participant) { - return new Response("Forbidden", { status: 403 }); - } - - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - const send = (event: unknown) => { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(event)}\n\n`), - ); - }; - - emitter.on(`chat:${conversationId}`, send); - - req.signal.addEventListener("abort", () => { - emitter.off(`chat:${conversationId}`, send); - controller.close(); - }); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }, - }); -} diff --git a/app/api/sse/chat/[conversationId]/typing/route.ts b/app/api/sse/chat/[conversationId]/typing/route.ts deleted file mode 100644 index 4e9f939..0000000 --- a/app/api/sse/chat/[conversationId]/typing/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { emitter } from "@/app/lib/emitter"; -import { prisma } from "@/app/lib/prisma"; - -export async function POST( - req: Request, - { params }: { params: Promise<{ conversationId: string }> }, -) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { conversationId } = await params; - - const participant = await prisma.conversationParticipant.findUnique({ - where: { - conversation_id_user_id: { - conversation_id: conversationId, - user_id: session.user.id, - }, - }, - }); - - if (!participant) { - return NextResponse.json({ error: "Forbidden." }, { status: 403 }); - } - - const { is_typing } = await req.json(); - - emitter.emit(`chat:${conversationId}`, { - type: "typing", - data: { - conversation_id: conversationId, - user_id: session.user.id, - email: participant.email, - is_typing: Boolean(is_typing), - }, - }); - - return NextResponse.json({ success: true }); -} diff --git a/app/api/sse/conversations/route.ts b/app/api/sse/conversations/route.ts deleted file mode 100644 index 46023ab..0000000 --- a/app/api/sse/conversations/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { auth } from "@/app/lib/auth"; -import { emitter } from "@/app/lib/emitter"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return new Response("Unauthorized", { status: 401 }); - } - - const userId = session.user.id; - const encoder = new TextEncoder(); - - const stream = new ReadableStream({ - start(controller) { - const send = (event: unknown) => { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(event)}\n\n`), - ); - }; - - emitter.on(`user:${userId}`, send); - - req.signal.addEventListener("abort", () => { - emitter.off(`user:${userId}`, send); - controller.close(); - }); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }, - }); -} diff --git a/app/api/sse/kanban/route.ts b/app/api/sse/kanban/route.ts deleted file mode 100644 index 077bc36..0000000 --- a/app/api/sse/kanban/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { auth } from "@/app/lib/auth"; -import { emitter } from "@/app/lib/emitter"; - -export async function GET(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return new Response("Unauthorized", { status: 401 }); - } - - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - - const send = (data: unknown) => { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); - }; - - emitter.on("kanban", send); - - req.signal.addEventListener("abort", () => { - emitter.off("kanban", send); - controller.close(); - }); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -} diff --git a/app/api/users/badges/route.ts b/app/api/users/badges/route.ts deleted file mode 100644 index f6ef4e7..0000000 --- a/app/api/users/badges/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; - -export async function GET(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { searchParams } = new URL(req.url); - const ids = searchParams.getAll("id"); - - if (ids.length === 0) { - return NextResponse.json([]); - } - - const stats = await prisma.userStats.findMany({ - where: { user_id: { in: ids } }, - select: { user_id: true, total_seconds: true }, - }); - - return NextResponse.json( - stats.map((s) => ({ - user_id: s.user_id, - total_seconds: Number(s.total_seconds), - })), - ); -} diff --git a/app/api/users/route.ts b/app/api/users/route.ts deleted file mode 100644 index f626d9c..0000000 --- a/app/api/users/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/lib/auth"; -import { prisma } from "@/app/lib/prisma"; - -export async function GET(req: Request) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { searchParams } = new URL(req.url); - const conversationId = searchParams.get("conversationId"); - - if (!conversationId) { - return NextResponse.json( - { error: "conversationId is required." }, - { status: 400 }, - ); - } - - const participants = await prisma.conversationParticipant.findMany({ - where: { - conversation_id: conversationId, - user_id: { not: session.user.id }, - }, - select: { user_id: true, email: true }, - }); - - const users = participants - .filter((p) => p.email) - .map((p) => ({ user_id: p.user_id, email: p.email })) - .sort((a, b) => a.email.localeCompare(b.email)); - - return NextResponse.json(users); -} diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx deleted file mode 100644 index 61444e1..0000000 --- a/app/components/Chat.tsx +++ /dev/null @@ -1,845 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faFile, - faPaperPlane, - faPlus, - faSearch, - faInfoCircle, - faBellSlash, - faChevronDown, - faChevronUp, - faXmark, - faChevronLeft, - faTrash, - faPlay, -} from "@fortawesome/free-solid-svg-icons"; -import Conversations from "./chat/Conversations"; -import Messages from "./chat/Messages"; -import MediaViewerModal, { - type MediaViewerPayload, -} from "./chat/MediaViewerModal"; -import { useActiveConversationStream } from "./chat/hooks/useActiveConversationStream"; -import { useChatAttachmentInput } from "./chat/hooks/useChatAttachmentInput"; -import { useChatBadWords } from "./chat/hooks/useChatBadWords"; -import { useChatBadges } from "./chat/hooks/useChatBadges"; -import { useChatConversationActions } from "./chat/hooks/useChatConversationActions"; -import { useChatConversationsRealtime } from "./chat/hooks/useChatConversationsRealtime"; -import { useChatInputBehavior } from "./chat/hooks/useChatInputBehavior"; -import { useChatMessageComposer } from "./chat/hooks/useChatMessageComposer"; -import { useChatPresence } from "./chat/hooks/useChatPresence"; -import { useChatTyping } from "./chat/hooks/useChatTyping"; -import { useChatUserPicker } from "./chat/hooks/useChatUserPicker"; -import Image from "next/image"; -import { toast } from "react-toastify"; - -export interface Conversation { - id: string; - users: { id: string; email: string }[]; - type: string; - created_at?: string; -} - -export interface Message { - id: string; - conversation_id: string; - sender_id: string; - text: string; - attachments: { - filename: string; - mimetype: string; - filesize: number; - public_url: string; - }[]; - created_at: string; - optimistic?: boolean; -} - -export interface ChatUser { - user_id: string; - email: string; -} - -export interface ConversationParticipant { - id: string; - users: { - user_id: string; - }[]; -} - -export interface ConversationParticipantRow { - email: string; - conversation: ConversationParticipant[]; -} - -interface ParticipantPresence { - last_seen_at: string | null; - last_read_at: string | null; -} - -export interface TypingState { - user_id: string; - label: string; -} - -export interface ChatUserShape { - id: string; - email: string | null; - name?: string | null; -} - -const GLOBAL_CONVERSATION_ID = - process.env.NEXT_PUBLIC_GLOBAL_CONVERSATION_ID ?? - "00000000-0000-0000-0000-000000000001"; -const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; -const MAX_PRESENCE_FUTURE_SKEW_MS = 30_000; -const PRESENCE_HEARTBEAT_MS = 45_000; -const READ_RECEIPT_THROTTLE_MS = 1_500; -const TYPING_INACTIVE_TIMEOUT_MS = 1_800; -const TYPING_REMOTE_EXPIRE_MS = 2_500; -const PRESENCE_UNSEEN_AT_ISO = "1970-01-01T00:00:00.000Z"; - -export default function Chat({ user }: { user: ChatUserShape }) { - const [conversations, setConversations] = useState([]); - const [conversationId, setConversationId] = useState(null); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [showModal, setShowModal] = useState(false); - const [messageSearch, setMessageSearch] = useState(""); - const [unreadCountByConversationId, setUnreadCountByConversationId] = - useState>({}); - const [, setParticipantMetaByConversationId] = useState< - Record - >({}); - const conversationIdsRef = useRef>(new Set()); - const activeConversationIdRef = useRef(null); - const bottomRef = useRef(null); - const [dmSortOrder, setDmSortOrder] = useState< - "newest" | "oldest" | "az" | "za" - >("newest"); - const [isDmSortOpen, setIsDmSortOpen] = useState(false); - const creatingRef = useRef(false); - const fileInputRef = useRef(null); - const [showRightSidebar, setShowRightSidebar] = useState(false); - const [showAllMedia, setShowAllMedia] = useState(false); - const [mediaViewer, setMediaViewer] = useState( - null, - ); - const lastReadSyncAtRef = useRef>({}); - - const { - setLastSeenByUserId, - onlineByUserId, - fetchUnreadCountsForConversations, - markConversationAsRead, - } = useChatPresence({ - userId: user.id, - onlineTimeoutMs: ONLINE_TIMEOUT_MS, - maxPresenceFutureSkewMs: MAX_PRESENCE_FUTURE_SKEW_MS, - presenceHeartbeatMs: PRESENCE_HEARTBEAT_MS, - readReceiptThrottleMs: READ_RECEIPT_THROTTLE_MS, - setParticipantMetaByConversationId, - setUnreadCountByConversationId, - lastReadSyncAtRef, - }); - - const { - typingByConversationId, - setRemoteTypingState, - stopTyping, - markTypingFromInput, - } = useChatTyping({ - userId: user.id, - userEmail: user.email ?? "", - typingInactiveTimeoutMs: TYPING_INACTIVE_TIMEOUT_MS, - typingRemoteExpireMs: TYPING_REMOTE_EXPIRE_MS, - }); - - const { - attachments, - setAttachments, - isDraggingOver, - handleFileChange, - handleDrop, - handleDragOver, - onDragLeave, - handlePaste, - removeAttachment, - } = useChatAttachmentInput(); - - const { search, setSearch, allUsers, filteredUsers } = useChatUserPicker({ - userId: user.id, - showModal, - globalConversationId: GLOBAL_CONVERSATION_ID, - }); - - const { badgesByUserId } = useChatBadges({ - userId: user.id, - conversations, - }); - - const { badWords } = useChatBadWords(); - - useEffect(() => { - conversationIdsRef.current = new Set(conversations.map((conv) => conv.id)); - }, [conversations]); - - useEffect(() => { - activeConversationIdRef.current = conversationId; - }, [conversationId]); - - useChatConversationsRealtime({ - userId: user.id, - userEmail: user.email ?? "", - globalConversationId: GLOBAL_CONVERSATION_ID, - conversationIdsRef, - activeConversationIdRef, - setConversations, - setParticipantMetaByConversationId, - setLastSeenByUserId, - fetchUnreadCountsForConversations, - markConversationAsRead, - }); - - useActiveConversationStream({ - conversationId, - userId: user.id, - bottomRef, - setMessages, - markConversationAsRead, - setRemoteTypingState, - stopTyping, - }); - - const { - createConversation, - openPrivateChatFromGlobalProfile, - handleDeleteConversation, - } = useChatConversationActions({ - userId: user.id, - userEmail: user.email, - conversationId, - conversations, - creatingRef, - unseenPresenceIso: PRESENCE_UNSEEN_AT_ISO, - setConversationId, - setShowModal, - setShowRightSidebar, - setConversations, - setUnreadCountByConversationId, - setParticipantMetaByConversationId, - }); - - const { sendMessage, isSendingMessage } = useChatMessageComposer({ - userId: user.id, - conversationId, - input, - badWords, - bottomRef, - setInput, - setMessages, - stopTyping, - markConversationAsRead, - }); - - const { textareaRef, handleInputChange, handleInputKeyDown } = - useChatInputBehavior({ - input, - isSendingMessage, - conversationId, - attachmentsCount: attachments.length, - setInput, - markTypingFromInput, - sendMessage, - maxChars: 1000, - }); - - const canSendMessage = - !isSendingMessage && (input.trim().length > 0 || attachments.length > 0); - - const globalConversations = conversations.filter((c) => c.type === "global"); - const privateConversations = conversations - .filter((c) => c.type !== "global") - .sort((a, b) => { - if (dmSortOrder === "newest") { - return ( - (b.created_at ? new Date(b.created_at).getTime() : 0) - - (a.created_at ? new Date(a.created_at).getTime() : 0) - ); - } - if (dmSortOrder === "oldest") { - return ( - (a.created_at ? new Date(a.created_at).getTime() : 0) - - (b.created_at ? new Date(b.created_at).getTime() : 0) - ); - } - - const aName = - a.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; - const bName = - b.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; - - if (dmSortOrder === "az") { - return aName.localeCompare(bName); - } - if (dmSortOrder === "za") { - return bName.localeCompare(aName); - } - return 0; - }); - - const activeConversation = conversations.find((c) => c.id === conversationId); - const activeOtherUser = activeConversation?.users.find( - (u) => u.id !== user.id, - ); - const isGlobalActive = activeConversation?.type === "global"; - const activeOtherUserOnline = - !!activeOtherUser?.id && !!onlineByUserId[activeOtherUser.id]; - const activeTypingState = conversationId - ? typingByConversationId[conversationId] - : undefined; - - const activeLabel = isGlobalActive - ? "Global" - : activeOtherUser?.email?.split("@")[0] || "Unknown"; - - const activeSublabel = isGlobalActive - ? "Worldwide" - : activeOtherUserOnline - ? "Online" - : "Offline"; - - const activeSublabelClass = activeOtherUserOnline - ? "text-emerald-600" - : "text-gray-500"; - - const typingIndicatorText = activeTypingState - ? isGlobalActive - ? `${activeTypingState.label} is typing...` - : "Typing..." - : ""; - - const activeInitials = isGlobalActive - ? "G" - : (activeOtherUser?.email?.[0]?.toUpperCase() ?? "?"); - - const allMediaAttachments = useMemo(() => { - return messages - .flatMap((m) => m.attachments || []) - .filter( - (a) => - a?.mimetype?.startsWith("image/") || - a?.mimetype?.startsWith("video/"), - ) - .reverse(); - }, [messages]); - - // Performance optimization: Memoize the filtered messages to avoid O(N) string manipulation on every keystroke - // when typing a message (which triggers a re-render of Chat.tsx). This significantly improves typing latency - // in conversations with many messages. - const filteredMessages = useMemo(() => { - if (!messageSearch) return messages; - const lowerSearch = messageSearch.toLowerCase(); - return messages.filter((m) => - (m.text || "").toLowerCase().includes(lowerSearch), - ); - }, [messages, messageSearch]); - - return ( - <> - -
- {/* Left Sidebar */} -
-
-
-

- Messages -

- -
-
- - setMessageSearch(e.target.value)} - placeholder="Search Message..." - className="w-full bg-gray-50 border border-transparent rounded py-2 pl-9 pr-4 text-sm text-gray-700 placeholder:text-gray-500 outline-none focus:border-blue-500/50 transition-colors" - /> -
-
- -
-
-

- ROOMS -

- -
- -
-
-

- DIRECT MESSAGE -

-
- setIsDmSortOpen(!isDmSortOpen)} - className="text-[10px] text-gray-500 bg-gray-50 px-2 py-0.5 rounded cursor-pointer hover:bg-gray-100 flex items-center gap-1 select-none" - > - {dmSortOrder === "newest" && "Newest"} - {dmSortOrder === "oldest" && "Oldest"} - {dmSortOrder === "az" && "A-Z"} - {dmSortOrder === "za" && "Z-A"} - - - - {isDmSortOpen && ( -
- - - - -
- )} -
-
- -
-
-
- - {/* Middle Chat Area */} -
- {conversationId ? ( - <> - {/* Header */} -
-
- -
-

- {activeLabel} -

-

- {activeSublabel} -

-
-
-
- -
-
- -
- -
- - {activeTypingState && ( -
-
-
- - - -
- {typingIndicatorText} -
-
- )} - - {/* Input Area */} -
- {attachments.length > 0 && ( -
- {attachments.map((file, index) => ( -
- {file.type.startsWith("image/") ? ( - {file.name} - ) : ( - - )} - - {file.name} - - -
- ))} -
- )} - -
- {/**/} - - -
-