diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx index 38991aa59f..dad5617ab1 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx @@ -4,6 +4,7 @@ import { SearchInput } from "../components/SearchInput"; import { ConversationHeaderActions, type ConversationArchiveAction, + type ConversationPublishAction, } from "./ConversationHeaderActions"; import { ConversationDetailsDrawer } from "./ConversationDetailsDrawer"; import type { TranscriptViewMode } from "./transcriptRenderModel"; @@ -20,6 +21,7 @@ export function ConversationHeader(props: { onSearchChange(value: string): void; onViewChange(value: TranscriptViewMode): void; privacy: ReactNode; + publish?: ConversationPublishAction; search: string; stats: ReactNode; title: string; @@ -70,6 +72,7 @@ export function ConversationHeader(props: { setSearchOpen(true); }} onViewChange={props.onViewChange} + publish={props.publish} searchOpen={searchOpenVisible} view={props.view} /> @@ -95,6 +98,12 @@ export function ConversationHeader(props: { ) : null} + {props.publish?.error ? ( +
+ Could not make this conversation public. +
+ ) : null} + {props.meta ? (
{props.meta} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx index 292a347840..fefbe062be 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { Archive, ArchiveRestore, + Globe2, Info, MessagesSquare, ScrollText, @@ -22,6 +23,14 @@ export type ConversationArchiveAction = { pending: boolean; }; +export type ConversationPublishAction = { + disabled: boolean; + error: boolean; + onClick(): void; + pending: boolean; + visible: boolean; +}; + /** Render the compact icon controls for one conversation header. */ export function ConversationHeaderActions(props: { archive: ConversationArchiveAction; @@ -30,6 +39,7 @@ export function ConversationHeaderActions(props: { onDetailsClick(): void; onSearchClick(): void; onViewChange(value: TranscriptViewMode): void; + publish?: ConversationPublishAction; searchOpen: boolean; view: TranscriptViewMode; }) { @@ -41,6 +51,9 @@ export function ConversationHeaderActions(props: { /> {props.copyAction} + {props.publish?.visible ? ( + + ) : null} ); } + +function PublishConversationButton(props: ConversationPublishAction) { + const label = props.pending ? "Making public" : "Make public"; + return ( + + + + ); +} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index c2359b65be..7759cad50d 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -10,6 +10,7 @@ import { useArchiveConversation, useCancelConversationPendingMessages, useConversationData, + usePublishConversation, type PendingArchiveConversationUpdate, } from "./queries"; import type { ConversationMailboxMessage } from "./conversationOutbox"; @@ -63,6 +64,7 @@ export function ConversationPage(props: { const conversations = buildConversations(summaries); const detail = useConversationData(conversationId); const archive = useArchiveConversation(conversationId); + const publish = usePublishConversation(conversationId); const feedConversation = conversations.find( (item) => item.id === conversationId, ); @@ -121,6 +123,25 @@ export function ConversationPage(props: { }), pending: archive.isPending, }} + publish={{ + disabled: publish.isPending, + error: Boolean(publish.error), + onClick: () => { + if ( + !window.confirm( + "Make this conversation public? Anyone in this workspace can read it. You cannot undo this.", + ) + ) { + return; + } + publish.mutate(); + }, + pending: publish.isPending, + visible: Boolean( + detail.data?.isParticipant && + conversation?.visibility === "private", + ), + }} identity={ hasConversationIdentity({ conversation, diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index ec87badc21..527b2e746c 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -21,6 +21,7 @@ import { conversationDetailReportSchema, conversationEventPageSchema, conversationPendingMessagesReportSchema, + publishConversationResponseSchema, } from "@sentry/junior/api/schema"; import { @@ -282,6 +283,63 @@ export function useCancelConversationPendingMessages(conversationId: string) { }); } +/** One-way private→public publish for a conversation the viewer participates in. */ +export function usePublishConversation(conversationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["dashboard", "publish-conversation", conversationId], + mutationFn: () => + post( + publishConversationResponseSchema, + `/api/conversations/${encodeURIComponent(conversationId)}/publish`, + {}, + ), + onMutate: async () => { + await Promise.all([ + queryClient.cancelQueries({ + queryKey: ["dashboard", "conversations"], + }), + queryClient.cancelQueries({ + exact: true, + queryKey: conversationDetailQueryKey(conversationId), + }), + ]); + }, + onSuccess: () => { + const detailQueryKey = conversationDetailQueryKey(conversationId); + queryClient.setQueryData( + detailQueryKey, + (detail) => (detail ? { ...detail, visibility: "public" } : detail), + ); + const conversationQueries = { queryKey: ["dashboard", "conversations"] }; + const feeds = + queryClient.getQueriesData(conversationQueries); + for (const [queryKey, feed] of feeds) { + if (!feed) continue; + queryClient.setQueryData(queryKey, { + ...feed, + conversations: feed.conversations.map((conversation) => + conversation.conversationId === conversationId + ? { ...conversation, visibility: "public" } + : conversation, + ), + }); + } + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["dashboard", "conversations"], + }), + queryClient.invalidateQueries({ + exact: true, + queryKey: conversationDetailQueryKey(conversationId), + }), + ]); + }, + }); +} + /** Archive or restore one conversation with an immediate reversible cache update. */ export function useArchiveConversation( conversationId: string, diff --git a/packages/junior/src/api/conversations/publish.ts b/packages/junior/src/api/conversations/publish.ts new file mode 100644 index 0000000000..beab86ed58 --- /dev/null +++ b/packages/junior/src/api/conversations/publish.ts @@ -0,0 +1,110 @@ +import { and, eq, isNull, ne, sql } from "drizzle-orm"; +import type { User } from "@sentry/junior-plugin-api"; +import { getDb, getSqlExecutor } from "@/chat/db"; +import { resolveRootVisibility } from "@/chat/conversations/sql/privacy"; +import { juniorConversations, juniorDestinations } from "@/db/schema"; +import { throwApiError } from "../http"; +import type { PublishConversationResponse } from "../schema/conversation"; +import { readConversationAccessFromSql } from "./access"; + +/** + * Make one conversation public by flipping its root destination visibility. + * One-way only: non-public becomes public; already-public stays public. + * + * Refuses destinations shared by other roots so one publish cannot expose + * unrelated private conversations on the same channel. + */ +export async function publishConversationForViewer( + viewer: User, + conversationId: string, +): Promise { + const access = ( + await readConversationAccessFromSql(getDb(), [conversationId], viewer) + ).get(conversationId); + if (!access) { + throwApiError(404, "Conversation not found."); + } + if (!access.isParticipant) { + throwApiError(403, "Only conversation participants can make this public."); + } + + const executor = getSqlExecutor(); + const root = await resolveRootVisibility(executor, conversationId); + if (root.visibility === null) { + throwApiError(409, "Conversation has no destination to publish."); + } + + // Resolve the root destination id under the same privacy authority used by + // access and retention. Child conversations publish the parent root only. + const [destination] = await executor + .db() + .select({ destinationId: juniorConversations.destinationId }) + .from(juniorConversations) + .where( + and( + eq(juniorConversations.conversationId, root.rootConversationId), + eq( + juniorConversations.rootConversationId, + juniorConversations.conversationId, + ), + ), + ) + .limit(1); + + if (!destination?.destinationId) { + throwApiError(409, "Conversation has no destination to publish."); + } + const destinationId = destination.destinationId; + + await executor.transaction(async () => { + // Root creation upserts the destination before it inserts the conversation. + // Locking this row makes the shared-root check and visibility update atomic + // against a concurrent root that targets the same destination. + const [lockedDestination] = await executor + .db() + .select({ visibility: juniorDestinations.visibility }) + .from(juniorDestinations) + .where(eq(juniorDestinations.id, destinationId)) + .for("update"); + if (!lockedDestination) { + throwApiError(404, "Conversation not found."); + } + + // Already public is success without re-checking shared roots: the flip is + // one-way and the destination is already exposed. + if (lockedDestination.visibility === "public") { + return; + } + + const [shared] = await executor + .db() + .select({ + count: sql`count(*)::int`, + }) + .from(juniorConversations) + .where( + and( + eq(juniorConversations.destinationId, destinationId), + isNull(juniorConversations.parentConversationId), + ne(juniorConversations.conversationId, root.rootConversationId), + ), + ); + if ((shared?.count ?? 0) > 0) { + throwApiError( + 409, + "This destination is shared by other conversations, so it cannot be made public from one conversation.", + ); + } + + await executor + .db() + .update(juniorDestinations) + .set({ + updatedAt: sql`now()`, + visibility: "public", + }) + .where(eq(juniorDestinations.id, destinationId)); + }); + + return { visibility: "public" }; +} diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index a5ba27c92c..e851c58aaa 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -6,6 +6,7 @@ import { acceptedConversationMessageSchema, archiveConversationBodySchema, archiveConversationResponseSchema, + publishConversationResponseSchema, cancelConversationPendingMessagesBodySchema, cancelConversationPendingMessagesResponseSchema, conversationAttachmentParamsSchema, @@ -28,6 +29,7 @@ import { conversationAttachmentHeaders, requireConversationAttachment, } from "./attachments"; +import { publishConversationForViewer } from "./publish"; import { appendConversationMessageForViewer, createConversationForViewer, @@ -133,6 +135,24 @@ export function createConversationRoutes(options: { }, ); + app.post( + "/:conversationId/publish", + requireViewer, + validateRequest( + "param", + conversationParamsSchema, + "Invalid route parameters.", + ), + async (context) => { + const viewer = context.get("viewer"); + const { conversationId } = context.req.valid("param"); + return jsonResponse( + publishConversationResponseSchema, + await publishConversationForViewer(viewer, conversationId), + ); + }, + ); + app.get( "/:conversationId/events", validateRequest( diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 02326405ca..76bce81707 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -4,6 +4,7 @@ export { acceptedConversationMessageSchema, archiveConversationBodySchema, archiveConversationResponseSchema, + publishConversationResponseSchema, cancelConversationPendingMessagesBodySchema, cancelConversationPendingMessagesResponseSchema, conversationAuxiliaryCostsSchema, @@ -30,6 +31,7 @@ export type { AcceptedConversationMessage, ArchiveConversationBody, ArchiveConversationResponse, + PublishConversationResponse, CancelConversationPendingMessagesBody, CancelConversationPendingMessagesResponse, ActorIdentity, diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 24bbdb5293..98c3ce349e 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -65,6 +65,11 @@ export const archiveConversationResponseSchema = z .object({ archived: z.boolean() }) .strict(); +/** One-way private→public publish for a conversation root destination. */ +export const publishConversationResponseSchema = z + .object({ visibility: z.literal("public") }) + .strict(); + export const createConversationBodySchema = z .object({ idempotencyKey: z.string().trim().min(1).max(200), @@ -827,6 +832,9 @@ export type ArchiveConversationBody = z.infer< export type ArchiveConversationResponse = z.infer< typeof archiveConversationResponseSchema >; +export type PublishConversationResponse = z.infer< + typeof publishConversationResponseSchema +>; export type CreateConversationBody = z.infer< typeof createConversationBodySchema >; diff --git a/packages/junior/tests/integration/api/conversations/publish.test.ts b/packages/junior/tests/integration/api/conversations/publish.test.ts new file mode 100644 index 0000000000..50fee06fa4 --- /dev/null +++ b/packages/junior/tests/integration/api/conversations/publish.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { createJuniorApi, type JuniorApiVariables } from "@/api"; +import { + apiErrorSchema, + conversationDetailReportSchema, + publishConversationResponseSchema, +} from "@/api/schema"; +import { closeDb, getConversationStore } from "@/chat/db"; +import { testViewer } from "../../../fixtures/user"; + +function authenticatedApi(email: string) { + const app = new Hono<{ Variables: JuniorApiVariables }>(); + app.use("*", async (context, next) => { + context.set("viewer", testViewer(email)); + await next(); + }); + app.route("/", createJuniorApi()); + return app; +} + +describe("conversation publish API", () => { + afterEach(async () => { + await closeDb(); + }); + + it("lets a participant publish a private conversation", async () => { + const conversationId = "local:web:publish-private"; + await getConversationStore().recordActivity({ + actor: { + email: "owner@example.com", + fullName: "Owner Example", + }, + conversationId, + destination: { + conversationId, + platform: "local", + }, + nowMs: 1_000, + source: "web", + title: "Private web conversation", + visibility: "private", + }); + + const stranger = authenticatedApi("stranger@example.com"); + const denied = await stranger.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/publish`, + { method: "POST" }, + ); + expect(denied.status).toBe(403); + expect(apiErrorSchema.parse(await denied.json())).toEqual({ + error: "Only conversation participants can make this public.", + }); + + const owner = authenticatedApi("owner@example.com"); + const published = await owner.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/publish`, + { method: "POST" }, + ); + expect(published.status).toBe(200); + expect(publishConversationResponseSchema.parse(await published.json())).toEqual({ + visibility: "public", + }); + + const again = await owner.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/publish`, + { method: "POST" }, + ); + expect(again.status).toBe(200); + expect(publishConversationResponseSchema.parse(await again.json())).toEqual({ + visibility: "public", + }); + + const detail = await stranger.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}`, + ); + expect(detail.status).toBe(200); + expect(conversationDetailReportSchema.parse(await detail.json())).toMatchObject({ + conversationId, + visibility: "public", + }); + }); + + it("requires authentication and a real conversation", async () => { + const app = createJuniorApi(); + const unauthenticated = await app.request( + "http://localhost/api/conversations/missing/publish", + { method: "POST" }, + ); + expect(unauthenticated.status).toBe(401); + + const missing = await authenticatedApi("owner@example.com").request( + "http://localhost/api/conversations/missing/publish", + { method: "POST" }, + ); + expect(missing.status).toBe(404); + expect(apiErrorSchema.parse(await missing.json())).toEqual({ + error: "Conversation not found.", + }); + }); + + it("refuses destinations shared by other private roots", async () => { + const store = getConversationStore(); + await store.recordActivity({ + actor: { + email: "owner@example.com", + fullName: "Owner Example", + platform: "slack", + slackUserId: "UOWNER", + teamId: "TSHARE", + }, + conversationId: "slack:CSHARE:1700000000.000100", + destination: { + channelId: "CSHARE", + platform: "slack", + teamId: "TSHARE", + }, + nowMs: 1_000, + source: "slack", + title: "First private thread", + visibility: "private", + }); + await store.recordActivity({ + actor: { + email: "owner@example.com", + fullName: "Owner Example", + platform: "slack", + slackUserId: "UOWNER", + teamId: "TSHARE", + }, + conversationId: "slack:CSHARE:1700000000.000200", + destination: { + channelId: "CSHARE", + platform: "slack", + teamId: "TSHARE", + }, + nowMs: 2_000, + source: "slack", + title: "Second private thread", + visibility: "private", + }); + + const owner = authenticatedApi("owner@example.com"); + const response = await owner.request( + "http://localhost/api/conversations/slack%3ACSHARE%3A1700000000.000100/publish", + { method: "POST" }, + ); + expect(response.status).toBe(409); + expect(apiErrorSchema.parse(await response.json())).toEqual({ + error: + "This destination is shared by other conversations, so it cannot be made public from one conversation.", + }); + + const detail = await owner.request( + "http://localhost/api/conversations/slack%3ACSHARE%3A1700000000.000100", + ); + expect(detail.status).toBe(200); + expect( + conversationDetailReportSchema.parse(await detail.json()), + ).toMatchObject({ + conversationId: "slack:CSHARE:1700000000.000100", + visibility: "private", + }); + }); +});