Implement stable conference room chat URLs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 23:27:17 +00:00
1 parent 0e6f0f1fd8
commit a30c25a50c
6 files changed
+579 -162

No files matched your search

@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
getById: vi.fn(),
}));
const mockAssertInstanceAdmin = vi.hoisted(() => vi.fn());
const mockAssertCompanyAccess = vi.hoisted(() => vi.fn());
@@ -35,6 +36,8 @@ async function createApp(
const OPEN_BOARD_ISSUE = {
id: "issue-open",
companyId: "company-1",
identifier: "PAP-1",
title: "How is hiring going?",
originKind: "board_chat",
status: "todo",
@@ -49,6 +52,7 @@ describe("POST /api/board/chat/conversations (PAP-11123)", () => {
mockIssueService.list.mockResolvedValue([]);
mockIssueService.create.mockResolvedValue({ id: "issue-new", originKind: "board_chat" });
mockIssueService.update.mockResolvedValue(undefined);
mockIssueService.getById.mockResolvedValue(null);
});
it("returns 403 FEATURE_DISABLED when enableConferenceRoomChat is off", async () => {
@@ -107,6 +111,19 @@ describe("POST /api/board/chat/conversations (PAP-11123)", () => {
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("reuses a done board_chat issue because stable URLs stay available until cancellation", async () => {
mockIssueService.list.mockResolvedValue([{ ...OPEN_BOARD_ISSUE, id: "issue-done", status: "done" }]);
const app = await createApp();
const res = await request(app)
.post("/api/board/chat/conversations")
.send({ companyId: "company-1" });
expect(res.status).toBe(200);
expect(res.body.issue.id).toBe("issue-done");
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("mints an origin-tagged todo issue when no conversation is open", async () => {
const app = await createApp();
@@ -155,4 +172,39 @@ describe("POST /api/board/chat/conversations (PAP-11123)", () => {
expect(mockGetExperimental).not.toHaveBeenCalled();
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("resolves a valid direct board_chat conversation ref without creating", async () => {
mockIssueService.getById.mockResolvedValue(OPEN_BOARD_ISSUE);
const app = await createApp();
const res = await request(app)
.get("/api/board/chat/conversations/PAP-1")
.query({ companyId: "company-1" });
expect(res.status).toBe(200);
expect(res.body).toEqual({
issue: expect.objectContaining({ id: "issue-open", identifier: "PAP-1" }),
unavailableReason: null,
});
expect(mockIssueService.getById).toHaveBeenCalledWith("PAP-1");
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it.each([
["not_found", null],
["cancelled", { ...OPEN_BOARD_ISSUE, status: "cancelled" }],
["wrong_company", { ...OPEN_BOARD_ISSUE, companyId: "company-2" }],
["wrong_kind", { ...OPEN_BOARD_ISSUE, originKind: "manual" }],
])("returns %s for unavailable direct conversation refs", async (reason, issue) => {
mockIssueService.getById.mockResolvedValue(issue);
const app = await createApp();
const res = await request(app)
.get("/api/board/chat/conversations/some-ref")
.query({ companyId: "company-1" });
expect(res.status).toBe(200);
expect(res.body).toEqual({ issue: null, unavailableReason: reason });
expect(mockIssueService.create).not.toHaveBeenCalled();
});
});
+56 -5
View File
@@ -8,6 +8,12 @@ const BOARD_CHAT_ORIGIN_KIND = "board_chat";
const LEGACY_BOARD_CHAT_TITLE = "Board Operations";
const LEGACY_BOARD_CHAT_DESCRIPTION = "Standing issue for board concierge conversations and decision log";
const BOARD_CHAT_DESCRIPTION = "Standing issue for Conference Room conversations and decision log";
const BOARD_CHAT_UNAVAILABLE_REASONS = {
NOT_FOUND: "not_found",
WRONG_COMPANY: "wrong_company",
WRONG_KIND: "wrong_kind",
CANCELLED: "cancelled",
} as const;
function deriveBoardChatIssueTitle(message: string): string {
const singleLine = message.replace(/\s+/g, " ").trim();
@@ -16,8 +22,8 @@ function deriveBoardChatIssueTitle(message: string): string {
return `${singleLine.slice(0, 77).trimEnd()}...`;
}
function isOpenBoardChatIssue(issue: { status?: string | null }) {
return issue.status !== "done" && issue.status !== "cancelled";
function isAvailableBoardChatIssue(issue: { status?: string | null }) {
return issue.status !== "cancelled";
}
function isLegacyBoardChatIssue(issue: {
@@ -34,10 +40,25 @@ function isLegacyBoardChatIssue(issue: {
(issue.originKind === undefined || issue.originKind === null || issue.originKind === "manual") &&
issue.assigneeAgentId === null &&
issue.assigneeUserId === null &&
isOpenBoardChatIssue(issue)
isAvailableBoardChatIssue(issue)
);
}
function getBoardChatUnavailableReason(
issue: {
companyId?: string | null;
originKind?: string | null;
status?: string | null;
} | null,
companyId: string,
) {
if (!issue) return BOARD_CHAT_UNAVAILABLE_REASONS.NOT_FOUND;
if (issue.companyId !== companyId) return BOARD_CHAT_UNAVAILABLE_REASONS.WRONG_COMPANY;
if (issue.originKind !== BOARD_CHAT_ORIGIN_KIND) return BOARD_CHAT_UNAVAILABLE_REASONS.WRONG_KIND;
if (issue.status === "cancelled") return BOARD_CHAT_UNAVAILABLE_REASONS.CANCELLED;
return null;
}
/**
* Resolve the standing `board_chat` conversation issue for a company, creating
* one (origin-tagged) if none is open. The real-agent Conference Room
@@ -46,7 +67,7 @@ function isLegacyBoardChatIssue(issue: {
*
* - `wantsNewConversation` skips reuse and always creates a fresh conversation
* (the room's "New chat" control).
* - Otherwise reuse the most-recent open origin-tagged issue, then adopt and
* - Otherwise reuse the most-recent non-cancelled origin-tagged issue, then adopt and
* repair a legacy "Board Operations" issue, then create.
* - `message`, when present, seeds a first-message title; absent (the room
* mints before the first message), it falls back to "New chat".
@@ -62,7 +83,7 @@ async function resolveOrCreateBoardChatIssue(
sortField: "updated",
sortDir: "desc",
});
const boardIssue = boardChatIssues.find(isOpenBoardChatIssue);
const boardIssue = boardChatIssues.find(isAvailableBoardChatIssue);
if (boardIssue) return boardIssue;
const legacyIssues = await issueSvc.list(companyId, {
@@ -136,5 +157,35 @@ export function boardChatRoutes(
res.status(200).json({ issue });
});
// Resolve a direct Conference Room URL ref. This intentionally does not
// create or redirect: invalid direct links need a visible unavailable state
// while valid UUID links can be canonicalized client-side to identifiers.
router.get("/board/chat/conversations/:conversationRef", async (req, res) => {
const experimental = await instanceSettingsService(db).getExperimental();
if (experimental.enableConferenceRoomChat !== true) {
res.status(403).json({
error: "Conference Room Chat is not enabled",
code: "FEATURE_DISABLED",
});
return;
}
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
return;
}
assertInstanceAdmin(req);
assertCompanyAccess(req, companyId);
const issue = await issueService(db).getById(req.params.conversationRef as string);
const unavailableReason = getBoardChatUnavailableReason(issue, companyId);
res.status(200).json({
issue: unavailableReason ? null : issue,
unavailableReason,
});
});
return router;
}