diff --git a/CHANGELOG.md b/CHANGELOG.md index eaea6e8..3fd3739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,14 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public - Public-release checklist (`docs/PUBLIC_RELEASE.md`) and clearer vulnerability reporting guidance - Default **PE Engineering Career Framework** (Graduate → CTO) seeded on empty and demo workspaces - **Cursor Cloud Agents** AI provider — use dashboard `crsr_…` keys via the Cloud Agents API (no-repo agents for digests/drafts) +- Guided **/setup** onboarding: team → assign-first roles → optional AI → integrations with credential guides → evidence backfill with progress ### Changed - Settings → Check for updates explains missing GitHub Releases (404) instead of a generic failure - MCP / AGENTS docs no longer imply a silent `PRM_PASSWORD=workbench` default - Settings → AI: OpenAI-compatible gateways are a separate provider from Cursor Cloud Agents +- Empty workspaces land in setup until finished or skipped; demo seed marks onboarding complete ### Fixed diff --git a/apps/api/src/featureRoutes.ts b/apps/api/src/featureRoutes.ts index 31544c2..e99336a 100644 --- a/apps/api/src/featureRoutes.ts +++ b/apps/api/src/featureRoutes.ts @@ -1,11 +1,16 @@ import type { Hono } from "hono"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { + achievements, cycleParticipants, cycles, documents, + feedbackItems, + goals, + integrationSettings, people, reviews, + roleAssignments, workspace, } from "@prm/db"; import { buildAiEstimate } from "./aiEstimate.js"; @@ -23,6 +28,7 @@ import { purgeEligibleCycles, readWorkspaceSettings, writeWorkspaceSettings, + type OnboardingStep, type RetentionPolicy, } from "./workspaceSettings.js"; import { getDb, id, logActivity, nowIso } from "./store.js"; @@ -60,7 +66,7 @@ export function registerFeatureRoutes(app: Hono, helpers: Helpers) { const body = await c.req.json<{ retention?: RetentionPolicy; legalHold?: boolean; - onboarding?: { completed?: boolean; step?: string }; + onboarding?: { completed?: boolean; step?: string; skipped?: Partial> }; }>(); if (body.retention && !["current_plus_previous", "current_only", "keep_all"].includes(body.retention)) { return c.json({ error: "Invalid retention policy" }, 400); @@ -335,17 +341,81 @@ export function registerFeatureRoutes(app: Hono, helpers: Helpers) { app.get("/api/onboarding", (c) => { const db = getDb(); const ws = db.select().from(workspace).limit(1).all()[0]; - const personCount = db.select().from(people).all().length; + const allPeople = db.select().from(people).all(); + const directs = allPeople.filter((p) => p.managerId); const cycleCount = db.select().from(cycles).all().filter((x) => x.status !== "purged").length; const settings = readWorkspaceSettings(); + const ai = getAiConfig(); + + let unassignedRoleCount = 0; + let thinEvidenceCount = 0; + for (const p of directs) { + const active = db + .select() + .from(roleAssignments) + .where(and(eq(roleAssignments.personId, p.id), isNull(roleAssignments.effectiveTo))) + .all()[0]; + if (!active) unassignedRoleCount += 1; + const ach = db.select().from(achievements).where(eq(achievements.personId, p.id)).all().length; + const fb = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, p.id)).all().length; + const docs = db.select().from(documents).where(eq(documents.personId, p.id)).all().length; + const goalsN = db.select().from(goals).where(eq(goals.personId, p.id)).all().length; + if (ach + fb + docs + goalsN < 2) thinEvidenceCount += 1; + } + + const integrationsEnabled = db + .select() + .from(integrationSettings) + .all() + .filter((r) => r.enabled) + .map((r) => r.id); + + const skipped = settings.onboarding?.skipped ?? {}; + const inferredStep = (): OnboardingStep => { + if (allPeople.length === 0 || directs.length === 0) return "team"; + if (unassignedRoleCount > 0 && !skipped.roles) return "roles"; + if (!ai.hasApiKey && !ai.enabled && !skipped.ai) return "ai"; + if (integrationsEnabled.length === 0 && !skipped.integrations) return "integrations"; + if (thinEvidenceCount > 0 && !skipped.backfill) return "backfill"; + return "done"; + }; + + const step = (settings.onboarding?.step as OnboardingStep | undefined) ?? inferredStep(); + const neverStarted = settings.onboarding === undefined; + const completed = + Boolean(settings.onboarding?.completed) || (neverStarted && allPeople.length > 0); + return c.json({ - completed: Boolean(settings.onboarding?.completed) || (personCount > 0 && cycleCount > 0), - step: settings.onboarding?.step ?? (personCount === 0 ? "team" : cycleCount === 0 ? "cycle" : "done"), - personCount, + completed, + step: completed ? "done" : step, + skipped, + personCount: allPeople.length, + directCount: directs.length, + unassignedRoleCount, + thinEvidenceCount, cycleCount, + hasAiKey: ai.hasApiKey, + aiEnabled: ai.enabled, + integrationsEnabled, workspaceName: ws?.name ?? null, }); }); + + app.put("/api/onboarding", async (c) => { + const body = await c.req.json<{ + completed?: boolean; + step?: string; + skipped?: Partial>; + }>(); + const next = writeWorkspaceSettings({ + onboarding: { + completed: body.completed, + step: body.step, + skipped: body.skipped, + }, + }); + return c.json({ ok: true, onboarding: next.onboarding }); + }); } /** Used by bundle export to prefer custom template fields. */ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b102972..0b61a98 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -371,7 +371,7 @@ app.post("/api/workspace/init", async (c) => { id: wsId, name: body.name || "My Team", createdAt: nowIso(), - settingsJson: JSON.stringify({ onboarding: { completed: false, step: "team" } }), + settingsJson: JSON.stringify({ onboarding: { completed: false, step: "team", skipped: {} } }), passwordHash: hash, passwordSalt: salt, }) @@ -1097,6 +1097,55 @@ app.post("/api/roles/assign", async (c) => { return c.json({ ok: true, assignmentId }); }); +app.post("/api/roles/assign-bulk", async (c) => { + const body = await c.req.json<{ + assignments?: Array<{ + personId: string; + roleDefinitionId: string; + targetNextRoleDefinitionId?: string | null; + }>; + }>(); + const list = body.assignments ?? []; + if (!list.length) return c.json({ error: "assignments required" }, 400); + if (list.length > 100) return c.json({ error: "Max 100 assignments" }, 400); + const db = getDb(); + const today = nowIso().slice(0, 10); + let saved = 0; + const errors: string[] = []; + for (const item of list) { + if (!item.personId || !item.roleDefinitionId) { + errors.push("Missing personId or roleDefinitionId"); + continue; + } + const role = db.select().from(roleDefinitions).where(eq(roleDefinitions.id, item.roleDefinitionId)).all()[0]; + if (!role) { + errors.push(`Role not found for ${item.personId}`); + continue; + } + const existing = db + .select() + .from(roleAssignments) + .where(and(eq(roleAssignments.personId, item.personId), isNull(roleAssignments.effectiveTo))) + .all(); + for (const row of existing) { + db.update(roleAssignments).set({ effectiveTo: today }).where(eq(roleAssignments.id, row.id)).run(); + } + db.insert(roleAssignments) + .values({ + id: id("ra"), + personId: item.personId, + roleDefinitionId: item.roleDefinitionId, + frameworkVersionId: role.frameworkVersionId, + targetNextRoleDefinitionId: item.targetNextRoleDefinitionId ?? null, + effectiveFrom: today, + }) + .run(); + saved += 1; + } + logActivity("role.assign_bulk", "workspace", null, { saved, errors: errors.length }); + return c.json({ ok: true, saved, errors }); +}); + app.get("/api/cycles", (c) => { const db = getDb(); const rows = db.select().from(cycles).all(); diff --git a/apps/api/src/seed.ts b/apps/api/src/seed.ts index 8f35975..f3c5745 100644 --- a/apps/api/src/seed.ts +++ b/apps/api/src/seed.ts @@ -80,7 +80,11 @@ export async function seedWorkspace(opts: { id: wsId, name: opts.name ?? "Platform Engineering", createdAt, - settingsJson: JSON.stringify({ retention: "current_plus_previous", theme: "workbench" }), + settingsJson: JSON.stringify({ + retention: "current_plus_previous", + theme: "workbench", + onboarding: { completed: true, step: "done" }, + }), passwordHash: hash, passwordSalt: salt, }).run(); diff --git a/apps/api/src/workspaceSettings.ts b/apps/api/src/workspaceSettings.ts index 0a569fc..271774e 100644 --- a/apps/api/src/workspaceSettings.ts +++ b/apps/api/src/workspaceSettings.ts @@ -4,11 +4,19 @@ import { getDb, id, logActivity, nowIso } from "./store.js"; export type RetentionPolicy = "current_plus_previous" | "current_only" | "keep_all"; +export type OnboardingStep = "team" | "roles" | "ai" | "integrations" | "backfill" | "done"; + +export type OnboardingState = { + completed?: boolean; + step?: OnboardingStep | string; + skipped?: Partial>; +}; + export type WorkspaceSettings = { retention: RetentionPolicy; legalHold: boolean; templates?: Record; - onboarding?: { completed?: boolean; step?: string }; + onboarding?: OnboardingState; }; export const DEFAULT_WORKSPACE_SETTINGS: WorkspaceSettings = { @@ -43,11 +51,22 @@ export function writeWorkspaceSettings(patch: Partial): Works const row = db.select().from(workspace).limit(1).all()[0]; if (!row) throw new Error("Workspace not initialized"); const current = readWorkspaceSettings(); + const nextOnboarding = + patch.onboarding !== undefined + ? { + ...current.onboarding, + ...patch.onboarding, + skipped: { + ...(current.onboarding?.skipped ?? {}), + ...(patch.onboarding.skipped ?? {}), + }, + } + : current.onboarding; const next: WorkspaceSettings = { ...current, ...patch, templates: patch.templates !== undefined ? patch.templates : current.templates, - onboarding: patch.onboarding !== undefined ? { ...current.onboarding, ...patch.onboarding } : current.onboarding, + onboarding: nextOnboarding, }; // Preserve unrelated settingsJson keys let raw: Record = {}; diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 9329705..8450181 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -20,6 +20,8 @@ import { SprintPulsePage } from "./pages/SprintPulsePage"; import { SearchPage } from "./pages/SearchPage"; import { BackfillPage } from "./pages/BackfillPage"; import { TemplatesPage } from "./pages/TemplatesPage"; +import { SetupPage } from "./pages/SetupPage"; +import type { OnboardingStateDTO } from "@prm/shared"; const NAV = [ { @@ -56,6 +58,7 @@ export function App() { const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [navOpen, setNavOpen] = useState(false); + const [needsSetup, setNeedsSetup] = useState(false); const { resolved, toggle } = useTheme(); const location = useLocation(); @@ -92,6 +95,26 @@ export function App() { refresh(); }, []); + const unlocked = Boolean(getToken()) && Boolean(status?.unlocked); + + useEffect(() => { + if (!status?.initialized || !unlocked) { + setNeedsSetup(false); + return; + } + let cancelled = false; + void api("/api/onboarding") + .then((o) => { + if (!cancelled) setNeedsSetup(!o.completed); + }) + .catch(() => { + if (!cancelled) setNeedsSetup(false); + }); + return () => { + cancelled = true; + }; + }, [status?.initialized, unlocked, location.pathname]); + const themeToggle = ( - - } - /> - )} - - {step === "cycle" && ( - - - Create cycle - - - Edit team - - - } - /> - )} - - {step === "done" && ( - + + Open setup + - } - /> - )} + + } + /> ); } diff --git a/apps/ui/src/pages/SetupPage.tsx b/apps/ui/src/pages/SetupPage.tsx new file mode 100644 index 0000000..6c1dc69 --- /dev/null +++ b/apps/ui/src/pages/SetupPage.tsx @@ -0,0 +1,1430 @@ +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import type { + OnboardingStateDTO, + PeopleImportResult, + PersonDTO, + RoleDefinitionDTO, +} from "@prm/shared"; +import { api } from "../lib/api"; + +const LEVELS = ["IC1", "IC2", "IC3", "IC4", "IC5", "IC6", "L1", "M1", "M2", "M3"]; + +const STEPS = [ + { id: "team", label: "Team" }, + { id: "roles", label: "Roles" }, + { id: "ai", label: "AI" }, + { id: "integrations", label: "Integrations" }, + { id: "backfill", label: "Backfill" }, + { id: "done", label: "Done" }, +] as const; + +type StepId = (typeof STEPS)[number]["id"]; + +type AssignDraft = { roleId: string; nextId: string }; + +type ThinItem = { + personId: string; + name: string; + evidenceCount: number; + thin: boolean; + achievementCount: number; + feedbackCount: number; +}; + +type AiForm = { + enabled: boolean; + provider: string; + modelDigest: string; + modelDraft: string; + localOnly: boolean; + ollamaBaseUrl: string; +}; + +const INTEGRATION_GUIDES: Record< + string, + { title: string; blurb: string; steps: string[]; links: Array<{ label: string; href: string }> } +> = { + jira: { + title: "Jira Cloud", + blurb: "Pulls cycle-window issues (and optional comments) into dossier achievements.", + steps: [ + "Open your Jira site (https://your-domain.atlassian.net).", + "Click your avatar → Account settings → Security → Create and manage API tokens.", + "Create a token, copy it once, and paste it below with your Atlassian account email.", + "Site URL is the full https://…atlassian.net host (no trailing path).", + "After save + Test, link each direct on their person page, then Sync.", + ], + links: [ + { label: "Create Atlassian API token", href: "https://id.atlassian.com/manage-profile/security/api-tokens" }, + { label: "Jira Cloud REST docs", href: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/" }, + ], + }, + bitbucket: { + title: "Bitbucket Cloud", + blurb: "Imports merged PRs in the active cycle window as achievements.", + steps: [ + "Note your workspace slug from bitbucket.org/your-workspace/….", + "Avatar → Personal settings → App passwords → Create app password.", + "Grant read access for repositories and pull requests.", + "Use your Bitbucket username (not email) plus the app password.", + "Link accounts on each person page before Sync all.", + ], + links: [ + { + label: "Create Bitbucket app password", + href: "https://bitbucket.org/account/settings/app-passwords/", + }, + ], + }, + github: { + title: "GitHub", + blurb: "Imports merged PRs for linked GitHub users in the cycle window.", + steps: [ + "GitHub → Settings → Developer settings → Personal access tokens.", + "Classic PAT: enable repo (private) or public_repo as needed.", + "Fine-grained: grant read access to the repositories you care about.", + "Optional org slug scopes searches; API base defaults to https://api.github.com.", + "Link GitHub users on person pages, then Sync.", + ], + links: [ + { label: "GitHub personal access tokens", href: "https://github.com/settings/tokens" }, + ], + }, + linear: { + title: "Linear", + blurb: "Imports completed issues assigned to linked Linear users.", + steps: [ + "Linear → Settings → API → Personal API keys.", + "Create a key (starts with lin_api_…) and paste it below.", + "GraphQL endpoint defaults to https://api.linear.app/graphql.", + "Link Linear users on person pages before syncing.", + ], + links: [{ label: "Linear API keys", href: "https://linear.app/settings/account/security/api-keys" }], + }, +}; + +async function persistStep(step: StepId, extra?: { completed?: boolean; skipped?: Partial> }) { + await api("/api/onboarding", { + method: "PUT", + body: JSON.stringify({ step, ...extra }), + }); +} + +export function SetupPage() { + const navigate = useNavigate(); + const [status, setStatus] = useState(null); + const [step, setStep] = useState("team"); + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + + async function loadStatus() { + const data = await api("/api/onboarding"); + setStatus(data); + if (data.completed) { + navigate("/", { replace: true }); + return data; + } + const allowed = STEPS.map((s) => s.id); + const next = allowed.includes(data.step as StepId) ? (data.step as StepId) : "team"; + setStep(next); + return data; + } + + useEffect(() => { + void loadStatus().catch((e) => setMsg(e instanceof Error ? e.message : "Failed to load setup")); + }, []); + + async function go(next: StepId, opts?: { skipped?: Partial>; completed?: boolean }) { + setBusy(true); + setMsg(null); + try { + await persistStep(next, opts); + if (opts?.completed) { + navigate("/", { replace: true }); + return; + } + setStep(next); + await loadStatus(); + } catch (e) { + setMsg(e instanceof Error ? e.message : "Could not save progress"); + } finally { + setBusy(false); + } + } + + async function finish() { + await go("done", { completed: true }); + } + + async function skipCurrent() { + const idx = STEPS.findIndex((s) => s.id === step); + const next = STEPS[Math.min(idx + 1, STEPS.length - 1)]!.id; + await go(next, { skipped: { [step]: true } }); + } + + if (!status) { + return ( +
+

Loading setup…

+
+ ); + } + + const stepIndex = STEPS.findIndex((s) => s.id === step); + + return ( +
+
+
+

Workspace setup

+

{status.workspaceName ?? "Your team"}

+

+ One path: team → roles → optional AI & integrations → evidence. Catalog edits stay secondary. +

+
+ +
+ +
    + {STEPS.map((s, i) => { + const done = i < stepIndex || Boolean(status.skipped?.[s.id]); + const current = s.id === step; + return ( +
  1. + +
  2. + ); + })} +
+ + {msg &&

{msg}

} + +
+ {step === "team" && ( + void go("roles")} + onChanged={() => void loadStatus()} + busy={busy} + /> + )} + {step === "roles" && ( + void go("ai")} + onSkip={() => void skipCurrent()} + busy={busy} + /> + )} + {step === "ai" && ( + void go("integrations")} + onSkip={() => void skipCurrent()} + busy={busy} + /> + )} + {step === "integrations" && ( + void go("backfill")} + onSkip={() => void skipCurrent()} + busy={busy} + /> + )} + {step === "backfill" && ( + void go("done")} + onSkip={() => void skipCurrent()} + busy={busy} + /> + )} + {step === "done" && void finish()} busy={busy} />} +
+
+ ); +} + +function StepChrome({ + title, + detail, + children, + primary, + secondary, +}: { + title: string; + detail: string; + children: ReactNode; + primary?: ReactNode; + secondary?: ReactNode; +}) { + return ( + <> +
+

{title}

+

+ {detail} +

+
+ {children} + {(primary || secondary) && ( +
+ {secondary} + {primary} +
+ )} + + ); +} + +function TeamStep({ + onContinue, + onChanged, + busy, +}: { + onContinue: () => void; + onChanged: () => void; + busy: boolean; +}) { + const [people, setPeople] = useState([]); + const [name, setName] = useState(""); + const [title, setTitle] = useState(""); + const [levelKey, setLevelKey] = useState("IC3"); + const [csvText, setCsvText] = useState(""); + const [msg, setMsg] = useState(null); + const [localBusy, setLocalBusy] = useState(false); + + async function load() { + setPeople(await api("/api/people")); + } + + useEffect(() => { + void load(); + }, []); + + const em = people.find((p) => !p.managerId); + const directs = people.filter((p) => p.managerId); + + async function addPerson(e: FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + setLocalBusy(true); + setMsg(null); + try { + await api("/api/people", { + method: "POST", + body: JSON.stringify({ + name: name.trim(), + title: title.trim() || undefined, + levelKey, + managerId: em?.id, + }), + }); + setName(""); + setTitle(""); + await load(); + onChanged(); + } catch (err) { + setMsg(err instanceof Error ? err.message : "Could not add person"); + } finally { + setLocalBusy(false); + } + } + + async function importCsv(e: FormEvent) { + e.preventDefault(); + if (!csvText.trim()) return; + setLocalBusy(true); + setMsg(null); + try { + const result = await api("/api/people/import", { + method: "POST", + body: JSON.stringify({ csv: csvText }), + }); + setMsg(`Created ${result.created.length}, skipped ${result.skipped.length}, errors ${result.errors.length}`); + if (result.created.length) { + setCsvText(""); + await load(); + onChanged(); + } + } catch (err) { + setMsg(err instanceof Error ? err.message : "Import failed"); + } finally { + setLocalBusy(false); + } + } + + return ( + + Continue to roles + + } + secondary={ + directs.length === 0 ? ( + Add at least one direct to continue. + ) : ( + + {em ? `EM: ${em.name}` : "No EM yet"} · {directs.length} direct{directs.length === 1 ? "" : "s"} + + ) + } + > + {em ? ( +

+ EM account: {em.name} + {em.title ? ` · ${em.title}` : ""} +

+ ) : ( +

+ First person without a manager becomes EM — add yourself below if create skipped your name. +

+ )} + +
+
+
+ + setName(e.target.value)} required placeholder="Maya Chen" /> +
+
+ + setTitle(e.target.value)} placeholder="Software Engineer" /> +
+
+ + +
+
+ +
+ + {directs.length > 0 && ( +
    + {directs.map((p) => ( +
  • + {p.name} + + {p.title ?? "—"} + {p.levelKey ? ` · ${p.levelKey}` : ""} + +
  • + ))} +
+ )} + +
+ Paste CSV instead +
+

+ Columns: name,email,title,levelKey,hireDate. EM must already exist. +

+