diff --git a/src/app/(dashboard)/chat/chat-page-client.tsx b/src/app/(dashboard)/chat/chat-page-client.tsx new file mode 100644 index 0000000..da4fad8 --- /dev/null +++ b/src/app/(dashboard)/chat/chat-page-client.tsx @@ -0,0 +1,75 @@ +"use client" + +import { useEffect, useState } from "react" +import { useAuth } from "@/providers/auth-provider" +import { useChatStore } from "@/stores/chat-store" +import { useConversations } from "@/hooks/iam/use-chat" +import { ConversationList } from "@/components/iam/chat/conversation-list" +import { MessageThread } from "@/components/iam/chat/message-thread" +import { GroupSettingsPanel } from "@/components/iam/chat/group-settings-panel" +import { PageHeader } from "@/components/common/page-header" +import { EmptyState } from "@/components/common/empty-state" +import { getConversationDisplayName } from "@/types/iam/chat" +import ChatLoading from "./loading" + +export function ChatPageClient() { + const { user, isLoading: authLoading } = useAuth() + const currentUserId = user?.userId ?? "" + + const activeId = useChatStore((s) => s.activeConversationId) + const setActive = useChatStore((s) => s.setActiveConversation) + const conversations = useChatStore((s) => s.conversations) + const setConversations = useChatStore((s) => s.setConversations) + const [settingsOpen, setSettingsOpen] = useState(false) + + const { data } = useConversations() + useEffect(() => { + if (data) setConversations(data) + }, [data, setConversations]) + + if (authLoading) return + + const activeConv = conversations.find((c) => c.conversationId === activeId) + + return ( +
+ +
+ {/* Left: Conversation list */} +
+ +
+ {/* Right: Message thread */} +
+ {activeConv ? ( + setActive(null)} + onOpenSettings={() => setSettingsOpen(true)} + /> + ) : ( +
+ +
+ )} +
+
+ + {activeConv && activeConv.type === "GROUP" && ( + + )} +
+ ) +} diff --git a/src/app/(dashboard)/chat/loading.tsx b/src/app/(dashboard)/chat/loading.tsx new file mode 100644 index 0000000..40a2b4e --- /dev/null +++ b/src/app/(dashboard)/chat/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function ChatLoading() { + return ( +
+
+ + +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/chat/page.tsx b/src/app/(dashboard)/chat/page.tsx new file mode 100644 index 0000000..eb76724 --- /dev/null +++ b/src/app/(dashboard)/chat/page.tsx @@ -0,0 +1,8 @@ +import { generateMetadata as genMeta } from "@/config/site" +import { ChatPageClient } from "./chat-page-client" + +export const metadata = genMeta("Chat") + +export default function ChatPage() { + return +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index c4117df..99b7e66 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -17,6 +17,10 @@ import { SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar" +import { ChatFab } from "@/components/iam/chat/chat-fab" +import { ChatDrawer } from "@/components/iam/chat/chat-drawer" +import { ChatbotFab } from "@/components/chatbot/chatbot-fab" +import { ChatbotPanel } from "@/components/chatbot/chatbot-panel" function DashboardSkeleton() { return ( @@ -111,6 +115,13 @@ export default function DashboardLayout({ + {/* Floating action buttons — survive navigation */} +
+ + +
+ + ) } diff --git a/src/app/api/v1/chatbot/message/route.ts b/src/app/api/v1/chatbot/message/route.ts new file mode 100644 index 0000000..3d37dda --- /dev/null +++ b/src/app/api/v1/chatbot/message/route.ts @@ -0,0 +1,225 @@ +// Streaming SSE bridge for the AI chatbot. +// 1. Extracts userId from the JWT access-token cookie (claims decoded +// without signature verification — good enough for a UX-level RBAC +// hint; every downstream BFF route still enforces real auth/permissions). +// 2. Runs guardrails (Layer 3 pattern filter + Layer 1/2 rate-limit stubs). +// 3. Builds system prompt + trimmed history + tool defs. +// 4. Streams the DeepSeek response back to the browser as SSE. +// 5. Executes tool calls, then makes a second DeepSeek call with the result. +// 6. Fires an async, non-blocking audit log request. + +import { NextRequest } from "next/server" +import { + streamDeepSeek, + SYSTEM_PROMPT, + type ChatMessage as DeepSeekMsg, + type ToolCall, +} from "@/lib/chatbot/deepseek-client" +import { CHATBOT_TOOLS } from "@/lib/chatbot/tools" +import { executeTool } from "@/lib/chatbot/tool-executor" +import { checkGuardrails } from "@/lib/chatbot/guardrails" +import { AUTH_COOKIES } from "@/lib/auth/config" + +export const runtime = "nodejs" +// Streaming responses must never be cached or statically optimized. +export const dynamic = "force-dynamic" + +interface ChatbotRequestBody { + message: string + history?: Array<{ role: string; content: string }> +} + +// Decodes the userId claim from a JWT payload without verifying the +// signature — this route only needs the user identity for scoping tool +// calls and audit logs; every downstream BFF request still carries the +// real cookie and is authorized independently. +function extractUserId(token: string): string { + try { + const payloadSegment = token.split(".")[1] + if (!payloadSegment) return "" + const payload = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf-8")) as { + user_id?: string + sub?: string + } + return payload.user_id ?? payload.sub ?? "" + } catch { + return "" + } +} + +export async function POST(request: NextRequest) { + const token = request.cookies.get(AUTH_COOKIES.ACCESS_TOKEN)?.value + if (!token) { + return new Response("Unauthorized", { status: 401 }) + } + + const userId = extractUserId(token) + if (!userId) { + return new Response("Unauthorized", { status: 401 }) + } + + let body: ChatbotRequestBody + try { + body = (await request.json()) as ChatbotRequestBody + } catch { + return new Response("Invalid JSON body", { status: 400 }) + } + + const { message, history = [] } = body + if (!message || typeof message !== "string") { + return new Response("message is required", { status: 400 }) + } + + const encoder = new TextEncoder() + + // Layer 3: prompt injection / jailbreak pattern filter. + const guardrail = await checkGuardrails({ message, userId }) + if (guardrail.blocked) { + const blockedStream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: "blocked", reason: guardrail.reason })}\n\n`) + ) + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + }, + }) + return new Response(blockedStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }) + } + + const maxHistory = parseInt(process.env.CHATBOT_MAX_HISTORY_MESSAGES ?? "20", 10) + const maxResponseTokens = parseInt(process.env.CHATBOT_MAX_RESPONSE_TOKENS ?? "2000", 10) + + // Layer 4: system prompt is always first and immutable. + const messages: DeepSeekMsg[] = [ + { role: "system", content: SYSTEM_PROMPT }, + ...history.slice(-maxHistory).map((m) => ({ role: m.role as DeepSeekMsg["role"], content: m.content })), + { role: "user", content: message }, + ] + + const toolsCalled: string[] = [] + const requestTokenEstimate = messages.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0) + const sessionId = request.headers.get("x-chatbot-session-id") ?? "unknown" + const cookieHeader = request.headers.get("cookie") ?? "" + + const stream = new ReadableStream({ + async start(controller) { + let closed = false + const enqueue = (data: unknown) => { + if (closed) return + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + } catch { + closed = true + } + } + + let responseTokens = 0 + + try { + let pendingToolCall: Partial | null = null + + for await (const chunk of streamDeepSeek(messages, CHATBOT_TOOLS, maxResponseTokens)) { + if (chunk.type === "delta" && chunk.content) { + responseTokens += 1 + enqueue({ type: "delta", content: chunk.content }) + } else if (chunk.type === "tool_call" && chunk.toolCall) { + // Tool call arguments can arrive fragmented across chunks. + if (!pendingToolCall) { + pendingToolCall = { ...chunk.toolCall, function: { ...chunk.toolCall.function } } + } else { + if (chunk.toolCall.function?.name) { + pendingToolCall.function!.name = chunk.toolCall.function.name + } + if (chunk.toolCall.function?.arguments) { + pendingToolCall.function!.arguments = + (pendingToolCall.function!.arguments ?? "") + chunk.toolCall.function.arguments + } + } + } else if (chunk.type === "done") { + if (pendingToolCall?.function?.name) { + const toolName = pendingToolCall.function.name + toolsCalled.push(toolName) + enqueue({ type: "tool_call", name: toolName }) + + let toolArgs: unknown = {} + try { + toolArgs = JSON.parse(pendingToolCall.function.arguments ?? "{}") + } catch { + // invalid JSON args — executeTool's Zod validation will reject it + } + + const toolResult = await executeTool(toolName, toolArgs, userId) + enqueue({ type: "tool_result", name: toolName, data: toolResult.data, error: toolResult.error }) + + const messagesWithTool: DeepSeekMsg[] = [ + ...messages, + { role: "assistant", content: "", tool_call_id: pendingToolCall.id }, + { + role: "tool", + content: JSON.stringify(toolResult.data ?? { error: toolResult.error }), + tool_call_id: pendingToolCall.id ?? "", + name: toolName, + }, + ] + + for await (const chunk2 of streamDeepSeek(messagesWithTool, [], maxResponseTokens)) { + if (chunk2.type === "delta" && chunk2.content) { + responseTokens += 1 + enqueue({ type: "delta", content: chunk2.content }) + } else if (chunk2.type === "done" || chunk2.type === "error") { + break + } + } + } + + // Fire-and-forget audit log — never blocks or fails the response. + void fetch(new URL("/api/v1/chatbot/audit", request.url), { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookieHeader }, + body: JSON.stringify({ + sessionId, + requestTokens: requestTokenEstimate, + responseTokens, + toolsCalled, + wasBlocked: false, + }), + }).catch(() => { + // Audit logging is best-effort — swallow network errors. + }) + + enqueue({ type: "done" }) + break + } else if (chunk.type === "error") { + enqueue({ type: "error", error: chunk.error }) + break + } + } + } catch (err) { + enqueue({ type: "error", error: err instanceof Error ? err.message : "Unknown error" }) + } finally { + closed = true + try { + controller.close() + } catch { + // already closed + } + } + }, + }) + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }) +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/attachments/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/attachments/route.ts new file mode 100644 index 0000000..4d94a70 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/attachments/route.ts @@ -0,0 +1,86 @@ +// IAM Chat route — upload a file/image attachment to a conversation. +// Accepts multipart/form-data with a single "file", forwards it to the gRPC +// UploadChatAttachment RPC as bytes, and returns the attachment metadata. + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string }> } + +const MAX_FILE_SIZE = 25 * 1024 * 1024 // 25MB (matches proto validation) +const ALLOWED_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "text/csv", + "text/plain", + "application/zip", +] + +function errorResponse(status: number, message: string, field?: string) { + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: String(status), + message, + validationErrors: field ? [{ field, message }] : [], + }, + data: null, + }, + { status } + ) +} + +// POST /api/v1/iam/chat/conversations/[id]/attachments — upload one file. +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const formData = await request.formData() + const file = formData.get("file") as File | null + + if (!file) { + return errorResponse(400, "No file provided", "file") + } + if (!ALLOWED_TYPES.includes(file.type)) { + return errorResponse(400, `Unsupported file type: ${file.type || "unknown"}`, "file") + } + if (file.size > MAX_FILE_SIZE) { + return errorResponse( + 400, + `File too large. Maximum size is 25MB (got ${(file.size / 1024 / 1024).toFixed(1)}MB)`, + "file" + ) + } + + const arrayBuffer = await file.arrayBuffer() + const fileData = new Uint8Array(arrayBuffer) + + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + const response = await client.uploadChatAttachment( + { + conversationId: id, + fileName: file.name, + contentType: file.type, + fileData, + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error uploading chat attachment:", error) + return errorResponse(500, "Failed to upload attachment") + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/history/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/history/route.ts new file mode 100644 index 0000000..a921463 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/history/route.ts @@ -0,0 +1,34 @@ +// IAM Chat routes - Clear conversation history (per-caller view only) + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string }> } + +// DELETE /api/v1/iam/chat/conversations/[id]/history - Clear caller's own view of history +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + const response = await client.clearConversationHistory({ conversationId: id }, metadata) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error clearing conversation history:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to clear conversation history", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/history/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/history/route.ts new file mode 100644 index 0000000..4fa2cb1 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/history/route.ts @@ -0,0 +1,40 @@ +// IAM Chat route - Get message edit history + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string; msgId: string }> } + +// GET /api/v1/iam/chat/conversations/[id]/messages/[msgId]/history +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { id, msgId } = await context.params + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.getMessageEditHistory( + { conversationId: id, messageId: msgId }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error fetching message edit history:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to fetch message edit history", + validationErrors: [], + }, + data: [], + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/route.ts new file mode 100644 index 0000000..3bb5971 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/route.ts @@ -0,0 +1,73 @@ +// IAM Chat routes - Edit, delete a message + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string; msgId: string }> } + +// PUT /api/v1/iam/chat/conversations/[id]/messages/[msgId] - Edit message body +export async function PUT(request: NextRequest, context: RouteContext) { + try { + const { id, msgId } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.editMessage( + { + conversationId: id, + messageId: msgId, + body: body.body || "", + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error editing message:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to edit message", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} + +// DELETE /api/v1/iam/chat/conversations/[id]/messages/[msgId] - Soft-delete message +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const { id, msgId } = await context.params + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.deleteMessage({ conversationId: id, messageId: msgId }, metadata) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error deleting message:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to delete message", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/messages/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/messages/route.ts new file mode 100644 index 0000000..eee3ae1 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/messages/route.ts @@ -0,0 +1,88 @@ +// IAM Chat routes - List messages, send message in a conversation + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string }> } + +// GET /api/v1/iam/chat/conversations/[id]/messages - Cursor-paginated message list +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const { searchParams } = new URL(request.url) + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.listMessages( + { + conversationId: id, + pageSize: Number(searchParams.get("pageSize") || searchParams.get("page_size")) || 30, + beforeCursor: searchParams.get("beforeCursor") || searchParams.get("before_cursor") || "", + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: { + messages: response.data, + nextCursor: response.nextCursor, + hasMore: response.hasMore, + }, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error listing messages:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to list messages", + validationErrors: [], + }, + data: { messages: [], nextCursor: "", hasMore: false }, + }, + { status: 500 } + ) + } +} + +// POST /api/v1/iam/chat/conversations/[id]/messages - Send a message +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.sendMessage( + { + conversationId: id, + body: body.body || "", + replyToId: body.replyToId || body.reply_to_id || "", + attachmentIds: body.attachmentIds || body.attachment_ids || [], + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error sending message:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to send message", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/participants/[userId]/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/participants/[userId]/route.ts new file mode 100644 index 0000000..ca85b4a --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/participants/[userId]/route.ts @@ -0,0 +1,34 @@ +// IAM Chat routes - Remove a participant from a group conversation + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string; userId: string }> } + +// DELETE /api/v1/iam/chat/conversations/[id]/participants/[userId] - Remove a participant +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const { id, userId } = await context.params + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + const response = await client.removeParticipant({ conversationId: id, userId }, metadata) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error removing participant:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to remove participant", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/participants/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/participants/route.ts new file mode 100644 index 0000000..3b27909 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/participants/route.ts @@ -0,0 +1,41 @@ +// IAM Chat routes - Add participants to a group conversation + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string }> } + +// POST /api/v1/iam/chat/conversations/[id]/participants - Add participants to group conversation +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + const response = await client.addParticipants( + { + conversationId: id, + userIds: body.userIds || body.user_ids || [], + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error adding participants:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to add participants", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/read/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/read/route.ts new file mode 100644 index 0000000..ce45d6c --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/read/route.ts @@ -0,0 +1,35 @@ +// IAM Chat route - Mark conversation as read + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string }> } + +// POST /api/v1/iam/chat/conversations/[id]/read +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.markConversationRead({ conversationId: id }, metadata) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error marking conversation as read:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to mark conversation as read", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/route.ts new file mode 100644 index 0000000..6560367 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/route.ts @@ -0,0 +1,100 @@ +// IAM Chat routes - Get, update (group), leave conversation by ID + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string }> } + +// GET /api/v1/iam/chat/conversations/[id] +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + const response = await client.getConversation({ conversationId: id }, metadata) + + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error fetching conversation:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to fetch conversation", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} + +// PUT /api/v1/iam/chat/conversations/[id] - Update group conversation (name, avatarUrl) +export async function PUT(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + const response = await client.updateGroupConversation( + { + conversationId: id, + name: body.name || "", + avatarUrl: body.avatarUrl || body.avatar_url || "", + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error updating conversation:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to update conversation", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} + +// DELETE /api/v1/iam/chat/conversations/[id] - Leave conversation +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + const response = await client.leaveConversation({ conversationId: id }, metadata) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error leaving conversation:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to leave conversation", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/[id]/typing/route.ts b/src/app/api/v1/iam/chat/conversations/[id]/typing/route.ts new file mode 100644 index 0000000..746aae3 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/typing/route.ts @@ -0,0 +1,42 @@ +// IAM Chat route - Set typing indicator + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ id: string }> } + +// POST /api/v1/iam/chat/conversations/[id]/typing +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.setTyping( + { + conversationId: id, + isTyping: Boolean(body.isTyping ?? body.is_typing ?? false), + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error setting typing indicator:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to set typing indicator", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/chat/conversations/route.ts b/src/app/api/v1/iam/chat/conversations/route.ts new file mode 100644 index 0000000..5f58d48 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/route.ts @@ -0,0 +1,89 @@ +// IAM Chat routes - List conversations, create direct/group conversation + +import { NextRequest, NextResponse } from "next/server" +import { getChatClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +// GET /api/v1/iam/chat/conversations - List conversations for current user +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const response = await client.listConversations( + { + page: Number(searchParams.get("page")) || 1, + pageSize: Number(searchParams.get("pageSize") || searchParams.get("page_size")) || 20, + search: searchParams.get("search") || "", + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: response.data, + pagination: response.pagination, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error listing conversations:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to list conversations", + validationErrors: [], + }, + data: [], + pagination: { currentPage: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, + }, + { status: 500 } + ) + } +} + +// POST /api/v1/iam/chat/conversations - Create direct (peer_user_id) or group (name+participant_ids) conversation +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getChatClient() + + const peerUserId = body.peerUserId || body.peer_user_id + if (peerUserId) { + const response = await client.createDirectConversation({ peerUserId }, metadata) + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } + + const response = await client.createGroupConversation( + { + name: body.name || "", + participantIds: body.participantIds || body.participant_ids || [], + }, + metadata + ) + + return NextResponse.json({ + base: response.base, + data: response.data, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error creating conversation:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to create conversation", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/notifications/stream/route.ts b/src/app/api/v1/iam/notifications/stream/route.ts index 42429b9..d6a77a9 100644 --- a/src/app/api/v1/iam/notifications/stream/route.ts +++ b/src/app/api/v1/iam/notifications/stream/route.ts @@ -1,14 +1,101 @@ -// SSE bridge — proxies the IAM gRPC StreamNotifications server-streaming RPC -// to the browser as Server-Sent Events. +// SSE bridge — proxies the IAM gRPC StreamNotifications AND StreamChatEvents +// server-streaming RPCs to the browser over a single Server-Sent Events +// connection. // // Reconnect resumes via the standard `Last-Event-ID` HTTP header, which the -// EventSource API sends automatically. We forward it as `since` to the -// upstream gRPC stream which replays missed notifications from the DB. +// EventSource API sends automatically. We forward it as `since`/`lastEventId` +// to both upstream gRPC streams, which each replay their own missed events +// from the DB using their own event-id sequence. import { NextRequest } from "next/server" import { createMetadataFromRequest } from "@/lib/grpc" import { getNotificationStreamingClient } from "@/lib/grpc/notification-stream-client" +import { getChatStreamingClient } from "@/lib/grpc/chat-stream-client" import type { StreamNotificationsResponse } from "@/types/generated/iam/v1/notification" +import type { StreamChatEventsResponse } from "@/types/generated/iam/v1/chat" + +interface FlatAttachment { + attachmentId?: string + fileName?: string + fileUrl?: string + contentType?: string + fileSize?: number | string + thumbnailUrl?: string +} + +interface FlatChatEvent { + type: string + conversationId?: string + messageId?: string + senderUserId?: string + senderName?: string + body?: string + isEdited?: boolean + isDeleted?: boolean + attachments?: FlatAttachment[] + createdAt?: string + updatedAt?: string + userId?: string + userName?: string + isTyping?: boolean + readAt?: string + isOnline?: boolean +} + +function flattenChatEvent(event: StreamChatEventsResponse): FlatChatEvent { + if (event.messageReceived) { + const m = event.messageReceived.message + return { + type: "message_received", + conversationId: event.messageReceived.conversationId, + messageId: m?.messageId, senderUserId: m?.senderUserId, + senderName: m?.senderName, body: m?.body, + isEdited: m?.isEdited, isDeleted: m?.isDeleted, + attachments: m?.attachments, + createdAt: m?.createdAt, updatedAt: m?.updatedAt, + } + } + if (event.messageEdited) { + const m = event.messageEdited.message + return { + type: "message_edited", + conversationId: event.messageEdited.conversationId, + messageId: m?.messageId, senderUserId: m?.senderUserId, + body: m?.body, isEdited: m?.isEdited, isDeleted: m?.isDeleted, + attachments: m?.attachments, + createdAt: m?.createdAt, updatedAt: m?.updatedAt, + } + } + if (event.messageDeleted) { + return { + type: "message_deleted", + conversationId: event.messageDeleted.conversationId, + messageId: event.messageDeleted.messageId, + } + } + if (event.typing) { + return { + type: "typing", + conversationId: event.typing.conversationId, + userId: event.typing.userId, userName: event.typing.userName, + isTyping: event.typing.isTyping, + } + } + if (event.readReceipt) { + return { + type: "read_receipt", + conversationId: event.readReceipt.conversationId, + userId: event.readReceipt.userId, readAt: event.readReceipt.readAt, + } + } + if (event.presence) { + return { + type: "presence", + userId: event.presence.userId, isOnline: event.presence.isOnline, + } + } + return { type: "unknown" } +} export const runtime = "nodejs" // Streaming responses must NOT be cached; also disable Next's static optimization. @@ -17,27 +104,48 @@ export const dynamic = "force-dynamic" export async function GET(request: NextRequest) { const since = request.headers.get("Last-Event-ID") ?? "" const metadata = createMetadataFromRequest(request) - const client = getNotificationStreamingClient() + const notificationClient = getNotificationStreamingClient() + const chatClient = getChatStreamingClient() const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder() - const call = client.streamNotifications({ since }, metadata) + const notificationCall = notificationClient.streamNotifications({ since }, metadata) + const chatCall = chatClient.streamChatEvents({ lastEventId: since }, metadata) + + let notificationDone = false + let chatDone = false + let closed = false - const closeSafe = () => { + const closeController = () => { + if (closed) return + closed = true try { - call.cancel() + controller.close() + } catch { + // already closed + } + } + + const maybeCloseController = () => { + if (notificationDone && chatDone) closeController() + } + + const closeAll = () => { + try { + notificationCall.cancel() } catch { // already cancelled } try { - controller.close() + chatCall.cancel() } catch { - // already closed + // already cancelled } + closeController() } - call.on("data", (event: StreamNotificationsResponse) => { + notificationCall.on("data", (event: StreamNotificationsResponse) => { // Encode as SSE frame. // Heartbeat events have notification=null — we still send them so the // browser keeps the connection warm and tracks Last-Event-ID. @@ -48,15 +156,45 @@ export async function GET(request: NextRequest) { controller.enqueue(encoder.encode(frame)) } catch { // controller closed by client disconnect - closeSafe() + closeAll() + } + }) + + notificationCall.on("end", () => { + notificationDone = true + maybeCloseController() + }) + + notificationCall.on("error", (err) => { + // Forward as SSE error frame, then close. EventSource will reconnect. + const frame = `event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n` + try { + controller.enqueue(encoder.encode(frame)) + } catch { + // ignore + } + notificationDone = true + closeAll() + }) + + chatCall.on("data", (event: StreamChatEventsResponse) => { + const eventId = event.eventId ?? "" + const flat = flattenChatEvent(event) + const frame = `id: ${eventId}\nevent: chat\ndata: ${JSON.stringify(flat)}\n\n` + try { + controller.enqueue(encoder.encode(frame)) + } catch { + // controller closed by client disconnect + closeAll() } }) - call.on("end", () => { - closeSafe() + chatCall.on("end", () => { + chatDone = true + maybeCloseController() }) - call.on("error", (err) => { + chatCall.on("error", (err) => { // Forward as SSE error frame, then close. EventSource will reconnect. const frame = `event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n` try { @@ -64,11 +202,12 @@ export async function GET(request: NextRequest) { } catch { // ignore } - closeSafe() + chatDone = true + closeAll() }) - // Tear down upstream gRPC call when the browser disconnects. - request.signal.addEventListener("abort", closeSafe) + // Tear down both upstream gRPC calls when the browser disconnects. + request.signal.addEventListener("abort", closeAll) }, }) diff --git a/src/app/api/v1/iam/presence/heartbeat/route.ts b/src/app/api/v1/iam/presence/heartbeat/route.ts new file mode 100644 index 0000000..4276471 --- /dev/null +++ b/src/app/api/v1/iam/presence/heartbeat/route.ts @@ -0,0 +1,32 @@ +// IAM Presence route - Heartbeat to keep current user marked online + +import { NextRequest, NextResponse } from "next/server" +import { getPresenceClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +// POST /api/v1/iam/presence/heartbeat +export async function POST(request: NextRequest) { + try { + const metadata = createMetadataFromRequest(request) + const client = getPresenceClient() + + const response = await client.heartbeat({}, metadata) + + return NextResponse.json({ + base: response.base, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error sending heartbeat:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to send heartbeat", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/presence/online/route.ts b/src/app/api/v1/iam/presence/online/route.ts new file mode 100644 index 0000000..7eb93fd --- /dev/null +++ b/src/app/api/v1/iam/presence/online/route.ts @@ -0,0 +1,40 @@ +// IAM Presence route - Get online user IDs + +import { NextRequest, NextResponse } from "next/server" +import { getPresenceClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +// GET /api/v1/iam/presence/online?user_ids=uuid1,uuid2 +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const metadata = createMetadataFromRequest(request) + const client = getPresenceClient() + + const rawUserIds = searchParams.get("userIds") || searchParams.get("user_ids") || "" + const userIds = rawUserIds + ? rawUserIds.split(",").map((v) => v.trim()).filter(Boolean) + : [] + + const response = await client.getOnlineUsers({ userIds }, metadata) + + return NextResponse.json({ + base: response.base, + data: { userIds: response.userIds }, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error fetching online users:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to fetch online users", + validationErrors: [], + }, + data: { userIds: [] }, + }, + { status: 500 } + ) + } +} diff --git a/src/components/chatbot/chatbot-fab.tsx b/src/components/chatbot/chatbot-fab.tsx new file mode 100644 index 0000000..5bd6744 --- /dev/null +++ b/src/components/chatbot/chatbot-fab.tsx @@ -0,0 +1,22 @@ +"use client" + +import { Bot } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useChatbotStore } from "@/stores/chatbot-store" + +export function ChatbotFab() { + const isOpen = useChatbotStore((s) => s.isOpen) + const setOpen = useChatbotStore((s) => s.setOpen) + + return ( + + ) +} diff --git a/src/components/chatbot/chatbot-header.tsx b/src/components/chatbot/chatbot-header.tsx new file mode 100644 index 0000000..72304b7 --- /dev/null +++ b/src/components/chatbot/chatbot-header.tsx @@ -0,0 +1,40 @@ +"use client" + +import { Bot, RefreshCw, X } from "lucide-react" +import { useChatbotStore } from "@/stores/chatbot-store" +import { Button } from "@/components/ui/button" + +export function ChatbotHeader() { + const resetSession = useChatbotStore((s) => s.resetSession) + const setOpen = useChatbotStore((s) => s.setOpen) + + return ( +
+
+ + GoApps Assistant +
+
+ + +
+
+ ) +} diff --git a/src/components/chatbot/chatbot-input.tsx b/src/components/chatbot/chatbot-input.tsx new file mode 100644 index 0000000..9506826 --- /dev/null +++ b/src/components/chatbot/chatbot-input.tsx @@ -0,0 +1,53 @@ +"use client" + +import { useRef, useState } from "react" +import { Textarea } from "@/components/ui/textarea" +import { Button } from "@/components/ui/button" +import { Send } from "lucide-react" + +interface ChatbotInputProps { + onSend: (message: string) => void + disabled?: boolean +} + +export function ChatbotInput({ onSend, disabled }: ChatbotInputProps) { + const [value, setValue] = useState("") + const textareaRef = useRef(null) + + const handleSend = () => { + const trimmed = value.trim() + if (!trimmed || disabled) return + setValue("") + onSend(trimmed) + setTimeout(() => textareaRef.current?.focus(), 50) + } + + return ( +
+