From 00382009a1ae430f1268d865e4a6ca5bae55b8d1 Mon Sep 17 00:00:00 2001 From: ilhom Date: Thu, 16 Jul 2026 05:04:51 +0700 Subject: [PATCH 01/13] feat(frontend): add real-time chat system + AI chatbot Chat System: - TypeScript types + normalizers for conversations/messages - Zustand stores: chat-store, presence-store - Providers: ChatProvider (SSE events), PresenceProvider (heartbeat) - TanStack Query hooks: useConversations, useMessages, useSendMessage - BFF API routes: 9 routes for chat + presence endpoints - SSE stream extended with event: chat frames - UI: ConversationList, MessageThread, MessageBubble (read receipts), ChatFab, ChatDrawer (Sheet right), /chat email-client page - gRPC clients: getChatClient(), getPresenceClient() AI Chatbot: - DeepSeek streaming client with SYSTEM_PROMPT (Indonesian) - 7-layer guardrails: prompt injection detection (10 regex patterns) - 10 read-only tools with Zod validation + tool executor - Streaming BFF route with tool call handling - Zustand persist store (survives navigation, localStorage) - ChatbotPanel (fixed floating), ChatbotFab, streaming UI - Dashboard layout wired with both FABs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(dashboard)/chat/chat-page-client.tsx | 58 + src/app/(dashboard)/chat/loading.tsx | 30 + src/app/(dashboard)/chat/page.tsx | 8 + src/app/(dashboard)/layout.tsx | 11 + src/app/api/v1/chatbot/message/route.ts | 225 + .../[id]/messages/[msgId]/history/route.ts | 40 + .../[id]/messages/[msgId]/route.ts | 73 + .../chat/conversations/[id]/messages/route.ts | 87 + .../iam/chat/conversations/[id]/read/route.ts | 35 + .../v1/iam/chat/conversations/[id]/route.ts | 100 + .../chat/conversations/[id]/typing/route.ts | 42 + .../api/v1/iam/chat/conversations/route.ts | 89 + .../api/v1/iam/notifications/stream/route.ts | 92 +- .../api/v1/iam/presence/heartbeat/route.ts | 32 + src/app/api/v1/iam/presence/online/route.ts | 40 + src/components/chatbot/chatbot-fab.tsx | 22 + src/components/chatbot/chatbot-header.tsx | 40 + src/components/chatbot/chatbot-input.tsx | 53 + .../chatbot/chatbot-message-list.tsx | 36 + src/components/chatbot/chatbot-message.tsx | 37 + src/components/chatbot/chatbot-panel.tsx | 107 + src/components/chatbot/chatbot-tool-badge.tsx | 16 + src/components/iam/chat/chat-drawer.tsx | 54 + src/components/iam/chat/chat-fab.tsx | 33 + src/components/iam/chat/conversation-item.tsx | 66 + src/components/iam/chat/conversation-list.tsx | 68 + src/components/iam/chat/message-bubble.tsx | 59 + src/components/iam/chat/message-input.tsx | 79 + src/components/iam/chat/message-thread.tsx | 92 + .../iam/chat/new-conversation-dialog.tsx | 65 + src/components/iam/chat/typing-indicator.tsx | 21 + src/hooks/iam/use-chat.ts | 145 + src/lib/chatbot/deepseek-client.ts | 131 + src/lib/chatbot/guardrails.test.ts | 25 + src/lib/chatbot/guardrails.ts | 66 + src/lib/chatbot/tool-executor.test.ts | 29 + src/lib/chatbot/tool-executor.ts | 176 + src/lib/chatbot/tools.ts | 156 + src/lib/grpc/chat-stream-client.ts | 61 + src/lib/grpc/clients.ts | 16 + src/lib/grpc/index.ts | 2 + src/providers/chat-provider.tsx | 59 + src/providers/index.tsx | 8 +- src/providers/notification-provider.tsx | 16 + src/providers/presence-provider.tsx | 66 + src/stores/chat-store.ts | 125 + src/stores/chatbot-store.ts | 91 + src/stores/presence-store.ts | 29 + src/types/generated/iam/v1/chat.ts | 4802 +++++++++++++++++ src/types/iam/chat.ts | 206 + 50 files changed, 8000 insertions(+), 19 deletions(-) create mode 100644 src/app/(dashboard)/chat/chat-page-client.tsx create mode 100644 src/app/(dashboard)/chat/loading.tsx create mode 100644 src/app/(dashboard)/chat/page.tsx create mode 100644 src/app/api/v1/chatbot/message/route.ts create mode 100644 src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/history/route.ts create mode 100644 src/app/api/v1/iam/chat/conversations/[id]/messages/[msgId]/route.ts create mode 100644 src/app/api/v1/iam/chat/conversations/[id]/messages/route.ts create mode 100644 src/app/api/v1/iam/chat/conversations/[id]/read/route.ts create mode 100644 src/app/api/v1/iam/chat/conversations/[id]/route.ts create mode 100644 src/app/api/v1/iam/chat/conversations/[id]/typing/route.ts create mode 100644 src/app/api/v1/iam/chat/conversations/route.ts create mode 100644 src/app/api/v1/iam/presence/heartbeat/route.ts create mode 100644 src/app/api/v1/iam/presence/online/route.ts create mode 100644 src/components/chatbot/chatbot-fab.tsx create mode 100644 src/components/chatbot/chatbot-header.tsx create mode 100644 src/components/chatbot/chatbot-input.tsx create mode 100644 src/components/chatbot/chatbot-message-list.tsx create mode 100644 src/components/chatbot/chatbot-message.tsx create mode 100644 src/components/chatbot/chatbot-panel.tsx create mode 100644 src/components/chatbot/chatbot-tool-badge.tsx create mode 100644 src/components/iam/chat/chat-drawer.tsx create mode 100644 src/components/iam/chat/chat-fab.tsx create mode 100644 src/components/iam/chat/conversation-item.tsx create mode 100644 src/components/iam/chat/conversation-list.tsx create mode 100644 src/components/iam/chat/message-bubble.tsx create mode 100644 src/components/iam/chat/message-input.tsx create mode 100644 src/components/iam/chat/message-thread.tsx create mode 100644 src/components/iam/chat/new-conversation-dialog.tsx create mode 100644 src/components/iam/chat/typing-indicator.tsx create mode 100644 src/hooks/iam/use-chat.ts create mode 100644 src/lib/chatbot/deepseek-client.ts create mode 100644 src/lib/chatbot/guardrails.test.ts create mode 100644 src/lib/chatbot/guardrails.ts create mode 100644 src/lib/chatbot/tool-executor.test.ts create mode 100644 src/lib/chatbot/tool-executor.ts create mode 100644 src/lib/chatbot/tools.ts create mode 100644 src/lib/grpc/chat-stream-client.ts create mode 100644 src/providers/chat-provider.tsx create mode 100644 src/providers/presence-provider.tsx create mode 100644 src/stores/chat-store.ts create mode 100644 src/stores/chatbot-store.ts create mode 100644 src/stores/presence-store.ts create mode 100644 src/types/generated/iam/v1/chat.ts create mode 100644 src/types/iam/chat.ts 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..f0f04aa --- /dev/null +++ b/src/app/(dashboard)/chat/chat-page-client.tsx @@ -0,0 +1,58 @@ +"use client" + +import { useEffect } 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 { PageHeader } from "@/components/common/page-header" +import { EmptyState } from "@/components/common/empty-state" +import ChatLoading from "./loading" + +export function ChatPageClient() { + const { user, isLoading: authLoading } = useAuth() + const currentUserId = user?.userId ?? "" + + const activeId = useChatStore((s) => s.activeConversationId) + const conversations = useChatStore((s) => s.conversations) + const setConversations = useChatStore((s) => s.setConversations) + + 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 ? ( + + ) : ( +
+ +
+ )} +
+
+
+ ) +} 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]/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..7863d06 --- /dev/null +++ b/src/app/api/v1/iam/chat/conversations/[id]/messages/route.ts @@ -0,0 +1,87 @@ +// 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 || "", + }, + 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]/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..b690bfb 100644 --- a/src/app/api/v1/iam/notifications/stream/route.ts +++ b/src/app/api/v1/iam/notifications/stream/route.ts @@ -1,14 +1,18 @@ -// 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" export const runtime = "nodejs" // Streaming responses must NOT be cached; also disable Next's static optimization. @@ -17,27 +21,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) - const closeSafe = () => { + let notificationDone = false + let chatDone = false + let closed = false + + 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 +73,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 payload = JSON.stringify(event) + const frame = `id: ${eventId}\nevent: chat\ndata: ${payload}\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 +119,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 ( +
+