import { asc, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; import { type Tag, type User, tags, userTags, users } from "@/db/schema"; import { hashPassword, verifyPassword } from "@/lib/auth/password"; /** User row without the password hash — safe to hand to pages. */ export type SafeUser = Omit; export type UserWithTags = SafeUser & { tags: Tag[] }; const safeColumns = { id: users.id, username: users.username, role: users.role, canCreateTags: users.canCreateTags, canPublishPosts: users.canPublishPosts, canUnpublishPosts: users.canUnpublishPosts, canDeletePosts: users.canDeletePosts, canApproveComments: users.canApproveComments, createdAt: users.createdAt, } as const; /** The five grantable author permissions, as stored on the row. */ export type PermissionColumns = Pick< User, | "canCreateTags" | "canPublishPosts" | "canUnpublishPosts" | "canDeletePosts" | "canApproveComments" >; export async function listUsersWithTags(): Promise { const [userRows, grantRows] = await Promise.all([ db.select(safeColumns).from(users).orderBy(asc(users.createdAt), asc(users.id)), db .select({ userId: userTags.userId, tag: tags }) .from(userTags) .innerJoin(tags, eq(tags.id, userTags.tagId)) .orderBy(asc(tags.name)), ]); const tagsByUser = new Map(); for (const row of grantRows) { const list = tagsByUser.get(row.userId) ?? []; list.push(row.tag); tagsByUser.set(row.userId, list); } return userRows.map((u) => ({ ...u, tags: tagsByUser.get(u.id) ?? [] })); } export async function getUserWithTags(id: number): Promise { const [user] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1); if (!user) return null; const grants = await db .select({ tag: tags }) .from(userTags) .innerJoin(tags, eq(tags.id, userTags.tagId)) .where(eq(userTags.userId, id)) .orderBy(asc(tags.name)); return { ...user, tags: grants.map((g) => g.tag) }; } /** Tag ids an author may publish under. (Admins bypass this check.) */ export async function getAllowedTagIds(userId: number): Promise { const rows = await db .select({ tagId: userTags.tagId }) .from(userTags) .where(eq(userTags.userId, userId)); return rows.map((r) => r.tagId); } async function replaceTagGrants(userId: number, tagIds: number[]): Promise { await db.transaction(async (tx) => { await tx.delete(userTags).where(eq(userTags.userId, userId)); if (tagIds.length > 0) { // Silently drop ids for tags deleted since the form rendered. const existing = await tx .select({ id: tags.id }) .from(tags) .where(inArray(tags.id, tagIds)); if (existing.length > 0) { await tx.insert(userTags).values(existing.map((t) => ({ userId, tagId: t.id }))); } } }); } /** New accounts are always authors — there is exactly one admin. */ export async function createUser(input: { username: string; password: string; tagIds: number[]; /** Defaults to no permissions (write/edit own drafts only). */ permissions?: PermissionColumns; }): Promise { const passwordHash = await hashPassword(input.password); const [user] = await db .insert(users) .values({ username: input.username, passwordHash, role: "author", ...(input.permissions ?? {}), }) .returning(safeColumns); await replaceTagGrants(user.id, input.tagIds); return user; } /** * Updates an account's tag grants, permissions, and optionally its * password. Roles are never changed — the single admin stays the admin, * and permission/grant edits are ignored for the admin account. */ export async function updateUser( id: number, input: { password: string | null; tagIds: number[]; permissions?: PermissionColumns }, ): Promise { const [existing] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1); if (!existing) return null; if (input.password !== null) { const passwordHash = await hashPassword(input.password); await db.update(users).set({ passwordHash }).where(eq(users.id, id)); } // Grants and permissions only mean something for authors. if (existing.role === "author") { if (input.permissions) { await db.update(users).set(input.permissions).where(eq(users.id, id)); } await replaceTagGrants(id, input.tagIds); } return existing; } /** Verifies the current password, then replaces it. For any signed-in account. */ export async function changeOwnPassword( userId: number, currentPassword: string, newPassword: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!user) return { ok: false, error: "Your account no longer exists." }; if (!(await verifyPassword(user.passwordHash, currentPassword))) { return { ok: false, error: "Your current password is incorrect." }; } const passwordHash = await hashPassword(newPassword); await db.update(users).set({ passwordHash }).where(eq(users.id, userId)); return { ok: true }; } /** Adds tag grants without touching existing ones (used for author-created tags). */ export async function grantTags(userId: number, tagIds: number[]): Promise { if (tagIds.length === 0) return; await db .insert(userTags) .values(tagIds.map((tagId) => ({ userId, tagId }))) .onConflictDoNothing(); } /** * Deletes an author account. The admin account cannot be deleted. The * account's posts survive as unowned rows (posts.author_id -> NULL); * its sessions cascade away, signing the account out everywhere. */ export async function deleteUser(id: number): Promise<{ ok: boolean; error?: string }> { const [user] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1); if (!user) return { ok: false, error: "That account no longer exists." }; if (user.role === "admin") { return { ok: false, error: "The admin account cannot be deleted." }; } await db.delete(users).where(eq(users.id, id)); return { ok: true }; }