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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user