diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e25eada011..cbcdc728ec 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2286,6 +2286,50 @@ export class PostHogAPIClient { return (await response.json()) as TaskChannel[]; } + // Rename a public channel by id, so its task feed follows the new name. + async renameTaskChannel(id: string, name: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_channels/${encodeURIComponent(id)}/`; + const response = await this.api.fetcher.fetch({ + method: "patch", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + overrides: { body: JSON.stringify({ name }) }, + }); + if (!response.ok) { + // The backend rejects invalid/taken names with a JSON `detail` message. + const detail = await response + .json() + .then((body) => (body as { detail?: string }).detail) + .catch(() => undefined); + throw new Error( + `Failed to rename task channel: ${detail ?? response.statusText}`, + ); + } + return (await response.json()) as TaskChannel; + } + + // Delete a public channel by id. Soft-delete server-side, so the channel's + // tasks and messages are recoverable. + async deleteTaskChannel(id: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_channels/${encodeURIComponent(id)}/`; + const response = await this.api.fetcher.fetch({ + method: "delete", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + }); + if (!response.ok) { + const detail = await response + .json() + .then((body) => (body as { detail?: string }).detail) + .catch(() => undefined); + throw new Error( + `Failed to delete task channel: ${detail ?? response.statusText}`, + ); + } + } + // Resolve-or-create a public channel by name (idempotent server-side). async resolveTaskChannel(name: string): Promise { const teamId = await this.getTeamId(); diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index d1182b6847..949e86cf91 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -117,7 +117,7 @@ function useChannelActions(channel: Channel): { ), ]); - await deleteChannel(channel.id); + await deleteChannel(channel.id, channel.name); removeStar(); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "delete", @@ -444,8 +444,8 @@ function ChannelSection({ channel }: { channel: Channel }) { Every canvas saved in this channel is permanently deleted.
  • - Filed tasks are removed from the channel, but the tasks - themselves are not deleted. + Tasks in this channel are deleted with it. Their data is + retained, so they can be restored later.
  • diff --git a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx index 2384d5369b..5521073a3c 100644 --- a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx @@ -39,7 +39,7 @@ export function RenameChannelModal({ const submit = async () => { if (!trimmed || unchanged || validationError || isRenaming) return; try { - await renameChannel(channel.id, trimmed); + await renameChannel(channel.id, trimmed, channel.name); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "rename", surface: "sidebar", diff --git a/packages/ui/src/features/canvas/hooks/useChannels.test.tsx b/packages/ui/src/features/canvas/hooks/useChannels.test.tsx index 81c1e7e8e4..6efdeefc2d 100644 --- a/packages/ui/src/features/canvas/hooks/useChannels.test.tsx +++ b/packages/ui/src/features/canvas/hooks/useChannels.test.tsx @@ -1,4 +1,5 @@ import type { Schemas } from "@posthog/api-client"; +import type { TaskChannel } from "@posthog/shared/domain-types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; @@ -7,6 +8,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockClient = vi.hoisted(() => ({ getDesktopFileSystemChannels: vi.fn(), createDesktopFileSystemChannel: vi.fn(), + renameDesktopFileSystemChannel: vi.fn(), + deleteDesktopFileSystem: vi.fn(), + getTaskChannels: vi.fn(), + renameTaskChannel: vi.fn(), + deleteTaskChannel: vi.fn(), + getTasks: vi.fn(), + deleteTask: vi.fn(), })); vi.mock("@posthog/ui/features/auth/authClient", () => ({ useOptionalAuthenticatedClient: () => mockClient, @@ -99,3 +107,205 @@ describe("useChannelMutations", () => { expect(list.result.current.channels.map((c) => c.id)).toEqual(["1"]); }); }); + +describe("useChannelMutations rename", () => { + function taskChannel( + id: string, + name: string, + channel_type: TaskChannel["channel_type"] = "public", + ): TaskChannel { + return { id, name, channel_type, created_at: "2026-01-01T00:00:00Z" }; + } + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + it("renames the backend task channel alongside the folder", async () => { + // The feed's backend channel is looked up by name, so a folder rename + // must carry it along or the channel's tasks/messages are orphaned. + mockClient.getTaskChannels.mockResolvedValue([ + taskChannel("bc-1", "mobile"), + taskChannel("bc-2", "web"), + ]); + mockClient.renameTaskChannel.mockResolvedValue( + taskChannel("bc-1", "mobile-app"), + ); + mockClient.renameDesktopFileSystemChannel.mockResolvedValue( + folder("1", "mobile-app"), + ); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await act(async () => { + await mutations.result.current.renameChannel("1", "mobile-app", "mobile"); + }); + + expect(mockClient.renameTaskChannel).toHaveBeenCalledWith( + "bc-1", + "mobile-app", + ); + expect(mockClient.renameDesktopFileSystemChannel).toHaveBeenCalledWith( + "1", + "mobile-app", + ); + }); + + it("skips the backend rename when no backend channel has the old name", async () => { + mockClient.getTaskChannels.mockResolvedValue([taskChannel("bc-2", "web")]); + mockClient.renameDesktopFileSystemChannel.mockResolvedValue( + folder("1", "mobile-app"), + ); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await act(async () => { + await mutations.result.current.renameChannel("1", "mobile-app", "mobile"); + }); + + expect(mockClient.renameTaskChannel).not.toHaveBeenCalled(); + expect(mockClient.renameDesktopFileSystemChannel).toHaveBeenCalledWith( + "1", + "mobile-app", + ); + }); + + it("does not rename the folder when the backend rename fails", async () => { + mockClient.getTaskChannels.mockResolvedValue([ + taskChannel("bc-1", "mobile"), + ]); + mockClient.renameTaskChannel.mockRejectedValue(new Error("name taken")); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await expect( + act(() => + mutations.result.current.renameChannel("1", "mobile-app", "mobile"), + ), + ).rejects.toThrow("name taken"); + + expect(mockClient.renameDesktopFileSystemChannel).not.toHaveBeenCalled(); + }); + + it("reverts the backend rename when the folder rename fails", async () => { + mockClient.getTaskChannels.mockResolvedValue([ + taskChannel("bc-1", "mobile"), + ]); + mockClient.renameTaskChannel.mockResolvedValue( + taskChannel("bc-1", "mobile-app"), + ); + mockClient.renameDesktopFileSystemChannel.mockRejectedValue( + new Error("folder rename failed"), + ); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await expect( + act(() => + mutations.result.current.renameChannel("1", "mobile-app", "mobile"), + ), + ).rejects.toThrow("folder rename failed"); + + expect(mockClient.renameTaskChannel).toHaveBeenNthCalledWith( + 1, + "bc-1", + "mobile-app", + ); + expect(mockClient.renameTaskChannel).toHaveBeenNthCalledWith( + 2, + "bc-1", + "mobile", + ); + }); +}); + +describe("useChannelMutations delete", () => { + function taskChannel( + id: string, + name: string, + channel_type: TaskChannel["channel_type"] = "public", + ): TaskChannel { + return { id, name, channel_type, created_at: "2026-01-01T00:00:00Z" }; + } + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + it("soft-deletes the backend channel's tasks and the channel before the folder", async () => { + mockClient.getTaskChannels.mockResolvedValue([ + taskChannel("bc-1", "mobile"), + taskChannel("bc-2", "web"), + ]); + mockClient.getTasks.mockResolvedValue([{ id: "t-1" }, { id: "t-2" }]); + mockClient.deleteTask.mockResolvedValue(undefined); + mockClient.deleteTaskChannel.mockResolvedValue(undefined); + mockClient.deleteDesktopFileSystem.mockResolvedValue(undefined); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await act(async () => { + await mutations.result.current.deleteChannel("1", "mobile"); + }); + + expect(mockClient.getTasks).toHaveBeenCalledWith({ channel: "bc-1" }); + expect(mockClient.deleteTask).toHaveBeenCalledWith("t-1"); + expect(mockClient.deleteTask).toHaveBeenCalledWith("t-2"); + expect(mockClient.deleteTaskChannel).toHaveBeenCalledWith("bc-1"); + expect(mockClient.deleteDesktopFileSystem).toHaveBeenCalledWith("1"); + }); + + it("deletes just the folder when no backend channel matches the name", async () => { + mockClient.getTaskChannels.mockResolvedValue([taskChannel("bc-2", "web")]); + mockClient.deleteDesktopFileSystem.mockResolvedValue(undefined); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await act(async () => { + await mutations.result.current.deleteChannel("1", "mobile"); + }); + + expect(mockClient.getTasks).not.toHaveBeenCalled(); + expect(mockClient.deleteTaskChannel).not.toHaveBeenCalled(); + expect(mockClient.deleteDesktopFileSystem).toHaveBeenCalledWith("1"); + }); + + it("still deletes the backend channel when a task soft delete fails", async () => { + // Task deletes are best-effort: a straggler stays attached to the + // soft-deleted channel (recoverable) rather than blocking the delete. + mockClient.getTaskChannels.mockResolvedValue([ + taskChannel("bc-1", "mobile"), + ]); + mockClient.getTasks.mockResolvedValue([{ id: "t-1" }, { id: "t-2" }]); + mockClient.deleteTask + .mockRejectedValueOnce(new Error("task delete failed")) + .mockResolvedValueOnce(undefined); + mockClient.deleteTaskChannel.mockResolvedValue(undefined); + mockClient.deleteDesktopFileSystem.mockResolvedValue(undefined); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await act(async () => { + await mutations.result.current.deleteChannel("1", "mobile"); + }); + + expect(mockClient.deleteTaskChannel).toHaveBeenCalledWith("bc-1"); + expect(mockClient.deleteDesktopFileSystem).toHaveBeenCalledWith("1"); + }); + + it("does not delete the folder when the backend channel delete fails", async () => { + mockClient.getTaskChannels.mockResolvedValue([ + taskChannel("bc-1", "mobile"), + ]); + mockClient.getTasks.mockResolvedValue([]); + mockClient.deleteTaskChannel.mockRejectedValue( + new Error("channel delete failed"), + ); + + const mutations = renderHook(() => useChannelMutations(), { wrapper }); + await expect( + act(() => mutations.result.current.deleteChannel("1", "mobile")), + ).rejects.toThrow("channel delete failed"); + + expect(mockClient.deleteDesktopFileSystem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannels.ts b/packages/ui/src/features/canvas/hooks/useChannels.ts index ba2e78945a..54a2666c40 100644 --- a/packages/ui/src/features/canvas/hooks/useChannels.ts +++ b/packages/ui/src/features/canvas/hooks/useChannels.ts @@ -1,5 +1,11 @@ import type { Schemas } from "@posthog/api-client"; +import type { TaskChannel } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { + normalizeChannelName, + PERSONAL_CHANNEL_NAME, + TASK_CHANNELS_QUERY_KEY, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; @@ -99,27 +105,93 @@ export function useChannelMutations() { }); const deleteMutation = useMutation({ - mutationFn: async (id: string) => { + mutationFn: async ({ id, name }: { id: string; name: string }) => { if (!client) throw new Error("Not authenticated"); + // The task feed lives on a separate backend channel. Deleting only the + // folder would leave that channel live but unreachable, with its tasks + // orphaned under it. Instead, soft-delete the tasks and the backend + // channel first (both are soft deletes server-side, so nothing is + // lost), then remove the folder. Backend first: if it fails, nothing + // local has changed. + const normalized = normalizeChannelName(name); + if (normalized !== PERSONAL_CHANNEL_NAME) { + const backendChannels = await client.getTaskChannels(); + const backendChannel = backendChannels.find( + (c) => c.channel_type === "public" && c.name === normalized, + ); + if (backendChannel) { + const tasks = await client.getTasks({ channel: backendChannel.id }); + // Best-effort per task — one failed soft delete shouldn't strand + // the rest or block the channel delete (the failed task stays + // attached to the soft-deleted channel, still recoverable). + await Promise.allSettled( + tasks.map((task) => client.deleteTask(task.id)), + ); + await client.deleteTaskChannel(backendChannel.id); + } + } return client.deleteDesktopFileSystem(id); }, - onSuccess: invalidate, + onSuccess: () => { + invalidate(); + void queryClient.invalidateQueries({ queryKey: TASK_CHANNELS_QUERY_KEY }); + }, }); const renameMutation = useMutation({ - mutationFn: async ({ id, name }: { id: string; name: string }) => { + mutationFn: async ({ + id, + name, + oldName, + }: { + id: string; + name: string; + oldName: string; + }) => { if (!client) throw new Error("Not authenticated"); - return client.renameDesktopFileSystemChannel(id, name); + // The task feed lives on a separate backend channel that is resolved by + // name, so it must move in lockstep with the folder — otherwise the feed + // would resolve to a brand-new empty channel and the existing tasks and + // messages would be orphaned under the old name. Rename the data-bearing + // backend channel first: if that fails, nothing has changed. + const from = normalizeChannelName(oldName); + const to = normalizeChannelName(name); + let backendChannel: TaskChannel | undefined; + if (from !== to && from !== PERSONAL_CHANNEL_NAME) { + const backendChannels = await client.getTaskChannels(); + backendChannel = backendChannels.find( + (c) => c.channel_type === "public" && c.name === from, + ); + if (backendChannel) { + await client.renameTaskChannel(backendChannel.id, to); + } + } + try { + return await client.renameDesktopFileSystemChannel(id, name); + } catch (error) { + // The folder rename failed after the backend channel moved — put the + // backend name back so the two sides stay in sync. + if (backendChannel) { + await client + .renameTaskChannel(backendChannel.id, from) + .catch(() => undefined); + } + throw error; + } + }, + onSuccess: () => { + invalidate(); + void queryClient.invalidateQueries({ queryKey: TASK_CHANNELS_QUERY_KEY }); }, - onSuccess: invalidate, }); return { createChannel: (name: string) => createMutation.mutateAsync(name).then(toChannel), - deleteChannel: (id: string) => deleteMutation.mutateAsync(id), - renameChannel: (id: string, name: string) => - renameMutation.mutateAsync({ id, name }).then(toChannel), + deleteChannel: (id: string, name: string) => + deleteMutation.mutateAsync({ id, name }), + renameChannel: (id: string, name: string, oldName: string) => + renameMutation.mutateAsync({ id, name, oldName }).then(toChannel), isCreating: createMutation.isPending, isDeleting: deleteMutation.isPending, isRenaming: renameMutation.isPending,