From ae75fb68a69a89ae6c565d264f41c310f2ca1a8d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 13 May 2026 19:38:28 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=B8=20refactor:=20Refresh=20Shared=20L?= =?UTF-8?q?inks=20With=20Latest=20Snapshot=20(#13095)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: refresh shared links with latest target * fix: validate shared link refresh payload --- api/server/routes/share.js | 8 +- .../ConvoOptions/SharedLinkButton.tsx | 2 +- client/src/data-provider/mutations.ts | 8 +- packages/data-provider/src/data-service.ts | 7 +- .../src/react-query/react-query-service.ts | 5 +- packages/data-provider/src/schemas.ts | 1 + packages/data-provider/src/types.ts | 6 +- .../data-schemas/src/methods/share.test.ts | 103 ++++++++++++++++++ packages/data-schemas/src/methods/share.ts | 27 ++++- packages/data-schemas/src/types/share.ts | 3 + 10 files changed, 150 insertions(+), 20 deletions(-) diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 296644afde..4c0427f197 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -87,6 +87,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { return res.status(200).json({ success: share.success, shareId: share.shareId, + targetMessageId: share.targetMessageId, conversationId: req.params.conversationId, }); } catch (error) { @@ -112,7 +113,12 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => { router.patch('/:shareId', requireJwtAuth, async (req, res) => { try { - const updatedShare = await updateSharedLink(req.user.id, req.params.shareId); + const { targetMessageId } = req.body ?? {}; + if (targetMessageId !== undefined && typeof targetMessageId !== 'string') { + return res.status(400).json({ message: 'targetMessageId must be a string' }); + } + + const updatedShare = await updateSharedLink(req.user.id, req.params.shareId, targetMessageId); if (updatedShare) { res.status(200).json(updatedShare); } else { diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index 7c53cab64c..724ee822e1 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -92,7 +92,7 @@ export default function SharedLinkButton({ if (!shareId) { return; } - const updateShare = await mutateAsync({ shareId }); + const updateShare = await mutateAsync({ shareId, targetMessageId }); const newLink = generateShareLink(updateShare.shareId); setSharedLink(newLink); setAnnouncement(localize('com_ui_link_refreshed')); diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index e10aff030a..6edb8f3552 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -176,17 +176,17 @@ export const useCreateSharedLinkMutation = ( }; export const useUpdateSharedLinkMutation = ( - options?: t.MutationOptions, -): UseMutationResult => { + options?: t.MutationOptions, +): UseMutationResult => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ shareId }) => { + ({ shareId, targetMessageId }) => { if (!shareId) { throw new Error('Share ID is required'); } - return dataService.updateSharedLink(shareId); + return dataService.updateSharedLink(shareId, targetMessageId); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 15cf7ed17d..4704a5bbe0 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -83,8 +83,11 @@ export function createSharedLink( return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); } -export function updateSharedLink(shareId: string): Promise { - return request.patch(endpoints.updateSharedLink(shareId)); +export function updateSharedLink( + shareId: string, + targetMessageId?: string, +): Promise { + return request.patch(endpoints.updateSharedLink(shareId), { targetMessageId }); } export function deleteSharedLink(shareId: string): Promise { diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 571dce5830..b865ab3841 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -51,10 +51,7 @@ export const useGetSharedLinkQuery = ( refetchOnReconnect: false, refetchOnMount: false, onSuccess: (data) => { - queryClient.setQueryData([QueryKeys.sharedLinks, conversationId], { - conversationId: data.conversationId, - shareId: data.shareId, - }); + queryClient.setQueryData([QueryKeys.sharedLinks, conversationId], data); }, ...config, }, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index bf9c119eec..3d72940872 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1024,6 +1024,7 @@ export type TConversation = z.infer & { export const tSharedLinkSchema = z.object({ conversationId: z.string(), shareId: z.string(), + targetMessageId: z.string().optional(), messages: z.array(z.string()), isPublic: z.boolean(), title: z.string(), diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index b1c037c94f..0cfb826819 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -313,12 +313,14 @@ export type TSharedMessagesResponse = Omit & { export type TCreateShareLinkRequest = Pick; -export type TUpdateShareLinkRequest = Pick; +export type TUpdateShareLinkRequest = Pick; export type TSharedLinkResponse = Pick & + Pick & Pick; -export type TSharedLinkGetResponse = TSharedLinkResponse & { +export type TSharedLinkGetResponse = Omit & { + shareId: string | null; success: boolean; }; diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts index 302b16811c..4f045d5b97 100644 --- a/packages/data-schemas/src/methods/share.test.ts +++ b/packages/data-schemas/src/methods/share.test.ts @@ -26,6 +26,7 @@ describe('Share Methods', () => { user: { type: String, index: true }, messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }], shareId: { type: String, index: true }, + targetMessageId: { type: String, required: false, index: true }, isPublic: { type: Boolean, default: true }, }, { timestamps: true }, @@ -714,6 +715,108 @@ describe('Share Methods', () => { ); }); + test('should update branch target to the latest refreshed message', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + const rootMessageId = `msg_${nanoid()}`; + const oldAnswerId = `msg_${nanoid()}`; + const rerunPromptId = `msg_${nanoid()}`; + const rerunAnswerId = `msg_${nanoid()}`; + + await Conversation.create({ + conversationId, + title: 'Analysis Conversation', + user: userId, + }); + + const initialMessages = await Message.create([ + { + messageId: rootMessageId, + conversationId, + user: userId, + text: 'Analyze February 2023 to October 2025', + isCreatedByUser: true, + parentMessageId: Constants.NO_PARENT, + }, + { + messageId: oldAnswerId, + conversationId, + user: userId, + text: 'Old analysis result', + isCreatedByUser: false, + parentMessageId: rootMessageId, + }, + ]); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: initialMessages.map((message) => message._id), + targetMessageId: oldAnswerId, + isPublic: true, + }); + + await Message.create([ + { + messageId: rerunPromptId, + conversationId, + user: userId, + text: 'Rerun for March 2023 to January 2026', + isCreatedByUser: true, + parentMessageId: oldAnswerId, + }, + { + messageId: rerunAnswerId, + conversationId, + user: userId, + text: 'Updated analysis result', + isCreatedByUser: false, + parentMessageId: rerunPromptId, + }, + ]); + + const result = await shareMethods.updateSharedLink(userId, shareId, rerunAnswerId); + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }).populate( + 'messages', + ); + const sharedMessages = await shareMethods.getSharedMessages(result.shareId); + + expect(result.shareId).not.toBe(shareId); + expect(result.targetMessageId).toBe(rerunAnswerId); + expect(updatedShare?.targetMessageId).toBe(rerunAnswerId); + expect(updatedShare?.messages).toHaveLength(4); + expect(sharedMessages?.messages.map((message) => message.text)).toEqual([ + 'Analyze February 2023 to October 2025', + 'Old analysis result', + 'Rerun for March 2023 to January 2026', + 'Updated analysis result', + ]); + }); + + test('should preserve existing branch target when refresh has no target override', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + const targetMessageId = `msg_${nanoid()}`; + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [], + targetMessageId, + isPublic: true, + }); + + const result = await shareMethods.updateSharedLink(userId, shareId); + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }); + + expect(result.targetMessageId).toBe(targetMessageId); + expect(updatedShare?.targetMessageId).toBe(targetMessageId); + }); + test('should not allow user to update shared link they do not own', async () => { const ownerUserId = new mongoose.Types.ObjectId().toString(); const otherUserId = new mongoose.Types.ObjectId().toString(); diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 2a0d2bc3bd..c6b6400e64 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -410,7 +410,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { ...(targetMessageId && { targetMessageId }), }); - return { shareId, conversationId }; + return { shareId, conversationId, targetMessageId }; } catch (error) { if (error instanceof ShareServiceError) { throw error; @@ -439,14 +439,19 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { try { const SharedLink = mongoose.models.SharedLink as Model; const share = (await SharedLink.findOne({ conversationId, user, isPublic: true }) - .select('shareId -_id') - .lean()) as { shareId?: string } | null; + .select('shareId targetMessageId -_id') + .sort({ updatedAt: -1 }) + .lean()) as { shareId?: string; targetMessageId?: string } | null; if (!share) { return { shareId: null, success: false }; } - return { shareId: share.shareId || null, success: true }; + return { + shareId: share.shareId || null, + targetMessageId: share.targetMessageId, + success: true, + }; } catch (error) { logger.error('[getSharedLink] Error getting shared link', { error: error instanceof Error ? error.message : 'Unknown error', @@ -460,7 +465,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { /** * Update a shared link with new messages */ - async function updateSharedLink(user: string, shareId: string): Promise { + async function updateSharedLink( + user: string, + shareId: string, + targetMessageId?: string, + ): Promise { if (!user || !shareId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); } @@ -481,10 +490,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { .lean(); const newShareId = nanoid(); + const resolvedTargetMessageId = targetMessageId ?? share.targetMessageId; const update = { messages: updatedMessages, user, shareId: newShareId, + ...(resolvedTargetMessageId && { targetMessageId: resolvedTargetMessageId }), }; const updatedShare = (await SharedLink.findOneAndUpdate({ shareId, user }, update, { @@ -499,7 +510,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { anonymizeConvo(updatedShare); - return { shareId: newShareId, conversationId: updatedShare.conversationId }; + return { + shareId: newShareId, + conversationId: updatedShare.conversationId, + targetMessageId: updatedShare.targetMessageId, + }; } catch (error) { logger.error('[updateSharedLink] Error updating shared link', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 8b54990cf4..6ef16ad87a 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -43,11 +43,13 @@ export interface SharedMessagesResult { export interface CreateShareResult { shareId: string; conversationId: string; + targetMessageId?: string; } export interface UpdateShareResult { shareId: string; conversationId: string; + targetMessageId?: string; } export interface DeleteShareResult { @@ -58,6 +60,7 @@ export interface DeleteShareResult { export interface GetShareLinkResult { shareId: string | null; + targetMessageId?: string; success: boolean; }