diff --git a/docs/doctoring/dashboard_request_cancellation.md b/docs/doctoring/dashboard_request_cancellation.md new file mode 100644 index 000000000..b78ae53e4 --- /dev/null +++ b/docs/doctoring/dashboard_request_cancellation.md @@ -0,0 +1,143 @@ +# Dashboard request cancellation + +## Evidence and decision + +PR #1570 at `a48e8ba85b6d3a4ceb78e110582b3d5bc76d0d7c` protected state +updates with a cancelled flag and request generation, but its five dashboard +GET requests shared only a 15-second timeout signal. Retrying or leaving the +dashboard made responses irrelevant without aborting those fetches. The same +file's startup-search effect already aborts its controller during cleanup. + +Reuse that native pattern for the dashboard generation. Combine its controller +with the existing timeout signal, then mark the generation cancelled before +aborting in cleanup. This preserves the five API routes, signed-cookie transport, +independent source states and request-version guard. It does not change model +timeouts or claim that abort rolls back work already accepted by a server. These +are read requests, not provider writes. + +When both `AbortSignal.any` and `AbortSignal.timeout` exist, use the native +combinators. If either static method is unavailable, compose the same boundary +with a generation-local `AbortController`, the request-controller abort event, +and the unchanged 15,000 ms timer. Abort clears the fallback timer and listener; +timeout uses a `TimeoutError` DOMException. This avoids inventing a repository- +wide browser floor solely for this feature while keeping modern browsers on the +native path. + +Do not replace the timeout, increase its duration, add a client wrapper, or +depend on ignoring stale responses alone. A retry gets a fresh controller; +cleanup must never abort that newer generation. + +## Verification and limits + +The existing retry-race harness now inspects all five old signals and all five +new signals, still delivers a late old response, and checks final unmount. +A StrictMode case checks discarded-mount cancellation, active-mount survival, +absence of a spurious error alert, and final cleanup. Both tests failed before +the source repair. Afterward, those cases plus dashboard, succession (including +the existing 15,000 ms timeout test), and API-client contracts passed: 30 tests. +Mock signal assertions prove cancellation delivery, not server-side cleanup or +measured bandwidth savings. Hosted checks and protected merge remain separate. + +Focused lint with zero allowed warnings and TypeScript checking passed. +Native signal smoke checks passed in installed Chromium 151.0.7922.34, +Firefox 153.0 and WebKit 26.5 for both manual abort and timeout reason. +The full suite passed 53 files / 448 tests, but retained three CalendarLayout +and four EmailDetail act diagnostics outside this repair. Their existing owner +repairs must be inherited and revalidated; this is not warning-free full-suite +evidence or permission to suppress those diagnostics. + +Review `3944626310` later identified that direct use of the two static +combinators made dashboard startup depend on browser support that the repository +had never declared. MDN marks both static methods as Baseline 2024 but explicitly +notes that older devices or browsers may not support them. The WHATWG DOM Living +Standard defines `AbortSignal.any()` as propagating the first source abort reason +and `AbortSignal.timeout()` as aborting with a `TimeoutError` DOMException. The +fallback therefore preserves the relevant native contract instead of silently +changing timeout or cancellation semantics. + +Source-order RED commit `d510f5a99cf69f50db3bacfb3105260e78981345` +adds a focused regression that removes those static methods and requires five +dashboard request signals to remain cancellable and to abort at the existing +15-second deadline. The predecessor implementation calls `AbortSignal.any` +unconditionally, so that scenario cannot reach the dashboard requests. Causal +fix `2e937e49ccabda6e848a824d1bc7cee4ceb3ae2c` adds the guarded composition +above. These commits establish test-first source provenance; they are not +by themselves a claim that hosted CI has executed the final exact head. + +## Follow-up: malformed array members + +Review [3944460600](https://github.com/ContextualWisdomLab/naruon/pull/1570#discussion_r3944460600) +described a gap still present at `b88161f0c9515b39b101fe3fa8444be3b1895022`. +The envelope checks accepted arrays containing null or invalid records. Render +filters then dereferenced those values outside the promise rejection handler, +so an unavailable source could crash the dashboard instead of offering retry. + +Validate the consumed email, task and calendar shapes before their state +setters; both email endpoints share one predicate. Use native type checks and +reject the whole invalid source rather than silently filtering members, which +would misreport counts and task completion rates. Preserve valid empty arrays, +independent sibling responses, cancellation and signed-cookie transport. +No dependency or general-purpose schema framework is needed for these three +local shapes. Project folders currently consume only length and are unchanged. + +The first 12 null/empty-record/number cases failed before the repair. Additional +cases cover numeric email subject, object date, array task status (no string +coercion), and string calendar capabilities. Existing successful payloads and +retry/StrictMode tests remain regression evidence. Backend contracts checked: +`EmailListItem` in `backend/api/emails.py`, `TicketTaskResponse` in +`backend/api/tasks.py`, and `WritebackSource` in `backend/api/calendar.py`. +The guards cover consumed fields, not backend semantic correctness or +schema-wide conformance. + +The first production-browser run disproved the initial local-only repair: +the three HTTP 503 recovery cases passed, but all three HTTP 200/null-member +cases failed. The trace recorded `Cannot read properties of null (reading 'id')` +and the page error boundary replaced the dashboard. `EmailList` was mounted +alongside the dashboard and accepted `data.emails || []` before mapping +`email.id`; dashboard unit tests mocked that component and missed the path. +Keep that failed trace and result; do not omit the malformed-response case. + +Three additional EmailList inbox/sent/search regressions failed before the +sibling repair. Move the mail predicate to `frontend/src/lib/mail-response.ts` +and apply it to both dashboard mail sources and every located `/api/search` +consumer (EmailList, WorkspaceHome startup search, MobileApiPanel, SearchLayout). +This is a now-demonstrated shared responsibility, not a speculative wrapper. +Keep task/calendar predicates local. EmailList reports a fixed user-facing +message on validation failure rather than displaying payload values or internal +validator details. Optional nullable reply counts remain accepted because the +backend emits them. Original cancellation and envelope failures stay covered. + +Scoped visual follow-up: the initial successful 503 desktop capture still +shows `source-linked` under pending tasks and `충돌 토큰 있음` under calendar +sources. These are unresolved user-facing implementation-detail leaks, not +acceptance of the complete product copy. Record them with the product Gap; +do not expand this availability repair into a translation framework or redesign. + +## Exact-head gate recovery on 2026-09-08 + +The source head `a6e6ac59a72173793a834627b8489bea79805f16` retained successful +application, image, Semgrep, Trivy, and repository CodeQL evidence. Its central +compatibility CodeQL shards remained red because the first attempt intentionally +returned `pending` after dispatch and no authenticated terminal callback reached +the consumer run. OpenCode likewise had no current-head verdict. Strix reported +`STRIX_SANDBOX_UNAVAILABLE` after two Caido `loginAsGuest` bootstrap failures; +the displayed zero count preceded the transport failure and is not a clean scan. + +Central `.github` PR #2028 later repaired missing dispatch-verdict recovery, but +that merge does not rewrite old consumer evidence. Converting this PR to Draft +and back to ready produced no new runs because the current callers do not +subscribe to `ready_for_review`. Therefore, never cite the UI state transition as +revalidation. This documentation delta records the causal logs and creates a +normal synchronize event without an empty commit. Acceptance still requires new +run ids on this exact successor head, terminal security analysis, and an +independent current-head review; no stale success is inherited. + +## References + +MDN contributors. (2026, September 1). *AbortSignal: any() static method*. +MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static + +MDN contributors. (2026, September 1). *AbortSignal: timeout() static method*. +MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static + +WHATWG. (2026, August 25). *DOM Living Standard*. https://dom.spec.whatwg.org/ diff --git a/frontend/src/app/page.test.tsx b/frontend/src/app/page.test.tsx index dc44feb06..8d5036c20 100644 --- a/frontend/src/app/page.test.tsx +++ b/frontend/src/app/page.test.tsx @@ -473,7 +473,7 @@ describe("Home workspace action bridge", () => { expect(fetch).toHaveBeenCalledWith("/api/search", expect.objectContaining({ method: "POST" })); }); - it("shows startup dashboard empty states and ignores malformed API payloads", async () => { + it("shows an unavailable dashboard state for malformed API payloads", async () => { localStorage.setItem("naruon_startup_view", "dashboard"); vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: true, @@ -486,11 +486,12 @@ describe("Home workspace action bridge", () => { await act(async () => { root?.render(); }); - await waitForCondition(() => container?.textContent?.includes("수신된 메일이 없습니다.") ?? false); + await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]'))); - expect(container.textContent).toContain("수신된 메일이 없습니다."); - expect(container.textContent).toContain("답변 대기 중인 보낸 메일이 없습니다."); - expect(container.textContent).toContain("대기 작업이 없습니다."); + expect(container.textContent).toContain("업무 현황을 모두 불러오지 못했습니다."); + expect(container.textContent).not.toContain("수신된 메일이 없습니다."); + expect(container.textContent).not.toContain("답변 대기 중인 보낸 메일이 없습니다."); + expect(container.textContent).not.toContain("대기 작업이 없습니다."); }); it("shows desktop calendar empty and error states from the search API", async () => { diff --git a/frontend/src/components/EmailList.test.tsx b/frontend/src/components/EmailList.test.tsx index 771a4cd85..987828e37 100644 --- a/frontend/src/components/EmailList.test.tsx +++ b/frontend/src/components/EmailList.test.tsx @@ -44,6 +44,29 @@ describe("EmailList", () => { vi.unstubAllGlobals(); }); + it.each((['inbox', 'sent', 'search'] as const).flatMap((source) => [false, true].map((nullEnvelope) => ({ source, nullEnvelope }))))('rejects malformed $source data (null envelope: $nullEnvelope)', async ({ source, nullEnvelope }) => { + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(jsonResponse( + String(input).endsWith('/api/search') ? (nullEnvelope ? null : { results: [null] }) + : source === 'search' ? { emails: [] } : nullEnvelope ? null : { emails: [null] }, + )))); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + if (source === 'search') { + const input = container.querySelector('#email-search')!; + await act(async () => setInputValue(input, 'example')); + await act(async () => input.closest('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); + await flushAsyncWork(); + } + expect(container.querySelector('[role="alert"]')?.textContent).toContain('메일 목록을 확인하지 못했습니다.'); + expect(container.querySelector('[role="alert"]')?.textContent).not.toMatch(/TypeError|Cannot read|emails|results/); + expect(container.textContent).not.toContain('메일이 없습니다'); + }); + it("renders the branded dense inbox and selected thread state", async () => { const fetchMock = vi.fn(() => Promise.resolve( diff --git a/frontend/src/components/EmailList.tsx b/frontend/src/components/EmailList.tsx index 6dbb722ea..76dbac4f0 100644 --- a/frontend/src/components/EmailList.tsx +++ b/frontend/src/components/EmailList.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { CheckCircle2, Loader2, Mail, MessagesSquare, Network, Search, Sparkles, X } from "lucide-react"; import { formatEmailDate } from "@/lib/email-threading"; import { toMailDisplayText } from "@/lib/mail-text"; +import { isMailListItem } from '@/lib/mail-response'; interface EmailItem { id: number; @@ -37,7 +38,12 @@ async function fetchFolderEmails(folder: MailFolder) { if (folderRequests.has(folder)) return folderRequests.get(folder)!; const request = apiClient.get<{ emails: EmailItem[] }>(folderEndpoint(folder)) - .then((data) => data.emails || []) + .then((data) => { + if (!Array.isArray(data.emails) || !data.emails.every(isMailListItem)) { + throw new Error('메일 목록을 확인하지 못했습니다. 다시 조회하세요.'); + } + return data.emails; + }) .finally(() => { folderRequests.delete(folder); }); @@ -135,10 +141,13 @@ export function EmailList({ } else { setIsSearching(true); const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query }); - setEmails(data.results || []); + if (!Array.isArray(data.results) || !data.results.every(isMailListItem)) { + throw new Error('메일 목록을 확인하지 못했습니다. 다시 조회하세요.'); + } + setEmails(data.results); } - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Failed to load emails"); + } catch { + setError('메일 목록을 확인하지 못했습니다. 다시 조회하세요.'); } finally { setLoading(false); setIsSearching(false); diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 92f16b184..6a6cb0cc6 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -20,6 +20,7 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { apiClient } from "@/lib/api-client"; +import { isMailListItem } from '@/lib/mail-response'; import { bucketSearchRank, bucketTextLength, @@ -391,6 +392,7 @@ export function SearchLayout() { ) .then((response) => { if (controller.signal.aborted) return; + if (!Array.isArray(response.results) || !response.results.every(isMailListItem)) throw new Error('Invalid search response'); setResults(response.results); setActiveResultId(response.results[0]?.id ?? null); recordProductEvent("latency_guardrail_recorded", { diff --git a/frontend/src/components/WorkspaceHome.abort-signal-fallback.test.tsx b/frontend/src/components/WorkspaceHome.abort-signal-fallback.test.tsx new file mode 100644 index 000000000..fafc24fad --- /dev/null +++ b/frontend/src/components/WorkspaceHome.abort-signal-fallback.test.tsx @@ -0,0 +1,107 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/EmailList", () => ({ + EmailList: () =>
mock email list
, +})); + +vi.mock("@/components/EmailDetail", () => ({ + EmailDetail: () =>
mock email detail
, +})); + +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); + +vi.mock("@/components/mobile-workspace-panels", () => ({ + MobileCalendarPanel: () =>
mock calendar
, + MobileSearchPanel: () =>
mock search
, +})); + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Inbox: () => , + Network: () => , + Send: () => , + Settings: () => , + Sparkles: () => , +})); + +import { WorkspaceHome } from "./WorkspaceHome"; + +describe("WorkspaceHome dashboard abort-signal compatibility", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + localStorage.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("keeps dashboard cancellation and timeout when AbortSignal static combinators are unavailable", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + + const nativeAbortSignal = AbortSignal; + vi.stubGlobal("AbortSignal", { prototype: nativeAbortSignal.prototype }); + + const dashboardSignals: AbortSignal[] = []; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/search")) { + return Promise.resolve({ ok: true, json: async () => ({ results: [] }) }); + } + + expect(init?.signal).toBeDefined(); + dashboardSignals.push(init!.signal!); + if (url.endsWith("/api/emails") || url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks") || url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + await Promise.resolve(); + }); + + expect(dashboardSignals).toHaveLength(5); + expect(dashboardSignals.every((signal) => !signal.aborted)).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + expect(dashboardSignals.every((signal) => signal.aborted)).toBe(true); + }); +}); diff --git a/frontend/src/components/WorkspaceHome.dashboard.test.tsx b/frontend/src/components/WorkspaceHome.dashboard.test.tsx index fa8213adc..e5397cea5 100644 --- a/frontend/src/components/WorkspaceHome.dashboard.test.tsx +++ b/frontend/src/components/WorkspaceHome.dashboard.test.tsx @@ -172,6 +172,114 @@ describe("WorkspaceHome Today dashboard", () => { expect(headers["X-Dev-Auth-Token"]).toBeUndefined(); }); + it("shows a retryable unavailable state without presenting failed data as empty", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + let primaryDataAvailable = false; + const primaryCalls: string[] = []; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if ( + url.endsWith("/api/emails") + || url.endsWith("/api/emails/pending-replies?limit=3") + || url.endsWith("/api/tasks") + ) { + primaryCalls.push(url); + if (!primaryDataAvailable) return Promise.reject(new Error("backend unavailable")); + if (url.endsWith("/api/emails")) { + return Promise.resolve({ + ok: true, + json: async () => ({ emails: [{ id: 101, subject: "복구된 고객 메일", sender: "customer@example.com", date: "2026-05-17T09:00:00Z", snippet: "계약 확인" }] }), + }); + } + return Promise.resolve({ ok: true, json: async () => url.endsWith("/api/tasks") ? [] : ({ emails: [] }) }); + } + const sourceEvidenceResponse = emptySourceEvidenceResponse(url); + if (sourceEvidenceResponse) return sourceEvidenceResponse; + const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url); + if (calendarCandidateResponse) return calendarCandidateResponse; + throw new Error(`Unexpected fetch: ${url}`); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("업무 현황을 모두 불러오지 못했습니다.") ?? false); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("다시 시도"); + expect(container.textContent).toContain("최근 메일을 확인하지 못했습니다."); + expect(container.textContent).not.toContain("수신된 메일이 없습니다."); + + const callsBeforeRetry = primaryCalls.length; + primaryDataAvailable = true; + await act(async () => { + container?.querySelector('[role="alert"] button')?.click(); + }); + await waitForCondition(() => primaryCalls.length === callsBeforeRetry + 3); + await waitForCondition(() => container?.querySelector('[role="alert"]') === null); + + expect(container.textContent).toContain("복구된 고객 메일"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("preserves project evidence when only calendar sources fail", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/calendar/writeback-sources")) { + return Promise.reject(new Error("calendar unavailable")); + } + if (url.endsWith("/api/webdav/folders")) { + return Promise.resolve({ + ok: true, + json: async () => ([{ + folder_uid: "project-folder-1", + project_name: "계약 검토", + webdav_path: "/contracts", + }]), + }); + } + if (url.endsWith("/api/emails")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) { + return Promise.resolve({ ok: true, json: async () => ([]) }); + } + const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url); + if (calendarCandidateResponse) return calendarCandidateResponse; + throw new Error(`Unexpected fetch: ${url}`); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.textContent?.includes("일정 원본 목록 응답을 확인할 수 없습니다.") ?? false); + + const metrics = container.querySelector('[aria-label="홈 지표"]'); + expect(metrics?.textContent).toContain("일정 원본오류"); + expect(metrics?.textContent).toContain("프로젝트 원본1"); + expect(metrics?.textContent).not.toContain("프로젝트 원본오류"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("다시 시도"); + }); + it("creates overdue reply follow-up tasks from the Today dashboard with signed headers", async () => { vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ matches: false, diff --git a/frontend/src/components/WorkspaceHome.project-folder-contract.test.tsx b/frontend/src/components/WorkspaceHome.project-folder-contract.test.tsx new file mode 100644 index 000000000..f731ab1a4 --- /dev/null +++ b/frontend/src/components/WorkspaceHome.project-folder-contract.test.tsx @@ -0,0 +1,144 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/EmailList", () => ({ + EmailList: () =>
mock email list
, +})); + +vi.mock("@/components/EmailDetail", () => ({ + EmailDetail: () =>
mock email detail
, +})); + +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); + +vi.mock("@/components/mobile-workspace-panels", () => ({ + MobileCalendarPanel: () =>
mock calendar
, + MobileSearchPanel: () =>
mock search
, +})); + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Inbox: () => , + Network: () => , + Send: () => , + Settings: () => , + Sparkles: () => , +})); + +import { WorkspaceHome } from "./WorkspaceHome"; + +async function flushAsyncWork() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitForCondition(condition: () => boolean) { + for (let index = 0; index < 30; index += 1) { + if (condition()) return; + await flushAsyncWork(); + } + throw new Error("waitForCondition timed out after 30 attempts"); +} + +function stubDesktopViewport() { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); +} + +function successfulDashboardResponse(url: string, projectFolders: unknown[]) { + if (url.endsWith("/api/webdav/folders")) { + return Promise.resolve({ ok: true, json: async () => projectFolders }); + } + if (url.endsWith("/api/calendar/writeback-sources")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url.endsWith("/api/search")) { + return Promise.resolve({ ok: true, json: async () => ({ results: [] }) }); + } + if (url.endsWith("/api/emails") || url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + throw new Error(`Unexpected fetch: ${url}`); +} + +describe("WorkspaceHome project-folder response contract", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + const mountedRoot = root; + if (mountedRoot) act(() => mountedRoot.unmount()); + root = null; + container?.remove(); + container = null; + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + async function renderDashboard(projectFolders: unknown[]) { + stubDesktopViewport(); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => successfulDashboardResponse(String(input), projectFolders))); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + } + + it.each([ + null, + {}, + { folder_uid: 7, project_name: "Project", webdav_path: "/projects/one", owner_user_id: "user-1", organization_id: null }, + { folder_uid: "folder-1", project_name: null, webdav_path: "/projects/one", owner_user_id: "user-1", organization_id: null }, + { folder_uid: "folder-1", project_name: "Project", webdav_path: null, owner_user_id: "user-1", organization_id: null }, + { folder_uid: "folder-1", project_name: "Project", webdav_path: "/projects/one", organization_id: null }, + { folder_uid: "folder-1", project_name: "Project", webdav_path: "/projects/one", owner_user_id: "user-1", organization_id: 42 }, + ])("fails closed for malformed project-folder member %#", async (member) => { + await renderDashboard([member]); + await waitForCondition(() => container?.textContent?.includes("업무 현황을 모두 불러오지 못했습니다.") ?? false); + + const projectFolderCard = container?.querySelector('[aria-label="프로젝트 원본"]'); + expect(projectFolderCard?.textContent).toContain("오류"); + expect(projectFolderCard?.textContent).toContain("확인 필요"); + expect(container?.querySelector('[role="alert"] button')?.textContent).toBe("다시 시도"); + }); + + it.each([null, "org-1"])("keeps a backend-contract project folder available with organization_id=%s", async (organizationId) => { + await renderDashboard([{ + folder_uid: "folder-1", + project_name: "Project", + webdav_path: "/projects/one", + owner_user_id: "user-1", + organization_id: organizationId, + }]); + await waitForCondition(() => container?.querySelector('[aria-label="프로젝트 원본"]')?.textContent?.includes("1") ?? false); + + const projectFolderCard = container?.querySelector('[aria-label="프로젝트 원본"]'); + expect(projectFolderCard?.textContent).not.toContain("오류"); + expect(projectFolderCard?.textContent).toContain("1개"); + }); +}); diff --git a/frontend/src/components/WorkspaceHome.retry-race.test.tsx b/frontend/src/components/WorkspaceHome.retry-race.test.tsx new file mode 100644 index 000000000..3c2f206f9 --- /dev/null +++ b/frontend/src/components/WorkspaceHome.retry-race.test.tsx @@ -0,0 +1,215 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/EmailList", () => ({ + EmailList: () =>
mock email list
, +})); + +vi.mock("@/components/EmailDetail", () => ({ + EmailDetail: () =>
mock email detail
, +})); + +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); + +vi.mock("@/components/mobile-workspace-panels", () => ({ + MobileCalendarPanel: () =>
mock calendar
, + MobileSearchPanel: () =>
mock search
, +})); + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Inbox: () => , + Network: () => , + Send: () => , + Settings: () => , + Sparkles: () => , +})); + +import { WorkspaceHome } from "./WorkspaceHome"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function flushAsyncWork() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitForCondition(condition: () => boolean) { + for (let index = 0; index < 30; index += 1) { + if (condition()) return; + await flushAsyncWork(); + } + throw new Error("waitForCondition timed out after 30 attempts"); +} + +describe("WorkspaceHome dashboard retry ordering", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + const mountedRoot = root; + if (mountedRoot) { + act(() => mountedRoot.unmount()); + } + root = null; + container?.remove(); + container = null; + localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it("cancels the discarded StrictMode mount without cancelling the active mount", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + const dashboardSignals: AbortSignal[] = []; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/search")) { + return Promise.resolve({ ok: true, json: async () => ({ results: [] }) }); + } + expect(init?.signal).toBeDefined(); + dashboardSignals.push(init!.signal!); + return new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + }); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + expect(dashboardSignals).toHaveLength(10); + expect(dashboardSignals.slice(0, 5).every((signal) => signal.aborted)).toBe(true); + expect(dashboardSignals.slice(5).every((signal) => !signal.aborted)).toBe(true); + expect(container.querySelector('[role="alert"]')).toBeNull(); + await act(async () => root?.unmount()); + root = null; + expect(dashboardSignals.every((signal) => signal.aborted)).toBe(true); + }); + + it("keeps a recovered retry result when an older request resolves later", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + + const firstEmails = deferred<{ ok: boolean; json: () => Promise }>(); + let emailCallCount = 0; + let calendarCallCount = 0; + const dashboardSignals: AbortSignal[] = []; + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!url.endsWith("/api/search")) { + expect(init?.signal).toBeDefined(); + dashboardSignals.push(init!.signal!); + } + if (url.endsWith("/api/emails")) { + emailCallCount += 1; + if (emailCallCount === 1) return firstEmails.promise; + return Promise.resolve({ + ok: true, + json: async () => ({ + emails: [{ + id: 202, + subject: "재시도 후 최신 메일", + sender: "customer@example.com", + date: "2026-09-05T08:00:00Z", + snippet: "최신 결과", + }], + }), + }); + } + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url.endsWith("/api/calendar/writeback-sources")) { + calendarCallCount += 1; + if (calendarCallCount === 1) return Promise.reject(new Error("calendar unavailable")); + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url.endsWith("/api/webdav/folders")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url.endsWith("/api/search")) { + return Promise.resolve({ ok: true, json: async () => ({ results: [] }) }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await waitForCondition(() => container?.querySelector('[role="alert"]') !== null); + + await act(async () => { + container?.querySelector('[role="alert"] button')?.click(); + }); + await waitForCondition(() => container?.textContent?.includes("재시도 후 최신 메일") ?? false); + expect(dashboardSignals).toHaveLength(10); + expect(dashboardSignals.slice(0, 5).every((signal) => signal.aborted)).toBe(true); + expect(dashboardSignals.slice(5).every((signal) => !signal.aborted)).toBe(true); + + firstEmails.resolve({ + ok: true, + json: async () => ({ + emails: [{ + id: 101, + subject: "늦게 도착한 이전 메일", + sender: "stale@example.com", + date: "2026-09-05T07:00:00Z", + snippet: "이전 결과", + }], + }), + }); + await flushAsyncWork(); + await flushAsyncWork(); + + expect(container.textContent).toContain("재시도 후 최신 메일"); + expect(container.textContent).not.toContain("늦게 도착한 이전 메일"); + expect(emailCallCount).toBe(2); + expect(calendarCallCount).toBe(2); + await act(async () => root?.unmount()); + root = null; + expect(dashboardSignals.every((signal) => signal.aborted)).toBe(true); + }); +}); diff --git a/frontend/src/components/WorkspaceHome.succession-contract.test.tsx b/frontend/src/components/WorkspaceHome.succession-contract.test.tsx new file mode 100644 index 000000000..264afa26b --- /dev/null +++ b/frontend/src/components/WorkspaceHome.succession-contract.test.tsx @@ -0,0 +1,293 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/EmailList", () => ({ + EmailList: () =>
mock email list
, +})); + +vi.mock("@/components/EmailDetail", () => ({ + EmailDetail: () =>
mock email detail
, +})); + +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); + +vi.mock("@/components/mobile-workspace-panels", () => ({ + MobileCalendarPanel: () =>
mock calendar
, + MobileSearchPanel: () =>
mock search
, +})); + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Inbox: () => , + Network: () => , + Send: () => , + Settings: () => , + Sparkles: () => , +})); + +import { WorkspaceHome } from "./WorkspaceHome"; + +async function flushAsyncWork() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitForCondition(condition: () => boolean) { + for (let index = 0; index < 30; index += 1) { + if (condition()) return; + await flushAsyncWork(); + } + throw new Error("waitForCondition timed out after 30 attempts"); +} + +function supportResponse(url: string) { + if (url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url.endsWith("/api/search")) { + return Promise.resolve({ ok: true, json: async () => ({ results: [] }) }); + } + return null; +} + +describe("WorkspaceHome dashboard successor contracts", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + const mountedRoot = root; + if (mountedRoot) act(() => mountedRoot.unmount()); + root = null; + container?.remove(); + container = null; + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + async function renderDashboard() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + } + + it.each([401, 403])("routes a %i core response to login recovery instead of retry", async (status) => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + const support = supportResponse(url); + if (support) return support; + if ( + url.endsWith("/api/emails") + || url.endsWith("/api/emails/pending-replies?limit=3") + || url.endsWith("/api/tasks") + ) { + return Promise.resolve({ ok: false, status }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + + await renderDashboard(); + await waitForCondition(() => container?.textContent?.includes("로그인이 필요합니다.") ?? false); + + expect(container?.textContent).toContain("세션이 만료됐거나 이 작업공간에 접근할 수 없습니다."); + expect(container?.querySelector('a[href="/settings"]')?.textContent).toContain("로그인 설정 열기"); + expect(container?.querySelector('[role="alert"] button')).toBeNull(); + }); + + it.each([ + ["/api/calendar/writeback-sources", 401], + ["/api/webdav/folders", 403], + ])("routes %s status %i to login recovery", async (failingPath, status) => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(failingPath)) return Promise.resolve({ ok: false, status }); + const support = supportResponse(url); + if (support) return support; + if (url.endsWith("/api/emails")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) return Promise.resolve({ ok: true, json: async () => [] }); + throw new Error(`Unexpected fetch: ${url}`); + })); + + await renderDashboard(); + await waitForCondition(() => container?.textContent?.includes("로그인이 필요합니다.") ?? false); + + expect(container?.querySelector('a[href="/settings"]')?.textContent).toContain("로그인 설정 열기"); + expect(container?.querySelector('[role="alert"]')?.textContent).not.toContain("다시 시도"); + expect(container?.querySelector('[role="alert"] button')).toBeNull(); + }); + + it("fails closed when a successful mail response omits the emails array", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + const support = supportResponse(url); + if (support) return support; + if (url.endsWith("/api/emails")) return Promise.resolve({ ok: true, json: async () => ({}) }); + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) return Promise.resolve({ ok: true, json: async () => [] }); + throw new Error(`Unexpected fetch: ${url}`); + })); + + await renderDashboard(); + await waitForCondition(() => container?.textContent?.includes("최근 메일을 확인하지 못했습니다.") ?? false); + + expect(container?.textContent).not.toContain("수신된 메일이 없습니다."); + }); + + it.each([ + { endpoint: "/api/emails", message: "최근 메일을 확인하지 못했습니다.", malformed: { id: 1, subject: 42, sender: "example@example.com", snippet: "" } }, + { endpoint: "/api/emails/pending-replies?limit=3", message: "답변 대기 메일을 확인하지 못했습니다.", malformed: { id: 1, subject: null, sender: "example@example.com", snippet: "", date: {} } }, + { endpoint: "/api/tasks", message: "작업 현황을 확인하지 못했습니다.", malformed: { id: "task-example", title: "Example", status: ["open"], priority: "normal", created_at: "", updated_at: "" } }, + { endpoint: "/api/calendar/writeback-sources", message: "일정 원본 목록 응답을 확인할 수 없습니다.", malformed: { source_id: "calendar-example", writeback_enabled: true, capabilities: "write" } }, + ].flatMap(({ endpoint, message, malformed }) => [null, {}, 42, malformed].map((member) => ({ endpoint, message, member }))))( + "rejects malformed $member in $endpoint without losing the dashboard", + async ({ endpoint, message, member }) => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(endpoint)) { + const payload = endpoint.startsWith("/api/emails") ? { emails: [member] } : [member]; + return Promise.resolve({ ok: true, json: async () => payload }); + } + const support = supportResponse(url); + if (support) return support; + return Promise.resolve({ ok: true, json: async () => url.endsWith("/api/tasks") ? [] : { emails: [] } }); + })); + + await renderDashboard(); + await waitForCondition(() => container?.textContent?.includes(message) ?? false); + expect(container?.querySelector('[role="alert"] button')?.textContent).toBe("다시 시도"); + expect(container?.querySelector('[aria-label="홈 지표"]')).not.toBeNull(); + }, + ); + + it("publishes ready source data without waiting for an unrelated stalled core read", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + const support = supportResponse(url); + if (support) return support; + if (url.endsWith("/api/emails")) return new Promise(() => undefined); + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ + ok: true, + json: async () => ({ + emails: [{ + id: 501, + subject: "독립적으로 확인된 답변 대기", + sender: "customer@example.com", + date: "2026-09-05T00:00:00Z", + snippet: "메일 목록 응답과 무관하게 표시되어야 합니다.", + }], + }), + }); + } + if (url.endsWith("/api/tasks")) { + return Promise.resolve({ + ok: true, + json: async () => [{ + id: "task-independent-ready", + title: "독립적으로 확인된 작업", + status: "open", + priority: "normal", + created_at: "2026-09-05T00:00:00Z", + updated_at: "2026-09-05T00:00:00Z", + }], + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + + await renderDashboard(); + await waitForCondition(() => container?.textContent?.includes("독립적으로 확인된 답변 대기") ?? false); + + expect(container?.textContent).toContain("독립적으로 확인된 작업"); + expect(container?.textContent).toContain("메일을 불러오는 중..."); + }); + + it("bounds a stalled dashboard read with the native timeout signal", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + const timeoutController = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const support = supportResponse(url); + if (support) return support; + if (url.endsWith("/api/emails")) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("timed out", "AbortError")), + { once: true }, + ); + }); + } + if (url.endsWith("/api/emails/pending-replies?limit=3")) { + return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) }); + } + if (url.endsWith("/api/tasks")) return Promise.resolve({ ok: true, json: async () => [] }); + throw new Error(`Unexpected fetch: ${url}`); + })); + + await renderDashboard(); + expect(timeoutSpy).toHaveBeenCalledWith(15_000); + await act(async () => timeoutController.abort()); + await waitForCondition(() => container?.textContent?.includes("최근 메일을 확인하지 못했습니다.") ?? false); + + expect(container?.textContent).not.toContain("메일을 불러오는 중..."); + }); +}); diff --git a/frontend/src/components/WorkspaceHome.tsx b/frontend/src/components/WorkspaceHome.tsx index ad0c58140..fe458d773 100644 --- a/frontend/src/components/WorkspaceHome.tsx +++ b/frontend/src/components/WorkspaceHome.tsx @@ -10,6 +10,7 @@ import dynamic from 'next/dynamic'; import { CalendarDays, CheckCircle2, Inbox, Network, Send, Settings, Sparkles } from 'lucide-react'; import { useTasks, type TaskItem } from '@/hooks/useTasks'; import { apiClient } from '@/lib/api-client'; +import { isMailListItem } from '@/lib/mail-response'; import { setMobileWorkspaceView, useMobileWorkspaceView } from '@/lib/mobile-workspace'; import { toSafeReactText } from '@/lib/safe-text'; import { setWorkspaceStartupView, useWorkspaceStartupView, type WorkspaceStartupView } from '@/lib/workspace-preferences'; @@ -18,6 +19,11 @@ const NetworkGraph = dynamic(() => import('@/components/NetworkGraph'), { ssr: f type WorkspaceActionCommand = { id: number; action: string; target: 'desktop' | 'tablet'; modeVersion: number }; type MobileActionCommand = { id: number; action: string; modeVersion: number }; +type DashboardDataStatus = 'loading' | 'ready' | 'auth' | 'error'; +type DashboardDataStatuses = Record< + 'emails' | 'pendingReplies' | 'tasks' | 'calendarSources' | 'projectFolders', + DashboardDataStatus +>; type StartupSearchResult = { id: number; subject: string | null; @@ -36,6 +42,7 @@ function useStartupSearch(query: string, limit: number) { void apiClient.post<{ results: StartupSearchResult[] }>('/api/search', { query, limit }, { signal: controller.signal }) .then((response) => { if (cancelled) return; + if (!Array.isArray(response.results) || !response.results.every(isMailListItem)) throw new Error('Invalid search response'); setResults(response.results); setStatus(response.results.length > 0 ? 'success' : 'empty'); }) @@ -66,8 +73,10 @@ type CalendarWritebackSource = { type ProjectFolder = { folder_uid: string; - project_name?: string; - webdav_path?: string; + project_name: string; + webdav_path: string; + owner_user_id: string; + organization_id: string | null; }; const dashboardQuickActions = [ @@ -90,6 +99,39 @@ interface EmailItem { unread?: boolean; } +function isDashboardRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isDashboardTask(value: unknown): value is TaskItem { + return isDashboardRecord(value) + && typeof value.id === 'string' && value.id.length > 0 + && typeof value.title === 'string' + && typeof value.status === 'string' && ['open', 'in_progress', 'blocked', 'done'].includes(value.status) + && typeof value.priority === 'string' && ['low', 'normal', 'high', 'urgent'].includes(value.priority) + && typeof value.created_at === 'string' && typeof value.updated_at === 'string'; +} + +function isDashboardCalendarSource(value: unknown): value is CalendarWritebackSource { + return isDashboardRecord(value) + && typeof value.source_id === 'string' && value.source_id.length > 0 + && (value.provider === undefined || typeof value.provider === 'string') + && (value.protocol === undefined || typeof value.protocol === 'string') + && (value.capabilities === undefined || (Array.isArray(value.capabilities) + && value.capabilities.every((capability: unknown) => typeof capability === 'string'))) + && (value.writeback_enabled === undefined || typeof value.writeback_enabled === 'boolean') + && (value.etag === undefined || value.etag === null || typeof value.etag === 'string'); +} + +function isDashboardProjectFolder(value: unknown): value is ProjectFolder { + return isDashboardRecord(value) + && typeof value.folder_uid === 'string' + && typeof value.project_name === 'string' + && typeof value.webdav_path === 'string' + && typeof value.owner_user_id === 'string' + && (value.organization_id === null || typeof value.organization_id === 'string'); +} + function isWritableCalendarSource(source: CalendarWritebackSource) { return Boolean( source.writeback_enabled @@ -146,57 +188,155 @@ function buildCompletionRate(tasks: TaskItem[]) { return Math.round((tasks.filter((task) => task.status === 'done').length / tasks.length) * 100); } +function getApiErrorStatus(error: unknown) { + const shapedError = error as { status?: unknown; response?: { status?: unknown } } | null; + if (typeof shapedError?.status === 'number') return shapedError.status; + if (typeof shapedError?.response?.status === 'number') return shapedError.response.status; + return null; +} + +function isDashboardDataUnavailable(status: DashboardDataStatus) { + return status === 'auth' || status === 'error'; +} + +function createDashboardReadSignal(requestSignal: AbortSignal, timeoutMs: number) { + if (typeof AbortSignal.any === 'function' && typeof AbortSignal.timeout === 'function') { + return AbortSignal.any([requestSignal, AbortSignal.timeout(timeoutMs)]); + } + + const fallbackController = new AbortController(); + const abortFallback = (reason: unknown) => { + if (!fallbackController.signal.aborted) fallbackController.abort(reason); + }; + const handleRequestAbort = () => abortFallback(requestSignal.reason); + requestSignal.addEventListener('abort', handleRequestAbort, { once: true }); + const timeoutId = window.setTimeout( + () => abortFallback(new DOMException('The operation timed out.', 'TimeoutError')), + timeoutMs, + ); + fallbackController.signal.addEventListener('abort', () => { + window.clearTimeout(timeoutId); + requestSignal.removeEventListener('abort', handleRequestAbort); + }, { once: true }); + if (requestSignal.aborted) handleRequestAbort(); + return fallbackController.signal; +} + function useDashboardData() { const [emails, setEmails] = useState([]); const [pendingReplies, setPendingReplies] = useState([]); const [tasks, setTasks] = useState([]); const [calendarSources, setCalendarSources] = useState([]); const [projectFolders, setProjectFolders] = useState([]); - const [loading, setLoading] = useState(true); - const [sourceEvidenceStatus, setSourceEvidenceStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const [dataStatus, setDataStatus] = useState({ + emails: 'loading', + pendingReplies: 'loading', + tasks: 'loading', + calendarSources: 'loading', + projectFolders: 'loading', + }); + const [reloadVersion, setReloadVersion] = useState(0); + const requestVersionRef = useRef(0); useEffect(() => { let cancelled = false; - let pendingRequests = 2; - const finishRequest = () => { - pendingRequests -= 1; - if (pendingRequests === 0 && !cancelled) { - setLoading(false); - } + const requestVersion = reloadVersion; + const requestController = new AbortController(); + const dashboardReadSignal = createDashboardReadSignal(requestController.signal, 15_000); + const isStaleRequest = () => cancelled || requestVersion !== requestVersionRef.current; + const setSourceStatus = (source: keyof DashboardDataStatuses, status: DashboardDataStatus) => { + if (isStaleRequest()) return; + setDataStatus((currentStatus) => ({ ...currentStatus, [source]: status })); + }; + const failureStatus = (error: unknown): DashboardDataStatus => { + const status = getApiErrorStatus(error); + return status === 401 || status === 403 ? 'auth' : 'error'; }; - Promise.all([ - apiClient.get<{ emails: EmailItem[] }>('/api/emails').catch(() => ({ emails: [] })), - apiClient.get<{ emails: EmailItem[] }>('/api/emails/pending-replies?limit=3').catch(() => ({ emails: [] })), - apiClient.get('/api/tasks').catch(() => []), - ]).then(([emailRes, pendingReplyRes, tasksRes]) => { - if (cancelled) return; - setEmails(Array.isArray(emailRes.emails) ? emailRes.emails : []); - setPendingReplies(Array.isArray(pendingReplyRes.emails) ? pendingReplyRes.emails : []); - setTasks(Array.isArray(tasksRes) ? tasksRes : []); - }).finally(finishRequest); - - Promise.all([ - apiClient.get('/api/calendar/writeback-sources'), - apiClient.get('/api/webdav/folders'), - ]).then(([calendarSourceRows, projectFolderRows]) => { - if (cancelled) return; - setCalendarSources(Array.isArray(calendarSourceRows) ? calendarSourceRows : []); - setProjectFolders(Array.isArray(projectFolderRows) ? projectFolderRows : []); - setSourceEvidenceStatus('ready'); - }).catch(() => { - if (cancelled) return; - setCalendarSources([]); - setProjectFolders([]); - setSourceEvidenceStatus('error'); - }).finally(finishRequest); + void apiClient.get<{ emails: EmailItem[] }>('/api/emails', { signal: dashboardReadSignal }) + .then((response) => { + if (isStaleRequest()) return; + if (!Array.isArray(response.emails) || !response.emails.every(isMailListItem)) throw new Error('Invalid dashboard email response'); + setEmails(response.emails); + setSourceStatus('emails', 'ready'); + }) + .catch((error: unknown) => { + if (isStaleRequest()) return; + setEmails([]); + setSourceStatus('emails', failureStatus(error)); + }); + + void apiClient.get<{ emails: EmailItem[] }>('/api/emails/pending-replies?limit=3', { signal: dashboardReadSignal }) + .then((response) => { + if (isStaleRequest()) return; + if (!Array.isArray(response.emails) || !response.emails.every(isMailListItem)) throw new Error('Invalid pending-reply response'); + setPendingReplies(response.emails); + setSourceStatus('pendingReplies', 'ready'); + }) + .catch((error: unknown) => { + if (isStaleRequest()) return; + setPendingReplies([]); + setSourceStatus('pendingReplies', failureStatus(error)); + }); + + void apiClient.get('/api/tasks', { signal: dashboardReadSignal }) + .then((response) => { + if (isStaleRequest()) return; + if (!Array.isArray(response) || !response.every(isDashboardTask)) throw new Error('Invalid dashboard task response'); + setTasks(response); + setSourceStatus('tasks', 'ready'); + }) + .catch((error: unknown) => { + if (isStaleRequest()) return; + setTasks([]); + setSourceStatus('tasks', failureStatus(error)); + }); + + void apiClient.get('/api/calendar/writeback-sources', { signal: dashboardReadSignal }) + .then((response) => { + if (isStaleRequest()) return; + if (!Array.isArray(response) || !response.every(isDashboardCalendarSource)) throw new Error('Invalid calendar-source response'); + setCalendarSources(response); + setSourceStatus('calendarSources', 'ready'); + }) + .catch((error: unknown) => { + if (isStaleRequest()) return; + setCalendarSources([]); + setSourceStatus('calendarSources', failureStatus(error)); + }); + + void apiClient.get('/api/webdav/folders', { signal: dashboardReadSignal }) + .then((response) => { + if (isStaleRequest()) return; + if (!Array.isArray(response) || !response.every(isDashboardProjectFolder)) throw new Error('Invalid project-folder response'); + setProjectFolders(response); + setSourceStatus('projectFolders', 'ready'); + }) + .catch((error: unknown) => { + if (isStaleRequest()) return; + setProjectFolders([]); + setSourceStatus('projectFolders', failureStatus(error)); + }); return () => { cancelled = true; + requestController.abort(); }; + }, [reloadVersion]); + + const retryDashboardData = useCallback(() => { + setDataStatus({ + emails: 'loading', + pendingReplies: 'loading', + tasks: 'loading', + calendarSources: 'loading', + projectFolders: 'loading', + }); + requestVersionRef.current += 1; + setReloadVersion(requestVersionRef.current); }, []); - return { emails, pendingReplies, tasks, setTasks, calendarSources, projectFolders, loading, sourceEvidenceStatus }; + return { emails, pendingReplies, tasks, setTasks, calendarSources, projectFolders, dataStatus, retryDashboardData }; } function formatStartupDate(value: string) { @@ -239,7 +379,7 @@ function StartupResultList({ results }: { results: StartupSearchResult[] }) { } function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupView) => void }) { - const { emails, pendingReplies, tasks, setTasks: setDashboardTasks, calendarSources, projectFolders, loading, sourceEvidenceStatus } = useDashboardData(); + const { emails, pendingReplies, tasks, setTasks: setDashboardTasks, calendarSources, projectFolders, dataStatus, retryDashboardData } = useDashboardData(); const calendarCandidateEvidence = useStartupSearch('일정 충돌 일정 조율 회의 후보', 3); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [currentTimestamp, setCurrentTimestamp] = useState(''); @@ -250,26 +390,43 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV const completedTaskCount = tasks.filter((task) => task.status === 'done').length; const writableCalendarSourceCount = calendarSources.filter(isWritableCalendarSource).length; const taskCompletionRate = buildCompletionRate(tasks); - const sourceEvidenceLoading = sourceEvidenceStatus === 'loading'; - const sourceEvidenceError = sourceEvidenceStatus === 'error'; + const emailLoading = dataStatus.emails === 'loading'; + const emailUnavailable = isDashboardDataUnavailable(dataStatus.emails); + const pendingReplyLoading = dataStatus.pendingReplies === 'loading'; + const pendingReplyUnavailable = isDashboardDataUnavailable(dataStatus.pendingReplies); + const taskLoading = dataStatus.tasks === 'loading'; + const taskUnavailable = isDashboardDataUnavailable(dataStatus.tasks); + const calendarSourceLoading = dataStatus.calendarSources === 'loading'; + const calendarSourceError = isDashboardDataUnavailable(dataStatus.calendarSources); + const projectFolderLoading = dataStatus.projectFolders === 'loading'; + const projectFolderError = isDashboardDataUnavailable(dataStatus.projectFolders); + const dashboardAuthenticationRequired = Object.values(dataStatus).includes('auth'); + const dashboardDataError = Object.values(dataStatus).some(isDashboardDataUnavailable); const dashboardStats = useMemo(() => ([ - { title: '받은 메일', value: loading ? '-' : emails.length.toString(), diff: unreadCount > 0 ? `+${unreadCount}` : '-', diffText: '안 읽음', icon: Inbox, color: 'text-primary' }, - { title: '답변 대기', value: loading ? '-' : pendingReplyCount.toString(), diff: pendingReplyCount > 0 ? `${pendingReplyCount}건` : '-', diffText: '보낸 메일', icon: Send, color: 'text-rose-500' }, - { title: '일정 원본', value: sourceEvidenceError ? '오류' : sourceEvidenceLoading ? '-' : calendarSources.length.toString(), diff: sourceEvidenceError ? '확인 필요' : sourceEvidenceLoading ? '-' : `${writableCalendarSourceCount}개`, diffText: sourceEvidenceError ? '원본 확인' : '반영 가능', icon: CalendarDays, color: sourceEvidenceError ? 'text-red-500' : 'text-blue-500' }, - { title: '대기 작업', value: loading ? '-' : pendingTasks.length.toString(), diff: '-', diffText: 'source-linked', icon: CheckCircle2, color: 'text-green-500' }, - { title: '프로젝트 원본', value: sourceEvidenceError ? '오류' : sourceEvidenceLoading ? '-' : projectFolders.length.toString(), diff: sourceEvidenceError ? '확인 필요' : sourceEvidenceLoading ? '-' : `${projectFolders.length}개`, diffText: 'WebDAV 폴더', icon: Network, color: sourceEvidenceError ? 'text-red-500' : 'text-purple-500' }, - { title: '작업 완료율', value: loading ? '-' : `${taskCompletionRate}%`, diff: loading ? '-' : `${completedTaskCount}/${tasks.length}`, diffText: '완료', icon: CheckCircle2, color: 'text-emerald-500' }, + { title: '받은 메일', value: emailUnavailable ? '오류' : emailLoading ? '-' : emails.length.toString(), diff: emailUnavailable ? '확인 필요' : unreadCount > 0 ? `+${unreadCount}` : '-', diffText: '안 읽음', icon: Inbox, color: emailUnavailable ? 'text-red-500' : 'text-primary' }, + { title: '답변 대기', value: pendingReplyUnavailable ? '오류' : pendingReplyLoading ? '-' : pendingReplyCount.toString(), diff: pendingReplyUnavailable ? '확인 필요' : pendingReplyCount > 0 ? `${pendingReplyCount}건` : '-', diffText: '보낸 메일', icon: Send, color: pendingReplyUnavailable ? 'text-red-500' : 'text-rose-500' }, + { title: '일정 원본', value: calendarSourceError ? '오류' : calendarSourceLoading ? '-' : calendarSources.length.toString(), diff: calendarSourceError ? '확인 필요' : calendarSourceLoading ? '-' : `${writableCalendarSourceCount}개`, diffText: calendarSourceError ? '원본 확인' : '반영 가능', icon: CalendarDays, color: calendarSourceError ? 'text-red-500' : 'text-blue-500' }, + { title: '대기 작업', value: taskUnavailable ? '오류' : taskLoading ? '-' : pendingTasks.length.toString(), diff: taskUnavailable ? '확인 필요' : '-', diffText: 'source-linked', icon: CheckCircle2, color: taskUnavailable ? 'text-red-500' : 'text-green-500' }, + { title: '프로젝트 원본', value: projectFolderError ? '오류' : projectFolderLoading ? '-' : projectFolders.length.toString(), diff: projectFolderError ? '확인 필요' : projectFolderLoading ? '-' : `${projectFolders.length}개`, diffText: 'WebDAV 폴더', icon: Network, color: projectFolderError ? 'text-red-500' : 'text-purple-500' }, + { title: '작업 완료율', value: taskUnavailable ? '오류' : taskLoading ? '-' : `${taskCompletionRate}%`, diff: taskUnavailable ? '확인 필요' : taskLoading ? '-' : `${completedTaskCount}/${tasks.length}`, diffText: '완료', icon: CheckCircle2, color: taskUnavailable ? 'text-red-500' : 'text-emerald-500' }, ]), [ calendarSources.length, completedTaskCount, + emailLoading, + emailUnavailable, emails.length, - loading, pendingReplyCount, + pendingReplyLoading, + pendingReplyUnavailable, pendingTasks.length, projectFolders.length, - sourceEvidenceError, - sourceEvidenceLoading, + calendarSourceError, + calendarSourceLoading, + projectFolderError, + projectFolderLoading, taskCompletionRate, + taskLoading, + taskUnavailable, tasks.length, unreadCount, writableCalendarSourceCount, @@ -339,6 +496,35 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
+ {dashboardDataError ? ( +
+
+

{dashboardAuthenticationRequired ? '로그인이 필요합니다.' : '업무 현황을 모두 불러오지 못했습니다.'}

+

+ {dashboardAuthenticationRequired + ? '세션이 만료됐거나 이 작업공간에 접근할 수 없습니다.' + : '연결을 확인한 뒤 다시 시도하세요. 확인된 항목은 그대로 표시됩니다.'} +

+
+ {dashboardAuthenticationRequired ? ( + + 로그인 설정 열기 + + ) : ( + + )} +
+ ) : null} + {/* KPI Cards */}
{dashboardStats.map((stat) => ( @@ -362,15 +548,15 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
-

답변 대기 {loading ? '-' : pendingReplyCount}건

-

보낸 메일 중 회신 확인이 필요한 항목입니다.

+

답변 대기 {pendingReplyUnavailable ? '확인 필요' : pendingReplyLoading ? '-' : `${pendingReplyCount}건`}

+

{pendingReplyUnavailable ? '보낸 메일 응답 상태를 확인할 수 없습니다.' : '보낸 메일 중 회신 확인이 필요한 항목입니다.'}

보낸 메일 보기