From baabfce9e2acc6ad3254d19c3f284023ee72cf62 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 4 Jun 2026 10:21:04 -0400 Subject: [PATCH] feat(01-02): implement upsertUser with stable color assignment (AUTH-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export COLOR_PALETTE (6 accessible hex hues, round-robin assignment) - upsertUser(oidcIss, oidcSub, displayName?) keyed on iss+sub never email - First login: COUNT existing users → assign COLOR_PALETTE[count % len] - Re-upsert: returns existing row unchanged (idempotent, no duplicate insert) - Uses $returningId() + re-select pattern (mysql2 no RETURNING clause) - All 6 tests pass (GREEN) --- apps/api/src/auth/user.ts | 83 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/api/src/auth/user.ts diff --git a/apps/api/src/auth/user.ts b/apps/api/src/auth/user.ts new file mode 100644 index 0000000..a3073fb --- /dev/null +++ b/apps/api/src/auth/user.ts @@ -0,0 +1,83 @@ +/** + * User identity upsert + stable per-member color assignment. + * + * Identity is keyed on oidc_iss + oidc_sub — never email (D-10). + * Color is auto-assigned from a curated palette on first login, round-robin + * by join order (D-06). Stable across sessions: re-upsert returns same row. + * + * Source: RESEARCH.md § "User upsert with color assignment" + */ + +import { and, eq, sql } from 'drizzle-orm' +import { db } from '../db/client.js' +import { users } from '../db/schema.js' + +/** + * Accessible, visually-distinct palette for per-member color assignment. + * Assigned round-robin by join order (COUNT of existing users at insert time). + * Values are Claude's choice per D-06. + */ +export const COLOR_PALETTE: string[] = [ + '#4A90D9', // calm blue + '#E8734A', // warm coral + '#5BA85A', // forest green + '#9B6DC5', // soft purple + '#E8A840', // warm amber + '#3AAFA9', // teal +] + +/** + * Upsert a user by their OIDC identity (iss + sub). + * + * - If a row with matching (oidc_iss, oidc_sub) exists, return it unchanged. + * - Otherwise, count current users to pick the next palette color, insert a + * new row, then re-select and return it. + * + * Never keys on email or displayName for identity. displayName is stored as a + * display hint only and may change without affecting identity. + */ +export async function upsertUser( + oidcIss: string, + oidcSub: string, + displayName?: string, +) { + // 1. Look up by composite identity key (iss + sub) — never email + const existing = await db + .select() + .from(users) + .where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub))) + .limit(1) + + if (existing[0]) { + return existing[0] + } + + // 2. Count existing users to determine round-robin color slot + const countResult = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + + const count = Number(countResult[0]?.count ?? 0) + const color = COLOR_PALETTE[count % COLOR_PALETTE.length] + + // 3. Insert new user row + // mysql2 has no RETURNING clause — use $returningId() then re-select + const [inserted] = await db + .insert(users) + .values({ + oidcIss, + oidcSub, + displayName: displayName ?? null, + color, + }) + .$returningId() + + // 4. Re-select to return the full typed row + const [newUser] = await db + .select() + .from(users) + .where(eq(users.id, inserted.id)) + .limit(1) + + return newUser +}