mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Implement stable conference room chat URLs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user