Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 76 additions & 6 deletions apps/api/src/featureRoutes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,6 +28,7 @@ import {
purgeEligibleCycles,
readWorkspaceSettings,
writeWorkspaceSettings,
type OnboardingStep,
type RetentionPolicy,
} from "./workspaceSettings.js";
import { getDb, id, logActivity, nowIso } from "./store.js";
Expand Down Expand Up @@ -60,7 +66,7 @@ export function registerFeatureRoutes(app: Hono<any>, helpers: Helpers) {
const body = await c.req.json<{
retention?: RetentionPolicy;
legalHold?: boolean;
onboarding?: { completed?: boolean; step?: string };
onboarding?: { completed?: boolean; step?: string; skipped?: Partial<Record<string, boolean>> };
}>();
if (body.retention && !["current_plus_previous", "current_only", "keep_all"].includes(body.retention)) {
return c.json({ error: "Invalid retention policy" }, 400);
Expand Down Expand Up @@ -335,17 +341,81 @@ export function registerFeatureRoutes(app: Hono<any>, 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<Record<string, boolean>>;
}>();
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. */
Expand Down
51 changes: 50 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
23 changes: 21 additions & 2 deletions apps/api/src/workspaceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean>>;
};

export type WorkspaceSettings = {
retention: RetentionPolicy;
legalHold: boolean;
templates?: Record<string, unknown>;
onboarding?: { completed?: boolean; step?: string };
onboarding?: OnboardingState;
};

export const DEFAULT_WORKSPACE_SETTINGS: WorkspaceSettings = {
Expand Down Expand Up @@ -43,11 +51,22 @@ export function writeWorkspaceSettings(patch: Partial<WorkspaceSettings>): 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<string, unknown> = {};
Expand Down
31 changes: 29 additions & 2 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -56,6 +58,7 @@ export function App() {
const [status, setStatus] = useState<WorkspaceStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [navOpen, setNavOpen] = useState(false);
const [needsSetup, setNeedsSetup] = useState(false);
const { resolved, toggle } = useTheme();
const location = useLocation();

Expand Down Expand Up @@ -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<OnboardingStateDTO>("/api/onboarding")
.then((o) => {
if (!cancelled) setNeedsSetup(!o.completed);
})
.catch(() => {
if (!cancelled) setNeedsSetup(false);
});
return () => {
cancelled = true;
};
}, [status?.initialized, unlocked, location.pathname]);

const themeToggle = (
<button
type="button"
Expand Down Expand Up @@ -149,10 +172,9 @@ export function App() {

if (!status) return <div className="unlock-screen"><p>Loading…</p></div>;

const unlocked = Boolean(getToken()) && status.unlocked;

async function handleWorkspaceDestroyed() {
setToken(null);
setNeedsSetup(false);
await refresh();
}

Expand All @@ -172,6 +194,10 @@ export function App() {
);
}

if (needsSetup && location.pathname !== "/setup") {
return <Navigate to="/setup" replace />;
}

return (
<div className={`app-shell${navOpen ? " nav-open" : ""}`}>
<header className="topbar">
Expand Down Expand Up @@ -252,6 +278,7 @@ export function App() {
</aside>
<main className="main">
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/team" element={<TeamPage />} />
<Route path="/team/:id" element={<PersonPage />} />
Expand Down
Loading