diff --git a/apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts b/apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts new file mode 100644 index 0000000..f5b0676 --- /dev/null +++ b/apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts @@ -0,0 +1,59 @@ +import { searchProjectForUser } from "../../../../../../../server/projects/search"; +import { createProjectRepository } from "../../../../../../../server/projects/repository"; +import type { ProjectRepository } from "../../../../../../../server/projects/types"; +import { WorkspaceError } from "../../../../../../../server/workspaces/core"; +import type { AppSession } from "../../../../../../../server/workspaces/types"; + +interface SearchRouteDependencies { + getSession: () => Promise; + projectRepository: Pick; +} + +async function getDefaultSession() { + const { getAppSession } = await import("../../../../../../../server/auth"); + return getAppSession(); +} + +const defaultDependencies: SearchRouteDependencies = { + getSession: getDefaultSession, + projectRepository: createProjectRepository() +}; + +export async function GET( + request: Request, + context: { + params: Promise<{ + slug: string; + key: string; + }>; + }, + overrideDependencies?: SearchRouteDependencies +) { + const dependencies = overrideDependencies ?? defaultDependencies; + const session = await dependencies.getSession(); + + if (!session) { + return Response.json({ error: "authentication required." }, { status: 401 }); + } + + const { slug, key } = await context.params; + const query = new URL(request.url).searchParams.get("q") ?? ""; + + try { + const response = await searchProjectForUser( + { projectRepository: dependencies.projectRepository }, + session, + slug, + key, + query + ); + + return Response.json(response); + } catch (error) { + if (error instanceof WorkspaceError) { + return Response.json({ error: error.message }, { status: error.status }); + } + + throw error; + } +} diff --git a/apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx b/apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx index 8cbde10..80b0463 100644 --- a/apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx +++ b/apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx @@ -34,7 +34,12 @@ export default async function ProjectOverviewPage({ stage={workspaceView.stage} workspaceSlug={slug} > - + ); diff --git a/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts b/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts index f3e7253..fb8bc3a 100644 --- a/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts +++ b/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts @@ -32,11 +32,23 @@ export async function loadProjectPageData(workspaceSlug: string, projectKey: str try { const membership = await requireWorkspaceMembership(workspaceRepository, session, workspace.id, "viewer"); - const [workspaces, project] = await Promise.all([ + const [workspaces, project, notificationRows, notificationPreferences] = await Promise.all([ listWorkspacesForUser(workspaceRepository, session), - getProjectForUser(projectRepository, session, workspaceSlug, projectKey) + getProjectForUser(projectRepository, session, workspaceSlug, projectKey), + listNotificationsForUser(notificationRepository, session, workspaceSlug, { limit: 20 }), + getNotificationPreferencesForUser(notificationRepository, session, workspaceSlug) ]); - const [workspaceView, projectStages, planItems, notificationRows, notificationPreferences] = await Promise.all([ + const projectReadinessNotificationRows = await listNotificationsForUser( + notificationRepository, + session, + workspaceSlug, + { + projectId: project.id, + unreadOnly: true, + limit: null + } + ); + const [workspaceView, projectStages, planItems] = await Promise.all([ getProjectWorkspaceForUser( { projectRepository, @@ -44,12 +56,11 @@ export async function loadProjectPageData(workspaceSlug: string, projectKey: str }, session, workspaceSlug, - projectKey + projectKey, + { notificationInbox: projectReadinessNotificationRows } ), projectRepository.listProjectStages(project.id), - projectRepository.listPlanItems(project.id), - listNotificationsForUser(notificationRepository, session, workspaceSlug, { limit: 20 }), - getNotificationPreferencesForUser(notificationRepository, session, workspaceSlug) + projectRepository.listPlanItems(project.id) ]); const notificationInbox = { notifications: notificationRows, diff --git a/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx b/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx index 701009c..98e4aeb 100644 --- a/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx +++ b/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx @@ -83,4 +83,21 @@ describe("EngineeringView", () => { ); expect(screen.getByText("OPS-1 · merged · passing · phase 2")).toBeInTheDocument(); }); + + it("shows setup guidance when repository signals need a GitHub connection", () => { + render( + + ); + + expect(screen.getByText("Connect GitHub to populate engineering readiness signals.")).toBeInTheDocument(); + expect(screen.getByText("Linked pull requests")).toBeInTheDocument(); + expect(screen.getByText("Failing checks")).toBeInTheDocument(); + expect(screen.getByText("Deployments")).toBeInTheDocument(); + expect(screen.getByText("OPS-1 · merged · passing · phase 2")).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/features/engineering/engineering-view.tsx b/apps/web/src/features/engineering/engineering-view.tsx index 99987c4..c451b42 100644 --- a/apps/web/src/features/engineering/engineering-view.tsx +++ b/apps/web/src/features/engineering/engineering-view.tsx @@ -31,6 +31,11 @@ export function EngineeringView({ engineering }: { engineering: ProjectWorkspace {engineering.connectionStatus} {engineering.defaultBranch ?? "no branch"} + {engineering.connectionStatus === "Setup required" ? ( +
+ Connect GitHub to populate engineering readiness signals. +
+ ) : null}

Pull requests

diff --git a/apps/web/src/features/overview/__tests__/overview-view.test.tsx b/apps/web/src/features/overview/__tests__/overview-view.test.tsx index 11d0caf..6fcf164 100644 --- a/apps/web/src/features/overview/__tests__/overview-view.test.tsx +++ b/apps/web/src/features/overview/__tests__/overview-view.test.tsx @@ -1,34 +1,370 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "../../../test/render"; import { OverviewView } from "../overview-view"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + const overview = { - currentStage: "Phase 2: Execution Surface", - health: ["Scope: stable", "Delivery risk: low", "Engineering risk: 1 failing check"], + currentStage: "Phase 8: Readiness", + health: ["Scope: stable", "Delivery risk: low", "Engineering risk: stable"], milestones: [ - { label: "Foundation Alignment", monthStart: 0, monthSpan: 2, tone: "completed" as const }, - { label: "Execution Surface", monthStart: 2, monthSpan: 2, tone: "current" as const }, - { label: "Engineering Signals", monthStart: 4, monthSpan: 2, tone: "upcoming" as const } - ] + { label: "Notifications", monthStart: 0, monthSpan: 2, tone: "completed" as const }, + { label: "Readiness", monthStart: 2, monthSpan: 2, tone: "current" as const } + ], + readiness: { + status: "Ready with risk" as const, + tone: "warning" as const, + narrative: "Ready with risk: 1 PR is awaiting review.", + metrics: [ + { label: "Plan", value: "4/5 done", detail: "1 current-stage item remains", tone: "warning" as const }, + { label: "Issues", value: "1 urgent", detail: "No blocked work", tone: "warning" as const }, + { label: "GitHub", value: "1 review", detail: "Checks are passing", tone: "warning" as const }, + { label: "Notifications", value: "2 unread", detail: "1 high priority", tone: "warning" as const } + ], + decisionCues: [ + { label: "Ship gate", value: "In review", tone: "warning" as const }, + { label: "Primary blocker", value: "None", tone: "success" as const } + ], + actions: [ + { + id: "review-pr-task-1", + title: "Review OPS-4 pull request", + detail: "Review requested before release.", + href: "/workspaces/platform-ops/projects/OPS/engineering", + sourceType: "github" as const, + priority: "medium" as const + } + ] + } }; describe("OverviewView", () => { - it("renders a lightweight milestone roadmap and current project health", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders readiness as the primary overview while keeping the milestone roadmap visible", () => { render( ); + expect(screen.getByText("Readiness command center")).toBeInTheDocument(); + expect(screen.getByText("Ready with risk")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument(); + expect(screen.getByText("Ready with risk: 1 PR is awaiting review.")).toBeInTheDocument(); + expect(screen.getByText("4/5 done")).toBeInTheDocument(); + expect(screen.getByText("Decision cues")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Review OPS-4 pull request/i })).toHaveAttribute( + "href", + "/workspaces/platform-ops/projects/OPS/engineering" + ); expect(screen.getByText("Milestone roadmap")).toBeInTheDocument(); - expect(screen.getAllByText("Foundation Alignment")).toHaveLength(2); - expect(screen.getAllByText("Execution Surface")).toHaveLength(2); - expect(screen.getAllByText("Engineering Signals")).toHaveLength(2); - expect(screen.getByText("Phase 2: Execution Surface")).toBeInTheDocument(); - expect(screen.getByText("Engineering risk: 1 failing check")).toBeInTheDocument(); + }); + + it("shows explicit empty readiness actions when the current stage has no blocking work", () => { + render( + + ); + + expect( + screen.getByText("No readiness actions. The current stage has no blocking work or high-priority signals.") + ).toBeInTheDocument(); + }); + + it("searches readiness signals from the overview", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + query: "pipeline", + results: [ + { + id: "work-item-1", + type: "work_item", + title: "OPS-1 Fix release pipeline", + snippet: "Pipeline blocks the release.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + chip: "Risk", + rank: 0 + } + ] + }) + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(screen.getByRole("searchbox", { name: "Readiness search" })).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Search blockers, PRs, comments..."), { + target: { value: "pipeline" } + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/workspaces/platform-ops/projects/OPS/search?q=pipeline"); + }); + expect(await screen.findByRole("link", { name: /OPS-1 Fix release pipeline/i })).toHaveAttribute( + "href", + "/workspaces/platform-ops/projects/OPS?selected=OPS-1" + ); + }); + + it("shows readiness search guidance before a valid query is entered", () => { + render(); + + expect( + screen.getByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "p" } + }); + + expect( + screen.getByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).toBeInTheDocument(); + }); + + it("shows an empty readiness search state for valid queries without results", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ results: [] }) + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "missing" } + }); + + expect(await screen.findByText('No readiness signals found for "missing".')).toBeInTheDocument(); + expect( + screen.queryByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).not.toBeInTheDocument(); + expect(screen.queryByText("Search failed. Try again from the project overview.")).not.toBeInTheDocument(); + }); + + it("shows an error instead of crashing when readiness search fails", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("network failed")) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ query: "pipeline" }) + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "network" } + }); + + expect(await screen.findByText("Search failed. Try again from the project overview.")).toBeInTheDocument(); + expect( + screen.queryByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).not.toBeInTheDocument(); + expect(screen.queryByText('No readiness signals found for "network".')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "pipeline" } + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/workspaces/platform-ops/projects/OPS/search?q=pipeline"); + }); + expect(await screen.findByText("Search failed. Try again from the project overview.")).toBeInTheDocument(); + }); + + it("keeps stale readiness search responses from replacing newer results", async () => { + const firstSearch = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const secondSearch = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi.fn().mockReturnValueOnce(firstSearch.promise).mockReturnValueOnce(secondSearch.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipe" } }); + fireEvent.change(input, { target: { value: "pipeline" } }); + + secondSearch.resolve({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "latest-result", + type: "work_item", + title: "OPS-2 Latest pipeline result", + snippet: "Newest search result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-2", + chip: "Current", + rank: 0 + } + ] + }) + }); + + expect(await screen.findByRole("link", { name: /OPS-2 Latest pipeline result/i })).toBeInTheDocument(); + + firstSearch.resolve({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "stale-result", + type: "work_item", + title: "OPS-1 Stale pipeline result", + snippet: "Old search result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + chip: "Stale", + rank: 0 + } + ] + }) + }); + + await waitFor(() => { + expect(screen.queryByRole("link", { name: /OPS-1 Stale pipeline result/i })).not.toBeInTheDocument(); + }); + expect(screen.getByRole("link", { name: /OPS-2 Latest pipeline result/i })).toBeInTheDocument(); + }); + + it("clears existing readiness search results as soon as a different valid query starts", async () => { + const nextSearch = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "pipeline-result", + type: "work_item", + title: "OPS-1 Pipeline result", + snippet: "Pipeline result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + chip: "Current", + rank: 0 + } + ] + }) + }) + .mockReturnValueOnce(nextSearch.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipeline" } }); + + expect(await screen.findByRole("link", { name: /OPS-1 Pipeline result/i })).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "release" } }); + + expect(screen.queryByRole("link", { name: /OPS-1 Pipeline result/i })).not.toBeInTheDocument(); + expect(screen.getByText("Searching readiness signals...")).toBeInTheDocument(); + }); + + it("shows only short-query guidance when an in-flight search is shortened", () => { + const search = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi.fn().mockReturnValueOnce(search.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipeline" } }); + + expect(screen.getByText("Searching readiness signals...")).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "p" } }); + + expect( + screen.getByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).toBeInTheDocument(); + expect(screen.queryByText("Searching readiness signals...")).not.toBeInTheDocument(); + }); + + it("does not repopulate readiness search results after the query is cleared", async () => { + const search = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi.fn().mockReturnValueOnce(search.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipeline" } }); + fireEvent.change(input, { target: { value: "" } }); + + search.resolve({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "late-result", + type: "work_item", + title: "OPS-3 Late pipeline result", + snippet: "Late result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-3", + chip: "Late", + rank: 0 + } + ] + }) + }); + + await waitFor(() => { + expect(screen.queryByRole("link", { name: /OPS-3 Late pipeline result/i })).not.toBeInTheDocument(); + }); }); }); diff --git a/apps/web/src/features/overview/overview-view.tsx b/apps/web/src/features/overview/overview-view.tsx index 2e196a7..6839fe3 100644 --- a/apps/web/src/features/overview/overview-view.tsx +++ b/apps/web/src/features/overview/overview-view.tsx @@ -1,4 +1,9 @@ +import type { ProjectReadinessView } from "@/server/projects/readiness"; + import { MilestoneRoadmap } from "./milestone-roadmap"; +import { ReadinessActionList } from "./readiness-action-list"; +import { ReadinessSearch } from "./readiness-search"; +import { ReadinessSummary } from "./readiness-summary"; interface OverviewMilestone { label: string; @@ -11,17 +16,30 @@ interface OverviewViewModel { currentStage: string; health: string[]; milestones: OverviewMilestone[]; + readiness: ProjectReadinessView; } -export function OverviewView({ brief, overview }: { brief: string; overview: OverviewViewModel }) { +export function OverviewView({ + brief, + overview, + workspaceSlug, + projectKey +}: { + brief: string; + overview: OverviewViewModel; + workspaceSlug: string; + projectKey: string; +}) { return ( -
+
-
+ + +

Overview

-

Project alignment

+

Project alignment

{brief}

-
+
@@ -29,6 +47,8 @@ export function OverviewView({ brief, overview }: { brief: string; overview: Ove ); diff --git a/apps/web/src/features/overview/readiness-action-list.tsx b/apps/web/src/features/overview/readiness-action-list.tsx new file mode 100644 index 0000000..e4d646c --- /dev/null +++ b/apps/web/src/features/overview/readiness-action-list.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; + +import type { ProjectReadinessAction } from "@/server/projects/readiness"; + +const sourceLabel: Record = { + github: "GitHub", + notification: "Notification", + plan: "Plan", + work_item: "Work item" +}; + +export function ReadinessActionList({ actions }: { actions: ProjectReadinessAction[] }) { + return ( +
+
+
+

Team action list

+

Next best moves

+
+ + Rule based + +
+ + {actions.length > 0 ? ( +
    + {actions.map((action) => ( +
  • + +
    +

    {action.title}

    + + {sourceLabel[action.sourceType]} + +
    +

    {action.detail}

    + +
  • + ))} +
+ ) : ( +

+ No readiness actions. The current stage has no blocking work or high-priority signals. +

+ )} +
+ ); +} diff --git a/apps/web/src/features/overview/readiness-search.tsx b/apps/web/src/features/overview/readiness-search.tsx new file mode 100644 index 0000000..e7b4174 --- /dev/null +++ b/apps/web/src/features/overview/readiness-search.tsx @@ -0,0 +1,140 @@ +"use client"; + +import Link from "next/link"; +import { useRef, useState } from "react"; + +import type { ProjectSearchResult } from "@/server/projects/search"; + +export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: string; projectKey: string }) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [error, setError] = useState(""); + const [pendingQuery, setPendingQuery] = useState(""); + const requestIdRef = useRef(0); + const latestQueryRef = useRef(""); + + function updateQuery(value: string) { + setQuery(value); + setError(""); + + const trimmedQuery = value.trim(); + latestQueryRef.current = trimmedQuery; + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + if (trimmedQuery.length < 2) { + setPendingQuery(""); + setResults([]); + return; + } + + setPendingQuery(trimmedQuery); + setResults([]); + + function isCurrentRequest() { + return requestIdRef.current === requestId && latestQueryRef.current === trimmedQuery; + } + + void (async () => { + try { + const response = await fetch( + `/api/workspaces/${workspaceSlug}/projects/${projectKey}/search?q=${encodeURIComponent(trimmedQuery)}` + ); + + if (!isCurrentRequest()) { + return; + } + + if (!response.ok) { + setError("Search failed. Try again from the project overview."); + setResults([]); + return; + } + + const body = (await response.json()) as { results?: unknown }; + const nextResults = Array.isArray(body.results) ? (body.results as ProjectSearchResult[]) : null; + + if (!isCurrentRequest()) { + return; + } + + if (!nextResults) { + setError("Search failed. Try again from the project overview."); + setResults([]); + return; + } + + setResults(nextResults); + } catch { + if (!isCurrentRequest()) { + return; + } + + setError("Search failed. Try again from the project overview."); + setResults([]); + } finally { + if (isCurrentRequest()) { + setPendingQuery(""); + } + } + })(); + } + + const trimmedQuery = query.trim(); + const isShortQuery = trimmedQuery.length < 2; + const isSearching = pendingQuery === trimmedQuery && !isShortQuery; + const showNoResults = trimmedQuery.length >= 2 && !isSearching && !error && results.length === 0; + + return ( +
+

+ Readiness search +

+ updateQuery(event.target.value)} + placeholder="Search blockers, PRs, comments..." + type="search" + value={query} + /> + + {isSearching ?

Searching readiness signals...

: null} + {error ?

{error}

: null} + {isShortQuery ? ( +

+ Search across blockers, PRs, comments, plan items, and notifications. +

+ ) : null} + +
+ {results.map((result) => ( + +
+

{result.title}

+ + {result.chip} + +
+

{result.snippet}

+ + ))} +
+ + {showNoResults ? ( +

No readiness signals found for "{trimmedQuery}".

+ ) : null} +
+ ); +} diff --git a/apps/web/src/features/overview/readiness-signal-card.tsx b/apps/web/src/features/overview/readiness-signal-card.tsx new file mode 100644 index 0000000..4c8ca45 --- /dev/null +++ b/apps/web/src/features/overview/readiness-signal-card.tsx @@ -0,0 +1,18 @@ +import type { ProjectReadinessMetric } from "@/server/projects/readiness"; + +const toneClass: Record = { + danger: "border-red-300/30 bg-red-500/10 text-red-100", + neutral: "border-white/10 bg-black/10 text-planka-text", + success: "border-emerald-300/30 bg-emerald-500/10 text-emerald-100", + warning: "border-amber-300/30 bg-amber-500/10 text-amber-100" +}; + +export function ReadinessSignalCard({ metric }: { metric: ProjectReadinessMetric }) { + return ( +
+

{metric.label}

+

{metric.value}

+

{metric.detail}

+
+ ); +} diff --git a/apps/web/src/features/overview/readiness-summary.tsx b/apps/web/src/features/overview/readiness-summary.tsx new file mode 100644 index 0000000..93fe44c --- /dev/null +++ b/apps/web/src/features/overview/readiness-summary.tsx @@ -0,0 +1,49 @@ +import type { ProjectReadinessTone, ProjectReadinessView } from "@/server/projects/readiness"; + +import { ReadinessSignalCard } from "./readiness-signal-card"; + +const tonePillClass: Record = { + danger: "border-red-300/30 bg-red-500/10 text-red-100", + neutral: "border-white/10 bg-black/10 text-planka-text-muted", + success: "border-emerald-300/30 bg-emerald-500/10 text-emerald-100", + warning: "border-amber-300/30 bg-amber-500/10 text-amber-100" +}; + +export function ReadinessSummary({ readiness }: { readiness: ProjectReadinessView }) { + return ( +
+
+
+
+

+ Readiness command center +

+ + {readiness.tone} + +
+

{readiness.status}

+

{readiness.narrative}

+
+ + +
+ +
+ {readiness.metrics.map((metric) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/server/notifications/repository.ts b/apps/web/src/server/notifications/repository.ts index 6acfdfd..d5910dd 100644 --- a/apps/web/src/server/notifications/repository.ts +++ b/apps/web/src/server/notifications/repository.ts @@ -234,7 +234,14 @@ export function createNotificationRepository(): NotificationRepository { }, async listInboxForUser(input) { - const rows = await db + const filters = [ + eq(notificationRecipients.workspaceId, input.workspaceId), + eq(notificationRecipients.recipientId, input.recipientId), + isNull(notificationRecipients.dismissedAt), + ...(input.projectId ? [eq(notificationEvents.projectId, input.projectId)] : []), + ...(input.unreadOnly ? [isNull(notificationRecipients.readAt)] : []) + ]; + const query = db .select({ event: notificationEvents, recipient: notificationRecipients, @@ -247,15 +254,9 @@ export function createNotificationRepository(): NotificationRepository { .innerJoin(workspaces, eq(notificationRecipients.workspaceId, workspaces.id)) .leftJoin(projects, eq(notificationEvents.projectId, projects.id)) .leftJoin(tasks, eq(notificationEvents.workItemId, tasks.id)) - .where( - and( - eq(notificationRecipients.workspaceId, input.workspaceId), - eq(notificationRecipients.recipientId, input.recipientId), - isNull(notificationRecipients.dismissedAt) - ) - ) - .orderBy(desc(notificationRecipients.createdAt)) - .limit(input.limit ?? 30); + .where(and(...filters)) + .orderBy(desc(notificationRecipients.createdAt)); + const rows = await (input.limit === null ? query : query.limit(input.limit ?? 30)); return rows.map( (row): NotificationInboxItem => ({ diff --git a/apps/web/src/server/notifications/service.ts b/apps/web/src/server/notifications/service.ts index ddf6147..284acfe 100644 --- a/apps/web/src/server/notifications/service.ts +++ b/apps/web/src/server/notifications/service.ts @@ -159,13 +159,15 @@ export async function listNotificationsForUser( repository: NotificationRepository, session: AppSession, workspaceSlug: string, - options: { limit?: number } = {} + options: { projectId?: string; unreadOnly?: boolean; limit?: number | null } = {} ) { const { workspace } = await resolveWorkspaceContext(repository, session, workspaceSlug, "viewer"); return repository.listInboxForUser({ workspaceId: workspace.id, recipientId: session.userId, + ...(options.projectId !== undefined ? { projectId: options.projectId } : {}), + ...(options.unreadOnly !== undefined ? { unreadOnly: options.unreadOnly } : {}), ...(options.limit !== undefined ? { limit: options.limit } : {}) }); } diff --git a/apps/web/src/server/notifications/types.ts b/apps/web/src/server/notifications/types.ts index 7e9f08a..23384ef 100644 --- a/apps/web/src/server/notifications/types.ts +++ b/apps/web/src/server/notifications/types.ts @@ -65,7 +65,9 @@ export interface NotificationRepository listInboxForUser(input: { workspaceId: string; recipientId: string; - limit?: number; + projectId?: string; + unreadOnly?: boolean; + limit?: number | null; }): Promise; markNotificationReadForUser(input: { workspaceId: string; diff --git a/apps/web/src/server/projects/readiness.ts b/apps/web/src/server/projects/readiness.ts new file mode 100644 index 0000000..bcfe2b7 --- /dev/null +++ b/apps/web/src/server/projects/readiness.ts @@ -0,0 +1,521 @@ +import type { + NotificationInboxItem, + PlanItemRecord, + ProjectStageRecord, + TaskGithubStatusRecord, + WorkItemRecord, +} from "@the-platform/shared"; + +export type ProjectReadinessStatus = "Ready" | "Ready with risk" | "Blocked"; +export type ProjectReadinessTone = "success" | "warning" | "danger" | "neutral"; +export type ProjectReadinessActionSource = + | "work_item" + | "plan" + | "github" + | "notification"; + +export interface ProjectReadinessMetric { + label: string; + value: string; + detail: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessDecisionCue { + label: string; + value: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessAction { + id: string; + title: string; + detail: string; + href: string; + sourceType: ProjectReadinessActionSource; + priority: "high" | "medium" | "low"; +} + +export interface ProjectReadinessEngineeringSignal { + taskId: string; + identifier: string; + checkLabel: string; + checkUrl: string | null; + pullRequestLabel: string; + pullRequestUrl: string | null; +} + +export interface ProjectReadinessView { + status: ProjectReadinessStatus; + tone: ProjectReadinessTone; + narrative: string; + metrics: ProjectReadinessMetric[]; + decisionCues: ProjectReadinessDecisionCue[]; + actions: ProjectReadinessAction[]; +} + +export interface BuildProjectReadinessInput { + baseProjectHref?: string; + currentStage: ProjectStageRecord | null; + stages: ProjectStageRecord[]; + planItems: PlanItemRecord[]; + tasks: WorkItemRecord[]; + githubStatuses: TaskGithubStatusRecord[]; + engineeringItems: ProjectReadinessEngineeringSignal[]; + notificationInbox: NotificationInboxItem[]; +} + +interface RankedReadinessAction extends ProjectReadinessAction { + rank: number; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function hasBlockedReason(task: WorkItemRecord) { + return Boolean(task.blockedReason?.trim()); +} + +function isActiveTask(task: WorkItemRecord) { + return task.status !== "Done"; +} + +function isIncompletePlanItem(planItem: PlanItemRecord) { + return planItem.status !== "Done"; +} + +function taskLabel(task: WorkItemRecord) { + return task.identifier ? `${task.identifier}: ${task.title}` : task.title; +} + +function stableHref(input: BuildProjectReadinessInput, suffix = "") { + return input.baseProjectHref + ? `${input.baseProjectHref.replace(/\/+$/, "")}${suffix}` + : "#"; +} + +function workItemHref(input: BuildProjectReadinessInput, task: WorkItemRecord) { + return task.identifier + ? stableHref(input, `/items/${task.identifier}`) + : stableHref(input); +} + +function planHref(input: BuildProjectReadinessInput) { + return stableHref(input, "/plan"); +} + +function engineeringHref(input: BuildProjectReadinessInput) { + return stableHref(input, "/engineering"); +} + +function findEngineeringItem( + engineeringItems: ProjectReadinessEngineeringSignal[], + githubStatus: TaskGithubStatusRecord +) { + return engineeringItems.find((item) => item.taskId === githubStatus.taskId); +} + +function buildMetrics( + input: BuildProjectReadinessInput +): ProjectReadinessMetric[] { + const activeTasks = input.tasks.filter(isActiveTask); + const blockedTaskCount = activeTasks.filter( + (task) => task.status === "Blocked" || hasBlockedReason(task) + ).length; + const urgentTaskCount = activeTasks.filter( + (task) => task.priority === "urgent" + ).length; + const failingCheckCount = input.githubStatuses.filter( + (status) => status.ciStatus === "Failing" + ).length; + const unreadNotificationCount = input.notificationInbox.filter( + (notification) => notification.isUnread + ).length; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const completedCurrentStagePlanItems = currentStagePlanItems.filter( + (planItem) => planItem.status === "Done" + ).length; + + return [ + { + label: "Work items", + value: pluralize(blockedTaskCount, "blocked"), + detail: `${pluralize(urgentTaskCount, "urgent item")} in scope`, + tone: + blockedTaskCount > 0 + ? "danger" + : urgentTaskCount > 0 + ? "warning" + : "success", + }, + { + label: "GitHub", + value: pluralize(failingCheckCount, "failing check"), + detail: `${pluralize(input.githubStatuses.length, "GitHub signal")} tracked`, + tone: failingCheckCount > 0 ? "danger" : "success", + }, + { + label: "Current plan", + value: `${completedCurrentStagePlanItems}/${currentStagePlanItems.length} complete`, + detail: input.currentStage?.title ?? "No current stage selected", + tone: currentStagePlanItems.some(isIncompletePlanItem) + ? "warning" + : "success", + }, + { + label: "Notifications", + value: `${unreadNotificationCount} unread`, + detail: `${pluralize( + input.notificationInbox.filter( + (notification) => + notification.isUnread && notification.event.priority === "high" + ).length, + "high-priority notification" + )}`, + tone: input.notificationInbox.some( + (notification) => + notification.isUnread && notification.event.priority === "high" + ) + ? "warning" + : "neutral", + }, + ]; +} + +function buildDecisionCues( + input: BuildProjectReadinessInput, + status: ProjectReadinessStatus +): ProjectReadinessDecisionCue[] { + const failingCheckCount = input.githubStatuses.filter( + (githubStatus) => githubStatus.ciStatus === "Failing" + ).length; + const stageBlockerCount = input.currentStage?.status === "Blocked" ? 1 : 0; + const activeTasks = input.tasks.filter(isActiveTask); + const blockedTaskCount = activeTasks.filter( + (task) => task.status === "Blocked" || hasBlockedReason(task) + ).length; + const reviewPrCount = input.githubStatuses.filter( + (githubStatus) => + githubStatus.prStatus === "Open PR" || + githubStatus.prStatus === "Review requested" + ).length; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const incompletePlanItemCount = + currentStagePlanItems.filter(isIncompletePlanItem).length; + + return [ + { + label: "Ship gate", + value: + status === "Blocked" + ? `${pluralize( + failingCheckCount + stageBlockerCount + blockedTaskCount, + "blocker" + )} to clear` + : status === "Ready with risk" + ? "Proceed with watch items" + : "Clear", + tone: + status === "Blocked" + ? "danger" + : status === "Ready with risk" + ? "warning" + : "success", + }, + { + label: "Review load", + value: pluralize(reviewPrCount, "PR needing attention"), + tone: reviewPrCount > 0 ? "warning" : "success", + }, + { + label: "Plan confidence", + value: pluralize(incompletePlanItemCount, "open plan item"), + tone: incompletePlanItemCount > 0 ? "warning" : "success", + }, + ]; +} + +function buildActions( + input: BuildProjectReadinessInput +): ProjectReadinessAction[] { + const actions: RankedReadinessAction[] = []; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + + for (const githubStatus of input.githubStatuses.filter( + (status) => status.ciStatus === "Failing" + )) { + const engineeringItem = findEngineeringItem( + input.engineeringItems, + githubStatus + ); + actions.push({ + id: `github-check-${githubStatus.id}`, + title: engineeringItem + ? `Fix failing checks for ${engineeringItem.identifier}` + : "Fix failing checks", + detail: + engineeringItem?.checkLabel ?? "CI is failing and blocks readiness.", + href: engineeringItem?.checkUrl ?? engineeringHref(input), + sourceType: "github", + priority: "high", + rank: 0, + }); + } + + for (const task of input.tasks.filter( + (item) => + isActiveTask(item) && + (item.status === "Blocked" || hasBlockedReason(item)) + )) { + actions.push({ + id: `blocked-work-item-${task.id}`, + title: `Unblock ${taskLabel(task)}`, + detail: task.blockedReason?.trim() || "Work item is blocked.", + href: workItemHref(input, task), + sourceType: "work_item", + priority: "high", + rank: 1, + }); + } + + if (input.currentStage?.status === "Blocked") { + actions.push({ + id: `stage-gate-${input.currentStage.id}`, + title: `Resolve blocked stage gate: ${input.currentStage.title}`, + detail: input.currentStage.gateStatus || input.currentStage.goal, + href: planHref(input), + sourceType: "plan", + priority: "high", + rank: 2, + }); + } + + for (const task of input.tasks.filter( + (item) => isActiveTask(item) && item.priority === "urgent" + )) { + actions.push({ + id: `urgent-work-item-${task.id}`, + title: `Resolve urgent work: ${taskLabel(task)}`, + detail: "Urgent priority is a readiness risk.", + href: workItemHref(input, task), + sourceType: "work_item", + priority: "medium", + rank: 3, + }); + } + + for (const githubStatus of input.githubStatuses.filter( + (status) => + status.prStatus === "Open PR" || status.prStatus === "Review requested" + )) { + const engineeringItem = findEngineeringItem( + input.engineeringItems, + githubStatus + ); + actions.push({ + id: `github-pr-${githubStatus.id}`, + title: engineeringItem + ? `Review ${engineeringItem.identifier}` + : "Review pull request", + detail: engineeringItem?.pullRequestLabel ?? githubStatus.prStatus, + href: engineeringItem?.pullRequestUrl ?? engineeringHref(input), + sourceType: "github", + priority: "medium", + rank: 4, + }); + } + + for (const notification of input.notificationInbox.filter( + (item) => item.isUnread && item.event.priority === "high" + )) { + actions.push({ + id: `notification-${notification.recipient.id}`, + title: notification.event.title, + detail: notification.event.body ?? "High-priority unread notification.", + href: notification.event.url, + sourceType: "notification", + priority: "medium", + rank: 5, + }); + } + + for (const planItem of currentStagePlanItems.filter(isIncompletePlanItem)) { + actions.push({ + id: `plan-${planItem.id}`, + title: `Finish plan item: ${planItem.title}`, + detail: planItem.blocker?.trim() || planItem.outcome, + href: planHref(input), + sourceType: "plan", + priority: "low", + rank: 6, + }); + } + + return actions + .sort( + (left, right) => left.rank - right.rank || left.id.localeCompare(right.id) + ) + .slice(0, 5) + .map((action) => ({ + id: action.id, + title: action.title, + detail: action.detail, + href: action.href, + sourceType: action.sourceType, + priority: action.priority, + })); +} + +function buildNarrative( + input: BuildProjectReadinessInput, + status: ProjectReadinessStatus +) { + const failingCheckCount = input.githubStatuses.filter( + (githubStatus) => githubStatus.ciStatus === "Failing" + ).length; + const isCurrentStageBlocked = input.currentStage?.status === "Blocked"; + const activeTasks = input.tasks.filter(isActiveTask); + const blockedTaskCount = activeTasks.filter( + (task) => task.status === "Blocked" || hasBlockedReason(task) + ).length; + const urgentTaskCount = activeTasks.filter( + (task) => task.priority === "urgent" + ).length; + const reviewPrCount = input.githubStatuses.filter( + (githubStatus) => + githubStatus.prStatus === "Open PR" || + githubStatus.prStatus === "Review requested" + ).length; + const highPriorityUnreadCount = input.notificationInbox.filter( + (notification) => + notification.isUnread && notification.event.priority === "high" + ).length; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const incompletePlanItemCount = + currentStagePlanItems.filter(isIncompletePlanItem).length; + const hasStagingDeployWithoutProduction = + input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Staging" + ) && + !input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Production" + ); + + if (status === "Blocked") { + const blockers = [ + isCurrentStageBlocked ? "current stage is blocked" : null, + failingCheckCount > 0 + ? pluralize(failingCheckCount, "failing check") + : null, + blockedTaskCount > 0 + ? pluralize(blockedTaskCount, "blocked work item") + : null, + ].filter((blocker): blocker is string => Boolean(blocker)); + + return `Readiness is blocked by ${ + blockers.length > 0 ? blockers.join(", ") : "a readiness blocker" + }.`; + } + + if (status === "Ready with risk") { + const risks = [ + urgentTaskCount > 0 + ? pluralize(urgentTaskCount, "urgent work item") + : null, + reviewPrCount > 0 + ? pluralize(reviewPrCount, "PR awaiting review", "PRs awaiting review") + : null, + highPriorityUnreadCount > 0 + ? pluralize(highPriorityUnreadCount, "high-priority notification") + : null, + hasStagingDeployWithoutProduction + ? "staging deploy without production" + : null, + incompletePlanItemCount > 0 + ? pluralize(incompletePlanItemCount, "incomplete plan item") + : null, + ].filter((risk): risk is string => Boolean(risk)); + + return `Ready with risk: ${ + risks.length > 0 ? risks.join(", ") : "readiness watch item" + } need attention.`; + } + + return "Ready: no blocking work, no urgent risks, and release signals are stable."; +} + +export function buildProjectReadiness( + input: BuildProjectReadinessInput +): ProjectReadinessView { + const hasProductionDeploy = input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Production" + ); + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const isBlocked = + input.currentStage?.status === "Blocked" || + input.tasks + .filter(isActiveTask) + .some((task) => task.status === "Blocked" || hasBlockedReason(task)) || + input.githubStatuses.some( + (githubStatus) => githubStatus.ciStatus === "Failing" + ); + const hasRisk = + input.tasks + .filter(isActiveTask) + .some((task) => task.priority === "urgent") || + input.githubStatuses.some( + (githubStatus) => + githubStatus.prStatus === "Open PR" || + githubStatus.prStatus === "Review requested" + ) || + (input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Staging" + ) && + !hasProductionDeploy) || + input.notificationInbox.some( + (notification) => + notification.isUnread && notification.event.priority === "high" + ) || + currentStagePlanItems.some(isIncompletePlanItem); + const status: ProjectReadinessStatus = isBlocked + ? "Blocked" + : hasRisk + ? "Ready with risk" + : "Ready"; + const tone: ProjectReadinessTone = + status === "Blocked" + ? "danger" + : status === "Ready with risk" + ? "warning" + : "success"; + + return { + status, + tone, + narrative: buildNarrative(input, status), + metrics: buildMetrics(input), + decisionCues: buildDecisionCues(input, status), + actions: buildActions(input), + }; +} diff --git a/apps/web/src/server/projects/search.ts b/apps/web/src/server/projects/search.ts new file mode 100644 index 0000000..ede0a4c --- /dev/null +++ b/apps/web/src/server/projects/search.ts @@ -0,0 +1,320 @@ +import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm"; +import type { SQLWrapper } from "drizzle-orm"; + +import { + comments, + db, + githubPullRequests, + notificationEvents, + notificationRecipients, + planItems, + projectStages, + taskGithubStatus, + tasks, + workItemGithubLinks +} from "@the-platform/db"; + +import { resolveWorkspaceContext } from "../work-management/utils"; +import { WorkspaceError } from "../workspaces/core"; +import type { AppSession } from "../workspaces/types"; +import type { ProjectRepository } from "./types"; + +export type ProjectSearchResultType = "work_item" | "plan" | "comment" | "engineering" | "notification"; + +export interface ProjectSearchResult { + id: string; + type: ProjectSearchResultType; + title: string; + snippet: string; + href: string; + chip: string; + rank: number; +} + +export interface ProjectSearchResponse { + query: string; + results: ProjectSearchResult[]; +} + +interface SearchProjectDependencies { + projectRepository: Pick; +} + +function workItemTitle(item: { identifier: string | null; title: string }) { + return `${item.identifier ?? "Work item"} ${item.title}`; +} + +function escapeLikeQuery(query: string) { + return query.replace(/[\\%_]/g, (character) => `\\${character}`); +} + +function ilikeLiteral(column: SQLWrapper, pattern: string) { + return sql`${column} ilike ${pattern} escape '\\'`; +} + +function projectScopedHref(url: string | null, baseHref: string) { + if (!url) { + return baseHref; + } + + return url === baseHref || url.startsWith(`${baseHref}/`) || url.startsWith(`${baseHref}?`) ? url : baseHref; +} + +function engineeringScore(row: { + prTitle: string | null; + headBranch: string | null; + branchName: string | null; + ciStatus: string | null; +}) { + if (row.ciStatus === "Failing") { + return 0; + } + + if (row.prTitle) { + return 1; + } + + if (row.headBranch || row.branchName) { + return 2; + } + + return 3; +} + +function dedupeEngineeringRows( + rows: TRow[] +) { + const byTaskId = new Map(); + + for (const row of rows) { + const current = byTaskId.get(row.id); + if ( + !current || + engineeringScore(row) < engineeringScore(current) || + (engineeringScore(row) === engineeringScore(current) && + (row.prTitle ?? row.headBranch ?? row.branchName ?? row.title).localeCompare( + current.prTitle ?? current.headBranch ?? current.branchName ?? current.title + ) < 0) + ) { + byTaskId.set(row.id, row); + } + } + + return [...byTaskId.values()]; +} + +export async function searchProjectForUser( + dependencies: SearchProjectDependencies, + session: AppSession, + workspaceSlug: string, + projectKey: string, + query: string +): Promise { + const trimmed = query.trim(); + if (trimmed.length < 2) { + return { query: trimmed, results: [] }; + } + + const { workspace } = await resolveWorkspaceContext( + dependencies.projectRepository, + session, + workspaceSlug, + "viewer" + ); + const project = await dependencies.projectRepository.getProjectByKey(workspace.id, projectKey); + + if (!project) { + throw new WorkspaceError(404, "project not found."); + } + + const pattern = `%${escapeLikeQuery(trimmed)}%`; + const baseHref = `/workspaces/${workspace.slug}/projects/${project.key}`; + + const workItemRows = await db + .select({ + id: tasks.id, + identifier: tasks.identifier, + title: tasks.title, + description: tasks.description, + status: tasks.status, + priority: tasks.priority, + labels: tasks.labels, + blockedReason: tasks.blockedReason + }) + .from(tasks) + .where( + and( + eq(tasks.projectId, project.id), + or( + ilikeLiteral(tasks.identifier, pattern), + ilikeLiteral(tasks.title, pattern), + ilikeLiteral(tasks.description, pattern), + ilikeLiteral(sql`${tasks.status}::text`, pattern), + ilikeLiteral(sql`${tasks.priority}::text`, pattern), + ilikeLiteral(sql`array_to_string(${tasks.labels}, ' ')`, pattern), + ilikeLiteral(tasks.blockedReason, pattern) + ) + ) + ); + + const commentRows = await db + .select({ + id: comments.id, + content: comments.content, + taskId: tasks.id, + identifier: tasks.identifier, + title: tasks.title + }) + .from(comments) + .innerJoin(tasks, eq(comments.workItemId, tasks.id)) + .where(and(eq(tasks.projectId, project.id), isNull(comments.deletedAt), ilikeLiteral(comments.content, pattern))); + + const planRows = await db + .select({ + id: planItems.id, + title: planItems.title, + outcome: planItems.outcome, + status: planItems.status, + blocker: planItems.blocker, + stageTitle: projectStages.title, + stageGoal: projectStages.goal, + gateStatus: projectStages.gateStatus + }) + .from(planItems) + .innerJoin(projectStages, eq(planItems.stageId, projectStages.id)) + .where( + and( + eq(projectStages.projectId, project.id), + or( + ilikeLiteral(planItems.title, pattern), + ilikeLiteral(planItems.outcome, pattern), + ilikeLiteral(planItems.blocker, pattern), + ilikeLiteral(projectStages.title, pattern), + ilikeLiteral(projectStages.goal, pattern), + ilikeLiteral(projectStages.gateStatus, pattern) + ) + ) + ); + + const engineeringRows = await db + .select({ + id: tasks.id, + identifier: tasks.identifier, + title: tasks.title, + prTitle: githubPullRequests.title, + prBody: githubPullRequests.body, + prNumber: githubPullRequests.number, + baseBranch: githubPullRequests.baseBranch, + headBranch: githubPullRequests.headBranch, + branchName: workItemGithubLinks.branchName, + prStatus: taskGithubStatus.prStatus, + ciStatus: taskGithubStatus.ciStatus, + deployStatus: taskGithubStatus.deployStatus + }) + .from(tasks) + .leftJoin(taskGithubStatus, eq(taskGithubStatus.taskId, tasks.id)) + .leftJoin(workItemGithubLinks, eq(workItemGithubLinks.workItemId, tasks.id)) + .leftJoin(githubPullRequests, eq(workItemGithubLinks.pullRequestId, githubPullRequests.id)) + .where( + and( + eq(tasks.projectId, project.id), + or(isNotNull(taskGithubStatus.id), isNotNull(workItemGithubLinks.id), isNotNull(githubPullRequests.id)), + or( + ilikeLiteral(tasks.identifier, pattern), + ilikeLiteral(tasks.title, pattern), + ilikeLiteral(githubPullRequests.title, pattern), + ilikeLiteral(githubPullRequests.body, pattern), + ilikeLiteral(sql`${githubPullRequests.number}::text`, pattern), + ilikeLiteral(githubPullRequests.baseBranch, pattern), + ilikeLiteral(githubPullRequests.headBranch, pattern), + ilikeLiteral(workItemGithubLinks.branchName, pattern), + ilikeLiteral(sql`${taskGithubStatus.prStatus}::text`, pattern), + ilikeLiteral(sql`${taskGithubStatus.ciStatus}::text`, pattern), + ilikeLiteral(sql`${taskGithubStatus.deployStatus}::text`, pattern) + ) + ) + ); + + const notificationRows = await db + .select({ + id: notificationEvents.id, + title: notificationEvents.title, + body: notificationEvents.body, + url: notificationEvents.url, + sourceType: notificationEvents.sourceType, + eventType: notificationEvents.eventType, + priority: notificationEvents.priority + }) + .from(notificationEvents) + .innerJoin(notificationRecipients, eq(notificationRecipients.eventId, notificationEvents.id)) + .where( + and( + eq(notificationEvents.workspaceId, workspace.id), + eq(notificationEvents.projectId, project.id), + eq(notificationRecipients.workspaceId, workspace.id), + eq(notificationRecipients.recipientId, session.userId), + or(ilikeLiteral(notificationEvents.title, pattern), ilikeLiteral(notificationEvents.body, pattern)) + ) + ); + + const results: ProjectSearchResult[] = [ + ...workItemRows.map((item): ProjectSearchResult => { + const selected = item.identifier ?? item.id; + + return { + id: `work-item-${item.id}`, + type: "work_item", + title: workItemTitle(item), + snippet: item.description || item.blockedReason || item.labels?.join(", ") || item.status, + href: `${baseHref}?selected=${selected}`, + chip: item.priority === "urgent" || item.status === "Blocked" ? "Risk" : item.status, + rank: item.identifier?.toLowerCase() === trimmed.toLowerCase() ? 0 : 10 + }; + }), + ...commentRows.map((comment): ProjectSearchResult => ({ + id: `comment-${comment.id}`, + type: "comment", + title: `${comment.identifier ?? "Work item"} comment`, + snippet: comment.content, + href: `${baseHref}?selected=${comment.identifier ?? comment.taskId}`, + chip: "Comment", + rank: 30 + })), + ...planRows.map((plan): ProjectSearchResult => ({ + id: `plan-${plan.id}`, + type: "plan", + title: plan.title, + snippet: plan.outcome || plan.stageGoal || plan.status, + href: `${baseHref}/plan`, + chip: plan.blocker ? "Blocked plan" : plan.gateStatus, + rank: plan.blocker ? 20 : 40 + })), + ...dedupeEngineeringRows(engineeringRows).map((engineering): ProjectSearchResult => { + const branch = engineering.headBranch ?? engineering.branchName ?? "No branch"; + + return { + id: `engineering-${engineering.id}`, + type: "engineering", + title: engineering.prTitle ? `${engineering.identifier ?? "Work item"} ${engineering.prTitle}` : workItemTitle(engineering), + snippet: `${branch} · ${engineering.prStatus ?? "No PR"} · ${engineering.ciStatus ?? "Unknown"}`, + href: `${baseHref}/engineering`, + chip: engineering.ciStatus === "Failing" ? "CI failing" : (engineering.deployStatus ?? engineering.prStatus ?? "Engineering"), + rank: engineering.ciStatus === "Failing" ? 20 : 50 + }; + }), + ...notificationRows.map((notification): ProjectSearchResult => ({ + id: `notification-${notification.id}`, + type: "notification", + title: notification.title, + snippet: notification.body ?? notification.eventType, + href: projectScopedHref(notification.url, baseHref), + chip: notification.priority === "high" ? "High notification" : notification.sourceType, + rank: notification.priority === "high" ? 20 : 60 + })) + ]; + + return { + query: trimmed, + results: results.sort((left, right) => left.rank - right.rank || left.title.localeCompare(right.title)).slice(0, 20) + }; +} diff --git a/apps/web/src/server/projects/workspace.ts b/apps/web/src/server/projects/workspace.ts index faf171c..699de01 100644 --- a/apps/web/src/server/projects/workspace.ts +++ b/apps/web/src/server/projects/workspace.ts @@ -1,4 +1,5 @@ import type { + NotificationInboxItem, PlanItemRecord, ProjectRecord, ProjectStageRecord, @@ -14,6 +15,7 @@ import { resolveWorkspaceContext } from "../work-management/utils"; import { WorkspaceError } from "../workspaces/core"; import type { AppSession } from "../workspaces/types"; +import { buildProjectReadiness, type ProjectReadinessView } from "./readiness"; import type { ProjectRepository, ProjectWorkItemEngineeringRecord } from "./types"; import type { WorkItemRepository } from "../work-items/types"; @@ -32,6 +34,10 @@ interface ProjectWorkspaceDependencies { workItemRepository: Pick; } +interface ProjectWorkspaceOptions { + notificationInbox?: NotificationInboxItem[]; +} + export interface ProjectWorkspaceCurrentStage { label: string; title: string; @@ -84,6 +90,7 @@ export interface ProjectWorkspaceView { currentStage: string; health: string[]; milestones: ProjectWorkspaceOverviewMilestone[]; + readiness: ProjectReadinessView; }; engineering: ProjectWorkspaceEngineeringView; } @@ -342,7 +349,8 @@ export async function getProjectWorkspaceForUser( dependencies: ProjectWorkspaceDependencies, session: AppSession, workspaceSlug: string, - projectKey: string + projectKey: string, + options: ProjectWorkspaceOptions = {} ): Promise { const { project } = await resolveProjectContext(dependencies.projectRepository, session, workspaceSlug, projectKey); @@ -412,6 +420,9 @@ export async function getProjectWorkspaceForUser( engineeringRecords, githubConnection ); + const projectNotificationInbox = (options.notificationInbox ?? []).filter( + (notification) => notification.event.projectId === project.id + ); return { project: { @@ -437,7 +448,17 @@ export async function getProjectWorkspaceForUser( overview: { currentStage: currentStageCard.title, health: buildHealthSummary(sortedTasks, githubStatuses), - milestones: buildOverviewMilestones(sortedStages, currentStageIndex) + milestones: buildOverviewMilestones(sortedStages, currentStageIndex), + readiness: buildProjectReadiness({ + currentStage, + stages: sortedStages, + planItems, + tasks: sortedTasks, + githubStatuses, + engineeringItems, + notificationInbox: projectNotificationInbox, + baseProjectHref: `/workspaces/${workspaceSlug}/projects/${projectKey}` + }) }, engineering: { repository: githubConnection?.repository.fullName ?? "No repository connected", diff --git a/docs/product/decision-brief.md b/docs/product/decision-brief.md index 365fcb9..421887b 100644 --- a/docs/product/decision-brief.md +++ b/docs/product/decision-brief.md @@ -1,87 +1,80 @@ -# Decision Brief: Phase 7 - Notifications and Collaboration Foundation +# Decision Brief: Phase 8 - Readiness Command Center ## Context -Phase 6 made GitHub engineering status durable and visible in the project workspace. Phase 7 adds the attention layer: users should know when comments, mentions, assignments, workflow changes, and GitHub engineering events need their response. - The product direction remains: Jira for execution structure, GitHub for engineering truth, and Notion-like lightness for collaboration. -## Key Decisions - -### 1. Durable notification events plus recipient rows +By the end of Phase 7, the project workspace has execution views, implementation plans, comments, GitHub engineering state, and in-app notifications. Phase 8 turns those surfaces into a lead-first decision layer so project owners and teammates can understand readiness without manually reconciling every tab. -Notification events describe the source change. Recipient rows describe each user's delivery/read state. - -**Why:** A single event can fan out to multiple users, and each user needs independent read state, preferences, and repair semantics. +## Key Decisions -### 2. Domain services emit notifications after successful mutations +### 1. Overview becomes the Readiness Command Center -Comment, work item, and GitHub services call the notification service after their primary write succeeds. +Overview now leads with readiness status, narrative, signal metrics, decision cues, team actions, scoped search, and milestone context. -**Why:** Notification creation should be layered onto existing behavior without changing response shapes or ownership of domain writes. +**Why:** Project owners need a fast decision surface before drilling into board, plan, engineering, docs, or notifications. -### 3. Local in-app inbox before external delivery +### 2. Readiness stays deterministic and local-first -The project shell contains the first notification surface: bell, unread count, inbox panel, mark-read controls, and preferences. +Readiness is derived by a server-side projection from project stage, plan items, work items, GitHub-derived engineering state, and notification inbox rows. -**Why:** The product needs an internal attention loop before adding email, Slack, push, or digest channels. +**Why:** Phase 8 needs explainable team alignment, not opaque recommendations or external analytics infrastructure. -### 4. Workspace-level coarse preferences +### 3. Team actions are source-linked -Users can toggle comments, mentions, assignments, GitHub engineering changes, and state changes. +Each action points to the project surface that owns the underlying signal: plan, work item, engineering, or notification context. -**Why:** This gives immediate noise control without prematurely designing per-project or per-issue preference complexity. +**Why:** A readiness surface is only useful if teams can immediately resolve the work that caused the signal. -### 5. Self-notification and preference filtering in one service +### 4. Search is scoped to readiness work -The notification service suppresses actor self-notifications, filters invalid workspace members, applies preferences, and inserts recipient rows idempotently. +Project search covers readiness-relevant work items, plan items, comments, GitHub signals, and notifications for the current project and current workspace member. -**Why:** Centralizing these rules avoids drift between comment, work item, GitHub, API, and worker paths. +**Why:** This improves discovery without committing to a global command palette or full-text search platform. -### 6. Worker-backed notification repair +### 5. Polish is limited to readiness-critical states -The worker has a `repair-notifications` mode for missing recipients and an explicitly enabled recent-activity backfill path. +Phase 8 hardens empty action states, search short-query/no-result/error states, and the no-GitHub-repository engineering setup state. -**Why:** Webhooks, request handlers, and deployments can fail. Repair must be safe to re-run and auditable from worker output. +**Why:** These are the states that directly affect whether the readiness surface can be trusted during real project use. ## Non-Goals -- Email, Slack, push, SMS, or mobile delivery -- Real-time websocket transport -- Cross-workspace global notification center -- Per-project or per-issue preferences -- Manual ambiguous GitHub link management -- Reporting/search dashboards +- Global portfolio dashboard +- Analytics warehouse +- AI-generated recommendations +- Full-text infrastructure +- Global command palette +- External reporting, BI, email, Slack, push, or websocket delivery ## RBAC Rules -| Role | Read Own Inbox | Mark Own Notifications Read | Update Own Preferences | Receive Notifications | Repair Recipients | -|------|----------------|-----------------------------|------------------------|-----------------------|-------------------| -| Viewer | Yes | Yes | Yes | Yes | No | -| Member | Yes | Yes | Yes | Yes | No | -| Admin | Yes | Yes | Yes | Yes | No | -| Owner | Yes | Yes | Yes | Yes | No | -| Worker/System | No UI access | No | No | Derived writes only | Yes | +| Role | Read Project Readiness | Use Project Search | Open Source-Linked Actions | Mutate Readiness Rules | +|------|------------------------|--------------------|----------------------------|------------------------| +| Viewer | Yes | Yes | Yes, for allowed surfaces | No | +| Member | Yes | Yes | Yes, for allowed surfaces | No | +| Admin | Yes | Yes | Yes, for allowed surfaces | No | +| Owner | Yes | Yes | Yes, for allowed surfaces | No | +| Worker/System | Derived writes only | No UI access | No UI access | No | ## Success Criteria -1. Notification source, event, priority, recipient reason, record, and inbox contracts are shared. -2. Durable notification tables exist with event uniqueness, recipient uniqueness, preferences, and inbox indexes. -3. Comment creation notifies valid mentions and relevant participants without notifying the actor. -4. Work item assignment, state change, and urgent-priority updates notify the right users. -5. GitHub PR/check/deploy changes and webhook failures create relevant notifications and remain replay-safe. -6. Notification API routes enforce workspace membership and current-user recipient scoping. -7. The project shell renders a compact unread badge and inbox panel. -8. Users can mark one notification read, mark all read, and update coarse preferences. -9. Worker repair can rebuild missing notification recipients and explicitly backfill recent activity safely. -10. Full repo lint, typecheck, test, and build pass. +1. Overview identifies Phase 8 as the Readiness Command Center. +2. Readiness status is deterministic and can represent `Ready`, `Ready with risk`, and `Blocked`. +3. Readiness metrics explain plan, issue, GitHub, and notification signals. +4. Decision cues summarize ship gate and primary blocker state. +5. Team actions are deterministic, source-linked, and empty-state aware. +6. Project-scoped readiness search covers work items, plan items, comments, GitHub signals, and notifications. +7. Search enforces workspace/project membership and handles short-query, empty, stale, malformed, and failed responses. +8. Engineering shows a clear setup state when no GitHub repository is connected. +9. Phase 8 product docs keep non-goals explicit. +10. Full repo lint, typecheck, test, build, targeted tests, and browser smoke validation pass. ## Next Phase -Phase 8 should focus on product readiness and discovery surfaces: +After Phase 8 closes, the next work should be selected from validated product gaps rather than extending this phase by default. Strong candidates are: -- reporting and portfolio-level summaries -- search across work items, docs, comments, and engineering signals -- manual polish for GitHub linking and project operations -- UX hardening for empty states, loading states, and navigation edges -- performance and build/deployment readiness +- merge and PR hardening +- performance and seed-data reliability +- deeper GitHub setup/linking workflows +- portfolio-level views, only after the single-project readiness loop proves useful diff --git a/docs/product/idea-brief.md b/docs/product/idea-brief.md index d03f5a8..bc23b93 100644 --- a/docs/product/idea-brief.md +++ b/docs/product/idea-brief.md @@ -1,82 +1,79 @@ -# Idea Brief: Phase 7 - Notifications and Collaboration Foundation +# Idea Brief: Phase 8 - Readiness Command Center ## Problem -The project workspace now has Jira-like execution, plan alignment, comments, and live GitHub engineering status. The remaining collaboration gap is attention: teammates still need to scan boards, comments, assignments, and engineering views to know what changed for them. +The project workspace now has Jira-like execution, plan alignment, lightweight comments, live GitHub engineering status, and in-app notifications. The remaining product gap is decision clarity: teams can see many surfaces, but they still need to interpret whether the current project is ready to move forward and what the next team action should be. -Without a durable notification layer, the product can show project truth but cannot reliably direct each teammate to the work that needs their response. +Without a lead-first readiness surface, project owners must synthesize plan progress, blocked work, engineering signals, and notifications manually. ## Goals -1. **Durable notification model** - Store notification source events separately from per-user recipient rows. +1. **Readiness Command Center** - Make Overview the primary project decision surface. -2. **Source-driven notification creation** - Emit notifications from comments, mentions, assignments, work item state or priority changes, GitHub PR/check/deploy changes, and webhook failures. +2. **Lead-first Overview reporting** - Show readiness status, narrative, signal metrics, decision cues, and milestone context before detailed execution views. -3. **In-app inbox** - Add a compact notification bell, unread count, inbox panel, mark-read, and mark-all-read controls to the project workspace shell. +3. **Deterministic team action list** - Convert local project, plan, work item, GitHub, and notification signals into explainable source-linked actions. -4. **Coarse preferences** - Let users tune comments, mentions, assignments, GitHub, and state-change notification categories per workspace. +4. **Scoped readiness search** - Add project-scoped search across readiness-relevant work items, plan items, comments, engineering signals, and notifications. -5. **Worker repair path** - Add a safe worker mode that rebuilds missing recipient rows and can explicitly backfill recent missed activity. - -6. **Local-first reads** - Keep the UI reading local Postgres notification state instead of relying on real-time transport in this phase. +5. **Readiness-critical polish** - Cover empty actions, search short-query/no-result/error states, and no-GitHub-repository engineering setup guidance. ## Constraints -- Users can only read or mutate notification rows for workspaces where they are members. -- Users can only mark their own recipient rows read. -- Notification creation must not change existing comment, work item, or GitHub response shapes. -- Self-notifications are suppressed. -- Recipient creation must remain idempotent across webhook replay and worker repair. -- Phase 7 does not add email, Slack, push, or global cross-workspace notification delivery. +- Readiness is derived from local project state and deterministic rules. +- Readiness search stays project-scoped and workspace-member scoped. +- Overview must remain lightweight enough for team alignment, not become a portfolio analytics product. +- Existing board, list, plan, engineering, docs, and notification surfaces remain the source-linked detail views. -## Decisions +## Non-Goals -1. **Event plus recipient tables** - Notification events capture what happened; recipient rows capture who should see it and read state. +- Global portfolio dashboard +- Analytics warehouse +- AI-generated recommendations +- Full-text infrastructure +- Global command palette +- External reporting or BI integrations -2. **Domain services emit events** - Comments, work items, and GitHub services call the notification service after successful mutations. They do not write notification tables directly. +## Decisions -3. **In-app first** - Phase 7 ships a project-workspace inbox before external delivery channels. +1. **Overview leads with readiness** - The project landing surface answers "can we move forward?" before showing lower-level detail. -4. **Coarse preference model** - Workspace-level category switches are enough for this phase. Per-project and per-issue preferences remain out of scope. +2. **Server-side readiness projection** - `apps/web/src/server/projects/readiness.ts` owns deterministic readiness derivation from local state. -5. **Repair worker** - The worker owns deterministic repair for missing recipients and optional recent activity backfill. +3. **Source-linked actions** - Actions link back to plan, work item, engineering, or notification surfaces so teams can resolve the signal directly. -## Non-Goals +4. **Simple Postgres-backed search** - Phase 8 uses scoped matching without introducing global search infrastructure. -- Email, Slack, push, SMS, or mobile notifications -- Real-time websocket delivery -- Global cross-workspace notification center -- Per-project or per-issue notification preferences -- Manual ambiguous GitHub link management -- Portfolio reporting or executive dashboards +5. **Polish only where readiness depends on it** - Empty, error, and setup states are tightened where they affect readiness comprehension. ## Success Criteria -- Shared notification contracts exist for source type, event type, priority, recipient reason, events, recipients, preferences, and inbox items. -- Postgres has durable notification event, recipient, and preference tables with uniqueness and inbox lookup indexes. -- Comments notify valid mentions, assignees, and prior participants without notifying the actor. -- Work item assignment, state change, and urgent-priority changes notify the right users. -- GitHub PR, check, deploy, and webhook failure events create relevant notifications without duplicate recipients on replay. -- API routes list the current user's inbox, mark one notification read, mark all read, and update preferences with workspace scoping. -- The project shell shows an unread badge and inbox panel with source, title, context, work item identifier, timestamp, read state, link, and preferences. -- Worker notification repair can rebuild missing recipients safely and idempotently. +- Overview renders a Readiness Command Center with status, narrative, metrics, decision cues, and milestone context. +- Readiness status can be `Ready`, `Ready with risk`, or `Blocked`, derived from deterministic local rules. +- The team action list is source-linked and explains blockers, review needs, urgent work, unread high-priority notifications, and plan gaps. +- Project-scoped readiness search returns relevant work item, plan, comment, GitHub, and notification results with RBAC enforced. +- Search has distinct short-query guidance, no-result copy, and failure copy. +- Engineering shows a clear setup state when no GitHub repository is connected. +- Phase 8 final verification passes: targeted tests, lint, typecheck, full tests, build, and browser smoke check. ## Technical Direction -- Shared contracts: `packages/shared/src/constants.ts`, `packages/shared/src/types.ts` -- Durable schema: `notification_events`, `notification_recipients`, `notification_preferences` -- Web notification service: `apps/web/src/server/notifications/` -- API routes: `apps/web/src/app/api/workspaces/[slug]/notifications/*` -- UI components: `apps/web/src/components/notification-bell.tsx`, `notification-inbox.tsx`, `notification-preferences.tsx` -- Worker repair: `apps/worker/src/notification-repair.ts` +- Readiness projection: `apps/web/src/server/projects/readiness.ts` +- Workspace integration: `apps/web/src/server/projects/workspace.ts` +- Search service: `apps/web/src/server/projects/search.ts` +- Search API: `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts` +- Overview UI: `apps/web/src/features/overview/` +- Engineering polish: `apps/web/src/features/engineering/engineering-view.tsx` +- Coverage: `tests/phase8-*.test.mjs`, Overview UI tests, Engineering UI tests ## Phase Position -Phase 7 of 8. Builds on: +Phase 8 of 8. Builds on: - Phase 2: Auth and workspace membership - Phase 3: Projects and work items - Phase 4: Board/list execution views - Phase 5: Detail, comments, plan, overview, docs, and project workspace shell - Phase 6: Live GitHub engineering integration +- Phase 7: In-app notifications and collaboration foundation -Next: Phase 8 should shift to reporting, search, and product polish now that execution, engineering status, and in-app notification foundations are in place. +Next: close Phase 8 with full verification, browser smoke validation, review, and PR/merge readiness. diff --git a/docs/product/prd.md b/docs/product/prd.md index b50f384..6fdbcad 100644 --- a/docs/product/prd.md +++ b/docs/product/prd.md @@ -1,158 +1,121 @@ -# PRD: Phase 7 - Notifications and Collaboration Foundation +# PRD: Phase 8 - Readiness Command Center ## Overview -Add a durable in-app notification foundation for comments, mentions, assignments, work item changes, and GitHub engineering updates. Phase 7 makes collaboration actionable without adding external delivery channels or real-time transport. +Add a lead-first Readiness Command Center to the project workspace Overview. Phase 8 synthesizes plan progress, work item state, GitHub engineering signals, and notification attention into deterministic readiness reporting, source-linked team actions, scoped readiness search, and readiness-critical polish. ## Scope Sections -### 7.1 Shared Notification Contracts +### 8.1 Readiness Projection -Notification concepts must be shared across web, DB, worker, and tests. +Readiness must be derived from local project state using deterministic server-side rules. **Requirements:** -- Define source types for comment, work item, GitHub, and system events. -- Define event types for comments, mentions, assignments, state changes, priority raises, GitHub PR/check/deploy changes, and webhook failures. -- Define priorities and recipient reasons. -- Export shared record types for events, recipients, preferences, and inbox items. +- Add a pure readiness projection for project stage, plan items, work items, GitHub signals, engineering state, and inbox rows. +- Return readiness status as `Ready`, `Ready with risk`, or `Blocked`. +- Return a plain-language narrative explaining the status. +- Return metrics for plan, issues, GitHub, and notifications. +- Return decision cues including ship gate and primary blocker state. +- Return source-linked actions for blockers, review needs, urgent work, high-priority notifications, and incomplete current-stage plan work. -### 7.2 Durable Notification Schema +### 8.2 Workspace Integration -Persist notifications locally before rendering or repairing them. +The project workspace loader must include readiness data in the Overview view model. **Requirements:** -- Add `notification_events` with workspace, project, work item, source identity, event type, actor, priority, title, body, URL, metadata, and created timestamp. -- Add `notification_recipients` with event id, workspace id, recipient id, reason, read timestamp, dismiss timestamp, and created timestamp. -- Add `notification_preferences` keyed by workspace id and user id. -- Enforce event uniqueness by workspace, source type, source id, and event type. -- Enforce recipient uniqueness by event, recipient, and reason. -- Add indexes for current-user inbox reads. +- Add readiness data to `ProjectWorkspaceView["overview"]`. +- Use project-scoped notification inbox rows for readiness signals. +- Preserve existing board, list, plan, engineering, docs, and notification shell behavior. +- Keep readiness links stable for project-scoped routes. -### 7.3 Notification Service and API +### 8.3 Overview Readiness UI -Centralize notification rules and expose current-user operations. +Overview should become the primary Phase 8 decision surface. **Requirements:** -- Create notifications from source events with recipient resolution and preference filtering. -- Suppress actor self-notifications. -- Keep recipient insertion idempotent. -- Enforce workspace membership for inbox reads and preference reads/writes. -- Add API routes to list inbox notifications, mark one notification read, mark all read, read preferences, and update preferences. -- Return 404 when a user tries to mark another user's recipient row. +- Render `Readiness command center`. +- Show readiness status, tone, and narrative prominently. +- Show signal cards for plan, issues, GitHub, and notifications. +- Show decision cues. +- Show deterministic team actions with source labels and links. +- Keep milestone roadmap visible as supporting context. +- Avoid duplicate page-level headings inside the project shell. -### 7.4 Comment and Mention Notifications +### 8.4 Project-Scoped Readiness Search -Comments should direct attention to valid teammates. +Project search should support readiness work without introducing global search infrastructure. **Requirements:** -- Parse deterministic `@userId` mentions from comment content. -- Notify valid mentioned workspace members with reason `mention`. -- Notify assignees and prior participants with reason `participant` when not already mentioned. -- Ignore unknown mentions and actor self-notifications. -- Do not emit notifications for comment edits or deletes in Phase 7. +- Search work items, current-stage plan items, comments, GitHub engineering signals, and notification signals. +- Enforce workspace membership and project scoping. +- Ignore queries shorter than two characters at the service/API level. +- Escape SQL wildcard characters for literal matching. +- Return stable result types, titles, snippets, hrefs, chips, and ranks. +- Deduplicate engineering results deterministically per task. -### 7.5 Work Item Change Notifications +### 8.5 Search API -Important execution changes should notify affected users. +Expose readiness search through a project-scoped API route. **Requirements:** -- Notify newly assigned users on assignment changes. -- Notify assignees and participants on workflow state changes. -- Notify assignees and participants when priority is raised to urgent. -- Resolve participants from assignee, creator activity, and prior commenters. -- Preserve existing activity log behavior. +- Add `GET /api/workspaces/[slug]/projects/[key]/search?q=...`. +- Return 401 when no session exists. +- Return 403 for non-members. +- Return 404 when the project is missing or inaccessible. +- Return an empty result set for short queries. +- Return malformed-body and failed-request errors safely to the UI. -### 7.6 GitHub Engineering Notifications +### 8.6 Scoped Search UI -Engineering changes should reach users attached to linked work items. +Overview should provide a lightweight readiness search box. **Requirements:** -- Notify linked item followers when PRs open, request review, merge, or close. -- Notify linked item followers when check rollups fail or recover. -- Notify linked item followers when deployments reach staging or production. -- Notify project owners/admins when a connected repository webhook delivery fails processing. -- Rely on notification event uniqueness to keep webhook replay duplicate-safe. +- Add an accessible `Readiness search` searchbox. +- Fetch from the project-scoped search API. +- Guard stale responses from replacing newer results. +- Clear stale results when a new valid query starts. +- Show short-query guidance: `Search across blockers, PRs, comments, plan items, and notifications.` +- Show no-result copy: `No readiness signals found for "{query}".` +- Show failure copy: `Search failed. Try again from the project overview.` -### 7.7 In-App Notification UI +### 8.7 Readiness-Critical Polish -The project shell should expose a lightweight inbox. +Polish is limited to states that affect readiness comprehension. **Requirements:** -- Extend the project page loader with recent inbox rows, unread count, and current preferences. -- Add a compact notification bell to the project shell header. -- Show unread count as a badge. -- Open an inbox panel with recent notifications. -- Render source label, title, context/body, work item identifier, timestamp, unread state, and link. -- Add mark-read and mark-all-read controls using the notification API. -- Add preference checkboxes for comments, mentions, assignments, GitHub, and state changes. +- Show explicit empty action copy when no readiness actions exist. +- Show a no-GitHub-repository engineering setup state: `Connect GitHub to populate engineering readiness signals.` +- Keep linked PR, failing checks, deployment, and issue summary sections intact. +- Keep search short-query, pending, no-result, and error states mutually clear. -### 7.8 Worker Notification Repair +### 8.8 Verification -The worker should repair notification gaps safely. +Automated and browser coverage must prove readiness behavior and phase stability. **Requirements:** -- Add a `repair-notifications` worker mode. -- Find notification events missing recipient rows. -- Rebuild recipients using the same membership, preference, self-notification, and participant semantics as the web notification service. -- Add an explicitly enabled recent-activity backfill path. -- Keep repair idempotent through event and recipient uniqueness. -- Emit summary logs with repaired events and inserted recipient counts. - -### 7.9 Verification +- Readiness projection tests pass. +- Project search service tests pass. +- Search API tests pass. +- Overview and Engineering UI tests pass. +- Full repo verification passes: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. +- Browser smoke check verifies the Overview readiness surface, action links, search states, and engineering setup state. -Automated coverage must prove notification contracts, domain behavior, UI behavior, and worker repair. +## Data Model -**Requirements:** -- Shared contract tests pass. -- DB schema tests pass. -- Notification service tests cover idempotency, preference filtering, self-notification suppression, and cross-workspace isolation. -- Comment, work item, GitHub, and API integration tests pass. -- UI tests cover unread badge, inbox rows, mark-read, mark-all-read, and preference toggles. -- Worker tests cover missing recipients, preference filtering, duplicate-safe re-run, and recent activity backfill. -- Full repo lint, typecheck, test, and build pass. +Phase 8 does not add durable readiness tables. Readiness is a server-side projection over existing project, stage, plan, work item, GitHub, engineering, comment, and notification data. -## Data Model +## Non-Goals -``` -notification_events: - id: uuid PK - workspace_id: uuid FK - project_id: uuid FK nullable - work_item_id: uuid FK nullable - source_type: enum - source_id: text - event_type: enum - actor_id: text nullable - priority: enum - title: text - body: text nullable - url: text - metadata: jsonb nullable - created_at: timestamp - -notification_recipients: - id: uuid PK - event_id: uuid FK - workspace_id: uuid FK - recipient_id: text - reason: enum - read_at: timestamp nullable - dismissed_at: timestamp nullable - created_at: timestamp - -notification_preferences: - workspace_id: uuid FK - user_id: text - comments_enabled: boolean - mentions_enabled: boolean - assignments_enabled: boolean - github_enabled: boolean - state_changes_enabled: boolean -``` +- Global portfolio dashboard +- Analytics warehouse +- AI-generated recommendations +- Full-text infrastructure +- Global command palette +- External reporting or BI integrations ## Exit Criteria -- All 10 success criteria from the decision brief are met. -- Phase 7 tests pass without external delivery services. -- Full repo verification passes: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. -- Product docs identify Phase 8 as reporting, search, and polish. +- All success criteria from the Phase 8 decision brief are met. +- Product docs identify Phase 8 as the Readiness Command Center with lead-first Overview reporting, deterministic team actions, scoped readiness search, and readiness-critical polish. +- Phase 8 non-goals remain explicit. +- Targeted tests, full repo verification, and browser smoke check pass. diff --git a/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md b/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md new file mode 100644 index 0000000..f622c82 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md @@ -0,0 +1,1448 @@ +# Phase 8 Readiness Command Center Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Overview into a readiness command center with deterministic readiness reporting, source-linked team actions, scoped readiness search, and readiness-critical polish. + +**Architecture:** Keep Phase 8 local-first and rule-based. Add a pure server-side readiness projection, wire it into the existing project workspace view model, add a project-scoped Postgres search service plus API route, and render the new Overview as a lead-first readiness snapshot with team actions below. + +**Tech Stack:** Next.js App Router, React client components where interactivity is required, Drizzle ORM, Postgres, existing project workspace server loaders, Node integration tests, Vitest UI tests, shared TypeScript contracts. + +--- + +## File Structure + +- `apps/web/src/server/projects/readiness.ts` owns pure readiness derivation from project, stage, plan, work item, GitHub, engineering, and inbox inputs. +- `apps/web/src/server/projects/search.ts` owns project-scoped readiness search and RBAC checks. +- `apps/web/src/server/projects/workspace.ts` continues to assemble the project workspace view and now includes readiness in `overview`. +- `apps/web/src/features/overview/overview-view.tsx` becomes the high-level page composition. +- `apps/web/src/features/overview/readiness-summary.tsx` renders the executive snapshot. +- `apps/web/src/features/overview/readiness-signal-card.tsx` renders the signal cards. +- `apps/web/src/features/overview/readiness-action-list.tsx` renders deterministic source-linked team actions. +- `apps/web/src/features/overview/readiness-search.tsx` owns the client-side scoped search UI. +- `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts` exposes project-scoped search. +- `tests/phase8-readiness-projection.test.mjs` covers pure readiness rules and workspace integration. +- `tests/phase8-project-search.test.mjs` covers search ranking and workspace isolation. +- `tests/phase8-search-api.test.mjs` covers auth, RBAC, query validation, and response shape. +- `apps/web/src/features/overview/__tests__/overview-view.test.tsx` covers the new Overview UI. + +--- + +### Task 1: Add Pure Readiness Projection + +**Files:** +- Create: `apps/web/src/server/projects/readiness.ts` +- Create: `tests/phase8-readiness-projection.test.mjs` + +- [x] **Step 1: Write failing tests for readiness status rules** + +Add `tests/phase8-readiness-projection.test.mjs` with three pure projection tests: + +```js +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectReadiness } from "../apps/web/src/server/projects/readiness.ts"; + +const now = new Date("2026-04-26T12:00:00.000Z").toISOString(); + +function task(overrides = {}) { + return { + id: overrides.id ?? "task-1", + projectId: "project-1", + workspaceId: "workspace-1", + identifier: overrides.identifier ?? "OPS-1", + title: overrides.title ?? "Ship the readiness view", + description: overrides.description ?? "", + status: overrides.status ?? "Doing", + type: "task", + parentId: null, + assigneeId: overrides.assigneeId ?? "user-2", + priority: overrides.priority ?? "medium", + labels: overrides.labels ?? null, + workflowStateId: null, + stageId: overrides.stageId ?? "stage-current", + planItemId: overrides.planItemId ?? "plan-current", + position: overrides.position ?? 0, + blockedReason: overrides.blockedReason ?? null, + dueDate: null, + completedAt: null, + createdAt: now, + updatedAt: now + }; +} + +function stage(overrides = {}) { + return { + id: overrides.id ?? "stage-current", + projectId: "project-1", + slug: "current", + title: overrides.title ?? "Phase 8: Readiness", + goal: "Make project readiness clear.", + status: overrides.status ?? "In Progress", + gateStatus: overrides.gateStatus ?? "In review", + sortOrder: overrides.sortOrder ?? 0 + }; +} + +function planItem(overrides = {}) { + return { + id: overrides.id ?? "plan-current", + stageId: overrides.stageId ?? "stage-current", + title: overrides.title ?? "Finish readiness projection", + outcome: "Overview can explain readiness.", + status: overrides.status ?? "Todo", + blocker: overrides.blocker ?? null, + sortOrder: overrides.sortOrder ?? 0 + }; +} + +function githubStatus(overrides = {}) { + return { + id: overrides.id ?? "github-1", + taskId: overrides.taskId ?? "task-1", + prStatus: overrides.prStatus ?? "No PR", + ciStatus: overrides.ciStatus ?? "Unknown", + deployStatus: overrides.deployStatus ?? "Not deployed" + }; +} + +function inbox(overrides = {}) { + return { + event: { + id: "event-1", + workspaceId: "workspace-1", + projectId: "project-1", + workItemId: "task-1", + sourceType: "work_item", + sourceId: "task-1", + eventType: "priority_raised", + actorId: "user-2", + priority: overrides.priority ?? "high", + title: "OPS-1 needs attention", + body: "Priority was raised.", + url: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + metadata: null, + createdAt: now + }, + recipient: { + id: "recipient-1", + eventId: "event-1", + workspaceId: "workspace-1", + recipientId: "user-1", + reason: "participant", + readAt: overrides.readAt ?? null, + dismissedAt: null, + createdAt: now + }, + workItemIdentifier: "OPS-1", + projectKey: "OPS", + workspaceSlug: "platform-ops", + isUnread: overrides.readAt === undefined || overrides.readAt === null + }; +} + +test("readiness projection marks blocked projects and explains the primary blockers", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem()], + tasks: [ + task({ + identifier: "OPS-12", + title: "Fix release pipeline", + status: "Blocked", + blockedReason: "CI is failing" + }) + ], + githubStatuses: [githubStatus({ taskId: "task-1", ciStatus: "Failing", prStatus: "Open PR" })], + engineeringItems: [], + notificationInbox: [] + }); + + assert.equal(readiness.status, "Blocked"); + assert.match(readiness.narrative, /1 failing check/); + assert.match(readiness.narrative, /1 blocked work item/); + assert.equal(readiness.decisionCues[0]?.label, "Ship gate"); + assert.equal(readiness.actions[0]?.sourceType, "github"); + assert.equal(readiness.actions[1]?.sourceType, "work_item"); +}); + +test("readiness projection marks projects ready with risk when work remains but blockers are absent", () => { + const readiness = buildProjectReadiness({ + currentStage: stage(), + stages: [stage()], + planItems: [planItem({ status: "In Review" })], + tasks: [task({ identifier: "OPS-7", priority: "urgent" })], + githubStatuses: [githubStatus({ prStatus: "Review requested", ciStatus: "Passing", deployStatus: "Staging" })], + engineeringItems: [], + notificationInbox: [inbox()] + }); + + assert.equal(readiness.status, "Ready with risk"); + assert.match(readiness.narrative, /urgent/); + assert.ok(readiness.metrics.some((metric) => metric.label === "Notifications" && metric.value === "1 unread")); + assert.ok(readiness.actions.some((action) => action.sourceType === "notification")); +}); + +test("readiness projection marks stable projects ready", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done", priority: "medium" })], + githubStatuses: [githubStatus({ prStatus: "Merged", ciStatus: "Passing", deployStatus: "Production" })], + engineeringItems: [], + notificationInbox: [] + }); + + assert.equal(readiness.status, "Ready"); + assert.match(readiness.narrative, /no blocking work/); + assert.equal(readiness.actions.length, 0); +}); +``` + +- [x] **Step 2: Run the failing test** + +Run: + +```bash +node --import tsx --test tests/phase8-readiness-projection.test.mjs +``` + +Expected: FAIL because `apps/web/src/server/projects/readiness.ts` does not exist. + +- [x] **Step 3: Implement readiness types and projection** + +Create `apps/web/src/server/projects/readiness.ts` with these exported types and functions: + +```ts +import type { + NotificationInboxItem, + PlanItemRecord, + ProjectStageRecord, + TaskGithubStatusRecord, + WorkItemRecord +} from "@the-platform/shared"; + +import type { ProjectWorkspaceEngineeringItemView } from "../../features/workspace/project-workspace-view"; + +export type ProjectReadinessStatus = "Ready" | "Ready with risk" | "Blocked"; +export type ProjectReadinessTone = "success" | "warning" | "danger" | "neutral"; +export type ProjectReadinessActionSource = "work_item" | "plan" | "github" | "notification"; + +export interface ProjectReadinessMetric { + label: string; + value: string; + detail: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessDecisionCue { + label: string; + value: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessAction { + id: string; + title: string; + detail: string; + href: string; + sourceType: ProjectReadinessActionSource; + priority: "high" | "medium" | "low"; +} + +export interface ProjectReadinessView { + status: ProjectReadinessStatus; + tone: ProjectReadinessTone; + narrative: string; + metrics: ProjectReadinessMetric[]; + decisionCues: ProjectReadinessDecisionCue[]; + actions: ProjectReadinessAction[]; +} + +interface BuildProjectReadinessInput { + currentStage: ProjectStageRecord | null; + stages: ProjectStageRecord[]; + planItems: PlanItemRecord[]; + tasks: WorkItemRecord[]; + githubStatuses: TaskGithubStatusRecord[]; + engineeringItems: ProjectWorkspaceEngineeringItemView[]; + notificationInbox: NotificationInboxItem[]; +} +``` + +Implement `buildProjectReadiness(input: BuildProjectReadinessInput): ProjectReadinessView` with these deterministic rules: + +- `Blocked` if the current stage is `Blocked`, any task has `status === "Blocked"` or a non-empty `blockedReason`, or any GitHub status has `ciStatus === "Failing"`. +- `Ready with risk` if not blocked and any task has `priority === "urgent"`, any GitHub status has `prStatus === "Open PR"` or `prStatus === "Review requested"`, any status has `deployStatus === "Staging"` without a production deploy in the project, any unread notification has event priority `high`, or the current stage has incomplete plan items. +- `Ready` if no blocked rule and no risk rule applies. +- Sort actions by priority order: failing checks, blocked work items, urgent work, review PRs, unread high-priority notifications, incomplete current-stage plan items. +- Cap `actions` at five entries. + +- [x] **Step 4: Run the readiness projection test** + +Run: + +```bash +node --import tsx --test tests/phase8-readiness-projection.test.mjs +``` + +Expected: PASS with 3 tests passing. + +- [x] **Step 5: Commit** + +Run: + +```bash +git add apps/web/src/server/projects/readiness.ts tests/phase8-readiness-projection.test.mjs +git commit -m "feat: add project readiness projection" +``` + +--- + +### Task 2: Wire Readiness Into Project Workspace View + +**Files:** +- Modify: `apps/web/src/server/projects/workspace.ts` +- Modify: `apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts` +- Modify: `tests/phase4-project-workspace.test.mjs` + +- [x] **Step 1: Extend workspace projection tests** + +In `tests/phase4-project-workspace.test.mjs`, update the first workspace projection test after the existing `workspaceView.overview.health` assertion: + +```js +assert.equal(workspaceView.overview.readiness.status, "Blocked"); +assert.ok(workspaceView.overview.readiness.metrics.some((metric) => metric.label === "GitHub")); +assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "github")); +``` + +Add a new test near the existing overview milestone test: + +```js +test("project workspace overview includes readiness signals from the current user's inbox", async () => { + const session = createNamedSession("readiness-owner"); + const project = { + id: "project-readiness", + workspaceId: "workspace-readiness", + key: "OPS3", + itemCounter: 1, + title: "Ops Readiness", + description: "Readiness notification harness.", + stage: "Active", + dueDate: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + const stages = [ + { + id: "stage-current", + projectId: project.id, + slug: "readiness", + title: "Phase 8: Readiness", + goal: "Expose project readiness.", + status: "In Progress", + gateStatus: "In review", + sortOrder: 0 + } + ]; + const notificationInbox = [ + { + event: { + id: "event-readiness", + workspaceId: project.workspaceId, + projectId: project.id, + workItemId: null, + sourceType: "system", + sourceId: "readiness", + eventType: "github_webhook_failed", + actorId: null, + priority: "high", + title: "Webhook failed", + body: "GitHub webhook failed processing.", + url: "/workspaces/alpha-workspace/projects/OPS3/engineering", + metadata: null, + createdAt: new Date().toISOString() + }, + recipient: { + id: "recipient-readiness", + eventId: "event-readiness", + workspaceId: project.workspaceId, + recipientId: session.userId, + reason: "system", + readAt: null, + dismissedAt: null, + createdAt: new Date().toISOString() + }, + workItemIdentifier: null, + projectKey: project.key, + workspaceSlug: "alpha-workspace", + isUnread: true + } + ]; + + const workspaceView = await getProjectWorkspaceForUser( + createProjectionDependencies({ + project, + stages, + planItems: [], + tasks: [], + githubStatuses: [] + }), + session, + "alpha-workspace", + project.key, + { notificationInbox } + ); + + assert.equal(workspaceView.overview.readiness.status, "Ready with risk"); + assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "notification")); +}); +``` + +- [x] **Step 2: Run the failing workspace projection test** + +Run: + +```bash +node --import tsx --test tests/phase4-project-workspace.test.mjs +``` + +Expected: FAIL because `overview.readiness` is not defined and `getProjectWorkspaceForUser` does not accept notification inbox options. + +- [x] **Step 3: Update workspace view model** + +In `apps/web/src/server/projects/workspace.ts`: + +- Import `buildProjectReadiness` and `NotificationInboxItem`. +- Add `readiness: ProjectReadinessView` to `ProjectWorkspaceView["overview"]`. +- Add a fourth optional parameter to `getProjectWorkspaceForUser`: + +```ts +interface ProjectWorkspaceOptions { + notificationInbox?: NotificationInboxItem[]; +} + +export async function getProjectWorkspaceForUser( + dependencies: ProjectWorkspaceDependencies, + session: AppSession, + workspaceSlug: string, + projectKey: string, + options: ProjectWorkspaceOptions = {} +): Promise { + // existing body +} +``` + +When building `overview`, call: + +```ts +readiness: buildProjectReadiness({ + currentStage, + stages: sortedStages, + planItems, + tasks: sortedTasks, + githubStatuses, + engineeringItems, + notificationInbox: options.notificationInbox ?? [] +}) +``` + +- [x] **Step 4: Pass inbox rows from the project page loader** + +In `apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts`, fetch notifications before calling `getProjectWorkspaceForUser`: + +```ts +const [workspaces, project, notificationRows, notificationPreferences] = await Promise.all([ + listWorkspacesForUser(workspaceRepository, session), + getProjectForUser(projectRepository, session, workspaceSlug, projectKey), + listNotificationsForUser(notificationRepository, session, workspaceSlug, { limit: 20 }), + getNotificationPreferencesForUser(notificationRepository, session, workspaceSlug) +]); + +const [workspaceView, projectStages, planItems] = await Promise.all([ + getProjectWorkspaceForUser( + { + projectRepository, + workItemRepository + }, + session, + workspaceSlug, + projectKey, + { notificationInbox: notificationRows } + ), + projectRepository.listProjectStages(project.id), + projectRepository.listPlanItems(project.id) +]); +``` + +Keep the existing `notificationInbox` object shape returned from `loadProjectPageData`. + +- [x] **Step 5: Run workspace projection tests** + +Run: + +```bash +node --import tsx --test tests/phase4-project-workspace.test.mjs +``` + +Expected: PASS. + +- [x] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/server/projects/workspace.ts apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts tests/phase4-project-workspace.test.mjs +git commit -m "feat: wire readiness into project overview data" +``` + +--- + +### Task 3: Redesign Overview Readiness UI + +**Files:** +- Modify: `apps/web/src/features/overview/overview-view.tsx` +- Create: `apps/web/src/features/overview/readiness-summary.tsx` +- Create: `apps/web/src/features/overview/readiness-signal-card.tsx` +- Create: `apps/web/src/features/overview/readiness-action-list.tsx` +- Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` + +- [x] **Step 1: Replace the Overview UI test with readiness expectations** + +Update `apps/web/src/features/overview/__tests__/overview-view.test.tsx` so the fixture includes `overview.readiness`: + +```tsx +const overview = { + currentStage: "Phase 8: Readiness", + health: ["Scope: stable", "Delivery risk: low", "Engineering risk: stable"], + milestones: [ + { label: "Notifications", monthStart: 0, monthSpan: 2, tone: "completed" as const }, + { label: "Readiness", monthStart: 2, monthSpan: 2, tone: "current" as const } + ], + readiness: { + status: "Ready with risk" as const, + tone: "warning" as const, + narrative: "Ready with risk: 1 PR is awaiting review.", + metrics: [ + { label: "Plan", value: "4/5 done", detail: "1 current-stage item remains", tone: "warning" as const }, + { label: "Issues", value: "1 urgent", detail: "No blocked work", tone: "warning" as const }, + { label: "GitHub", value: "1 review", detail: "Checks are passing", tone: "warning" as const }, + { label: "Notifications", value: "2 unread", detail: "1 high priority", tone: "warning" as const } + ], + decisionCues: [ + { label: "Ship gate", value: "In review", tone: "warning" as const }, + { label: "Primary blocker", value: "None", tone: "success" as const } + ], + actions: [ + { + id: "review-pr-task-1", + title: "Review OPS-4 pull request", + detail: "Review requested before release.", + href: "/workspaces/platform-ops/projects/OPS/engineering", + sourceType: "github" as const, + priority: "medium" as const + } + ] + } +}; +``` + +Assert: + +```tsx +expect(screen.getByText("Readiness command center")).toBeInTheDocument(); +expect(screen.getByText("Ready with risk")).toBeInTheDocument(); +expect(screen.getByText("Ready with risk: 1 PR is awaiting review.")).toBeInTheDocument(); +expect(screen.getByText("4/5 done")).toBeInTheDocument(); +expect(screen.getByText("Decision cues")).toBeInTheDocument(); +expect(screen.getByRole("link", { name: /Review OPS-4 pull request/i })).toHaveAttribute( + "href", + "/workspaces/platform-ops/projects/OPS/engineering" +); +expect(screen.getByText("Milestone roadmap")).toBeInTheDocument(); +``` + +- [x] **Step 2: Run the failing UI test** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: FAIL because the readiness components are not rendered. + +- [x] **Step 3: Add `ReadinessSignalCard`** + +Create `apps/web/src/features/overview/readiness-signal-card.tsx`: + +```tsx +import type { ProjectReadinessMetric } from "@/server/projects/readiness"; + +const toneClass: Record = { + danger: "border-red-300/30 bg-red-500/10 text-red-100", + neutral: "border-white/10 bg-black/10 text-planka-text", + success: "border-emerald-300/30 bg-emerald-500/10 text-emerald-100", + warning: "border-amber-300/30 bg-amber-500/10 text-amber-100" +}; + +export function ReadinessSignalCard({ metric }: { metric: ProjectReadinessMetric }) { + return ( +
+

{metric.label}

+

{metric.value}

+

{metric.detail}

+
+ ); +} +``` + +- [x] **Step 4: Add `ReadinessActionList`** + +Create `apps/web/src/features/overview/readiness-action-list.tsx`: + +```tsx +import Link from "next/link"; + +import type { ProjectReadinessAction } from "@/server/projects/readiness"; + +const sourceLabel: Record = { + github: "GitHub", + notification: "Notification", + plan: "Plan", + work_item: "Work item" +}; + +export function ReadinessActionList({ actions }: { actions: ProjectReadinessAction[] }) { + return ( +
+
+
+

Team action list

+

Next best moves

+
+ + Rule based + +
+
+ {actions.length > 0 ? ( + actions.map((action) => ( + +
+

{action.title}

+ + {sourceLabel[action.sourceType]} + +
+

{action.detail}

+ + )) + ) : ( +
+ No readiness actions. The current stage has no blocking work or high-priority signals. +
+ )} +
+
+ ); +} +``` + +- [x] **Step 5: Add `ReadinessSummary`** + +Create `apps/web/src/features/overview/readiness-summary.tsx`: + +```tsx +import type { ProjectReadinessView } from "@/server/projects/readiness"; + +import { ReadinessSignalCard } from "./readiness-signal-card"; + +const statusClass: Record = { + danger: "border-red-300/30 bg-red-500/10 text-red-100", + neutral: "border-white/10 bg-black/10 text-planka-text", + success: "border-emerald-300/30 bg-emerald-500/10 text-emerald-100", + warning: "border-amber-300/30 bg-amber-500/10 text-amber-100" +}; + +export function ReadinessSummary({ readiness }: { readiness: ProjectReadinessView }) { + return ( +
+
+
+

+ Readiness command center +

+
+

{readiness.status}

+ + {readiness.tone} + +
+

{readiness.narrative}

+
+ +
+
+ {readiness.metrics.map((metric) => ( + + ))} +
+
+ ); +} +``` + +- [x] **Step 6: Compose the new Overview** + +Modify `apps/web/src/features/overview/overview-view.tsx`: + +- Import `ReadinessSummary` and `ReadinessActionList`. +- Render `ReadinessSummary` first. +- Render `MilestoneRoadmap` below it. +- Render `ReadinessActionList` in the right/lower area on desktop. +- Keep the current milestone roadmap visible. + +- [x] **Step 7: Run the Overview UI test** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: PASS. + +- [x] **Step 8: Commit** + +Run: + +```bash +git add apps/web/src/features/overview apps/web/src/features/overview/__tests__/overview-view.test.tsx +git commit -m "feat: add readiness overview UI" +``` + +--- + +### Task 4: Add Project-Scoped Readiness Search Service + +**Files:** +- Create: `apps/web/src/server/projects/search.ts` +- Create: `tests/phase8-project-search.test.mjs` + +- [x] **Step 1: Write failing search tests** + +Create `tests/phase8-project-search.test.mjs` that: + +- creates two workspaces +- creates one project in each workspace +- adds work items, plan items, comments, and GitHub status to the first project +- calls `searchProjectForUser` +- asserts grouped result types and ranking +- asserts cross-workspace data is excluded + +Use this assertion shape: + +```js +assert.deepEqual(results.results.map((result) => result.type), [ + "work_item", + "comment", + "plan", + "engineering" +]); +assert.equal(results.results[0].title, "OPS-1 Fix release pipeline"); +assert.ok(results.results.every((result) => result.href.startsWith(`/workspaces/${workspace.slug}/projects/${projectKey}`))); +assert.ok(!results.results.some((result) => result.title.includes("Other workspace"))); +``` + +- [x] **Step 2: Run the failing search test** + +Run: + +```bash +node --import tsx --test tests/phase8-project-search.test.mjs +``` + +Expected: FAIL because `apps/web/src/server/projects/search.ts` does not exist. + +- [x] **Step 3: Implement search result types** + +Create `apps/web/src/server/projects/search.ts` with: + +```ts +import { and, eq, ilike, isNull, or } from "drizzle-orm"; + +import { + comments, + db, + githubPullRequests, + planItems, + projectStages, + projects, + taskGithubStatus, + tasks, + workItemGithubLinks +} from "@the-platform/db"; + +import { resolveWorkspaceContext } from "../work-management/utils"; +import { WorkspaceError } from "../workspaces/core"; +import type { AppSession } from "../workspaces/types"; +import type { ProjectRepository } from "./types"; + +export type ProjectSearchResultType = "work_item" | "plan" | "comment" | "engineering" | "notification"; + +export interface ProjectSearchResult { + id: string; + type: ProjectSearchResultType; + title: string; + snippet: string; + href: string; + chip: string; + rank: number; +} + +export interface ProjectSearchResponse { + query: string; + results: ProjectSearchResult[]; +} + +interface SearchProjectDependencies { + projectRepository: Pick; +} +``` + +- [x] **Step 4: Implement `searchProjectForUser`** + +Add: + +```ts +export async function searchProjectForUser( + dependencies: SearchProjectDependencies, + session: AppSession, + workspaceSlug: string, + projectKey: string, + query: string +): Promise { + const trimmed = query.trim(); + if (trimmed.length < 2) { + return { query: trimmed, results: [] }; + } + + const { workspace } = await resolveWorkspaceContext( + dependencies.projectRepository, + session, + workspaceSlug, + "viewer" + ); + const project = await dependencies.projectRepository.getProjectByKey(workspace.id, projectKey); + if (!project) { + throw new WorkspaceError(404, "project not found."); + } + + const pattern = `%${trimmed}%`; + const baseHref = `/workspaces/${workspace.slug}/projects/${project.key}`; + + const [workItemRows, planRows, commentRows, engineeringRows] = await Promise.all([ + db.select().from(tasks).where( + and( + eq(tasks.projectId, project.id), + or( + ilike(tasks.identifier, pattern), + ilike(tasks.title, pattern), + ilike(tasks.description, pattern) + ) + ) + ), + db + .select({ planItem: planItems, stage: projectStages }) + .from(planItems) + .innerJoin(projectStages, eq(planItems.stageId, projectStages.id)) + .where( + and( + eq(projectStages.projectId, project.id), + or( + ilike(planItems.title, pattern), + ilike(planItems.outcome, pattern), + ilike(planItems.blocker, pattern), + ilike(projectStages.title, pattern), + ilike(projectStages.goal, pattern), + ilike(projectStages.gateStatus, pattern) + ) + ) + ), + db + .select({ comment: comments, task: tasks }) + .from(comments) + .innerJoin(tasks, eq(comments.workItemId, tasks.id)) + .where(and(eq(tasks.projectId, project.id), isNull(comments.deletedAt), ilike(comments.content, pattern))), + db + .select({ task: tasks, githubStatus: taskGithubStatus, pullRequest: githubPullRequests }) + .from(taskGithubStatus) + .innerJoin(tasks, eq(taskGithubStatus.taskId, tasks.id)) + .leftJoin(workItemGithubLinks, eq(workItemGithubLinks.workItemId, tasks.id)) + .leftJoin(githubPullRequests, eq(workItemGithubLinks.pullRequestId, githubPullRequests.id)) + .where( + and( + eq(tasks.projectId, project.id), + or( + ilike(tasks.identifier, pattern), + ilike(tasks.title, pattern), + ilike(githubPullRequests.title, pattern), + ilike(githubPullRequests.headBranch, pattern) + ) + ) + ) + ]); + + const results: ProjectSearchResult[] = [ + ...workItemRows.map((item) => ({ + id: `work-item-${item.id}`, + type: "work_item" as const, + title: `${item.identifier ?? "Work item"} ${item.title}`, + snippet: item.description || item.status, + href: `${baseHref}?selected=${item.identifier ?? item.id}`, + chip: item.priority === "urgent" || item.status === "Blocked" ? "Risk" : item.status, + rank: item.identifier?.toLowerCase() === trimmed.toLowerCase() ? 0 : 10 + })), + ...commentRows.map((row) => ({ + id: `comment-${row.comment.id}`, + type: "comment" as const, + title: `${row.task.identifier ?? "Comment"} comment`, + snippet: row.comment.content, + href: `${baseHref}?selected=${row.task.identifier ?? row.task.id}`, + chip: "Comment", + rank: 30 + })), + ...planRows.map((row) => ({ + id: `plan-${row.planItem.id}`, + type: "plan" as const, + title: row.planItem.title, + snippet: row.planItem.blocker ?? row.planItem.outcome, + href: `${baseHref}/plan`, + chip: row.stage.gateStatus, + rank: row.planItem.blocker ? 20 : 40 + })), + ...engineeringRows.map((row) => ({ + id: `engineering-${row.task.id}`, + type: "engineering" as const, + title: `${row.task.identifier ?? "Work item"} engineering`, + snippet: row.pullRequest?.title ?? `${row.githubStatus.prStatus} · ${row.githubStatus.ciStatus}`, + href: `${baseHref}/engineering`, + chip: row.githubStatus.ciStatus === "Failing" ? "Failing" : row.githubStatus.prStatus, + rank: row.githubStatus.ciStatus === "Failing" ? 20 : 50 + })) + ]; + + return { + query: trimmed, + results: results.sort((left, right) => left.rank - right.rank || left.title.localeCompare(right.title)).slice(0, 20) + }; +} +``` + +If `ilike` rejects nullable columns during typecheck, wrap nullable text fields with `sql` expressions in the implementation. + +- [x] **Step 5: Run search tests** + +Run: + +```bash +node --import tsx --test tests/phase8-project-search.test.mjs +``` + +Expected: PASS. + +- [x] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/server/projects/search.ts tests/phase8-project-search.test.mjs +git commit -m "feat: add project readiness search service" +``` + +--- + +### Task 5: Add Search API Route + +**Files:** +- Create: `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts` +- Create: `tests/phase8-search-api.test.mjs` + +- [x] **Step 1: Write failing API tests** + +Create `tests/phase8-search-api.test.mjs` with tests for: + +- authenticated workspace member can search +- query shorter than 2 characters returns empty results +- non-member receives 403 +- missing project receives 404 + +Use route-level request calls by importing `GET` from the route file and stubbing session through the same pattern used in existing API tests. + +- [x] **Step 2: Run the failing API test** + +Run: + +```bash +node --import tsx --test tests/phase8-search-api.test.mjs +``` + +Expected: FAIL because the route file does not exist. + +- [x] **Step 3: Implement the route** + +Create `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts`: + +```ts +import { getAppSession } from "@/server/auth"; +import { createProjectRepository } from "@/server/projects/repository"; +import { searchProjectForUser } from "@/server/projects/search"; +import { WorkspaceError } from "@/server/workspaces/core"; + +const dependencies = { + getSession: getAppSession, + projectRepository: createProjectRepository() +}; + +function json(data: unknown, status = 200) { + return Response.json(data, { status }); +} + +function handleError(error: unknown) { + if (error instanceof WorkspaceError) { + return json({ error: error.message }, error.status); + } + + throw error; +} + +export async function GET( + request: Request, + context: { + params: Promise<{ + slug: string; + key: string; + }>; + } +) { + const session = await dependencies.getSession(); + if (!session) { + return json({ error: "authentication required." }, 401); + } + + const params = await context.params; + const url = new URL(request.url); + const query = url.searchParams.get("q") ?? ""; + + try { + const results = await searchProjectForUser( + { projectRepository: dependencies.projectRepository }, + session, + params.slug, + params.key, + query + ); + return json(results); + } catch (error) { + return handleError(error); + } +} +``` + +- [x] **Step 4: Run API tests** + +Run: + +```bash +node --import tsx --test tests/phase8-search-api.test.mjs +``` + +Expected: PASS. + +- [x] **Step 5: Commit** + +Run: + +```bash +git add apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts tests/phase8-search-api.test.mjs +git commit -m "feat: add readiness search api" +``` + +--- + +### Task 6: Add Scoped Search UI to Overview + +**Files:** +- Create: `apps/web/src/features/overview/readiness-search.tsx` +- Modify: `apps/web/src/features/overview/overview-view.tsx` +- Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` + +- [x] **Step 1: Extend Overview UI tests for search states** + +In `overview-view.test.tsx`, add a test that stubs `fetch`, types `pipeline`, and expects grouped results: + +```tsx +it("searches readiness signals from the overview", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + query: "pipeline", + results: [ + { + id: "work-item-1", + type: "work_item", + title: "OPS-1 Fix release pipeline", + snippet: "Pipeline blocks the release.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + chip: "Risk", + rank: 0 + } + ] + }) + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + fireEvent.change(screen.getByPlaceholderText("Search blockers, PRs, comments..."), { + target: { value: "pipeline" } + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/workspaces/platform-ops/projects/OPS/search?q=pipeline"); + }); + expect(await screen.findByRole("link", { name: /OPS-1 Fix release pipeline/i })).toHaveAttribute( + "href", + "/workspaces/platform-ops/projects/OPS?selected=OPS-1" + ); +}); +``` + +Import `fireEvent`, `waitFor`, and `vi` at the top of the test file. + +- [x] **Step 2: Run the failing Overview search test** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: FAIL because `OverviewView` does not accept `workspaceSlug` and `projectKey`, and `ReadinessSearch` does not exist. + +- [x] **Step 3: Create `ReadinessSearch`** + +Create `apps/web/src/features/overview/readiness-search.tsx`: + +```tsx +"use client"; + +import Link from "next/link"; +import { useState, useTransition } from "react"; + +import type { ProjectSearchResult } from "@/server/projects/search"; + +interface ReadinessSearchProps { + workspaceSlug: string; + projectKey: string; +} + +export function ReadinessSearch({ workspaceSlug, projectKey }: ReadinessSearchProps) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function updateQuery(value: string) { + setQuery(value); + setError(null); + + if (value.trim().length < 2) { + setResults([]); + return; + } + + startTransition(async () => { + const response = await fetch( + `/api/workspaces/${workspaceSlug}/projects/${projectKey}/search?q=${encodeURIComponent(value.trim())}` + ); + + if (!response.ok) { + setError("Search failed. Try again from the project overview."); + setResults([]); + return; + } + + const body = (await response.json()) as { results: ProjectSearchResult[] }; + setResults(body.results); + }); + } + + return ( +
+

Readiness search

+ updateQuery(event.target.value)} + /> + {isPending ?

Searching readiness signals...

: null} + {error ?

{error}

: null} +
+ {results.map((result) => ( + +
+

{result.title}

+ + {result.chip} + +
+

{result.snippet}

+ + ))} + {query.trim().length >= 2 && !isPending && !error && results.length === 0 ? ( +

+ No readiness signals found for "{query.trim()}". +

+ ) : null} +
+
+ ); +} +``` + +- [x] **Step 4: Wire search into `OverviewView`** + +Change `OverviewView` props: + +```ts +export function OverviewView({ + brief, + overview, + workspaceSlug, + projectKey +}: { + brief: string; + overview: OverviewViewModel; + workspaceSlug: string; + projectKey: string; +}) { + // render ReadinessSearch in the lower right section +} +``` + +Update `apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx` to pass `workspace.slug` and `project.key` from `loadProjectPageData`. + +- [x] **Step 5: Run Overview UI tests** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: PASS. + +- [x] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/features/overview/readiness-search.tsx apps/web/src/features/overview/overview-view.tsx apps/web/src/features/overview/__tests__/overview-view.test.tsx apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx +git commit -m "feat: add readiness search UI" +``` + +--- + +### Task 7: Add Readiness-Critical Polish States + +**Files:** +- Modify: `apps/web/src/features/overview/readiness-action-list.tsx` +- Modify: `apps/web/src/features/overview/readiness-search.tsx` +- Modify: `apps/web/src/features/engineering/engineering-view.tsx` +- Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` +- Modify: `apps/web/src/features/engineering/__tests__/engineering-view.test.tsx` + +- [x] **Step 1: Add UI tests for empty and no-repository states** + +In `overview-view.test.tsx`, assert empty actions are explicit: + +```tsx +const readyOverview = { + ...overview, + readiness: { + ...overview.readiness, + status: "Ready" as const, + tone: "success" as const, + narrative: "Ready: current stage has no blocking work and engineering signals are stable.", + actions: [] + } +}; + +render(); + +expect(screen.getByText("No readiness actions. The current stage has no blocking work or high-priority signals.")).toBeInTheDocument(); +``` + +In `engineering-view.test.tsx`, assert no repository connected renders a clear setup state: + +```tsx +expect(screen.getByText("Connect GitHub to populate engineering readiness signals.")).toBeInTheDocument(); +``` + +- [x] **Step 2: Run failing UI tests** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view engineering-view +``` + +Expected: FAIL if the engineering no-repository state is not explicit. + +- [x] **Step 3: Improve Engineering empty states** + +In `apps/web/src/features/engineering/engineering-view.tsx`, when `engineering.connectionStatus === "Setup required"`, render: + +```tsx +
+ Connect GitHub to populate engineering readiness signals. +
+``` + +Keep current linked PR, failing checks, deployments, and issue summary sections intact. + +- [x] **Step 4: Improve search empty and error copy** + +In `ReadinessSearch`, keep three distinct states: + +- query shorter than 2 characters: `Search across blockers, PRs, comments, plan items, and notifications.` +- no results: `No readiness signals found for "{query}".` +- failed request: `Search failed. Try again from the project overview.` + +- [x] **Step 5: Run UI tests** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view engineering-view +``` + +Expected: PASS. + +- [x] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/features/overview apps/web/src/features/engineering +git commit -m "feat: polish readiness empty states" +``` + +--- + +### Task 8: Final Documentation and Verification + +**Files:** +- Modify: `docs/product/idea-brief.md` +- Modify: `docs/product/decision-brief.md` +- Modify: `docs/product/prd.md` +- Modify: `docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md` + +- [x] **Step 1: Update product docs for Phase 8** + +Update product docs so they identify Phase 8 as: + +- Readiness Command Center +- lead-first Overview reporting +- deterministic team action list +- scoped readiness search +- readiness-critical polish + +Keep Phase 8 non-goals explicit: + +- no global portfolio dashboard +- no analytics warehouse +- no AI-generated recommendations +- no full-text infrastructure +- no global command palette + +- [x] **Step 2: Mark completed plan tasks** + +In this plan file, check off completed task steps as implementation progresses. + +- [x] **Step 3: Run targeted tests** + +Run: + +```bash +node --import tsx --test tests/phase8-readiness-projection.test.mjs tests/phase8-project-search.test.mjs tests/phase8-search-api.test.mjs +npm run test --workspace @the-platform/web -- overview-view engineering-view +``` + +Expected: PASS. + +- [x] **Step 4: Run full verification** + +Run: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +Expected: PASS. + +- [x] **Step 5: Browser smoke check** + +Start the app from the Phase 8 worktree: + +```bash +npm run dev +``` + +Open the local Next.js URL and verify: + +- Overview shows `Readiness command center`. +- The readiness status matches seeded project signals. +- Team action list links navigate to existing project surfaces. +- Search query with a known identifier returns a work item result. +- Search query with no result shows the no-result state. +- Engineering no-repository projects show the setup state. + +Smoke note: root `npm run dev` started web but the worker exited because local GitHub worker tokens are not configured (`GITHUB_TOKEN` or `GITHUB_APP_INSTALLATION_TOKEN`). Browser smoke used the Phase 8 worktree web server directly with `npm run dev --workspace @the-platform/web -- -p 3001`, reusing local Postgres/Redis. No DB reset was run; a local demo membership and `SETUP` no-repository smoke project were added to the existing development workspace. + +- [x] **Step 6: Commit final docs** + +Run: + +```bash +git add docs/product/idea-brief.md docs/product/decision-brief.md docs/product/prd.md docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md +git commit -m "docs: finalize phase 8 readiness plan" +``` diff --git a/docs/superpowers/specs/2026-04-26-phase8-readiness-command-center-design.md b/docs/superpowers/specs/2026-04-26-phase8-readiness-command-center-design.md new file mode 100644 index 0000000..e5dffed --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-phase8-readiness-command-center-design.md @@ -0,0 +1,253 @@ +# Phase 8 Readiness Command Center Design + +## Product Intent + +Phase 8 turns the project workspace from a set of working surfaces into a decision surface. + +The product already has Jira-like execution, implementation planning, GitHub engineering status, lightweight docs/comments, and in-app notifications. The remaining product gap is synthesis: a project lead should be able to open Overview and quickly understand whether the project is ready to move forward, what risk blocks it, and which concrete actions the team should take next. + +## Audience + +Phase 8 serves two audiences in one surface: + +- **Lead / reporting view:** The first screen answers "Is this project ready, risky, or blocked?" +- **Team execution view:** The lower screen answers "What should we do next?" + +The lead view gets priority in the Overview first screen. Team actionability follows immediately below it. + +## Scope + +### 1. Readiness Overview + +Replace the current light Overview summary with a readiness command center. + +The Overview should show: + +- overall readiness status: `Ready`, `Ready with risk`, or `Blocked` +- short readiness narrative in plain language +- scope completion ratio from plan items and work items +- blocked and urgent issue counts +- GitHub health: open/review PRs, failing checks, and deploy state +- notification pressure for the current user +- current stage and next gate signal + +The readiness status is derived from local project state. No external analytics service is introduced. + +### 2. Decision Cues + +Add a compact side panel that explains why the readiness status was chosen. + +Decision cues should include: + +- ship gate status +- primary blocker +- current engineering risk +- owner attention signal from unread notifications +- current stage gate signal + +The goal is not to create a full executive dashboard. The goal is to make the Overview defensible in a standup, stakeholder check-in, or release discussion. + +### 3. Team Action List + +Add a generated action list below the executive snapshot. + +Actions should be deterministic and source-linked. Examples: + +- fix a failing check linked to a work item +- resolve a blocked item +- review a PR that is waiting +- move a stale current-stage plan item forward +- respond to unread high-priority notifications + +The action list should not invent AI-style recommendations. It should be simple rule-based synthesis from existing project, work item, GitHub, plan, and notification state. + +### 4. Scoped Readiness Search + +Add a narrow project search surface that supports readiness work. + +Search should cover: + +- work item identifier, title, description, status, priority, labels +- plan item title, outcome, blocker +- project stage title, goal, gate status +- comment body +- GitHub PR title/branch/status where available +- notification title/body for the current user + +Search is project-scoped. Phase 8 does not add a global cross-workspace search center. + +### 5. Product Polish Where It Supports Readiness + +Polish should be limited to readiness-critical rough edges: + +- empty states for Overview, Engineering, Notifications, and Search +- loading and API error states for readiness search and notification-backed action signals +- clearer no-repository and no-GitHub-link states +- responsive behavior for the readiness cards and action list + +Manual GitHub link management remains out of scope unless required to make readiness signals explainable. + +## Non-Goals + +- Global portfolio dashboard across workspaces +- Full analytics warehouse or historical trends +- AI-generated summaries or recommendations +- Full-text search infrastructure such as Elasticsearch +- Global command palette +- Manual ambiguous GitHub link resolution workflow +- Email, Slack, push, or real-time delivery +- New billing, permissions, or workspace administration surfaces + +## Information Architecture + +Overview becomes the primary Phase 8 surface: + +1. **Executive Snapshot** + - readiness status + - narrative summary + - key metrics chips + +2. **Decision Cues** + - gate status + - primary blocker + - engineering risk + - notification pressure + +3. **Signal Cards** + - plan completion + - blocked / urgent work + - GitHub health + - notification pressure + +4. **Team Action List** + - deterministic next actions with source links + +5. **Scoped Search** + - project readiness search + - grouped result types + +Existing Board, Plan, Engineering, Docs, and Notifications surfaces stay in place. Overview links into those surfaces when the user needs detail. + +## Readiness Model + +The readiness model should be implemented as a server-side projection. + +### Inputs + +- project record +- project stages and gate statuses +- plan items +- work items +- comments +- task GitHub statuses and engineering records +- current user's notification inbox rows + +### Derived Status + +`Blocked` when any of these are true: + +- the current stage status is `Blocked` +- one or more work items are blocked +- one or more GitHub checks are failing on linked work + +`Ready with risk` when none of the blocked rules are true, but any of these are true: + +- urgent work exists +- open or review-requested PRs exist +- production deploy is missing while staging exists +- current user has unread high-priority notifications +- current stage has incomplete plan items + +`Ready` when: + +- no blocked rule is true +- no risk rule is true +- current stage plan items are complete or there are no current-stage plan items + +### Readiness Narrative + +The narrative should be deterministic and concise. + +Examples: + +- `Blocked by 1 failing check and 2 blocked work items.` +- `Ready with risk: 3 plan items remain and 1 PR is awaiting review.` +- `Ready: current stage has no blocking work and engineering signals are stable.` + +## Search Design + +Search should be implemented with simple Postgres-backed matching for Phase 8. + +Use case-insensitive substring matching over normalized source fields. Ranking can be lightweight: + +1. exact work item identifier match +2. work item title match +3. blocker / urgent / failing GitHub matches +4. comment, plan, stage, and notification text matches + +Results should be grouped by type: + +- Work items +- Plan +- Comments +- Engineering +- Notifications + +Each result should include: + +- type label +- title +- short context snippet +- status/risk chip where relevant +- link to the best existing project surface + +## UI Direction + +Keep the current dark, rounded, high-contrast project workspace language. Do not introduce a new visual system. + +The Overview should feel more intentional: + +- larger readiness headline +- fewer generic health lines +- stronger hierarchy between executive snapshot and team actions +- compact, scannable metrics +- source-linked actions instead of decorative cards + +Search should be lightweight, not a heavy modal or global command center in this phase. + +## API and Data Boundaries + +Prefer server projections over client-side assembly. + +Recommended boundaries: + +- `apps/web/src/server/projects/readiness.ts` owns readiness derivation. +- `apps/web/src/server/projects/search.ts` owns project-scoped search. +- `getProjectWorkspaceForUser` includes readiness data in the Overview view model. +- API routes expose search only for authenticated workspace members. + +Readiness and search must respect existing workspace membership and project scoping. Users should never see comments, notifications, or work items from another workspace. + +## Verification + +Automated coverage should prove: + +- readiness status derivation for ready, ready-with-risk, and blocked states +- action list ordering and source links +- search returns project-scoped grouped results +- search excludes cross-workspace data +- Overview renders snapshot, decision cues, signal cards, action list, empty states, and search states +- existing Board, Plan, Engineering, Notifications, and Docs tests continue to pass + +Full final verification: + +- `npm run lint` +- `npm run typecheck` +- `npm test` +- `npm run build` + +## Open Tradeoffs Resolved + +- **Reporting vs search vs polish:** Reporting/readiness is the backbone. Search and polish are scoped support work. +- **Lead vs team audience:** Lead view owns the first screen; team actions sit directly underneath. +- **Recommendation style:** Rule-based synthesis only. No AI-generated guidance in Phase 8. diff --git a/tests/phase4-project-workspace.test.mjs b/tests/phase4-project-workspace.test.mjs index 45d51c7..62441b3 100644 --- a/tests/phase4-project-workspace.test.mjs +++ b/tests/phase4-project-workspace.test.mjs @@ -268,6 +268,9 @@ test("project workspace view maps real stage, plan, and engineering records", as assert.equal(workspaceView.plan.items[1]?.linkedIssues.join(", "), `${drawerTask.identifier}, ${approvalTask.identifier}`); assert.equal(workspaceView.overview.currentStage, "Phase 2: Execution Surface"); assert.ok(workspaceView.overview.health.includes("Engineering risk: 1 failing check")); + assert.equal(workspaceView.overview.readiness.status, "Blocked"); + assert.ok(workspaceView.overview.readiness.metrics.some((metric) => metric.label === "GitHub")); + assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "github")); assert.equal(workspaceView.engineering.pullRequests, "2 open"); assert.equal(workspaceView.engineering.checks, "1 failing"); assert.ok( @@ -450,6 +453,164 @@ test("project workspace overview marks completed stages distinctly from upcoming ); }); +test("project workspace overview includes readiness signals from the current user's inbox", async () => { + const session = createNamedSession("readiness-owner"); + const project = { + id: "project-readiness", + workspaceId: "workspace-readiness", + key: "OPS3", + itemCounter: 1, + title: "Ops Readiness", + description: "Readiness notification harness.", + stage: "Active", + dueDate: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + const stages = [ + { + id: "stage-current", + projectId: project.id, + slug: "readiness", + title: "Phase 8: Readiness", + goal: "Expose project readiness.", + status: "In Progress", + gateStatus: "In review", + sortOrder: 0 + } + ]; + const notificationInbox = [ + { + event: { + id: "event-readiness", + workspaceId: project.workspaceId, + projectId: project.id, + workItemId: null, + sourceType: "system", + sourceId: "readiness", + eventType: "github_webhook_failed", + actorId: null, + priority: "high", + title: "Webhook failed", + body: "GitHub webhook failed processing.", + url: "/workspaces/alpha-workspace/projects/OPS3/engineering", + metadata: null, + createdAt: new Date().toISOString() + }, + recipient: { + id: "recipient-readiness", + eventId: "event-readiness", + workspaceId: project.workspaceId, + recipientId: session.userId, + reason: "system", + readAt: null, + dismissedAt: null, + createdAt: new Date().toISOString() + }, + workItemIdentifier: null, + projectKey: project.key, + workspaceSlug: "alpha-workspace", + isUnread: true + } + ]; + + const workspaceView = await getProjectWorkspaceForUser( + createProjectionDependencies({ + project, + stages, + planItems: [], + tasks: [], + githubStatuses: [] + }), + session, + "alpha-workspace", + project.key, + { notificationInbox } + ); + + assert.equal(workspaceView.overview.readiness.status, "Ready with risk"); + assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "notification")); +}); + +test("project workspace overview ignores unread notifications from other projects", async () => { + const session = createNamedSession("readiness-scoped-owner"); + const project = { + id: "project-readiness-current", + workspaceId: "workspace-readiness-scoped", + key: "OPS4", + itemCounter: 1, + title: "Scoped Readiness", + description: "Readiness notification scope harness.", + stage: "Active", + dueDate: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + const stages = [ + { + id: "stage-current-scoped", + projectId: project.id, + slug: "readiness", + title: "Phase 8: Readiness", + goal: "Expose project readiness.", + status: "In Progress", + gateStatus: "Passed", + sortOrder: 0 + } + ]; + const notificationInbox = [ + { + event: { + id: "event-other-project", + workspaceId: project.workspaceId, + projectId: "project-readiness-other", + workItemId: null, + sourceType: "system", + sourceId: "readiness", + eventType: "github_webhook_failed", + actorId: null, + priority: "high", + title: "Other project webhook failed", + body: "Another project has a GitHub webhook failure.", + url: "/workspaces/alpha-workspace/projects/OTHER/engineering", + metadata: null, + createdAt: new Date().toISOString() + }, + recipient: { + id: "recipient-other-project", + eventId: "event-other-project", + workspaceId: project.workspaceId, + recipientId: session.userId, + reason: "system", + readAt: null, + dismissedAt: null, + createdAt: new Date().toISOString() + }, + workItemIdentifier: null, + projectKey: "OTHER", + workspaceSlug: "alpha-workspace", + isUnread: true + } + ]; + + const workspaceView = await getProjectWorkspaceForUser( + createProjectionDependencies({ + project, + stages, + planItems: [], + tasks: [], + githubStatuses: [] + }), + session, + "alpha-workspace", + project.key, + { notificationInbox } + ); + + assert.equal(workspaceView.overview.readiness.status, "Ready"); + assert.equal(workspaceView.overview.readiness.actions.length, 0); +}); + test("task github status rejects invalid enum values at the database boundary", async (t) => { const harness = createPersistedHarness(t); const owner = createNamedSession("owner-enum"); diff --git a/tests/phase7-notifications-service.test.mjs b/tests/phase7-notifications-service.test.mjs index d1a9ae6..626c0b8 100644 --- a/tests/phase7-notifications-service.test.mjs +++ b/tests/phase7-notifications-service.test.mjs @@ -314,3 +314,90 @@ test("notification service marks only the current user's recipient rows read", a ); assert.equal(unreadAfterMarkAll.filter((entry) => entry.isUnread).length, 0); }); + +test("notification service lists project-scoped unread rows without workspace pagination", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-project-scope"); + const teammate = createNamedSession("teammate-project-scope"); + const workspace = await harness.createWorkspace(owner, "notify-project-scope"); + await harness.addMembership(workspace.id, teammate, "member"); + const currentProject = await createProjectWithItem(harness, owner, workspace); + const otherProject = await createProjectWithItem(harness, owner, workspace); + + const currentUnread = await createNotificationForSource( + harness.repositories.notificationRepository, + owner, + workspace.slug, + { + projectId: currentProject.item.projectId, + workItemId: currentProject.item.id, + sourceType: "work_item", + sourceId: `${currentProject.item.id}:unread`, + eventType: "priority_raised", + actorId: owner.userId, + priority: "high", + title: "Current project unread", + url: `/workspaces/${workspace.slug}/projects/${currentProject.projectKey}/items/${currentProject.item.identifier}`, + recipients: [{ recipientId: teammate.userId, reason: "participant" }] + } + ); + const currentRead = await createNotificationForSource( + harness.repositories.notificationRepository, + owner, + workspace.slug, + { + projectId: currentProject.item.projectId, + workItemId: currentProject.item.id, + sourceType: "work_item", + sourceId: `${currentProject.item.id}:read`, + eventType: "state_changed", + actorId: owner.userId, + priority: "high", + title: "Current project read", + url: `/workspaces/${workspace.slug}/projects/${currentProject.projectKey}/items/${currentProject.item.identifier}`, + recipients: [{ recipientId: teammate.userId, reason: "participant" }] + } + ); + await createNotificationForSource( + harness.repositories.notificationRepository, + owner, + workspace.slug, + { + projectId: otherProject.item.projectId, + workItemId: otherProject.item.id, + sourceType: "work_item", + sourceId: `${otherProject.item.id}:unread`, + eventType: "priority_raised", + actorId: owner.userId, + priority: "high", + title: "Other project unread", + url: `/workspaces/${workspace.slug}/projects/${otherProject.projectKey}/items/${otherProject.item.identifier}`, + recipients: [{ recipientId: teammate.userId, reason: "participant" }] + } + ); + + await markNotificationReadForUser( + harness.repositories.notificationRepository, + teammate, + workspace.slug, + currentRead.recipients[0].id + ); + + const projectInbox = await listNotificationsForUser( + harness.repositories.notificationRepository, + teammate, + workspace.slug, + { + projectId: currentProject.item.projectId, + unreadOnly: true, + limit: null + } + ); + + assert.deepEqual( + projectInbox.map((entry) => entry.recipient.id), + [currentUnread.recipients[0].id] + ); + assert.ok(projectInbox.every((entry) => entry.event.projectId === currentProject.item.projectId)); + assert.ok(projectInbox.every((entry) => entry.isUnread)); +}); diff --git a/tests/phase8-project-search.test.mjs b/tests/phase8-project-search.test.mjs new file mode 100644 index 0000000..7d1673d --- /dev/null +++ b/tests/phase8-project-search.test.mjs @@ -0,0 +1,658 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sql } from "../packages/db/src/client.ts"; +import { createProjectRepository } from "../apps/web/src/server/projects/repository.ts"; +import { createProjectForUser, getProjectForUser } from "../apps/web/src/server/projects/service.ts"; +import { searchProjectForUser } from "../apps/web/src/server/projects/search.ts"; +import { createWorkItemRepository } from "../apps/web/src/server/work-items/repository.ts"; +import { createWorkItemForUser } from "../apps/web/src/server/work-items/service.ts"; +import { createWorkspaceRepository } from "../apps/web/src/server/workspaces/repository.ts"; +import { createWorkspaceForUser } from "../apps/web/src/server/workspaces/service.ts"; + +function createSession(userId, email) { + return { + userId, + email, + displayName: email, + provider: "demo" + }; +} + +function uniqueSuffix() { + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} + +function createNamedSession(prefix) { + const suffix = uniqueSuffix(); + return createSession(`${prefix}-${suffix}`, `${prefix}-${suffix}@example.com`); +} + +function createRepositories() { + return { + projectRepository: createProjectRepository(), + workItemRepository: createWorkItemRepository(), + workspaceRepository: createWorkspaceRepository() + }; +} + +function createPersistedHarness(t) { + const repositories = createRepositories(); + const workspaceIds = []; + + t.after(async () => { + for (const workspaceId of workspaceIds) { + await sql`delete from workspaces where id = ${workspaceId}`; + } + }); + + return { + repositories, + async createWorkspace(session, label) { + const suffix = uniqueSuffix(); + const workspace = await createWorkspaceForUser(repositories.workspaceRepository, session, { + name: `${label} ${suffix}`, + slug: `${label.toLowerCase()}-${suffix}` + }); + workspaceIds.push(workspace.id); + return workspace; + } + }; +} + +async function createProjectWithSearchData(harness, owner, workspace, projectKey, label) { + const prefix = label ? `${label} ` : ""; + + await createProjectForUser(harness.repositories.projectRepository, owner, workspace.slug, { + name: `${prefix}Readiness`, + key: projectKey + }); + + const project = await getProjectForUser( + harness.repositories.projectRepository, + owner, + workspace.slug, + projectKey + ); + + const item = await createWorkItemForUser( + harness.repositories.workItemRepository, + owner, + workspace.slug, + projectKey, + { + title: `${prefix}Fix release pipeline`, + description: `${prefix}release pipeline needs safer rollout checks.`, + type: "task", + priority: "high", + position: 0 + } + ); + + const [stage] = await sql` + insert into project_stages (project_id, slug, title, goal, status, gate_status, sort_order) + values ( + ${project.id}, + 'readiness-search', + ${`${prefix}Readiness Search`}, + ${`${prefix}release pipeline readiness gate`}, + 'In Progress', + ${`${prefix}Gate ready`}, + 0 + ) + returning id + `; + + await sql` + insert into plan_items (stage_id, title, outcome, status, blocker) + values ( + ${stage.id}, + ${`${prefix}Stabilize release pipeline plan`}, + ${`${prefix}release pipeline is observable before rollout.`}, + 'Todo', + null + ) + `; + + await sql` + insert into comments (work_item_id, author_id, content) + values ( + ${item.id}, + ${owner.userId}, + ${`${prefix}release pipeline comment should be searchable.`} + ) + `; + + await sql` + insert into task_github_status (task_id, pr_status, ci_status, deploy_status) + values (${item.id}, 'Open PR', 'Passing', 'Staging') + `; + + const [repository] = await sql` + insert into github_repositories ( + workspace_id, + provider_repository_id, + owner, + name, + full_name, + default_branch, + installation_id + ) + values ( + ${workspace.id}, + ${`${projectKey.toLowerCase()}-${uniqueSuffix()}`}, + 'platform', + ${`${projectKey.toLowerCase()}-repo`}, + ${`platform/${projectKey.toLowerCase()}-repo`}, + 'main', + ${`${projectKey.toLowerCase()}-installation`} + ) + returning id + `; + + const [pullRequest] = await sql` + insert into github_pull_requests ( + repository_id, + provider_pull_request_id, + number, + title, + url, + base_branch, + head_branch, + head_sha + ) + values ( + ${repository.id}, + ${`${projectKey.toLowerCase()}-pr-${uniqueSuffix()}`}, + 1, + ${`${prefix}Release pipeline branch checks`}, + 'https://example.com/pr/1', + 'main', + ${`${projectKey.toLowerCase()}-release-pipeline`}, + ${`${projectKey.toLowerCase()}abcdef1234567890`} + ) + returning id + `; + + await sql` + insert into work_item_github_links (work_item_id, repository_id, pull_request_id, branch_name) + values ( + ${item.id}, + ${repository.id}, + ${pullRequest.id}, + ${`${projectKey.toLowerCase()}-release-pipeline`} + ) + `; + + return { project, item }; +} + +async function createProjectWithWorkItemOnly(harness, owner, workspace, projectKey, title) { + await createProjectForUser(harness.repositories.projectRepository, owner, workspace.slug, { + name: `${projectKey} Search`, + key: projectKey + }); + + const project = await getProjectForUser( + harness.repositories.projectRepository, + owner, + workspace.slug, + projectKey + ); + + const item = await createWorkItemForUser( + harness.repositories.workItemRepository, + owner, + workspace.slug, + projectKey, + { + title, + description: `${title} description`, + type: "task", + priority: "medium", + position: 0 + } + ); + + return { project, item }; +} + +async function addPullRequestLink(workspace, item, label) { + const suffix = uniqueSuffix(); + const [repository] = await sql` + insert into github_repositories ( + workspace_id, + provider_repository_id, + owner, + name, + full_name, + default_branch, + installation_id + ) + values ( + ${workspace.id}, + ${`${label.toLowerCase()}-${suffix}`}, + 'platform', + ${`${label.toLowerCase()}-repo-${suffix}`}, + ${`platform/${label.toLowerCase()}-repo-${suffix}`}, + 'main', + ${`${label.toLowerCase()}-installation-${suffix}`} + ) + returning id + `; + + const [pullRequest] = await sql` + insert into github_pull_requests ( + repository_id, + provider_pull_request_id, + number, + title, + url, + base_branch, + head_branch, + head_sha + ) + values ( + ${repository.id}, + ${`${label.toLowerCase()}-pr-${suffix}`}, + 1, + ${`${label} rollout wiring`}, + ${`https://example.com/${label.toLowerCase()}/${suffix}`}, + 'main', + ${`${label.toLowerCase()}-rollout-${suffix}`}, + ${`${label.toLowerCase()}${suffix}abcdef1234567890`} + ) + returning id + `; + + await sql` + insert into work_item_github_links (work_item_id, repository_id, pull_request_id, branch_name) + values ( + ${item.id}, + ${repository.id}, + ${pullRequest.id}, + ${`${label.toLowerCase()}-rollout-${suffix}`} + ) + `; + + return { repository, pullRequest }; +} + +test.after(async () => { + await sql.end({ timeout: 0 }); +}); + +test("project readiness search ranks grouped results and stays scoped to the requested project", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-search"); + const workspace = await harness.createWorkspace(owner, "search-alpha"); + const otherWorkspace = await harness.createWorkspace(owner, "search-beta"); + + const { project } = await createProjectWithSearchData(harness, owner, workspace, "OPS", ""); + await createProjectWithSearchData(harness, owner, otherWorkspace, "OPS", "Other workspace"); + + const results = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + " release pipeline " + ); + + assert.equal(results.query, "release pipeline"); + assert.deepEqual(results.results.map((result) => result.type), [ + "work_item", + "comment", + "plan", + "engineering" + ]); + assert.equal(results.results[0].title, "OPS-1 Fix release pipeline"); + assert.ok( + results.results.every((result) => + result.href.startsWith(`/workspaces/${workspace.slug}/projects/${project.key}`) + ) + ); + assert.equal( + results.results.find((result) => result.type === "plan")?.href, + `/workspaces/${workspace.slug}/projects/${project.key}/plan` + ); + assert.equal( + results.results.find((result) => result.type === "engineering")?.href, + `/workspaces/${workspace.slug}/projects/${project.key}/engineering` + ); + assert.ok(!results.results.some((result) => result.title.includes("Other workspace"))); +}); + +test("project readiness search returns no results for short trimmed queries", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-short"); + + assert.deepEqual( + await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + "missing-workspace", + "OPS", + " r " + ), + { query: "r", results: [] } + ); +}); + +test("project readiness search enforces workspace membership and project scope", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-access"); + const outsider = createNamedSession("outsider-access"); + const workspace = await harness.createWorkspace(owner, "search-access"); + const { project } = await createProjectWithSearchData(harness, owner, workspace, "OPS", ""); + + await assert.rejects( + () => + searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + outsider, + workspace.slug, + project.key, + "release" + ), + (error) => error instanceof Error && "status" in error && error.status === 403 + ); + + await assert.rejects( + () => + searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + "MISS", + "release" + ), + (error) => error instanceof Error && "status" in error && error.status === 404 + ); +}); + +test("project readiness search does not synthesize engineering results for plain task matches", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-plain-task"); + const workspace = await harness.createWorkspace(owner, "search-plain-task"); + const { project } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "PLN", + "Plain release pipeline task" + ); + + const results = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "release pipeline" + ); + + assert.deepEqual(results.results.map((result) => result.type), ["work_item"]); +}); + +test("project readiness search returns one engineering result per multi-link task", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-multi-link"); + const workspace = await harness.createWorkspace(owner, "search-multi-link"); + const { project, item } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "MLT", + "Prepare rollout task" + ); + + await addPullRequestLink(workspace, item, "Rollout"); + await addPullRequestLink(workspace, item, "Rollout"); + + const results = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "rollout" + ); + const engineeringResults = results.results.filter((result) => result.type === "engineering"); + + assert.equal(engineeringResults.length, 1); + assert.equal(new Set(engineeringResults.map((result) => result.id)).size, engineeringResults.length); +}); + +test("project readiness search matches scoped work item metadata and risk fields", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-risk-fields"); + const workspace = await harness.createWorkspace(owner, "search-risk-fields"); + const otherWorkspace = await harness.createWorkspace(owner, "search-risk-fields-other"); + + const { project, item } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "RSK", + "Validate launch handoff" + ); + await createProjectWithWorkItemOnly(harness, owner, otherWorkspace, "RSK", "Validate launch handoff"); + + await sql` + update tasks + set + status = 'Blocked', + priority = 'urgent', + labels = array['launch-label-only']::text[], + blocked_reason = 'release-council-only-token requires launch approval' + where id = ${item.id} + `; + + const blockedResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "blocked" + ); + const urgentResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "urgent" + ); + const labelResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "launch-label-only" + ); + const blockedReasonResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "release-council-only-token" + ); + + for (const results of [blockedResults, urgentResults, labelResults, blockedReasonResults]) { + assert.ok(results.results.some((result) => result.type === "work_item" && result.title === "RSK-1 Validate launch handoff")); + assert.ok( + results.results.every((result) => + result.href.startsWith(`/workspaces/${workspace.slug}/projects/${project.key}`) + ) + ); + } +}); + +test("project readiness search matches engineering GitHub status fields", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-github-status"); + const workspace = await harness.createWorkspace(owner, "search-github-status"); + const { project, item } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "GST", + "Validate neutral handoff" + ); + + const { pullRequest } = await addPullRequestLink(workspace, item, "Neutral"); + await sql` + update github_pull_requests + set body = 'pr-body-only-token confirms rollout readiness.' + where id = ${pullRequest.id} + `; + await sql` + insert into task_github_status (task_id, pr_status, ci_status, deploy_status) + values (${item.id}, 'Review requested', 'Failing', 'Production') + `; + + const failingResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "failing" + ); + const reviewResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "review requested" + ); + const productionResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "production" + ); + const prBodyResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "pr-body-only-token" + ); + const engineeringResult = failingResults.results.find((result) => result.type === "engineering"); + + assert.ok(engineeringResult); + assert.equal(engineeringResult.chip, "CI failing"); + assert.equal(engineeringResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); + for (const results of [reviewResults, productionResults, prBodyResults]) { + assert.ok(results.results.some((result) => result.type === "engineering")); + } +}); + +test("project readiness search returns current-user project notifications only", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-notification"); + const member = createNamedSession("member-notification"); + const workspace = await harness.createWorkspace(owner, "search-notification"); + const { project } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "NTF", + "Validate neutral notice" + ); + + await sql` + insert into workspace_members (workspace_id, user_id, role, joined_at) + values (${workspace.id}, ${member.userId}, 'member', now()) + `; + + const [event] = await sql` + insert into notification_events ( + workspace_id, + project_id, + source_type, + source_id, + event_type, + actor_id, + priority, + title, + body, + url + ) + values ( + ${workspace.id}, + ${project.id}, + 'github', + ${`notice-${uniqueSuffix()}`}, + 'github_check_changed', + ${member.userId}, + 'high', + 'Title-only deployment notice', + 'Body-only follow-up token is required.', + ${`/workspaces/${workspace.slug}/projects/${project.key}/engineering`} + ) + returning id + `; + + await sql` + insert into notification_recipients (event_id, workspace_id, recipient_id, reason) + values (${event.id}, ${workspace.id}, ${owner.userId}, 'owner') + `; + + const ownerTitleResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "title-only" + ); + const ownerBodyResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "body-only" + ); + const memberTitleResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + member, + workspace.slug, + project.key, + "title-only" + ); + const memberBodyResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + member, + workspace.slug, + project.key, + "body-only" + ); + + const titleNotificationResult = ownerTitleResults.results.find((result) => result.type === "notification"); + const bodyNotificationResult = ownerBodyResults.results.find((result) => result.type === "notification"); + + assert.ok(titleNotificationResult); + assert.ok(bodyNotificationResult); + assert.equal(titleNotificationResult.title, "Title-only deployment notice"); + assert.equal(bodyNotificationResult.title, "Title-only deployment notice"); + assert.equal(titleNotificationResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); + assert.equal(bodyNotificationResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); + assert.ok(!memberTitleResults.results.some((result) => result.type === "notification")); + assert.ok(!memberBodyResults.results.some((result) => result.type === "notification")); +}); + +test("project readiness search treats LIKE wildcards as literal query text", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-wildcard"); + const workspace = await harness.createWorkspace(owner, "search-wildcard"); + const { project } = await createProjectWithSearchData(harness, owner, workspace, "WLD", ""); + + const results = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "%%" + ); + + assert.deepEqual(results, { query: "%%", results: [] }); +}); diff --git a/tests/phase8-readiness-projection.test.mjs b/tests/phase8-readiness-projection.test.mjs new file mode 100644 index 0000000..43b21d6 --- /dev/null +++ b/tests/phase8-readiness-projection.test.mjs @@ -0,0 +1,390 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectReadiness } from "../apps/web/src/server/projects/readiness.ts"; + +const now = new Date("2026-04-26T12:00:00.000Z").toISOString(); + +function task(overrides = {}) { + return { + id: overrides.id ?? "task-1", + projectId: "project-1", + workspaceId: "workspace-1", + identifier: Object.hasOwn(overrides, "identifier") + ? overrides.identifier + : "OPS-1", + title: overrides.title ?? "Ship the readiness view", + description: overrides.description ?? "", + status: overrides.status ?? "Doing", + type: "task", + parentId: null, + assigneeId: overrides.assigneeId ?? "user-2", + priority: overrides.priority ?? "medium", + labels: overrides.labels ?? null, + workflowStateId: null, + stageId: overrides.stageId ?? "stage-current", + planItemId: overrides.planItemId ?? "plan-current", + position: overrides.position ?? 0, + blockedReason: overrides.blockedReason ?? null, + dueDate: null, + completedAt: null, + createdAt: now, + updatedAt: now, + }; +} + +function stage(overrides = {}) { + return { + id: overrides.id ?? "stage-current", + projectId: "project-1", + slug: "current", + title: overrides.title ?? "Phase 8: Readiness", + goal: "Make project readiness clear.", + status: overrides.status ?? "In Progress", + gateStatus: overrides.gateStatus ?? "In review", + sortOrder: overrides.sortOrder ?? 0, + }; +} + +function planItem(overrides = {}) { + return { + id: overrides.id ?? "plan-current", + stageId: overrides.stageId ?? "stage-current", + title: overrides.title ?? "Finish readiness projection", + outcome: "Overview can explain readiness.", + status: overrides.status ?? "Todo", + blocker: overrides.blocker ?? null, + sortOrder: overrides.sortOrder ?? 0, + }; +} + +function githubStatus(overrides = {}) { + return { + id: overrides.id ?? "github-1", + taskId: overrides.taskId ?? "task-1", + prStatus: overrides.prStatus ?? "No PR", + ciStatus: overrides.ciStatus ?? "Unknown", + deployStatus: overrides.deployStatus ?? "Not deployed", + }; +} + +function inbox(overrides = {}) { + return { + event: { + id: "event-1", + workspaceId: "workspace-1", + projectId: "project-1", + workItemId: "task-1", + sourceType: "work_item", + sourceId: "task-1", + eventType: "priority_raised", + actorId: "user-2", + priority: overrides.priority ?? "high", + title: "OPS-1 needs attention", + body: "Priority was raised.", + url: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + metadata: null, + createdAt: now, + }, + recipient: { + id: "recipient-1", + eventId: "event-1", + workspaceId: "workspace-1", + recipientId: "user-1", + reason: "participant", + readAt: overrides.readAt ?? null, + dismissedAt: null, + createdAt: now, + }, + workItemIdentifier: "OPS-1", + projectKey: "OPS", + workspaceSlug: "platform-ops", + isUnread: overrides.readAt === undefined || overrides.readAt === null, + }; +} + +test("readiness projection marks blocked projects and explains the primary blockers", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem()], + tasks: [ + task({ + identifier: "OPS-12", + title: "Fix release pipeline", + status: "Blocked", + blockedReason: "CI is failing", + }), + ], + githubStatuses: [ + githubStatus({ + taskId: "task-1", + ciStatus: "Failing", + prStatus: "Open PR", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Blocked"); + assert.match(readiness.narrative, /1 failing check/); + assert.match(readiness.narrative, /1 blocked work item/); + assert.equal(readiness.decisionCues[0]?.label, "Ship gate"); + assert.equal(readiness.actions[0]?.sourceType, "github"); + assert.equal(readiness.actions[1]?.sourceType, "work_item"); +}); + +test("readiness projection marks projects ready with risk when work remains but blockers are absent", () => { + const readiness = buildProjectReadiness({ + currentStage: stage(), + stages: [stage()], + planItems: [planItem({ status: "In Review" })], + tasks: [task({ identifier: "OPS-7", priority: "urgent" })], + githubStatuses: [ + githubStatus({ + prStatus: "Review requested", + ciStatus: "Passing", + deployStatus: "Staging", + }), + ], + engineeringItems: [], + notificationInbox: [inbox()], + }); + + assert.equal(readiness.status, "Ready with risk"); + assert.match(readiness.narrative, /urgent/); + assert.ok( + readiness.metrics.some( + (metric) => + metric.label === "Notifications" && metric.value === "1 unread" + ) + ); + assert.ok( + readiness.actions.every((action) => !action.id.startsWith("github-deploy-")) + ); + assert.ok( + readiness.actions.some((action) => action.sourceType === "notification") + ); +}); + +test("readiness projection marks stable projects ready", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done", priority: "medium" })], + githubStatuses: [ + githubStatus({ + prStatus: "Merged", + ciStatus: "Passing", + deployStatus: "Production", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready"); + assert.match(readiness.narrative, /no blocking work/); + assert.ok(readiness.metrics.some((metric) => metric.label === "GitHub")); + assert.equal(readiness.actions.length, 0); +}); + +test("readiness projection ignores completed blocked work items", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [ + task({ + status: "Done", + blockedReason: "Old blocker should not count", + completedAt: now, + }), + ], + githubStatuses: [ + githubStatus({ + prStatus: "Merged", + ciStatus: "Passing", + deployStatus: "Production", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready"); + assert.equal(readiness.actions.length, 0); +}); + +test("readiness projection ignores completed urgent work items", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done", priority: "urgent", completedAt: now })], + githubStatuses: [ + githubStatus({ + prStatus: "Merged", + ciStatus: "Passing", + deployStatus: "Production", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready"); + assert.equal(readiness.actions.length, 0); +}); + +test("readiness projection builds stable action hrefs when base project href is provided", () => { + const readiness = buildProjectReadiness({ + baseProjectHref: "/workspaces/platform-ops/projects/OPS/", + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem()], + tasks: [ + task({ + id: "task-blocked", + identifier: "OPS-12", + status: "Blocked", + blockedReason: "Waiting on approvals", + }), + task({ + id: "task-missing-identifier", + identifier: null, + priority: "urgent", + }), + ], + githubStatuses: [ + githubStatus({ + id: "github-failing", + taskId: "task-blocked", + ciStatus: "Failing", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal( + readiness.actions.find( + (action) => action.id === "blocked-work-item-task-blocked" + )?.href, + "/workspaces/platform-ops/projects/OPS/items/OPS-12" + ); + assert.equal( + readiness.actions.find( + (action) => action.id === "urgent-work-item-task-missing-identifier" + )?.href, + "/workspaces/platform-ops/projects/OPS" + ); + assert.equal( + readiness.actions.find( + (action) => action.id === "github-check-github-failing" + )?.href, + "/workspaces/platform-ops/projects/OPS/engineering" + ); + assert.equal( + readiness.actions.find((action) => action.id === "plan-plan-current")?.href, + "/workspaces/platform-ops/projects/OPS/plan" + ); +}); + +test("readiness projection explains stage-only blocked projects", () => { + const readiness = buildProjectReadiness({ + baseProjectHref: "/workspaces/platform-ops/projects/OPS", + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem({ status: "Done" })], + tasks: [], + githubStatuses: [], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Blocked"); + assert.match(readiness.narrative, /current stage is blocked/); + assert.equal(readiness.actions[0]?.sourceType, "plan"); + assert.equal( + readiness.actions[0]?.title, + "Resolve blocked stage gate: Phase 8: Readiness" + ); + assert.equal( + readiness.actions[0]?.href, + "/workspaces/platform-ops/projects/OPS/plan" + ); +}); + +test("readiness projection explains staging-only risk", () => { + const readiness = buildProjectReadiness({ + currentStage: stage(), + stages: [stage()], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done" })], + githubStatuses: [ + githubStatus({ + ciStatus: "Passing", + deployStatus: "Staging", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready with risk"); + assert.match(readiness.narrative, /staging deploy without production/); +}); + +test("readiness projection orders action categories and caps actions at five", () => { + const readiness = buildProjectReadiness({ + baseProjectHref: "/workspaces/platform-ops/projects/OPS", + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [ + planItem({ id: "plan-1" }), + planItem({ id: "plan-2", title: "Second current-stage plan item" }), + ], + tasks: [ + task({ + id: "task-blocked", + identifier: "OPS-12", + status: "Blocked", + blockedReason: "Dependency unavailable", + }), + task({ + id: "task-urgent", + identifier: "OPS-13", + priority: "urgent", + }), + ], + githubStatuses: [ + githubStatus({ + id: "github-failing", + taskId: "task-blocked", + ciStatus: "Failing", + }), + githubStatus({ + id: "github-review", + taskId: "task-urgent", + prStatus: "Review requested", + ciStatus: "Passing", + }), + ], + engineeringItems: [], + notificationInbox: [inbox()], + }); + + assert.deepEqual( + readiness.actions.map((action) => action.id), + [ + "github-check-github-failing", + "blocked-work-item-task-blocked", + "stage-gate-stage-current", + "urgent-work-item-task-urgent", + "github-pr-github-review", + ] + ); +}); diff --git a/tests/phase8-search-api.test.mjs b/tests/phase8-search-api.test.mjs new file mode 100644 index 0000000..0e75b94 --- /dev/null +++ b/tests/phase8-search-api.test.mjs @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sql } from "../packages/db/src/client.ts"; +import { createProjectRepository } from "../apps/web/src/server/projects/repository.ts"; +import { createProjectForUser, getProjectForUser } from "../apps/web/src/server/projects/service.ts"; +import { createWorkItemRepository } from "../apps/web/src/server/work-items/repository.ts"; +import { createWorkItemForUser } from "../apps/web/src/server/work-items/service.ts"; +import { createWorkspaceRepository } from "../apps/web/src/server/workspaces/repository.ts"; +import { createWorkspaceForUser } from "../apps/web/src/server/workspaces/service.ts"; + +function createSession(userId, email) { + return { + userId, + email, + displayName: email, + provider: "demo" + }; +} + +function uniqueSuffix() { + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} + +function createUniqueProjectKey() { + return `S${uniqueSuffix().replace(/[^a-z0-9]/gi, "").toUpperCase().slice(0, 7)}`; +} + +function createNamedSession(prefix) { + const suffix = uniqueSuffix(); + return createSession(`${prefix}-${suffix}`, `${prefix}-${suffix}@example.com`); +} + +async function importSearchRoute() { + try { + return await import("../apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts"); + } catch (error) { + assert.fail(`search API route is unavailable: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function createRepositories() { + return { + projectRepository: createProjectRepository(), + workItemRepository: createWorkItemRepository(), + workspaceRepository: createWorkspaceRepository() + }; +} + +function createPersistedHarness(t) { + const repositories = createRepositories(); + const workspaceIds = []; + + t.after(async () => { + for (const workspaceId of workspaceIds) { + await sql`delete from workspaces where id = ${workspaceId}`; + } + }); + + return { + repositories, + async createWorkspace(session, label) { + const suffix = uniqueSuffix(); + const workspace = await createWorkspaceForUser(repositories.workspaceRepository, session, { + name: `${label} ${suffix}`, + slug: `${label.toLowerCase()}-${suffix}` + }); + workspaceIds.push(workspace.id); + return workspace; + }, + async addMembership(workspaceId, session, role) { + return repositories.workspaceRepository.addMembership({ + workspaceId, + userId: session.userId, + role + }); + } + }; +} + +function createSearchRouteDependencies(session, repositories) { + return { + getSession: async () => session, + projectRepository: repositories.projectRepository + }; +} + +async function createProjectWithItem(harness, owner, workspace, title = "Release readiness checklist") { + const projectKey = createUniqueProjectKey(); + await createProjectForUser(harness.repositories.projectRepository, owner, workspace.slug, { + name: "Search API", + key: projectKey + }); + + const project = await getProjectForUser( + harness.repositories.projectRepository, + owner, + workspace.slug, + projectKey + ); + + const item = await createWorkItemForUser( + harness.repositories.workItemRepository, + owner, + workspace.slug, + projectKey, + { + title, + description: "Release search API result", + type: "task", + priority: "high", + position: 0 + } + ); + + return { project, item }; +} + +test.after(async () => { + await sql.end({ timeout: 0 }); +}); + +test("search API lets an authenticated workspace member search a project", async (t) => { + const { GET } = await importSearchRoute(); + const harness = createPersistedHarness(t); + const owner = createNamedSession("search-api-owner"); + const member = createNamedSession("search-api-member"); + const workspace = await harness.createWorkspace(owner, "search-api-member"); + await harness.addMembership(workspace.id, member, "member"); + const { project, item } = await createProjectWithItem(harness, owner, workspace); + + const response = await GET( + new Request(`http://localhost/api/workspaces/${workspace.slug}/projects/${project.key}/search?q=release`), + { params: Promise.resolve({ slug: workspace.slug, key: project.key }) }, + createSearchRouteDependencies(member, harness.repositories) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.query, "release"); + assert.ok(body.results.some((result) => result.type === "work_item" && result.title.includes(item.title))); +}); + +test("search API returns empty results for queries shorter than two characters", async (t) => { + const { GET } = await importSearchRoute(); + const harness = createPersistedHarness(t); + const owner = createNamedSession("search-api-short"); + const workspace = await harness.createWorkspace(owner, "search-api-short"); + const { project } = await createProjectWithItem(harness, owner, workspace); + + const response = await GET( + new Request(`http://localhost/api/workspaces/${workspace.slug}/projects/${project.key}/search?q=r`), + { params: Promise.resolve({ slug: workspace.slug, key: project.key }) }, + createSearchRouteDependencies(owner, harness.repositories) + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + query: "r", + results: [] + }); +}); + +test("search API returns 403 for non-members", async (t) => { + const { GET } = await importSearchRoute(); + const harness = createPersistedHarness(t); + const owner = createNamedSession("search-api-owner"); + const outsider = createNamedSession("search-api-outsider"); + const workspace = await harness.createWorkspace(owner, "search-api-denied"); + const { project } = await createProjectWithItem(harness, owner, workspace); + + const response = await GET( + new Request(`http://localhost/api/workspaces/${workspace.slug}/projects/${project.key}/search?q=release`), + { params: Promise.resolve({ slug: workspace.slug, key: project.key }) }, + createSearchRouteDependencies(outsider, harness.repositories) + ); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { + error: "workspace access denied." + }); +}); + +test("search API returns 404 for missing projects", async (t) => { + const { GET } = await importSearchRoute(); + const harness = createPersistedHarness(t); + const owner = createNamedSession("search-api-missing"); + const workspace = await harness.createWorkspace(owner, "search-api-missing"); + + const response = await GET( + new Request(`http://localhost/api/workspaces/${workspace.slug}/projects/MISS/search?q=release`), + { params: Promise.resolve({ slug: workspace.slug, key: "MISS" }) }, + createSearchRouteDependencies(owner, harness.repositories) + ); + + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { + error: "project not found." + }); +});