import type { Kysely, Selectable, Updateable } from "kysely"; import { ulid } from "ulidx"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import type { Database, UserTable } from "../types.js"; import { encodeCursor, decodeCursor, type FindManyResult } from "./types.js"; type UserRow = Selectable; /** * Valid role levels matching the database schema. * 10=subscriber, 20=contributor, 30=author, 40=editor, 50=admin */ export type UserRole = 10 | 20 | 30 | 40 | 50; /** String role names for convenience APIs */ export type UserRoleName = "subscriber" | "contributor" | "author" | "editor" | "admin"; export interface User { id: string; email: string; name: string | null; role: UserRole; avatarUrl: string | null; emailVerified: boolean; data: Record | null; createdAt: string; } export interface CreateUserInput { email: string; name?: string; role?: UserRole | UserRoleName; avatarUrl?: string; data?: Record; } export interface UpdateUserInput { name?: string; role?: UserRole | UserRoleName; avatarUrl?: string | null; data?: Record; } /** * User repository for CRUD operations */ export class UserRepository { constructor(private db: Kysely) {} /** * Create a new user */ async create(input: CreateUserInput): Promise { const id = ulid(); const row: Omit = { id, email: input.email.toLowerCase(), name: input.name ?? null, role: UserRepository.resolveRole(input.role ?? 10), avatar_url: input.avatarUrl ?? null, email_verified: 0, data: input.data ? JSON.stringify(input.data) : null, }; await this.db.insertInto("users").values(row).execute(); const user = await this.findById(id); if (!user) { throw new Error("Failed to create user"); } return user; } /** * Find user by ID */ async findById(id: string): Promise { const row = await this.db .selectFrom("users") .selectAll() .where("id", "=", id) .executeTakeFirst(); return row ? this.rowToUser(row) : null; } /** * Batch-resolve users by ID. Returns only the users that exist; missing * IDs are silently dropped. Chunked at `SQL_BATCH_SIZE` to stay within * D1's bind-parameter limit. */ async findByIds(ids: string[]): Promise { const unique = [...new Set(ids)].filter((id) => id.length > 0); if (unique.length === 0) return []; const out: User[] = []; for (const batch of chunks(unique, SQL_BATCH_SIZE)) { const rows = await this.db.selectFrom("users").selectAll().where("id", "in", batch).execute(); for (const row of rows) out.push(this.rowToUser(row)); } return out; } /** * Find user by email (case-insensitive) */ async findByEmail(email: string): Promise { const row = await this.db .selectFrom("users") .selectAll() .where("email", "=", email.toLowerCase()) .executeTakeFirst(); return row ? this.rowToUser(row) : null; } /** * List all users with cursor-based pagination */ async findMany( options: { role?: UserRole | UserRoleName; limit?: number; cursor?: string; } = {}, ): Promise> { const limit = Math.min(Math.max(1, options.limit || 50), 100); let query = this.db .selectFrom("users") .selectAll() .orderBy("created_at", "desc") .orderBy("id", "desc") .limit(limit + 1); if (options.role !== undefined) { query = query.where("role", "=", UserRepository.resolveRole(options.role)); } if (options.cursor) { const decoded = decodeCursor(options.cursor); query = query.where((eb) => eb.or([ eb("created_at", "<", decoded.orderValue), eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]), ]), ); } const rows = await query.execute(); const items = rows.slice(0, limit).map((row) => this.rowToUser(row)); const result: FindManyResult = { items }; if (rows.length > limit && items.length > 0) { const last = items.at(-1)!; result.nextCursor = encodeCursor(last.createdAt, last.id); } return result; } /** * Update a user */ async update(id: string, input: UpdateUserInput): Promise { const existing = await this.findById(id); if (!existing) return null; const updates: Updateable = {}; if (input.name !== undefined) updates.name = input.name; if (input.role !== undefined) updates.role = UserRepository.resolveRole(input.role); if (input.avatarUrl !== undefined) updates.avatar_url = input.avatarUrl; if (input.data !== undefined) updates.data = JSON.stringify(input.data); if (Object.keys(updates).length > 0) { await this.db.updateTable("users").set(updates).where("id", "=", id).execute(); } return this.findById(id); } /** * Delete a user */ async delete(id: string): Promise { const result = await this.db.deleteFrom("users").where("id", "=", id).executeTakeFirst(); return (result.numDeletedRows ?? 0) > 0; } /** * Count users */ async count(role?: UserRole | UserRoleName): Promise { let query = this.db.selectFrom("users").select((eb) => eb.fn.count("id").as("count")); if (role !== undefined) { query = query.where("role", "=", UserRepository.resolveRole(role)); } const result = await query.executeTakeFirst(); return Number(result?.count || 0); } /** * Check if email exists */ async emailExists(email: string): Promise { const row = await this.db .selectFrom("users") .select("id") .where("email", "=", email.toLowerCase()) .executeTakeFirst(); return !!row; } /** * Convert database row to User object */ private rowToUser(row: UserRow): User { return { id: row.id, email: row.email, name: row.name, role: UserRepository.toRole(row.role), avatarUrl: row.avatar_url, emailVerified: row.email_verified === 1, data: row.data ? JSON.parse(row.data) : null, createdAt: row.created_at, }; } /** Map of role name strings to numeric levels */ private static readonly ROLE_NAME_TO_LEVEL: Record = { subscriber: 10, contributor: 20, author: 30, editor: 40, admin: 50, }; /** Valid numeric role levels */ private static readonly VALID_LEVELS = new Set([10, 20, 30, 40, 50]); /** * Resolve a role name or number to a valid numeric UserRole. * Accepts both string names ("admin") and numeric levels (50). */ static resolveRole(role: UserRole | UserRoleName): UserRole { if (typeof role === "string") { const level = UserRepository.ROLE_NAME_TO_LEVEL[role]; if (level === undefined) { throw new Error(`Invalid role name: ${role}`); } return level; } if (!UserRepository.VALID_LEVELS.has(role)) { throw new Error(`Invalid role level: ${role}`); } return role; } /** * Convert a raw DB integer to a typed UserRole. * Falls back to subscriber (10) for unknown values. */ private static toRole(level: number): UserRole { if (UserRepository.VALID_LEVELS.has(level)) return level as UserRole; return 10; } }