From 7560f7be855149057f9390d87565a6b89265be5e Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:30:42 +0800 Subject: [PATCH] feat(core/issues): add TanStack Query layer and rewrite hooks (Phase 1, Commits 1-4) - Add getQueryClient() singleton for non-React contexts (WS handlers, Zustand) - Create issue query key factory + 5 queryOptions - Create 11 mutation hooks with optimistic updates and rollback - Create WS cache updaters + dual-write in use-realtime-sync - Rewrite useIssueTimeline, useIssueReactions, useIssueSubscribers to TQ (return types unchanged, consumers unaffected) - Add QueryClientProvider wrapper to issue detail tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 19 +- apps/web/core/index.ts | 2 +- apps/web/core/issues/index.ts | 28 ++ apps/web/core/issues/mutations.ts | 471 ++++++++++++++++++ apps/web/core/issues/queries.ts | 52 ++ apps/web/core/issues/ws-updaters.ts | 56 +++ apps/web/core/provider.tsx | 8 +- apps/web/core/query-client.ts | 13 + .../issues/hooks/use-issue-reactions.ts | 99 ++-- .../issues/hooks/use-issue-subscribers.ts | 137 +++-- .../issues/hooks/use-issue-timeline.ts | 300 +++++------ .../features/realtime/use-realtime-sync.ts | 27 +- 12 files changed, 894 insertions(+), 318 deletions(-) create mode 100644 apps/web/core/issues/index.ts create mode 100644 apps/web/core/issues/mutations.ts create mode 100644 apps/web/core/issues/queries.ts create mode 100644 apps/web/core/issues/ws-updaters.ts diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 7ec44b49..1bccf306 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -2,6 +2,7 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, Comment, TimelineEntry } from "@/shared/types"; // Mock next/navigation @@ -235,14 +236,26 @@ const mockTimeline: TimelineEntry[] = [ import IssueDetailPage from "./page"; +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); +} + // React 19 use(Promise) needs the promise to resolve within act + Suspense async function renderPage(id = "issue-1") { + const queryClient = createTestQueryClient(); let result: ReturnType; await act(async () => { result = render( - Suspense loading...}> - - , + + Suspense loading...}> + + + , ); }); return result!; diff --git a/apps/web/core/index.ts b/apps/web/core/index.ts index 97d2430c..26716d72 100644 --- a/apps/web/core/index.ts +++ b/apps/web/core/index.ts @@ -1,3 +1,3 @@ -export { createQueryClient } from "./query-client"; +export { createQueryClient, getQueryClient, setQueryClient } from "./query-client"; export { QueryProvider } from "./provider"; export { useWorkspaceId } from "./hooks"; diff --git a/apps/web/core/issues/index.ts b/apps/web/core/issues/index.ts new file mode 100644 index 00000000..a746aeb2 --- /dev/null +++ b/apps/web/core/issues/index.ts @@ -0,0 +1,28 @@ +export { + issueKeys, + issueListOptions, + issueDetailOptions, + issueTimelineOptions, + issueReactionsOptions, + issueSubscribersOptions, +} from "./queries"; + +export { + useCreateIssue, + useUpdateIssue, + useDeleteIssue, + useBatchUpdateIssues, + useBatchDeleteIssues, + useCreateComment, + useUpdateComment, + useDeleteComment, + useToggleCommentReaction, + useToggleIssueReaction, + useToggleIssueSubscriber, +} from "./mutations"; + +export { + onIssueCreated, + onIssueUpdated, + onIssueDeleted, +} from "./ws-updaters"; diff --git a/apps/web/core/issues/mutations.ts b/apps/web/core/issues/mutations.ts new file mode 100644 index 00000000..89d0ef5a --- /dev/null +++ b/apps/web/core/issues/mutations.ts @@ -0,0 +1,471 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import { issueKeys } from "./queries"; +import { useWorkspaceId } from "@core/hooks"; +import type { Issue, IssueReaction } from "@/shared/types"; +import type { + CreateIssueRequest, + UpdateIssueRequest, + ListIssuesResponse, +} from "@/shared/types"; +import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types"; + +// --------------------------------------------------------------------------- +// Issue CRUD +// --------------------------------------------------------------------------- + +export function useCreateIssue() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (data: CreateIssueRequest) => api.createIssue(data), + onSuccess: (newIssue) => { + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 } + : old, + ); + }, + }); +} + +export function useUpdateIssue() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) => + api.updateIssue(id, data), + onMutate: async ({ id, ...data }) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); + + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.map((i) => + i.id === id ? { ...i, ...data } : i, + ), + } + : old, + ); + qc.setQueryData(issueKeys.detail(wsId, id), (old) => + old ? { ...old, ...data } : old, + ); + return { prevList, prevDetail, id }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + if (ctx?.prevDetail) + qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); + }, + }); +} + +export function useDeleteIssue() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.deleteIssue(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.filter((i) => i.id !== id), + total: old.total - 1, + } + : old, + ); + qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) }); + return { prevList }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + }, + }); +} + +export function useBatchUpdateIssues() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ + ids, + updates, + }: { + ids: string[]; + updates: UpdateIssueRequest; + }) => api.batchUpdateIssues(ids, updates), + onMutate: async ({ ids, updates }) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.map((i) => + ids.includes(i.id) ? { ...i, ...updates } : i, + ), + } + : old, + ); + return { prevList }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + }, + }); +} + +export function useBatchDeleteIssues() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (ids: string[]) => api.batchDeleteIssues(ids), + onMutate: async (ids) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.filter((i) => !ids.includes(i.id)), + total: old.total - ids.length, + } + : old, + ); + return { prevList }; + }, + onError: (_err, _ids, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + }, + }); +} + +// --------------------------------------------------------------------------- +// Comments / Timeline +// --------------------------------------------------------------------------- + +export function useCreateComment(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + content, + type, + parentId, + attachmentIds, + }: { + content: string; + type?: string; + parentId?: string; + attachmentIds?: string[]; + }) => api.createComment(issueId, content, type, parentId, attachmentIds), + onSuccess: (comment) => { + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + const entry: TimelineEntry = { + type: "comment", + id: comment.id, + actor_type: comment.author_type, + actor_id: comment.author_id, + content: comment.content, + parent_id: comment.parent_id, + comment_type: comment.type, + reactions: comment.reactions ?? [], + attachments: comment.attachments ?? [], + created_at: comment.created_at, + updated_at: comment.updated_at, + }; + if (old.some((e) => e.id === comment.id)) return old; + return [...old, entry]; + }, + ); + }, + }); +} + +export function useUpdateComment(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ commentId, content }: { commentId: string; content: string }) => + api.updateComment(commentId, content), + onMutate: async ({ commentId, content }) => { + await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) }); + const prev = qc.getQueryData(issueKeys.timeline(issueId)); + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => (e.id === commentId ? { ...e, content } : e)), + ); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.timeline(issueId), ctx.prev); + }, + }); +} + +export function useDeleteComment(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (commentId: string) => api.deleteComment(commentId), + onMutate: async (commentId) => { + await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) }); + const prev = qc.getQueryData(issueKeys.timeline(issueId)); + + // Cascade: collect all child comment IDs + const toRemove = new Set([commentId]); + if (prev) { + let changed = true; + while (changed) { + changed = false; + for (const e of prev) { + if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) { + toRemove.add(e.id); + changed = true; + } + } + } + } + + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => old?.filter((e) => !toRemove.has(e.id)), + ); + return { prev }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.timeline(issueId), ctx.prev); + }, + }); +} + +export function useToggleCommentReaction(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + commentId, + emoji, + existing, + }: { + commentId: string; + emoji: string; + existing: Reaction | undefined; + }) => { + if (existing) { + await api.removeReaction(commentId, emoji); + return null; + } + return api.addReaction(commentId, emoji); + }, + onMutate: async ({ commentId, emoji, existing }) => { + await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) }); + const prev = qc.getQueryData(issueKeys.timeline(issueId)); + + if (existing) { + // Remove + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === commentId + ? { + ...e, + reactions: (e.reactions ?? []).filter( + (r) => r.id !== existing.id, + ), + } + : e, + ), + ); + } else { + // Add temp + const tempReaction: Reaction = { + id: `temp-${Date.now()}`, + comment_id: commentId, + actor_type: "", + actor_id: "", + emoji, + created_at: new Date().toISOString(), + }; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === commentId + ? { ...e, reactions: [...(e.reactions ?? []), tempReaction] } + : e, + ), + ); + } + return { prev }; + }, + onSuccess: (reaction, { commentId }) => { + if (reaction) { + // Replace temp with real + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === commentId + ? { + ...e, + reactions: (e.reactions ?? []).map((r) => + r.id.startsWith("temp-") && r.emoji === reaction.emoji + ? reaction + : r, + ), + } + : e, + ), + ); + } + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.timeline(issueId), ctx.prev); + }, + }); +} + +// --------------------------------------------------------------------------- +// Issue-level Reactions +// --------------------------------------------------------------------------- + +export function useToggleIssueReaction(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + emoji, + existing, + }: { + emoji: string; + existing: IssueReaction | undefined; + }) => { + if (existing) { + await api.removeIssueReaction(issueId, emoji); + return null; + } + return api.addIssueReaction(issueId, emoji); + }, + onMutate: async ({ emoji, existing }) => { + await qc.cancelQueries({ queryKey: issueKeys.reactions(issueId) }); + const prev = qc.getQueryData(issueKeys.reactions(issueId)); + + if (existing) { + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => old?.filter((r) => r.id !== existing.id), + ); + } else { + const temp: IssueReaction = { + id: `temp-${Date.now()}`, + issue_id: issueId, + actor_type: "", + actor_id: "", + emoji, + created_at: new Date().toISOString(), + }; + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => [...(old ?? []), temp], + ); + } + return { prev }; + }, + onSuccess: (reaction) => { + if (reaction) { + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => + old?.map((r) => + r.id.startsWith("temp-") && r.emoji === reaction.emoji + ? reaction + : r, + ), + ); + } + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.reactions(issueId), ctx.prev); + }, + }); +} + +// --------------------------------------------------------------------------- +// Issue Subscribers +// --------------------------------------------------------------------------- + +export function useToggleIssueSubscriber(issueId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + userId, + userType, + subscribed, + }: { + userId: string; + userType: "member" | "agent"; + subscribed: boolean; + }) => { + if (subscribed) { + await api.unsubscribeFromIssue(issueId, userId, userType); + } else { + await api.subscribeToIssue(issueId, userId, userType); + } + }, + onMutate: async ({ userId, userType, subscribed }) => { + await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) }); + const prev = qc.getQueryData( + issueKeys.subscribers(issueId), + ); + + if (subscribed) { + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => + old?.filter( + (s) => !(s.user_id === userId && s.user_type === userType), + ), + ); + } else { + const temp: IssueSubscriber = { + issue_id: issueId, + user_type: userType, + user_id: userId, + reason: "manual", + created_at: new Date().toISOString(), + }; + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => { + if ( + old?.some( + (s) => s.user_id === userId && s.user_type === userType, + ) + ) + return old; + return [...(old ?? []), temp]; + }, + ); + } + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev); + }, + }); +} diff --git a/apps/web/core/issues/queries.ts b/apps/web/core/issues/queries.ts new file mode 100644 index 00000000..b848cb47 --- /dev/null +++ b/apps/web/core/issues/queries.ts @@ -0,0 +1,52 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/shared/api"; + +export const issueKeys = { + all: (wsId: string) => ["issues", wsId] as const, + list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, + detail: (wsId: string, id: string) => + [...issueKeys.all(wsId), "detail", id] as const, + timeline: (issueId: string) => ["issues", "timeline", issueId] as const, + reactions: (issueId: string) => ["issues", "reactions", issueId] as const, + subscribers: (issueId: string) => + ["issues", "subscribers", issueId] as const, +}; + +export function issueListOptions(wsId: string) { + return queryOptions({ + queryKey: issueKeys.list(wsId), + queryFn: () => api.listIssues({ limit: 200 }), + select: (data) => data.issues, + }); +} + +export function issueDetailOptions(wsId: string, id: string) { + return queryOptions({ + queryKey: issueKeys.detail(wsId, id), + queryFn: () => api.getIssue(id), + }); +} + +export function issueTimelineOptions(issueId: string) { + return queryOptions({ + queryKey: issueKeys.timeline(issueId), + queryFn: () => api.listTimeline(issueId), + }); +} + +export function issueReactionsOptions(issueId: string) { + return queryOptions({ + queryKey: issueKeys.reactions(issueId), + queryFn: async () => { + const issue = await api.getIssue(issueId); + return issue.reactions ?? []; + }, + }); +} + +export function issueSubscribersOptions(issueId: string) { + return queryOptions({ + queryKey: issueKeys.subscribers(issueId), + queryFn: () => api.listIssueSubscribers(issueId), + }); +} diff --git a/apps/web/core/issues/ws-updaters.ts b/apps/web/core/issues/ws-updaters.ts new file mode 100644 index 00000000..7486c4fe --- /dev/null +++ b/apps/web/core/issues/ws-updaters.ts @@ -0,0 +1,56 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { issueKeys } from "./queries"; +import type { Issue } from "@/shared/types"; +import type { ListIssuesResponse } from "@/shared/types"; + +export function onIssueCreated( + qc: QueryClient, + wsId: string, + issue: Issue, +) { + qc.setQueryData(issueKeys.list(wsId), (old) => + old && !old.issues.some((i) => i.id === issue.id) + ? { ...old, issues: [...old.issues, issue], total: old.total + 1 } + : old, + ); +} + +export function onIssueUpdated( + qc: QueryClient, + wsId: string, + issue: Partial & { id: string }, +) { + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.map((i) => + i.id === issue.id ? { ...i, ...issue } : i, + ), + } + : old, + ); + qc.setQueryData(issueKeys.detail(wsId, issue.id), (old) => + old ? { ...old, ...issue } : old, + ); +} + +export function onIssueDeleted( + qc: QueryClient, + wsId: string, + issueId: string, +) { + qc.setQueryData(issueKeys.list(wsId), (old) => + old + ? { + ...old, + issues: old.issues.filter((i) => i.id !== issueId), + total: old.total - 1, + } + : old, + ); + qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) }); + qc.removeQueries({ queryKey: issueKeys.timeline(issueId) }); + qc.removeQueries({ queryKey: issueKeys.reactions(issueId) }); + qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) }); +} diff --git a/apps/web/core/provider.tsx b/apps/web/core/provider.tsx index 41331d2e..7723c771 100644 --- a/apps/web/core/provider.tsx +++ b/apps/web/core/provider.tsx @@ -3,11 +3,15 @@ import { useState } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { createQueryClient } from "./query-client"; +import { createQueryClient, setQueryClient } from "./query-client"; import type { ReactNode } from "react"; export function QueryProvider({ children }: { children: ReactNode }) { - const [queryClient] = useState(createQueryClient); + const [queryClient] = useState(() => { + const client = createQueryClient(); + setQueryClient(client); + return client; + }); return ( {children} diff --git a/apps/web/core/query-client.ts b/apps/web/core/query-client.ts index be5ebea0..b882845b 100644 --- a/apps/web/core/query-client.ts +++ b/apps/web/core/query-client.ts @@ -1,5 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; +let _queryClient: QueryClient | null = null; + export function createQueryClient(): QueryClient { return new QueryClient({ defaultOptions: { @@ -16,3 +18,14 @@ export function createQueryClient(): QueryClient { }, }); } + +/** Called by QueryProvider on mount to register the singleton. */ +export function setQueryClient(client: QueryClient) { + _queryClient = client; +} + +/** Access QueryClient outside React tree (WS handlers, Zustand actions). */ +export function getQueryClient(): QueryClient { + if (!_queryClient) throw new Error("QueryClient not initialized"); + return _queryClient; +} diff --git a/apps/web/features/issues/hooks/use-issue-reactions.ts b/apps/web/features/issues/hooks/use-issue-reactions.ts index 3c824933..7e7418e7 100644 --- a/apps/web/features/issues/hooks/use-issue-reactions.ts +++ b/apps/web/features/issues/hooks/use-issue-reactions.ts @@ -1,38 +1,29 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueReaction } from "@/shared/types"; import type { IssueReactionAddedPayload, IssueReactionRemovedPayload, } from "@/shared/types"; -import { api } from "@/shared/api"; -import { toast } from "sonner"; +import { issueReactionsOptions, issueKeys } from "@core/issues/queries"; +import { useToggleIssueReaction } from "@core/issues/mutations"; import { useWSEvent, useWSReconnect } from "@/features/realtime"; export function useIssueReactions(issueId: string, userId?: string) { - const [reactions, setReactions] = useState([]); - const [loading, setLoading] = useState(true); + const qc = useQueryClient(); + const { data: reactions = [], isLoading: loading } = useQuery( + issueReactionsOptions(issueId), + ); - // Initial fetch - useEffect(() => { - setReactions([]); - setLoading(true); - api - .getIssue(issueId) - .then((iss) => setReactions(iss.reactions ?? [])) - .catch((e) => { - console.error(e); - toast.error("Failed to load reactions"); - }) - .finally(() => setLoading(false)); - }, [issueId]); + const toggleMutation = useToggleIssueReaction(issueId); // Reconnect recovery useWSReconnect( useCallback(() => { - api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error); - }, [issueId]), + qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) }); + }, [qc, issueId]), ); // --- WS event handlers --- @@ -43,13 +34,18 @@ export function useIssueReactions(issueId: string, userId?: string) { (payload: unknown) => { const { reaction, issue_id } = payload as IssueReactionAddedPayload; if (issue_id !== issueId) return; - if (reaction.actor_type === "member" && reaction.actor_id === userId) return; - setReactions((prev) => { - if (prev.some((r) => r.id === reaction.id)) return prev; - return [...prev, reaction]; - }); + if (reaction.actor_type === "member" && reaction.actor_id === userId) + return; + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => { + if (!old) return old; + if (old.some((r) => r.id === reaction.id)) return old; + return [...old, reaction]; + }, + ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -60,13 +56,20 @@ export function useIssueReactions(issueId: string, userId?: string) { const p = payload as IssueReactionRemovedPayload; if (p.issue_id !== issueId) return; if (p.actor_type === "member" && p.actor_id === userId) return; - setReactions((prev) => - prev.filter( - (r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id), - ), + qc.setQueryData( + issueKeys.reactions(issueId), + (old) => + old?.filter( + (r) => + !( + r.emoji === p.emoji && + r.actor_type === p.actor_type && + r.actor_id === p.actor_id + ), + ), ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -76,36 +79,14 @@ export function useIssueReactions(issueId: string, userId?: string) { async (emoji: string) => { if (!userId) return; const existing = reactions.find( - (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId, + (r) => + r.emoji === emoji && + r.actor_type === "member" && + r.actor_id === userId, ); - if (existing) { - setReactions((prev) => prev.filter((r) => r.id !== existing.id)); - try { - await api.removeIssueReaction(issueId, emoji); - } catch { - setReactions((prev) => [...prev, existing]); - toast.error("Failed to remove reaction"); - } - } else { - const temp: IssueReaction = { - id: `temp-${Date.now()}`, - issue_id: issueId, - actor_type: "member", - actor_id: userId, - emoji, - created_at: new Date().toISOString(), - }; - setReactions((prev) => [...prev, temp]); - try { - const reaction = await api.addIssueReaction(issueId, emoji); - setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r))); - } catch { - setReactions((prev) => prev.filter((r) => r.id !== temp.id)); - toast.error("Failed to add reaction"); - } - } + toggleMutation.mutate({ emoji, existing }); }, - [issueId, userId, reactions], + [userId, reactions, toggleMutation], ); return { reactions, loading, toggleReaction }; diff --git a/apps/web/features/issues/hooks/use-issue-subscribers.ts b/apps/web/features/issues/hooks/use-issue-subscribers.ts index 7c440900..c462e4a2 100644 --- a/apps/web/features/issues/hooks/use-issue-subscribers.ts +++ b/apps/web/features/issues/hooks/use-issue-subscribers.ts @@ -1,38 +1,29 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueSubscriber } from "@/shared/types"; import type { SubscriberAddedPayload, SubscriberRemovedPayload, } from "@/shared/types"; -import { api } from "@/shared/api"; -import { toast } from "sonner"; +import { issueSubscribersOptions, issueKeys } from "@core/issues/queries"; +import { useToggleIssueSubscriber } from "@core/issues/mutations"; import { useWSEvent, useWSReconnect } from "@/features/realtime"; export function useIssueSubscribers(issueId: string, userId?: string) { - const [subscribers, setSubscribers] = useState([]); - const [loading, setLoading] = useState(true); + const qc = useQueryClient(); + const { data: subscribers = [], isLoading: loading } = useQuery( + issueSubscribersOptions(issueId), + ); - // Initial fetch - useEffect(() => { - setSubscribers([]); - setLoading(true); - api - .listIssueSubscribers(issueId) - .then((subs) => setSubscribers(subs)) - .catch((e) => { - console.error(e); - toast.error("Failed to load subscribers"); - }) - .finally(() => setLoading(false)); - }, [issueId]); + const toggleMutation = useToggleIssueSubscriber(issueId); // Reconnect recovery useWSReconnect( useCallback(() => { - api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error); - }, [issueId]), + qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) }); + }, [qc, issueId]), ); // --- WS event handlers --- @@ -43,21 +34,31 @@ export function useIssueSubscribers(issueId: string, userId?: string) { (payload: unknown) => { const p = payload as SubscriberAddedPayload; if (p.issue_id !== issueId) return; - setSubscribers((prev) => { - if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev; - return [ - ...prev, - { - issue_id: p.issue_id, - user_type: p.user_type as "member" | "agent", - user_id: p.user_id, - reason: p.reason as IssueSubscriber["reason"], - created_at: new Date().toISOString(), - }, - ]; - }); + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => { + if (!old) return old; + if ( + old.some( + (s) => + s.user_id === p.user_id && s.user_type === p.user_type, + ) + ) + return old; + return [ + ...old, + { + issue_id: p.issue_id, + user_type: p.user_type as "member" | "agent", + user_id: p.user_id, + reason: p.reason as IssueSubscriber["reason"], + created_at: new Date().toISOString(), + }, + ]; + }, + ); }, - [issueId], + [qc, issueId], ), ); @@ -67,11 +68,16 @@ export function useIssueSubscribers(issueId: string, userId?: string) { (payload: unknown) => { const p = payload as SubscriberRemovedPayload; if (p.issue_id !== issueId) return; - setSubscribers((prev) => - prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)), + qc.setQueryData( + issueKeys.subscribers(issueId), + (old) => + old?.filter( + (s) => + !(s.user_id === p.user_id && s.user_type === p.user_type), + ), ); }, - [issueId], + [qc, issueId], ), ); @@ -82,50 +88,29 @@ export function useIssueSubscribers(issueId: string, userId?: string) { ); const toggleSubscriber = useCallback( - async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => { - if (currentlySubscribed) { - // Optimistic remove + rollback on error - const removed = subscribers.find( - (s) => s.user_id === subUserId && s.user_type === userType, - ); - setSubscribers((prev) => - prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)), - ); - try { - await api.unsubscribeFromIssue(issueId, subUserId, userType); - } catch { - if (removed) setSubscribers((prev) => [...prev, removed]); - toast.error("Failed to update subscriber"); - } - } else { - // Optimistic add - const tempSub: IssueSubscriber = { - issue_id: issueId, - user_type: userType, - user_id: subUserId, - reason: "manual" as const, - created_at: new Date().toISOString(), - }; - setSubscribers((prev) => { - if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev; - return [...prev, tempSub]; - }); - try { - await api.subscribeToIssue(issueId, subUserId, userType); - } catch { - setSubscribers((prev) => - prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")), - ); - toast.error("Failed to update subscriber"); - } - } + async ( + subUserId: string, + userType: "member" | "agent", + currentlySubscribed: boolean, + ) => { + toggleMutation.mutate({ + userId: subUserId, + userType, + subscribed: currentlySubscribed, + }); }, - [issueId, subscribers], + [toggleMutation], ); const toggleSubscribe = useCallback(() => { if (userId) toggleSubscriber(userId, "member", isSubscribed); }, [userId, isSubscribed, toggleSubscriber]); - return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber }; + return { + subscribers, + loading, + isSubscribed, + toggleSubscribe, + toggleSubscriber, + }; } diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 294c5bfb..fd303c17 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import type { Comment, TimelineEntry } from "@/shared/types"; +import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Comment, TimelineEntry, Reaction } from "@/shared/types"; import type { CommentCreatedPayload, CommentUpdatedPayload, @@ -10,7 +11,13 @@ import type { ReactionAddedPayload, ReactionRemovedPayload, } from "@/shared/types"; -import { api } from "@/shared/api"; +import { issueTimelineOptions, issueKeys } from "@core/issues/queries"; +import { + useCreateComment, + useUpdateComment, + useDeleteComment, + useToggleCommentReaction, +} from "@core/issues/mutations"; import { useWSEvent, useWSReconnect } from "@/features/realtime"; import { toast } from "sonner"; @@ -30,29 +37,22 @@ function commentToTimelineEntry(c: Comment): TimelineEntry { } export function useIssueTimeline(issueId: string, userId?: string) { - const [timeline, setTimeline] = useState([]); + const qc = useQueryClient(); + const { data: timeline = [], isLoading: loading } = useQuery( + issueTimelineOptions(issueId), + ); const [submitting, setSubmitting] = useState(false); - const [loading, setLoading] = useState(true); - // Initial fetch + reset on id change - useEffect(() => { - setTimeline([]); - setLoading(true); - api - .listTimeline(issueId) - .then((entries) => setTimeline(entries)) - .catch((e) => { - console.error(e); - toast.error("Failed to load activity"); - }) - .finally(() => setLoading(false)); - }, [issueId]); + const createCommentMutation = useCreateComment(issueId); + const updateCommentMutation = useUpdateComment(issueId); + const deleteCommentMutation = useDeleteComment(issueId); + const toggleReactionMutation = useToggleCommentReaction(issueId); // Reconnect recovery useWSReconnect( useCallback(() => { - api.listTimeline(issueId).then(setTimeline).catch(console.error); - }, [issueId]), + qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) }); + }, [qc, issueId]), ); // --- WS event handlers --- @@ -63,13 +63,21 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { comment } = payload as CommentCreatedPayload; if (comment.issue_id !== issueId) return; - if (comment.author_type === "member" && comment.author_id === userId) return; - setTimeline((prev) => { - if (prev.some((e) => e.id === comment.id)) return prev; - return [...prev, commentToTimelineEntry(comment)]; - }); + if ( + comment.author_type === "member" && + comment.author_id === userId + ) + return; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + if (old.some((e) => e.id === comment.id)) return old; + return [...old, commentToTimelineEntry(comment)]; + }, + ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -79,12 +87,16 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { comment } = payload as CommentUpdatedPayload; if (comment.issue_id === issueId) { - setTimeline((prev) => - prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)), + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => + e.id === comment.id ? commentToTimelineEntry(comment) : e, + ), ); } }, - [issueId], + [qc, issueId], ), ); @@ -94,23 +106,31 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { comment_id, issue_id } = payload as CommentDeletedPayload; if (issue_id === issueId) { - setTimeline((prev) => { - const idsToRemove = new Set([comment_id]); - let added = true; - while (added) { - added = false; - for (const e of prev) { - if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { - idsToRemove.add(e.id); - added = true; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + const idsToRemove = new Set([comment_id]); + let added = true; + while (added) { + added = false; + for (const e of old) { + if ( + e.parent_id && + idsToRemove.has(e.parent_id) && + !idsToRemove.has(e.id) + ) { + idsToRemove.add(e.id); + added = true; + } } } - } - return prev.filter((e) => !idsToRemove.has(e.id)); - }); + return old.filter((e) => !idsToRemove.has(e.id)); + }, + ); } }, - [issueId], + [qc, issueId], ), ); @@ -122,12 +142,16 @@ export function useIssueTimeline(issueId: string, userId?: string) { if (p.issue_id !== issueId) return; const entry = p.entry; if (!entry || !entry.id) return; - setTimeline((prev) => { - if (prev.some((e) => e.id === entry.id)) return prev; - return [...prev, entry]; - }); + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => { + if (!old) return old; + if (old.some((e) => e.id === entry.id)) return old; + return [...old, entry]; + }, + ); }, - [issueId], + [qc, issueId], ), ); @@ -137,17 +161,23 @@ export function useIssueTimeline(issueId: string, userId?: string) { (payload: unknown) => { const { reaction, issue_id } = payload as ReactionAddedPayload; if (issue_id !== issueId) return; - if (reaction.actor_type === "member" && reaction.actor_id === userId) return; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== reaction.comment_id) return e; - const existing = e.reactions ?? []; - if (existing.some((r) => r.id === reaction.id)) return e; - return { ...e, reactions: [...existing, reaction] }; - }), + if ( + reaction.actor_type === "member" && + reaction.actor_id === userId + ) + return; + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => { + if (e.id !== reaction.comment_id) return e; + const existing = e.reactions ?? []; + if (existing.some((r) => r.id === reaction.id)) return e; + return { ...e, reactions: [...existing, reaction] }; + }), ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -158,19 +188,26 @@ export function useIssueTimeline(issueId: string, userId?: string) { const p = payload as ReactionRemovedPayload; if (p.issue_id !== issueId) return; if (p.actor_type === "member" && p.actor_id === userId) return; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== p.comment_id) return e; - return { - ...e, - reactions: (e.reactions ?? []).filter( - (r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id), - ), - }; - }), + qc.setQueryData( + issueKeys.timeline(issueId), + (old) => + old?.map((e) => { + if (e.id !== p.comment_id) return e; + return { + ...e, + reactions: (e.reactions ?? []).filter( + (r) => + !( + r.emoji === p.emoji && + r.actor_type === p.actor_type && + r.actor_id === p.actor_id + ), + ), + }; + }), ); }, - [issueId, userId], + [qc, issueId, userId], ), ); @@ -181,10 +218,9 @@ export function useIssueTimeline(issueId: string, userId?: string) { if (!content.trim() || submitting || !userId) return; setSubmitting(true); try { - const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds); - setTimeline((prev) => { - if (prev.some((e) => e.id === comment.id)) return prev; - return [...prev, commentToTimelineEntry(comment)]; + await createCommentMutation.mutateAsync({ + content, + attachmentIds, }); } catch { toast.error("Failed to send comment"); @@ -192,147 +228,61 @@ export function useIssueTimeline(issueId: string, userId?: string) { setSubmitting(false); } }, - [issueId, userId], + [userId, submitting, createCommentMutation], ); const submitReply = useCallback( async (parentId: string, content: string, attachmentIds?: string[]) => { if (!content.trim() || !userId) return; try { - const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds); - setTimeline((prev) => { - if (prev.some((e) => e.id === comment.id)) return prev; - return [...prev, commentToTimelineEntry(comment)]; + await createCommentMutation.mutateAsync({ + content, + type: "comment", + parentId, + attachmentIds, }); } catch { toast.error("Failed to send reply"); } }, - [issueId, userId], + [userId, createCommentMutation], ); const editComment = useCallback( async (commentId: string, content: string) => { - // Optimistic: update content immediately - let prevContent: string | undefined; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - prevContent = e.content; - return { ...e, content, updated_at: new Date().toISOString() }; - }), - ); try { - const updated = await api.updateComment(commentId, content); - setTimeline((prev) => - prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)), - ); + await updateCommentMutation.mutateAsync({ commentId, content }); } catch { - // Rollback - if (prevContent !== undefined) { - setTimeline((prev) => - prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)), - ); - } toast.error("Failed to update comment"); } }, - [], + [updateCommentMutation], ); const deleteComment = useCallback( async (commentId: string) => { - // Capture entries for rollback - let removedEntries: TimelineEntry[] = []; - setTimeline((prev) => { - const idsToRemove = new Set([commentId]); - let added = true; - while (added) { - added = false; - for (const e of prev) { - if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { - idsToRemove.add(e.id); - added = true; - } - } - } - removedEntries = prev.filter((e) => idsToRemove.has(e.id)); - return prev.filter((e) => !idsToRemove.has(e.id)); - }); try { - await api.deleteComment(commentId); + await deleteCommentMutation.mutateAsync(commentId); } catch { - // Rollback: re-add removed entries - setTimeline((prev) => [...prev, ...removedEntries]); toast.error("Failed to delete comment"); } }, - [], + [deleteCommentMutation], ); const toggleReaction = useCallback( async (commentId: string, emoji: string) => { if (!userId) return; const entry = timeline.find((e) => e.id === commentId); - const existing = (entry?.reactions ?? []).find( - (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId, + const existing: Reaction | undefined = (entry?.reactions ?? []).find( + (r) => + r.emoji === emoji && + r.actor_type === "member" && + r.actor_id === userId, ); - if (existing) { - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) }; - }), - ); - try { - await api.removeReaction(commentId, emoji); - } catch { - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: [...(e.reactions ?? []), existing] }; - }), - ); - toast.error("Failed to remove reaction"); - } - } else { - const tempReaction = { - id: `temp-${Date.now()}`, - comment_id: commentId, - actor_type: "member", - actor_id: userId, - emoji, - created_at: new Date().toISOString(), - }; - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: [...(e.reactions ?? []), tempReaction] }; - }), - ); - try { - const reaction = await api.addReaction(commentId, emoji); - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { - ...e, - reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)), - }; - }), - ); - } catch { - setTimeline((prev) => - prev.map((e) => { - if (e.id !== commentId) return e; - return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) }; - }), - ); - toast.error("Failed to add reaction"); - } - } + toggleReactionMutation.mutate({ commentId, emoji, existing }); }, - [userId, timeline], + [userId, timeline, toggleReactionMutation], ); return { diff --git a/apps/web/features/realtime/use-realtime-sync.ts b/apps/web/features/realtime/use-realtime-sync.ts index 85f66899..da38f5ae 100644 --- a/apps/web/features/realtime/use-realtime-sync.ts +++ b/apps/web/features/realtime/use-realtime-sync.ts @@ -9,6 +9,13 @@ import { useWorkspaceStore } from "@/features/workspace"; import { useAuthStore } from "@/features/auth"; import { createLogger } from "@/shared/logger"; import { api } from "@/shared/api"; +import { getQueryClient } from "@core/query-client"; +import { issueKeys } from "@core/issues/queries"; +import { + onIssueCreated, + onIssueUpdated, + onIssueDeleted, +} from "@core/issues/ws-updaters"; import type { MemberAddedPayload, WorkspaceDeletedPayload, @@ -96,16 +103,27 @@ export function useRealtimeSync(ws: WSClient | null) { if (issue.status) { useInboxStore.getState().updateIssueStatus(issue.id, issue.status); } + // Dual-write: TanStack Query cache + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onIssueUpdated(getQueryClient(), wsId, issue); }); const unsubIssueCreated = ws.on("issue:created", (p) => { const { issue } = p as IssueCreatedPayload; - if (issue) useIssueStore.getState().addIssue(issue); + if (!issue) return; + useIssueStore.getState().addIssue(issue); + // Dual-write: TanStack Query cache + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onIssueCreated(getQueryClient(), wsId, issue); }); const unsubIssueDeleted = ws.on("issue:deleted", (p) => { const { issue_id } = p as IssueDeletedPayload; - if (issue_id) useIssueStore.getState().removeIssue(issue_id); + if (!issue_id) return; + useIssueStore.getState().removeIssue(issue_id); + // Dual-write: TanStack Query cache + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onIssueDeleted(getQueryClient(), wsId, issue_id); }); const unsubInboxNew = ws.on("inbox:new", (p) => { @@ -167,6 +185,11 @@ export function useRealtimeSync(ws: WSClient | null) { const unsub = ws.onReconnect(async () => { logger.info("reconnected, refetching all data"); try { + // Dual-write: invalidate TanStack Query caches + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) { + getQueryClient().invalidateQueries({ queryKey: issueKeys.all(wsId) }); + } await Promise.all([ useIssueStore.getState().fetch(), useInboxStore.getState().fetch(),