feat(01-02): implement upsertUser with stable color assignment (AUTH-03)

- 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)
This commit is contained in:
Lucas Berger
2026-06-04 10:21:04 -04:00
parent 61c258c021
commit baabfce9e2
+83
View File
@@ -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<number>`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
}