0df6242128
POST /api/ai-chat/bound-chat 500'd with Postgres 22P02 because the client
sends a page slugId (10-char nanoid) in the request `pageId` field, which the
server passed straight into the UUID `page_id` column. The chat-to-document
binding silently broke (client fail-softs to a new chat) and every slug-URL
page open logged a 500.
Fix: resolve the incoming id to a real page UUID on the server. PageRepo.findById
already accepts both a uuid and a slugId (isValidUUID→slugId fallback), so
boundChat now resolves the page first, guards it against a foreign/unknown
workspace (returns {chatId:null} before any chat lookup — no cross-workspace
probe), and looks up the latest chat by the resolved page.id (real uuid).
Client: renamed the local pageId→slugId for clarity (the value is a slugId);
the wire body key stays `pageId` so the DTO is unchanged. DTO left @IsString()
(a @IsUUID() would only turn the 500 into a 400 and still break binding).
Test: bound-chat spec asserts a slugId resolves and findLatestByPage is called
with the real uuid; a foreign-workspace page → {chatId:null} without a chat
lookup (no leak); an unknown id → {chatId:null}, no throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
70 lines
2.9 KiB
TypeScript
70 lines
2.9 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useAtom, useSetAtom } from "jotai";
|
|
import { useMatch } from "react-router-dom";
|
|
import {
|
|
aiChatWindowOpenAtom,
|
|
activeAiChatIdAtom,
|
|
aiChatDraftAtom,
|
|
selectedAiRoleIdAtom,
|
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
|
import { extractPageSlugId } from "@/lib";
|
|
|
|
/**
|
|
* The generic "open the AI chat" action, WITH document binding: when invoked
|
|
* while viewing a page, it resolves that page's bound chat and selects it before
|
|
* opening — so the last chat for this document re-opens by itself. With no bound
|
|
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
|
|
* by the app-header entry point; NOT by the provenance badge (which deep-links).
|
|
*/
|
|
export function useOpenAiChatForCurrentPage() {
|
|
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
|
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
|
const setDraft = useSetAtom(aiChatDraftAtom);
|
|
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
|
|
|
|
// Same route-match trick the window uses: read :pageSlug from the pathname.
|
|
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
|
// see :pageSlug — match the full path against the authenticated page route.
|
|
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
|
// A page slugId (10-char nanoid), NOT a uuid; the server resolves it to the
|
|
// real page uuid (PageRepo.findById accepts slugId or uuid).
|
|
const slugId = extractPageSlugId(match?.params?.pageSlug);
|
|
|
|
return useCallback(async () => {
|
|
// Re-clicks while the window is already open (incl. minimized) must NOT
|
|
// re-resolve and yank the user to another chat: resolve only on a genuine
|
|
// closed -> open transition. (`windowOpen` is already true here, so there
|
|
// is nothing to set — just bail.)
|
|
if (windowOpen) return;
|
|
// Open the window FIRST so the control feels instant: the bound-chat
|
|
// round-trip below must never gate the window appearing, or on a slow
|
|
// connection the first click reads as a hung control until the POST returns.
|
|
setWindowOpen(true);
|
|
let resolved: string | null = activeChatId; // off-a-page: keep current
|
|
if (slugId) {
|
|
try {
|
|
resolved = await getBoundChat(slugId); // null => fresh chat
|
|
} catch {
|
|
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
|
}
|
|
}
|
|
// Clear the composer draft / picked role ONLY on an actual switch, so
|
|
// reopening the same chat does not wipe an in-progress draft. Applied after
|
|
// the resolve so the window is already visible while the switch settles.
|
|
if (resolved !== activeChatId) {
|
|
setActiveChatId(resolved);
|
|
setDraft("");
|
|
setSelectedRoleId(null);
|
|
}
|
|
}, [
|
|
windowOpen,
|
|
activeChatId,
|
|
slugId,
|
|
setWindowOpen,
|
|
setActiveChatId,
|
|
setDraft,
|
|
setSelectedRoleId,
|
|
]);
|
|
}
|