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