diff --git a/web/src/lib/intake-client.ts b/web/src/lib/intake-client.ts index 1671d22..0cc4eaf 100644 --- a/web/src/lib/intake-client.ts +++ b/web/src/lib/intake-client.ts @@ -1,6 +1,7 @@ "use client"; import { createNotesApiClient } from "@/lib/api-helpers"; +import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry"; // ── Types ──────────────────────────────────────────────────────── @@ -63,9 +64,12 @@ export async function submitIntake( const payload: Record = { url }; if (workspaceId) payload.workspaceId = workspaceId; if (templateOverride) payload.templateOverride = templateOverride; - return api.fetch("/intake", { - method: "POST", - body: JSON.stringify(payload), + return withMutationRetry({ + run: () => api.fetch("/intake", { + method: "POST", + body: JSON.stringify(payload), + }), + offlineMessage: RETRY_WHEN_ONLINE_MESSAGE, }); } @@ -98,16 +102,23 @@ export async function createIntakeRule( rule: Omit, ): Promise { const api = createNotesApiClient(); - return api.fetch("/intake-rules", { - method: "POST", - body: JSON.stringify(rule), + return withMutationRetry({ + run: () => api.fetch("/intake-rules", { + method: "POST", + body: JSON.stringify(rule), + }), + queue: { id: rule.name, action: "post", path: "/intake-rules", payload: rule }, }); } export async function deleteIntakeRule(id: string): Promise { const api = createNotesApiClient(); - await api.fetch(`/intake-rules/${encodeURIComponent(id)}`, { - method: "DELETE", + const path = `/intake-rules/${encodeURIComponent(id)}`; + await withMutationRetry({ + run: () => api.fetch(path, { + method: "DELETE", + }), + queue: { id, action: "delete", path, payload: {} }, }); } @@ -120,9 +131,13 @@ export async function shareNoteWithUser( permission: "view" | "comment" | "edit" = "view", ): Promise { const api = createNotesApiClient(); - return api.fetch(`/notes/${encodeURIComponent(noteId)}/share-with-user`, { - method: "POST", - body: JSON.stringify({ workspaceId, sharedWithUserId, permission }), + const path = `/notes/${encodeURIComponent(noteId)}/share-with-user`; + return withMutationRetry({ + run: () => api.fetch(path, { + method: "POST", + body: JSON.stringify({ workspaceId, sharedWithUserId, permission }), + }), + offlineMessage: RETRY_WHEN_ONLINE_MESSAGE, }); } @@ -146,8 +161,12 @@ export async function removeCollaborator( userId: string, ): Promise { const api = createNotesApiClient(); - await api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`, { - method: "DELETE", + const path = `/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`; + await withMutationRetry({ + run: () => api.fetch(path, { + method: "DELETE", + }), + offlineMessage: RETRY_WHEN_ONLINE_MESSAGE, }); } diff --git a/web/src/lib/mutation-retry.test.ts b/web/src/lib/mutation-retry.test.ts new file mode 100644 index 0000000..1eb7025 --- /dev/null +++ b/web/src/lib/mutation-retry.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { OFFLINE_QUEUE_MESSAGE, RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry"; + +const enqueueMock = vi.fn(); + +vi.mock("@/lib/offline-queue", () => ({ + getOfflineQueue: () => ({ enqueue: enqueueMock }), +})); + +function setOnline(value: boolean) { + Object.defineProperty(navigator, "onLine", { + configurable: true, + value, + }); +} + +describe("withMutationRetry", () => { + beforeEach(() => { + enqueueMock.mockClear(); + setOnline(true); + }); + + it("returns the mutation result when the operation succeeds", async () => { + await expect(withMutationRetry({ run: async () => "ok" })).resolves.toBe("ok"); + expect(enqueueMock).not.toHaveBeenCalled(); + }); + + it("queues retryable mutations while offline and throws a clear queued message", async () => { + setOnline(false); + + await expect(withMutationRetry({ + run: async () => { throw new Error("Failed to fetch"); }, + queue: { + id: "note-1", + action: "patch", + path: "/notes/note-1?workspaceId=ws-1", + payload: { title: "Offline edit" }, + }, + })).rejects.toThrow(OFFLINE_QUEUE_MESSAGE); + + expect(enqueueMock).toHaveBeenCalledWith({ + id: "note-1", + action: "patch", + path: "/notes/note-1?workspaceId=ws-1", + payload: { title: "Offline edit" }, + }); + }); + + it("uses clear retry UX for offline mutations that need immediate server results", async () => { + setOnline(false); + + await expect(withMutationRetry({ + run: async () => { throw new Error("Failed to fetch"); }, + })).rejects.toThrow(RETRY_WHEN_ONLINE_MESSAGE); + + expect(enqueueMock).not.toHaveBeenCalled(); + }); + + it("does not queue validation or server errors while online", async () => { + await expect(withMutationRetry({ + run: async () => { throw new Error("Validation failed"); }, + queue: { + id: "note-1", + action: "patch", + path: "/notes/note-1", + payload: {}, + }, + })).rejects.toThrow("Validation failed"); + + expect(enqueueMock).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/mutation-retry.ts b/web/src/lib/mutation-retry.ts new file mode 100644 index 0000000..4119b69 --- /dev/null +++ b/web/src/lib/mutation-retry.ts @@ -0,0 +1,44 @@ +"use client"; + +import { getOfflineQueue } from "@/lib/offline-queue"; + +export const OFFLINE_QUEUE_MESSAGE = "You are offline. This change was saved and will retry automatically."; +export const RETRY_WHEN_ONLINE_MESSAGE = "You are offline. Reconnect and try again."; + +type QueueAction = "post" | "patch" | "delete" | "create" | "update"; + +interface RetryableMutation { + run: () => Promise; + queue?: { + id: string; + action: QueueAction; + path: string; + payload: Record; + }; + offlineMessage?: string; +} + +export function isOffline(): boolean { + return typeof navigator !== "undefined" && navigator.onLine === false; +} + +export async function withMutationRetry({ + run, + queue, + offlineMessage, +}: RetryableMutation): Promise { + try { + return await run(); + } catch (error) { + if (!isOffline()) { + throw error; + } + + if (queue) { + getOfflineQueue().enqueue(queue); + throw new Error(offlineMessage ?? OFFLINE_QUEUE_MESSAGE); + } + + throw new Error(offlineMessage ?? RETRY_WHEN_ONLINE_MESSAGE); + } +} diff --git a/web/src/lib/notes-client.test.ts b/web/src/lib/notes-client.test.ts index 321730b..53a18e9 100644 --- a/web/src/lib/notes-client.test.ts +++ b/web/src/lib/notes-client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const fetchMock = vi.fn(); +const enqueueMock = vi.fn(); vi.mock("@bytelyst/api-client", () => ({ createApiClient: () => ({ @@ -8,11 +9,25 @@ vi.mock("@bytelyst/api-client", () => ({ }), })); -import { getNoteDetail } from "@/lib/notes-client"; +vi.mock("@/lib/offline-queue", () => ({ + getOfflineQueue: () => ({ enqueue: enqueueMock }), +})); + +import { createNote, getNoteDetail, updateNoteDetail } from "@/lib/notes-client"; +import { OFFLINE_QUEUE_MESSAGE } from "@/lib/mutation-retry"; + +function setOnline(value: boolean) { + Object.defineProperty(navigator, "onLine", { + configurable: true, + value, + }); +} describe("getNoteDetail", () => { beforeEach(() => { fetchMock.mockReset(); + enqueueMock.mockClear(); + setOnline(true); }); it("returns persisted tasks only, preserves artifact blob metadata, and normalizes review state", async () => { @@ -131,3 +146,41 @@ describe("getNoteDetail", () => { expect(note).toBeNull(); }); }); + +describe("retryable note mutations", () => { + beforeEach(() => { + fetchMock.mockReset(); + enqueueMock.mockClear(); + setOnline(true); + }); + + it("queues note updates when the browser is offline", async () => { + setOnline(false); + fetchMock.mockRejectedValue(new Error("Failed to fetch")); + + await expect(updateNoteDetail("note-1", "ws-1", { title: "Offline edit" })) + .rejects.toThrow(OFFLINE_QUEUE_MESSAGE); + + expect(enqueueMock).toHaveBeenCalledWith({ + id: "note-1", + action: "patch", + path: "/notes/note-1?workspaceId=ws-1", + payload: { title: "Offline edit" }, + }); + }); + + it("queues note creation when the browser is offline", async () => { + setOnline(false); + fetchMock.mockRejectedValue(new Error("Failed to fetch")); + + const input = { id: "note-new", workspaceId: "ws-1", title: "New", body: "Body" }; + await expect(createNote(input)).rejects.toThrow(OFFLINE_QUEUE_MESSAGE); + + expect(enqueueMock).toHaveBeenCalledWith({ + id: "note-new", + action: "post", + path: "/notes", + payload: input, + }); + }); +}); diff --git a/web/src/lib/notes-client.ts b/web/src/lib/notes-client.ts index e0f3dff..7cd98e2 100644 --- a/web/src/lib/notes-client.ts +++ b/web/src/lib/notes-client.ts @@ -1,6 +1,7 @@ "use client"; import { createNotesApiClient } from "@/lib/api-helpers"; +import { withMutationRetry } from "@/lib/mutation-retry"; import type { AgentTimelineItem, ArtifactSummary, @@ -264,13 +265,14 @@ export async function updateNoteDetail( }, ): Promise { const api = createNotesApiClient(); - await api.fetch( - `/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(workspaceId)}`, - { + const path = `/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(workspaceId)}`; + await withMutationRetry({ + run: () => api.fetch(path, { method: "PATCH", body: JSON.stringify(updates), - }, - ); + }), + queue: { id: noteId, action: "patch", path, payload: updates }, + }); } export async function createNoteArtifact(input: { @@ -285,9 +287,12 @@ export async function createNoteArtifact(input: { sizeBytes?: number; }): Promise { const api = createNotesApiClient(); - await api.fetch("/note-artifacts", { - method: "POST", - body: JSON.stringify(input), + await withMutationRetry({ + run: () => api.fetch("/note-artifacts", { + method: "POST", + body: JSON.stringify(input), + }), + queue: { id: input.id, action: "post", path: "/note-artifacts", payload: input }, }); } @@ -301,9 +306,12 @@ export async function createNoteTask(input: { source?: "manual" | "extracted"; }): Promise { const api = createNotesApiClient(); - await api.fetch("/note-tasks", { - method: "POST", - body: JSON.stringify(input), + await withMutationRetry({ + run: () => api.fetch("/note-tasks", { + method: "POST", + body: JSON.stringify(input), + }), + queue: { id: input.id, action: "post", path: "/note-tasks", payload: input }, }); } @@ -391,25 +399,36 @@ export async function createNote(input: { sourceType?: string; }): Promise { const api = createNotesApiClient(); - return api.fetch("/notes", { - method: "POST", - body: JSON.stringify(input), + return withMutationRetry({ + run: () => api.fetch("/notes", { + method: "POST", + body: JSON.stringify(input), + }), + queue: { id: input.id, action: "post", path: "/notes", payload: input }, }); } export async function archiveNote(noteId: string, workspaceId: string): Promise { const api = createNotesApiClient(); - await api.fetch(`/notes/${encodeURIComponent(noteId)}/archive`, { - method: "POST", - body: JSON.stringify({ workspaceId }), + const path = `/notes/${encodeURIComponent(noteId)}/archive`; + await withMutationRetry({ + run: () => api.fetch(path, { + method: "POST", + body: JSON.stringify({ workspaceId }), + }), + queue: { id: `${noteId}:archive`, action: "post", path, payload: { workspaceId } }, }); } export async function restoreNote(noteId: string, workspaceId: string): Promise { const api = createNotesApiClient(); - await api.fetch(`/notes/${encodeURIComponent(noteId)}/restore`, { - method: "POST", - body: JSON.stringify({ workspaceId }), + const path = `/notes/${encodeURIComponent(noteId)}/restore`; + await withMutationRetry({ + run: () => api.fetch(path, { + method: "POST", + body: JSON.stringify({ workspaceId }), + }), + queue: { id: `${noteId}:restore`, action: "post", path, payload: { workspaceId } }, }); } @@ -435,9 +454,12 @@ export async function createWorkspace(input: { description?: string; }): Promise { const api = createNotesApiClient(); - return api.fetch("/workspaces", { - method: "POST", - body: JSON.stringify(input), + return withMutationRetry({ + run: () => api.fetch("/workspaces", { + method: "POST", + body: JSON.stringify(input), + }), + queue: { id: input.id, action: "post", path: "/workspaces", payload: input }, }); } @@ -446,16 +468,24 @@ export async function updateWorkspace( updates: { name?: string; description?: string }, ): Promise { const api = createNotesApiClient(); - await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, { - method: "PATCH", - body: JSON.stringify(updates), + const path = `/workspaces/${encodeURIComponent(workspaceId)}`; + await withMutationRetry({ + run: () => api.fetch(path, { + method: "PATCH", + body: JSON.stringify(updates), + }), + queue: { id: workspaceId, action: "patch", path, payload: updates }, }); } export async function deleteWorkspace(workspaceId: string): Promise { const api = createNotesApiClient(); - await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, { - method: "DELETE", + const path = `/workspaces/${encodeURIComponent(workspaceId)}`; + await withMutationRetry({ + run: () => api.fetch(path, { + method: "DELETE", + }), + queue: { id: workspaceId, action: "delete", path, payload: {} }, }); } @@ -467,8 +497,11 @@ export async function createNoteRelationship(input: { relationshipType: string; }): Promise { const api = createNotesApiClient(); - await api.fetch("/note-relationships", { - method: "POST", - body: JSON.stringify(input), + await withMutationRetry({ + run: () => api.fetch("/note-relationships", { + method: "POST", + body: JSON.stringify(input), + }), + queue: { id: input.id, action: "post", path: "/note-relationships", payload: input }, }); } diff --git a/web/src/lib/offline-queue.ts b/web/src/lib/offline-queue.ts index 7f27934..6341134 100644 --- a/web/src/lib/offline-queue.ts +++ b/web/src/lib/offline-queue.ts @@ -6,7 +6,9 @@ import { createNotesApiClient } from "@/lib/api-helpers"; const ACTION_METHOD: Record = { create: "POST", + post: "POST", update: "PATCH", + patch: "PATCH", delete: "DELETE", }; diff --git a/web/src/lib/prompt-client.test.ts b/web/src/lib/prompt-client.test.ts index 1e19979..536b9cc 100644 --- a/web/src/lib/prompt-client.test.ts +++ b/web/src/lib/prompt-client.test.ts @@ -23,10 +23,19 @@ import { getKnowledgeGaps, listPromptHistory, } from "@/lib/prompt-client"; +import { RETRY_WHEN_ONLINE_MESSAGE } from "@/lib/mutation-retry"; + +function setOnline(value: boolean) { + Object.defineProperty(navigator, "onLine", { + configurable: true, + value, + }); +} describe("prompt-client", () => { beforeEach(() => { fetchMock.mockReset(); + setOnline(true); }); it("listPromptTemplates returns items array", async () => { @@ -68,6 +77,14 @@ describe("prompt-client", () => { expect(result.content).toBe("Summary"); }); + it("runPrompt gives clear retry UX when offline", async () => { + setOnline(false); + fetchMock.mockRejectedValue(new Error("Failed to fetch")); + + await expect(runPrompt({ templateId: "summarize", noteId: "n1", workspaceId: "ws1" })) + .rejects.toThrow(RETRY_WHEN_ONLINE_MESSAGE); + }); + it("suggestTags returns tag array", async () => { fetchMock.mockResolvedValue({ tags: ["tag1", "tag2"] }); const tags = await suggestTags("n1", "ws1"); diff --git a/web/src/lib/prompt-client.ts b/web/src/lib/prompt-client.ts index d69e9da..0142514 100644 --- a/web/src/lib/prompt-client.ts +++ b/web/src/lib/prompt-client.ts @@ -1,6 +1,7 @@ "use client"; import { createNotesApiClient, getAccessToken } from "@/lib/api-helpers"; +import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry"; import type { PromptTemplate, RunPromptInput, @@ -26,24 +27,34 @@ export async function createPromptTemplate( input: Omit, ): Promise { const api = createNotesApiClient(); - return api.fetch("/note-prompts", { - method: "POST", - body: JSON.stringify(input), + return withMutationRetry({ + run: () => api.fetch("/note-prompts", { + method: "POST", + body: JSON.stringify(input), + }), + queue: { id: input.slug, action: "post", path: "/note-prompts", payload: input }, }); } export async function deletePromptTemplate(id: string): Promise { const api = createNotesApiClient(); - await api.fetch(`/note-prompts/${encodeURIComponent(id)}`, { method: "DELETE" }); + const path = `/note-prompts/${encodeURIComponent(id)}`; + await withMutationRetry({ + run: () => api.fetch(path, { method: "DELETE" }), + queue: { id, action: "delete", path, payload: {} }, + }); } // ── Run prompts ─────────────────────────────────────────────── export async function runPrompt(input: RunPromptInput): Promise { const api = createNotesApiClient(); - return api.fetch("/note-prompts/run", { - method: "POST", - body: JSON.stringify(input), + return withMutationRetry({ + run: () => api.fetch("/note-prompts/run", { + method: "POST", + body: JSON.stringify(input), + }), + offlineMessage: RETRY_WHEN_ONLINE_MESSAGE, }); } diff --git a/web/src/lib/review-client.ts b/web/src/lib/review-client.ts index d66e4c5..a8dbe8c 100644 --- a/web/src/lib/review-client.ts +++ b/web/src/lib/review-client.ts @@ -1,4 +1,5 @@ import { createNotesApiClient } from "@/lib/api-helpers"; +import { RETRY_WHEN_ONLINE_MESSAGE, withMutationRetry } from "@/lib/mutation-retry"; import type { AgentTimelineItem, ApprovalQueueItem, NoteAgentActionDoc, NoteAgentActionListResponse } from "@/lib/types"; @@ -46,18 +47,19 @@ async function updateAgentActionState( reviewNote?: string, ): Promise { const api = createNotesApiClient(); + const path = `/note-agent-actions/${encodeURIComponent(id)}?workspaceId=${encodeURIComponent(workspaceId)}`; - return api.fetch( - `/note-agent-actions/${encodeURIComponent(id)}?workspaceId=${encodeURIComponent(workspaceId)}`, - { + return withMutationRetry({ + run: () => api.fetch(path, { method: "PATCH", body: JSON.stringify({ state, reviewedAt: new Date().toISOString(), ...(reviewNote ? { reviewNote } : {}), }), - }, - ); + }), + offlineMessage: RETRY_WHEN_ONLINE_MESSAGE, + }); } export async function listApprovalQueue(): Promise { @@ -93,16 +95,16 @@ export async function batchReviewItems( reviewNote?: string, ): Promise<{ updated: number; total: number }> { const api = createNotesApiClient(); - const result = await api.fetch<{ updated: number; total: number }>( - "/note-agent-actions/batch-review", - { + const result = await withMutationRetry({ + run: () => api.fetch<{ updated: number; total: number }>("/note-agent-actions/batch-review", { method: "POST", body: JSON.stringify({ ids: items.map((item) => ({ id: item.id, workspaceId: item.workspaceId })), state, ...(reviewNote ? { reviewNote } : {}), }), - }, - ); + }), + offlineMessage: RETRY_WHEN_ONLINE_MESSAGE, + }); return result; }