diff --git a/specs/032-user-disciplines/implementation-notes.html b/specs/032-user-disciplines/implementation-notes.html new file mode 100644 index 0000000..cf90517 --- /dev/null +++ b/specs/032-user-disciplines/implementation-notes.html @@ -0,0 +1,266 @@ + + + + + +Implementation notes · User disciplines + + + +
+
+
Spec 032 · User disciplines
+

Implementation notes

+

+ Running log of decisions, deviations, tradeoffs, and open questions captured while implementing + specs/032-user-disciplines/plan.html. Entries are appended in chronological order; each is + tagged so you can skim by category. +

+
+ +

Decisions

+ +
+ setup + decision + Use existing main-repo secrets verbatim in the worktree's .env.local. + The plan does not specify how to bootstrap env vars for the worktree. I copied every secret from + C:\Repos\ai-developer-hub\.env.local verbatim and only swapped DATABASE_URL / + DATABASE_URL_UNPOOLED to the new Neon branch host + ep-fragrant-fire-alm1dt17 (branch wt/introduce-roles, id + br-empty-voice-alx69zhl). The new branch is a copy-on-write fork of the main Neon branch + (br-quiet-brook-al03w27g, 184 users at branch time), so schema work is safely isolated. +
+ +
+ verify + decision + Browser verification — all surfaces green. + Walked through the dev server (localhost:3000) authenticated as the Nighthawk agent. Verified: + + Test user id=185 was cleaned up after verification (along with its invite-token and change-history + rows). Screenshots in screenshot-users-table.png and screenshot-profile.png. +
+ +

Deviations

+ +
+ github-sync + deviation + GitHub sync — "import as-is" path does NOT get a discipline picker. + The plan said both confirmGitHubSync insert sites should pull discipline from input. + But there are two distinct flows: + + For the "import as-is" path I hardcoded discipline: "developer" (matches the existing + role: "viewer" hardcoding on the same insert). Adding a discipline picker to the bulk + "tick to import" checkbox UI would balloon scope (it's a multi-select grid, not a per-row form). + Admins can reclassify any mis-attributed user from /users after the sync. Same fallback + the plan uses for the seeded agent user. +
+ +

Tradeoffs

+ +
+ validators + tradeoff + Zod 4 message: over errorMap:. + The plan's Zod sketch used z.enum(values, { errorMap: () => ({ message: "..." }) }), + which is Zod 3 syntax. The project is on Zod 4.3.6 where errorMap was renamed. + Switched to { message: "Please select a discipline" } — same UX, current API. +
+ +
+ disciplines.ts + tradeoff + Added isDiscipline() type guard not in the plan. + The plan only listed the const arrays + icon map. I added a one-line type-guard + isDiscipline(value) for safe CSV value narrowing. Trivial; kept because the + bulk-import path needs to narrow string to UserDiscipline when + forwarding to the typed action. +
+ +
+ change-history + tradeoff + Discipline diffs go through the same changeHistory table as role/circle. + The plan said "matches existing role/circle behavior". Confirmed: updateUser now records + a discipline change-history row when the value changes, with old/new values. The + user-detail page's history section already renders fieldName + previousValue + newValue + generically, so no UI work needed to surface discipline changes there. +
+ +
+ simplify-review + tradeoff + Defensive asDiscipline() on every icon-lookup site. + The /simplify code review (5 independent finder angles) flagged that + DISCIPLINE_ICON[value] and DISCIPLINE_LABEL[value] would crash with + "Element type is invalid" if a row ever surfaced an unexpected enum value (future migration + landing before the matching frontend bundle, manual SQL insert, stale SSR cache). Today the DB enum + constrains values to the three known members, so the bug is latent — but the fix is cheap: + a new asDiscipline(value) helper in src/lib/disciplines.ts falls back to + DEFAULT_DISCIPLINE for unrecognized values. Applied at four sites: users-table cell, + user-detail-client header, profile-header badge, assignment-detail subline. + The other review findings were either pre-existing patterns (naive CSV split, updateUser race), + already-documented deviations (GitHub "import as-is" defaults to developer), or behavioral changes + already noted in the release-note recommendation (CSV column-order shift). Single defensive fix + landed; the rest carry forward as known follow-ups. +
+ +

Open questions

+ +
+ analytics + open + Claude analytics pages still hide discipline. + Per plan §8 "out of scope this PR", I left the following unchanged: + + Confirm this is fine, or queue a follow-up spec to surface discipline as a filter/breakdown in + Claude reports. Recommend the follow-up — it's the natural payoff of capturing the data. +
+ +
+ github-sync + open + "Import as-is" GitHub members default silently to developer. + Detailed in the deviation above. If "import as-is" is heavily used at Unic and most GitHub members + are NOT developers, we should add a per-row discipline picker to that flow too — or change + the default to nothing and force admins through the inline-create form. Today's behaviour: defaults + to developer, admin reclassifies in /users after the fact. +
+ +
+ + diff --git a/specs/032-user-disciplines/plan.html b/specs/032-user-disciplines/plan.html new file mode 100644 index 0000000..89e9a16 --- /dev/null +++ b/specs/032-user-disciplines/plan.html @@ -0,0 +1,1318 @@ + + + + + +Plan · Introduce user disciplines + + + + +
+ + + +
+ +
+
Spec 032 · User disciplines · Implementation plan
+

Implementation Plan — Introduce discipline on users

+

+ Add a mandatory discipline field to every user record so the tool can correctly attribute + Conception and Business colleagues alongside Developers. This plan walks every input mask, display + surface, automated user creation path, and test fixture that must change, in a safe rollout order. +

+
+
Status
Plan · v1
+
Field name
discipline
+
Values
developer / conception / business
+
Backfill
developer (DB default)
+
+ +
+
1
New enum
+
1
New column
+
~25
Files touched
+
0
Breaking changes
+
+
+ + +

Scope & locked-in decisions

+ +

+ The proposal in specs/032-user-disciplines/proposal.html settled the naming question. This + plan assumes the following decisions are locked: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DecisionLocked answerRationale
Field namedisciplineDistinguishes cleanly from security role; agency-native vocabulary.
Enum valuesdeveloper · conception · businessClosed set; extend later via ALTER TYPE … ADD VALUE if needed.
NullabilityNOT NULLEvery user must have a discipline. DB default developer for backfill safety.
Required in UI?Yes on create; editable on updateForce admin to make a conscious choice in the form. DB default catches edge cases only.
Required in CSV import?Optional columnNew rows fall back to developer; upsert preserves existing value when column is blank.
NextAuth session?Not added to sessionDiscipline is descriptive, not authorizing. Keep JWT lean; fetch from DB where needed.
Profile API response?Yes, include disciplineExternal consumers (Profile API preview, downstream tooling) will want it.
Reports breakdown?Out of scope for this PRLand data first; reporting follow-up gets its own spec.
GitHub sync inline form?Add discipline pickerCheap to add; GitHub members are not all developers (PMs, designers).
Agent user discipline?developer (default)Keeps NOT NULL contract simple; hidden from UI.
+ +

Files in scope (high-level)

+ + + + + + + + + + + + + + + +
AreaFilesImpact
Schema & migration2High
Types & validators3High
Server actions & API5High
UI forms & dialogs5High
UI tables & display4Medium
Automated paths (sync, seeds)3Medium
Tests & fixtures5+Medium
Docs / CLAUDE.md1Low
+ + +

Schema & migration

+ +
+ Goal: add enum + column with safe backfill + Files: 2 + Risk: low (additive, NOT NULL with default) +
+ +

Edit: src/lib/db/schema.ts

+ +

Add the new enum next to userProfileEnum (currently lines 39–43), then add the column to + the users table.

+ +
@@ src/lib/db/schema.ts @@ + export const userProfileEnum = pgEnum("user_profile", [ + "boost", + "maxed", + "indie", + ]); ++ ++export const userDisciplineEnum = pgEnum("user_discipline", [ ++ "developer", ++ "conception", ++ "business", ++]); + + // ... inside users table definition ... + profile: userProfileEnum("profile"), ++ discipline: userDisciplineEnum("discipline").notNull().default("developer"), + mustChangePassword: boolean("must_change_password").notNull().default(true),
+ +

New: src/lib/db/migrations/00XX_add_user_discipline.sql

+ +

Generated by pnpm db:generate. Expected output (verify before committing):

+ +
migrations/00XX_add_user_discipline.sqlCREATE TYPE "public"."user_discipline" AS ENUM('developer', 'conception', 'business');
+
+ALTER TABLE "users"
+  ADD COLUMN "discipline" "user_discipline"
+    DEFAULT 'developer' NOT NULL;
+ +
+ Why this is safe + On Postgres 11+ ADD COLUMN … NOT NULL DEFAULT 'developer' is a metadata-only operation — + no row rewrite, no table lock beyond the brief AccessExclusive needed for the catalog + update. Safe to apply on any production-sized users table. +
+ +

Database review

+ +

+ Use the drizzle-migration-reviewer agent on the generated SQL before merging. The migration + should pass cleanly — no destructive operations, no FK changes. +

+ + +

Types & constants

+ +
+ Goal: expose a typed discipline value across the codebase + Files: 1 + 1 new helper +
+ +

Edit: src/types/index.ts

+ +

Existing User is InferSelectModel<typeof users>, so it inherits the new + column automatically. Add an explicit UserDiscipline alias for downstream consumers.

+ +
export type UserRole = "admin" | "viewer"; + export type UserStatus = "active" | "inactive"; + export type UserProfile = "boost" | "maxed" | "indie"; ++export type UserDiscipline = "developer" | "conception" | "business";
+ +

New: src/lib/disciplines.ts

+ +

+ Single source of truth for the human-readable labels and icon mapping. Keeps display strings out of the + schema and out of individual components. +

+ +
src/lib/disciplines.tsimport { Code2, Lightbulb, Briefcase } from "lucide-react";
+import type { UserDiscipline } from "@/types";
+
+export const DISCIPLINES: readonly UserDiscipline[] = [
+  "developer",
+  "conception",
+  "business",
+] as const;
+
+export const DISCIPLINE_LABEL: Record<UserDiscipline, string> = {
+  developer:  "Developer",
+  conception: "Conception",
+  business:   "Business",
+};
+
+export const DISCIPLINE_ICON = {
+  developer:  Code2,
+  conception: Lightbulb,
+  business:   Briefcase,
+} as const;
+
+export const DEFAULT_DISCIPLINE: UserDiscipline = "developer";
+ + +

Validators

+ +
+ Goal: require discipline at every entry point + Files: 1 +
+ +

Edit: src/lib/validators.ts

+ +

Four schemas need to learn about discipline. The exact required-ness differs by entry point.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
SchemaDiscipline fieldWhy
userSchema (create)RequiredAdmin must classify a new user when filling the form. UI surfaces it as a required select.
updateUserSchema (partial)OptionalPartial PATCH — admin may edit name only without re-sending discipline.
bulkImportUserSchemaOptionalCSV column is optional. Missing → defaults to developer for new rows; preserved on upsert.
inlineUserCreationSchema (GitHub sync)RequiredInline creation during sync — admin picks discipline alongside name/email in the unmatched member card.
+ +
// src/lib/validators.ts ++const disciplineValues = ["developer", "conception", "business"] as const; + + export const userSchema = z.object({ + name: z.string().min(1, "Name is required").max(255), + email: z.string().email("Invalid email address"), + circle: z.string().max(100).optional(), + role: z.enum(["admin", "viewer"]), ++ discipline: z.enum(disciplineValues), + githubUsername: z.string().max(255).optional(), + profile: z.enum(["boost", "maxed", "indie"]).optional(), + }); + + export const updateUserSchema = z.object({ + id: z.number().int().positive(), + /* ... existing fields ... */ ++ discipline: z.enum(disciplineValues).optional(), + }); + + export const bulkImportUserSchema = z.object({ + /* ... existing fields ... */ ++ discipline: z.enum(disciplineValues).optional(), + }); + + export const inlineUserCreationSchema = z.object({ + githubLogin: z.string().min(1), + name: z.string().min(1).max(255), + email: z.string().email(), ++ discipline: z.enum(disciplineValues), + });
+ +
+ Why a const tuple? + Defining disciplineValues once and reusing it in every z.enum() keeps the four + schemas in sync. If we ever add a fourth value, only one line changes. +
+ + +

Server actions & API

+ +
+ Goal: persist and return the new field correctly + Files: 5 +
+ +

Edit: src/actions/users.ts

+ + + +

Edit: src/types/index.ts · ExistingUserFields

+ +
export type ExistingUserFields = { + name: string; + circle: string | null; + role: UserRole; + githubUsername: string | null; + profile: UserProfile | null; ++ discipline: UserDiscipline; + };
+ +

Edit: src/app/api/export/users/route.ts

+ +

Add a discipline column to the CSV output. Place between circle and + role so it groups with the other categorical attributes.

+ +
- const header = "name,email,circle,role,github_username,profile"; ++ const header = "name,email,circle,discipline,role,github_username,profile"; + + // ... per-row mapping ... +- csvRow(u.name, u.email, u.circle, u.role, u.githubUsername, u.profile) ++ csvRow(u.name, u.email, u.circle, u.discipline, u.role, u.githubUsername, u.profile)
+ +
+ Backwards compatibility + Anyone re-importing an old exported CSV (without the new column) will land in the optional path: + existing users keep their value, new rows default to developer. Document this in the + release notes so admins aren't surprised when historic exports re-import cleanly. +
+ +

Edit: src/app/api/profile/route.ts

+ +

The Bearer-token-authed profile API consumed by external tooling. Add discipline to the + JSON shape returned per email.

+ +
return NextResponse.json({ + name: user.name, + email: user.email, + role: user.role, + circle: user.circle, + profile: user.profile, + status: user.status, ++ discipline: user.discipline, + lastSync: syncStatus.lastSyncCompletedAt, + });
+ +

Skip: NextAuth session (src/lib/auth.ts)

+ +

+ Do not add discipline to the session token. Discipline does not gate any route or action; + adding it to the JWT bloats every request and creates a stale-data problem (admin edits a user's + discipline → that user's session keeps the old value until next login). If a client component truly + needs the current user's discipline, fetch it from /api/profile or via a Server Component. +

+ +

Edit: src/types/next-auth.d.ts

+ +

No change. Confirmed in scope-review.

+ + +

User creation UI

+ +
+ Goal: every new-user entry mask collects discipline + Files: 2 +
+ +

Edit: src/app/users/new/new-user-form.tsx

+ +

+ The dedicated /users/new form. Add a required Select field. Place it + after circle and before role — disciplines describe what the person + does (closer to circle); role is security (separate concern). +

+ + + +
JSX sketch<FormField name="discipline" render={({ field }) => (
+  <FormItem>
+    <FormLabel>Discipline</FormLabel>
+    <Select onValueChange={field.onChange} value={field.value}>
+      <FormControl>
+        <SelectTrigger>
+          <SelectValue placeholder="Select a discipline" />
+        </SelectTrigger>
+      </FormControl>
+      <SelectContent>
+        {DISCIPLINES.map((d) => (
+          <SelectItem key={d} value={d}>{DISCIPLINE_LABEL[d]}</SelectItem>
+        ))}
+      </SelectContent>
+    </Select>
+    <FormMessage />
+  </FormItem>
+)} />
+ +

Edit: src/components/inline-user-form.tsx

+ +

+ The compact form used inside the GitHub member sync flow ("create unmatched user inline"). Today it + captures only githubLogin / name / email and the server action hardcodes role = + "viewer". Add the discipline picker — it is required. +

+ +
+ Why not just default to developer here? + GitHub members do not all map to developers (PMs, designers, account managers all use GitHub at Unic). + Auto-defaulting to developer from the sync flow would silently mis-classify these users + and pollute downstream reports. A required select keeps the data clean for ~1 second more of admin + effort per unmatched member. +
+ + +

User edit UI

+ +
+ Goal: every edit mask exposes discipline + Files: 2 +
+ +

Edit: src/components/edit-user-dialog.tsx

+ +

+ The modal opened from the users-table row kebab. Add a Select field for discipline, + pre-filled from user.discipline, placed between circle and role to match the create form's + layout. Update the defaultValues and the form.reset() call in the + useEffect. +

+ +
const form = useForm<EditUserInput>({ + resolver: zodResolver(editUserSchema), + defaultValues: { + name: user.name, + email: user.email, + circle: user.circle ?? undefined, ++ discipline: user.discipline, + role: user.role as "admin" | "viewer", + githubUsername: user.githubUsername ?? "", + profile: user.profile ?? null, + }, + });
+ +

Also update the form.reset() block inside useEffect(open) to include the same + field.

+ +

Edit: src/app/users/[id]/user-detail-client.tsx

+ +

The full-page edit form on the user detail screen. Same change pattern as the dialog — add the field + in the same position. Update the diff-summary section ("changed: …") to include discipline.

+ +

Profile self-edit?

+ +

+ src/app/profile/profile-client.tsx is read-only today. We do not let a + user edit their own discipline — that is an admin-managed attribute. We do display it (see next + section). +

+ + +

User display UI

+ +
+ Goal: show discipline everywhere a user appears + Files: 4 +
+ +

Edit: src/app/users/users-table.tsx

+ +

Add a new column Discipline, sortable, with a faceted filter (multi-select), placed between + Circle and Role.

+ +
column definition sketch{
+  accessorKey: "discipline",
+  header: ({ column }) => <DataTableColumnHeader column={column} title="Discipline" />,
+  cell: ({ row }) => {
+    const d = row.original.discipline;
+    const Icon = DISCIPLINE_ICON[d];
+    return (
+      <span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
+        <Icon className="h-3.5 w-3.5" />
+        {DISCIPLINE_LABEL[d]}
+      </span>
+    );
+  },
+  filterFn: "arrIncludesSome",
+}
+ +

+ Match the existing pattern used by circle and profile filters in the same + table. Use outlined / muted styling — discipline should not compete visually with the colored role + badge. +

+ +

Edit: src/app/users/[id]/user-detail-client.tsx (header section)

+ +

Add a discipline badge to the read-only header alongside role/status/profile. Outlined badge with + lucide icon. Suggested ordering of badges: role · status · discipline · profile — security + first, then identity attributes.

+ +

Edit: src/components/profile/profile-header.tsx

+ +

The logged-in user's own profile view at /profile. Add discipline as a badge alongside + circle/profile. Read-only; admin-managed.

+ +

Edit: src/app/assignments/[id]/assignment-detail-client.tsx

+ +

The single-assignment detail page shows "assigned to X" with name. Add discipline as a + sub-line next to circle so the budget owner can see at a glance who they're allocating to.

+ +

Out of scope this PR

+ + + + +

Selectors & pickers

+ +
+ Goal: keep pickers focused; no discipline noise + Files: 0 (no changes) +
+ +

+ Existing user pickers (src/components/user-combobox.tsx, + src/components/user-search-combobox.tsx) display name + email. They should + not show discipline by default — picker UX wants minimum visual noise. If a future + task needs disambiguation by discipline (e.g. assigning a Claude Maxed seat only to developers), + reconsider per-use-site. +

+ + +

CSV import & export

+ +
+ Goal: round-trip the field through CSV + Files: 2 +
+ +

Export (already covered above)

+ +

New CSV header: name,email,circle,discipline,role,github_username,profile.

+ +

Edit: src/app/users/import/bulk-import-form.tsx

+ + + +

CSV header migration

+ +

+ Existing CSVs in the wild (e.g. an admin's local export from yesterday) do not have + this column. They must still import cleanly. The implementation MUST: +

+
    +
  1. Detect the column by header name, not position.
  2. +
  3. Treat discipline as optional in the import schema.
  4. +
  5. Skip discipline from the upsert SET clause when the column is absent or blank.
  6. +
+ + +

GitHub sync & auto-create paths

+ +
+ Goal: automated paths can't sneak in NULL or default-only users + Files: 1 +
+ +

Edit: src/actions/github-sync.ts

+ +

+ confirmGitHubSync() creates users in two places (the report flagged lines ~305–316 and + ~472–481). Both pass role = "viewer" hardcoded. We need both to also pass + discipline. +

+ +

+ Source of the discipline value: the inline form (per decision above) now collects it as part of each + newUsers entry in the ConfirmSyncInput. The server action just reads it from + the input, with no hardcoding required. +

+ +
// creating brand-new users in the import branch + for (const newUser of input.newUsers) { + await tx.insert(users).values({ + name: newUser.name, + email: newUser.email, + passwordHash: await hashPlaceholder(), + githubUsername: newUser.githubLogin, + role: "viewer", ++ discipline: newUser.discipline, + status: "active", + mustChangePassword: true, + }); + }
+ +

No change: invite flow (src/actions/invite.ts)

+ +

+ The invite flow only writes passwordHash and mustChangePassword; it does not + create users. Discipline is set when the admin creates the user record, then the invite token is issued + for that already-classified record. No change needed. +

+ +

No change: Copilot sync

+ +

+ Copilot sync writes copilot_usage_metrics and copilot_seats; it does not + create or modify users rows directly. No change. +

+ +

No change: NextAuth authorize()

+ +

Reads user fields; does not write. No change.

+ + +

Seed scripts

+ +
+ Goal: seeded users have a discipline + Files: 2 +
+ +

Edit: src/lib/db/seed.ts

+ +
await db.insert(users).values({ + name: "Admin", + email: adminEmail, + passwordHash: hashedPassword, + circle: "Engineering", + role: "admin", ++ discipline: "developer", + status: "active", + mustChangePassword: false, + });
+ +

Edit: src/lib/db/seed-agent-user.ts

+ +

Same shape — seed the agent user with discipline: "developer". Hidden from UI; this is + only to keep the NOT NULL contract satisfied.

+ + +

Tests & fixtures

+ +
+ Goal: all tests construct valid users; new behaviors covered + Files: 5+ +
+ +

Existing fixtures to update

+ + + + + + + + + + +
FileChange
tests/unit/agent-auth.test.tsAdd discipline: "developer" to mockAgentRow.
tests/unit/api/profile.test.tsAdd field to mockUser; assert discipline appears in the JSON response.
tests/unit/api/assignments-export.test.tsAdd field to mockAdminUser.
tests/unit/reports/circle-report.test.tsAdd field to all user fixtures.
tests/unit/anthropic-users-phase2.test.tsAdd field to user objects in the test setup.
+ +

New tests to add

+ + + +

CSV fixture updates

+ +

If tests/ contains a sample CSV used by the bulk-import test (search: + *.csv), produce one with and one without the discipline column — both must import + cleanly.

+ + +

Documentation

+ +
+ Goal: the next contributor knows the field exists + Files: 1–2 +
+ + + + +

Verification checklist

+ +

Before marking the PR done, walk through this list with the dev server running.

+ +

Schema & data

+ + +

Backend

+ + +

UI — create

+ + +

UI — edit

+ + +

UI — display

+ + +

CSV

+ + +

GitHub sync

+ + + +

Risks & mitigations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RiskLikelihoodImpactMitigation
Misclassification: existing Conception/Business users sit at developer after backfill until reclassified.HighMedSend the release-note ping to admins immediately after deploy; expose a "discipline = developer with no GitHub username" quick-filter so reclassification candidates are easy to spot.
Old CSV imports break because admins assume the column is required.LowMedBulk import schema makes the column optional; preview UI surfaces "defaulted" rows clearly.
External profile API consumers can't parse the new field.LowLowAdditive change to the JSON response — old consumers ignoring new fields keep working.
Forgotten display surface (e.g. an unexpected place where users render).LowLowAudit completed via subagents — all known surfaces in §6–§9. Search regression: grep -r "user\.role" src as a sanity check.
Enum gap discovered post-launch (e.g. need a "Design" value).MedLowALTER TYPE user_discipline ADD VALUE 'design' is non-blocking; UI label map is one line.
NextAuth session becomes a foot-gun if someone later adds discipline to the JWT and forgets it goes stale.LowMedCode-level comment in src/lib/auth.ts explaining the deliberate omission.
+ + +

Rollout sequence

+ +

Single PR. Commits in this order to keep the diff reviewable:

+ +
    +
  1. Schema + migration. Add enum + column to schema.ts; run pnpm db:generate; commit the generated SQL. Run drizzle-migration-reviewer.
  2. +
  3. Types + helper. Add UserDiscipline type, src/lib/disciplines.ts, update ExistingUserFields.
  4. +
  5. Validators. Update the four Zod schemas. Verify pnpm typecheck.
  6. +
  7. Server actions. Update createUser, updateUser, bulkImportUsers, checkExistingUsers, confirmGitHubSync.
  8. +
  9. API routes. Update /api/export/users and /api/profile.
  10. +
  11. Seed scripts. Update both seed files.
  12. +
  13. UI — creation. Update new-user-form and inline-user-form.
  14. +
  15. UI — edit. Update edit-user-dialog and user-detail-client.
  16. +
  17. UI — display. Update users-table, profile header, assignment detail.
  18. +
  19. CSV import. Update bulk-import-form with parsing, preview, and validation.
  20. +
  21. Tests. Update fixtures; add new unit/integration/e2e tests.
  22. +
  23. Verification. Run through the checklist in §15 against a local dev server.
  24. +
  25. Release note. Draft the admin ping; ship after merge to main & production deploy.
  26. +
+ +
+ Done definition + Migration applied, all four Zod schemas validate, every UI surface in §6–§9 shows discipline, the + verification checklist in §15 is fully green, and the admin release note has been sent. +
+ +
+
+ + diff --git a/specs/032-user-disciplines/proposal.html b/specs/032-user-disciplines/proposal.html new file mode 100644 index 0000000..c8f8547 --- /dev/null +++ b/specs/032-user-disciplines/proposal.html @@ -0,0 +1,837 @@ + + + + + +Proposal · Categorizing users by discipline + + + + +
+ + + +
+ +
+
Spec 032 · User disciplines
+

Proposal — Categorizing users by discipline

+

+ Today every user is implicitly a developer. As the team expands the tool to cover Conception and + Business colleagues, we need a way to record what kind of work a person does — without colliding with + the existing security role column. This document proposes naming, schema, UI, and a + migration approach as a starting point for the implementation plan. +

+
+
Status
Proposal · v0
+
Author
tobias.studer@unic.com
+
Schema impact
1 enum · 1 column
+
Breaking
No (backfilled)
+
+
+ + +

Context & goal

+ +

+ The user record in src/lib/db/schema.ts currently captures four orthogonal facets of a + person: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnTypePurpose today
roleuser_role enum (admin | viewer)Security / authorization. Drives what the user can do in the app.
circlevarchar(100), nullable, free-textOrganizational grouping (a team or circle inside the company). Used for filtering/reporting.
profileuser_profile enum (boost | maxed | indie)Anthropic API tier the user is on. Drives cost attribution.
github_usernamevarchar(255), nullableGitHub identity for Copilot sync.
+ +

+ None of these capture what kind of work the person does. Up to now that has been irrelevant + because every tracked user was a developer. With Conception and Business now in scope, the budget / + reporting story needs to slice spend along that axis — e.g. "how much do our Conception colleagues + cost on Claude this month?" +

+ +

+ Goal: add a single mandatory categorical field on every user that records whether they + are Developer, Conception, or Business. Pick a name that does not collide + with the existing security role. +

+ +
+ Non-goal + We are not redesigning role (security) or profile (Anthropic tier). Those stay + as they are. This proposal only adds the new categorical field. +
+ + +

The naming problem

+ +

+ “Role” is already taken — using it again for the new field would force every conversation + and code-review to disambiguate which role. The candidates below were evaluated against: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameProsCons
discipline Recommended + Standard agency vocabulary for "Engineering / Conception / Business / Design". Translates 1:1 to + German (Disziplin). Zero overlap with role. Reads naturally: + user.discipline = "conception". + Slightly formal. Some readers may first think of "academic discipline".
practice + Also agency-native ("design practice", "engineering practice"). Implies a community of expertise. + + Overloaded in software (e.g. "best practices"). German translation (Praxis) is awkward + for an individual person — practices are usually groups, not labels. +
functionHR-standard. Unambiguous as "job function". + Word also means a code function — high collision in a TypeScript codebase. Reads corporate / + cold. +
trackShort. Common in career-ladder talk. + Implies a progression / level (IC1 → IC5), which is not what we mean. Likely to cause + confusion. +
craftWarm, agency-ish. Used at design-heavy shops.Strongly biased toward "design/build" connotation — fits Conception & Dev but not Business.
category / typeGeneric; nobody will misread it. + Too generic — gives the reader no hint of what's inside. Future contributors will need to read the + enum to find out. +
departmentHR-standard, obvious. + We already had a department column in migration 0000 that was renamed to + circle. Reintroducing it inverts that decision and re-creates ambiguity with + circle. +
+ +
+ Recommendation + Adopt discipline. It is unambiguous next to role, matches how Unic and similar + agencies already describe the Dev/Conception/Business split, and survives translation. Runner-up: + practice if the team feels "discipline" is too formal. +
+ + +

Discipline values

+ +

Proposed enum, stored lowercase to match existing conventions (admin, viewer, + boost, …):

+ + + + + + + + + + + + + + + + + + + + + + +
Enum valueDisplay labelWho
developerDeveloperEngineers — frontend, backend, mobile, DevOps. The current population.
conceptionConceptionUX, IA, content strategy, service design — the strategic / conceptual work upstream of build.
businessBusinessProject management, sales, account, leadership — non-craft roles that still use AI tools.
+ +
+ Decision needed + Should the enum stay closed (these three only) or be extensible (e.g. Design, Data, + QA later)? Adding values to a Postgres enum is cheap (ALTER TYPE … ADD VALUE), + removing them is hard. Recommend: start closed at these three, add more only when a real population + shows up. See open questions. +
+ + +

Data model

+ +

Schema change

+ +

+ Add a new enum and a non-null column on users. The column is required from day one — every + user must have a discipline — and the migration backfills existing rows to developer. +

+ +
src/lib/db/schema.ts// 1. New enum, alongside userRoleEnum / userStatusEnum / userProfileEnum
+export const userDisciplineEnum = pgEnum("user_discipline", [
+  "developer",
+  "conception",
+  "business",
+]);
+
+// 2. Column inside the users table
+export const users = pgTable("users", {
+  // ... existing columns ...
+  discipline: userDisciplineEnum("discipline")
+    .notNull()
+    .default("developer"),
+  // ... existing columns ...
+});
+ +

SQL migration sketch

+ +

Drizzle will emit something close to this. The default + backfill makes it safe on a live table:

+ +
src/lib/db/migrations/00XX_add_user_discipline.sqlCREATE TYPE user_discipline AS ENUM ('developer', 'conception', 'business');
+
+ALTER TABLE users
+  ADD COLUMN discipline user_discipline
+    NOT NULL DEFAULT 'developer';
+
+-- No backfill UPDATE needed: the DEFAULT applies to existing rows during ADD COLUMN.
+-- After the release, admins reclassify Conception / Business users via the UI.
+ +
+ Why default to developer? + It matches today's reality — every existing user is a developer — so the backfill is correct + data, not a placeholder. New users created after the migration must pick a value explicitly via the UI + (we'll surface the field as required in the form, even though the DB default exists as a safety net). +
+ + +

Validators

+ +

+ Mirror the enum into src/lib/validators.ts and require it on creation; allow partial + updates on edit. +

+ +
// src/lib/validators.ts + export const userSchema = z.object({ + name: z.string().min(1).max(255), + email: z.string().email(), + circle: z.string().max(100).optional(), + role: z.enum(["admin", "viewer"]), ++ discipline: z.enum(["developer", "conception", "business"]), + githubUsername: z.string().max(255).optional(), + profile: z.enum(["boost", "maxed", "indie"]).optional(), + }); + + export const updateUserSchema = z.object({ + id: z.number().int().positive(), + /* ... */ ++ discipline: z.enum(["developer", "conception", "business"]).optional(), + }); + + export const bulkImportUserSchema = z.object({ + /* ... */ ++ discipline: z.enum(["developer", "conception", "business"]).optional(), + // optional on bulk import — falls back to "developer" if omitted, + // mirroring the DB default for back-compat with existing CSVs. + });
+ + +

UI surface

+ +

The new field touches every screen that displays or edits a user.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SurfaceChangeEffort
src/app/users/new/new-user-form.tsxAdd a Select with the three options. Required field. Default selection blank (force a conscious choice).S
src/app/users/[id]/user-detail-client.tsxEditable Select. Show current value prominently next to the security role badge.S
src/app/users/users-table.tsxNew column Discipline, sortable, with a column filter (multi-select). Place between Circle and Role.S
src/app/users/import/bulk-import-form.tsxAccept a discipline column in the CSV. Show it in the preview table. Validate against the enum; reject rows with unknown values.M
src/app/api/export/users/route.tsAdd discipline column to CSV export (between circle and role to match the table).S
Reports (spec 005, 028)Add a discipline filter / breakdown to budget & cost reports — secondary follow-up, not part of this proposal's first cut.L (later)
+ +

Visual treatment

+ +

+ Discipline should read as a calm, neutral attribute — not a status. Suggest a subtle outlined badge + next to the user's name, using lucide-react icons for visual scent: +

+ + + +

+ Avoid colored badges that compete with the existing admin / viewer role pills — those + already use color and should stay the eye-catcher for authorization. +

+ + +

CSV import & export

+ +

+ The team relies on the export → edit → re-import workflow (spec 011). Both ends need to learn about the + new column. +

+ +

Export

+
// Current header
+name,email,circle,role,github_username,profile
+
+// New header
+name,email,circle,discipline,role,github_username,profile
+ +

Import

+ + + +

Migration & backfill

+ +

The rollout is mechanical and low-risk:

+ +
    +
  1. Add enum + column to schema.ts; pnpm db:generate to produce the migration file.
  2. +
  3. Run pnpm db:migrate — existing rows get developer via the column default.
  4. +
  5. Update Zod validators, server actions, and UI in one PR.
  6. +
  7. Update CSV import/export endpoints.
  8. +
  9. Communicate to admins: "new field on every user; defaulted to Developer; please reclassify Conception & Business colleagues at /users".
  10. +
+ +
+ Migration safety + Using ADD COLUMN … NOT NULL DEFAULT 'developer' on Postgres 11+ is a metadata-only + operation (no table rewrite). Safe even on a large users table. +
+ + +

Relationship to circle & profile

+ +

+ It is worth being explicit that discipline is orthogonal to the existing fields. They answer + different questions: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldAnswersExample
roleWhat can you do in this app?admin
discipline NewWhat kind of work do you do?conception
circleWhich team / circle do you belong to?"Team Phoenix"
profileWhich Anthropic API tier are you on?maxed
+ +

+ Two Conception people in different circles will share a discipline but not a circle. A developer and a + conception person in the same circle will share a circle but not a discipline. Keeping the columns + separate is the right model — do not repurpose circle for this. +

+ + +

Open questions

+ + + + +

Out of scope

+ + + +
+
+ + diff --git a/specs/032-user-disciplines/screenshot-profile.png b/specs/032-user-disciplines/screenshot-profile.png new file mode 100644 index 0000000..f2ff4bf Binary files /dev/null and b/specs/032-user-disciplines/screenshot-profile.png differ diff --git a/specs/032-user-disciplines/screenshot-users-table.png b/specs/032-user-disciplines/screenshot-users-table.png new file mode 100644 index 0000000..16335bf Binary files /dev/null and b/specs/032-user-disciplines/screenshot-users-table.png differ diff --git a/src/actions/github-sync.ts b/src/actions/github-sync.ts index 3f109dd..4922047 100644 --- a/src/actions/github-sync.ts +++ b/src/actions/github-sync.ts @@ -312,6 +312,9 @@ export async function confirmGitHubSync( passwordHash: tempPasswordHash, githubUsername: member.githubLogin, role: "viewer", + // "Import as-is" has no inline form for the admin to pick + // a discipline — default to developer; reclassify in /users. + discipline: "developer", status: "active", }) .returning({ id: users.id }); @@ -477,6 +480,7 @@ export async function confirmGitHubSync( passwordHash: tempPasswordHash, githubUsername: nu.githubLogin, role: "viewer", + discipline: nu.discipline, status: "active", }) .returning({ id: users.id }); diff --git a/src/actions/users.ts b/src/actions/users.ts index 61acce2..9beacf3 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -38,7 +38,7 @@ export async function createUser( }; } - const { name, email, circle, role, githubUsername, profile } = parsed.data; + const { name, email, circle, role, discipline, githubUsername, profile } = parsed.data; const normalizedEmail = email.toLowerCase(); // Check email uniqueness @@ -60,6 +60,7 @@ export async function createUser( passwordHash, circle: circle ?? null, role, + discipline, githubUsername: githubUsername ?? null, profile: profile ?? null, mustChangePassword: true, @@ -124,6 +125,13 @@ export async function updateUser( changes.role = { old: existing.role, new: updates.role }; values.role = updates.role; } + if ( + updates.discipline !== undefined && + updates.discipline !== existing.discipline + ) { + changes.discipline = { old: existing.discipline, new: updates.discipline }; + values.discipline = updates.discipline; + } if ( updates.githubUsername !== undefined && updates.githubUsername !== existing.githubUsername @@ -216,8 +224,22 @@ export async function deactivateUser(input: { /** Compare CSV row fields against existing user, return changed fields with old/new values. * Only considers a field changed if the CSV explicitly provides a value (not undefined). */ function computeUserDiff( - row: { name: string; circle?: string; role?: string; githubUsername?: string; profile?: string }, - existing: { name: string; circle: string | null; role: string; githubUsername: string | null; profile: string | null } + row: { + name: string; + circle?: string; + role?: string; + discipline?: string; + githubUsername?: string; + profile?: string; + }, + existing: { + name: string; + circle: string | null; + role: string; + discipline: string; + githubUsername: string | null; + profile: string | null; + } ): Record { const changes: Record = {}; @@ -235,6 +257,9 @@ function computeUserDiff( if (row.role !== undefined && row.role !== existing.role) { changes.role = { old: existing.role, new: row.role }; } + if (row.discipline !== undefined && row.discipline !== existing.discipline) { + changes.discipline = { old: existing.discipline, new: row.discipline }; + } if (row.githubUsername !== undefined) { const newGithubUsername = normalizeField(row.githubUsername); if (newGithubUsername !== existing.githubUsername) { @@ -275,6 +300,7 @@ export async function checkExistingUsers(input: { role: u.role, githubUsername: u.githubUsername, profile: u.profile, + discipline: u.discipline, }; } @@ -316,7 +342,8 @@ export async function bulkImportUsers(input: { continue; } - const { name, email, circle, role, githubUsername, profile } = parsed.data; + const { name, email, circle, role, discipline, githubUsername, profile } = + parsed.data; const lowerEmail = email.toLowerCase(); // Detect duplicate emails within the same file @@ -330,9 +357,11 @@ export async function bulkImportUsers(input: { const existing = existingMap.get(lowerEmail); if (existing) { - // Upsert: update existing user (never touch password or status) + // Upsert: update existing user (never touch password or status). + // discipline is omitted from the diff input when the CSV did not + // supply it, so the existing discipline is preserved on upsert. const diff = computeUserDiff( - { name, circle, role, githubUsername, profile }, + { name, circle, role, discipline, githubUsername, profile }, existing ); @@ -360,6 +389,9 @@ export async function bulkImportUsers(input: { passwordHash, circle: circle ?? null, role: role ?? "viewer", + // New rows default to "developer" when CSV omits the column, + // matching the DB default but explicit for traceability. + discipline: discipline ?? "developer", githubUsername: githubUsername ?? null, profile: profile ?? null, mustChangePassword: true, diff --git a/src/app/api/export/users/route.ts b/src/app/api/export/users/route.ts index d1bb0e5..ba7d882 100644 --- a/src/app/api/export/users/route.ts +++ b/src/app/api/export/users/route.ts @@ -15,6 +15,7 @@ export async function GET() { name: users.name, email: users.email, circle: users.circle, + discipline: users.discipline, role: users.role, githubUsername: users.githubUsername, profile: users.profile, @@ -26,13 +27,14 @@ export async function GET() { row.name, row.email, row.circle, + row.discipline, row.role, row.githubUsername ?? "", row.profile ?? "", ]); const csv = toCsv( - ["name", "email", "circle", "role", "github_username", "profile"], + ["name", "email", "circle", "discipline", "role", "github_username", "profile"], csvRows ); diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index 9bfa0b0..dbbb3ed 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -77,6 +77,7 @@ export async function GET(request: NextRequest) { role: profileData.user.role, circle: profileData.user.circle, profile: profileData.user.profile, + discipline: profileData.user.discipline, status: user.status, }, assignments: profileData.assignments.map((a) => ({ diff --git a/src/app/assignments/[id]/assignment-detail-client.tsx b/src/app/assignments/[id]/assignment-detail-client.tsx index d34a835..c80e561 100644 --- a/src/app/assignments/[id]/assignment-detail-client.tsx +++ b/src/app/assignments/[id]/assignment-detail-client.tsx @@ -18,7 +18,8 @@ import { type UpdateAssignmentInput, } from "@/lib/validators"; import { formatCurrency, cn, formatDateOnly } from "@/lib/utils"; -import type { AccessTier } from "@/types"; +import type { AccessTier, UserDiscipline } from "@/types"; +import { DISCIPLINE_ICON, DISCIPLINE_LABEL, asDiscipline } from "@/lib/disciplines"; import { Card, CardContent, @@ -69,7 +70,7 @@ interface AssignmentData { assignedAt: string | null; revokedAt: string | null; workspace: string | null; - user: { id: number; name: string }; + user: { id: number; name: string; discipline: UserDiscipline }; tool: { id: number; name: string }; tier: { id: number; name: string }; hasApiKey: boolean; @@ -242,8 +243,21 @@ export function AssignmentDetailClient({ {" "} → {assignment.tool.name} at {assignment.tier.name} -

- Assignment #{assignment.id} +

+ Assignment #{assignment.id} + + + {(() => { + const d = asDiscipline(assignment.user.discipline); + const Icon = DISCIPLINE_ICON[d]; + return ( + <> + + {DISCIPLINE_LABEL[d]} + + ); + })()} +

diff --git a/src/app/assignments/[id]/page.tsx b/src/app/assignments/[id]/page.tsx index 1c6480d..dc09274 100644 --- a/src/app/assignments/[id]/page.tsx +++ b/src/app/assignments/[id]/page.tsx @@ -49,7 +49,11 @@ export default async function AssignmentDetailPage({ assignedAt: assignment.assignedAt?.toISOString() ?? null, revokedAt: assignment.revokedAt?.toISOString() ?? null, workspace: assignment.workspace, - user: { id: assignment.user.id, name: assignment.user.name }, + user: { + id: assignment.user.id, + name: assignment.user.name, + discipline: assignment.user.discipline, + }, tool: { id: assignment.tool.id, name: assignment.tool.name }, tier: { id: assignment.tier.id, name: assignment.tier.name }, hasApiKey, diff --git a/src/app/settings/sync/github-member-sync-sheet.tsx b/src/app/settings/sync/github-member-sync-sheet.tsx index b677f55..0332397 100644 --- a/src/app/settings/sync/github-member-sync-sheet.tsx +++ b/src/app/settings/sync/github-member-sync-sheet.tsx @@ -49,6 +49,7 @@ import type { SyncUnmatchedMember, SyncUnmatchedSystemUser, PendingResolution, + UserDiscipline, } from "@/types"; interface GitHubMemberSyncSheetProps { @@ -185,6 +186,7 @@ export function GitHubMemberSyncSheet({ githubLogin: string; name: string; email: string; + discipline: UserDiscipline; }> = []; for (const r of resolutions.values()) { @@ -200,6 +202,7 @@ export function GitHubMemberSyncSheet({ githubLogin: r.githubLogin, name: r.name, email: r.email, + discipline: r.discipline, }); break; // skip: nothing to send @@ -635,6 +638,7 @@ function UnmatchedGitHubResolutionList({ githubLogin: data.githubLogin, name: data.name, email: data.email, + discipline: data.discipline, }) } onCancel={onCollapse} diff --git a/src/app/users/[id]/user-detail-client.tsx b/src/app/users/[id]/user-detail-client.tsx index 44e7c4a..83861fd 100644 --- a/src/app/users/[id]/user-detail-client.tsx +++ b/src/app/users/[id]/user-detail-client.tsx @@ -13,6 +13,7 @@ import { getTools, getToolWithTiers } from "@/actions/tools"; import { formatCurrency, formatDate } from "@/lib/utils"; import type { User, ChangeHistoryRecord, CostData, AiTool, AccessTier } from "@/types"; import { AdminCostSection } from "@/components/profile/admin-cost-section"; +import { DISCIPLINES, DISCIPLINE_ICON, DISCIPLINE_LABEL, asDiscipline } from "@/lib/disciplines"; import { Github, ExternalLink, BookOpen, KeyRound, Plus, RotateCcw, Eye, EyeOff } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; @@ -123,11 +124,15 @@ export function UserDetailClient({ email: user.email, circle: user.circle ?? undefined, role: user.role as "admin" | "viewer", + discipline: user.discipline, githubUsername: user.githubUsername ?? "", profile: user.profile ?? null, }, }); + const userDiscipline = asDiscipline(user.discipline); + const DisciplineIcon = DISCIPLINE_ICON[userDiscipline]; + async function onSubmit(data: EditUserInput) { const result = await updateUser({ id: user.id, ...data }); if (result.success) { @@ -237,6 +242,10 @@ export function UserDetailClient({ > {user.status} + + + {DISCIPLINE_LABEL[userDiscipline]} + {user.profile && ( {user.profile} @@ -358,6 +367,33 @@ export function UserDetailClient({ )} /> + ( + + Discipline + + + + )} + /> 0, githubUsername: row.github_username || row.githubusername || "", profile: (row.profile || "").trim().toLowerCase(), valid: true, @@ -80,6 +94,9 @@ function parseCSV(text: string): ParsedUser[] { } else if (rawProfile && !validProfiles.includes(rawProfile)) { user.valid = false; user.error = "Profile must be 'boost', 'maxed', or 'indie'"; + } else if (rawDiscipline && !validDisciplines.includes(rawDiscipline)) { + user.valid = false; + user.error = `Discipline must be one of: ${DISCIPLINES.join(", ")}`; } return user; @@ -141,11 +158,14 @@ export function BulkImportForm() { async function handleImport() { const validUsers = parsedUsers .filter((u) => u.valid) - .map(({ name, email, circle, role, githubUsername, profile }) => ({ + .map(({ name, email, circle, role, discipline, disciplineProvided, githubUsername, profile }) => ({ name, email, circle: circle || undefined, role, + // Pass discipline only when CSV explicitly provided a value so the + // server-side upsert preserves the existing value on update. + discipline: disciplineProvided ? (discipline as UserDiscipline) : undefined, githubUsername: githubUsername || undefined, profile: profile || undefined, })); @@ -187,7 +207,9 @@ export function BulkImportForm() {

Bulk Import Users

Upload a CSV file with columns: name, email (required); circle (or - department), role, github_username, profile (optional) + department), discipline, role, github_username, profile (optional). + New rows without a discipline default to developer; + existing rows keep their value when the column is blank.