import { and, desc, eq, ne, sql } from 'drizzle-orm'; import { db } from '$lib/server/db/client'; import { companyUsers, users } from '$lib/server/db/schema/tenancy'; import { hashPassword } from '$lib/server/auth/password'; import { normalizeEmail } from '$lib/utils/email'; export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer'; export interface CompanyUserRow { userId: string; email: string; displayName: string; isActive: boolean; lastLoginAt: Date | null; role: CompanyRole; membershipId: string; joinedAt: Date; } export async function listCompanyUsers(companyId: string): Promise { const rows = await db .select({ userId: users.id, email: users.email, displayName: users.displayName, isActive: users.isActive, lastLoginAt: users.lastLoginAt, role: companyUsers.role, membershipId: companyUsers.id, joinedAt: companyUsers.joinedAt }) .from(companyUsers) .innerJoin(users, eq(users.id, companyUsers.userId)) .where(eq(companyUsers.companyId, companyId)) .orderBy(desc(users.isActive), desc(companyUsers.joinedAt)); return rows as CompanyUserRow[]; } async function countAdmins(companyId: string): Promise { const [{ n }] = await db .select({ n: sql`count(*)::int` }) .from(companyUsers) .innerJoin(users, eq(users.id, companyUsers.userId)) .where( and( eq(companyUsers.companyId, companyId), eq(companyUsers.role, 'admin'), eq(users.isActive, true) ) ); return n; } export interface CreateUserInput { companyId: string; email: string; displayName: string; password: string; role: CompanyRole; } /** * Create a new user and add them to this company at the given role. * If a user with this normalized email already exists we reuse them and * just add the membership (common invite flow). */ export async function createUserAndAddToCompany( input: CreateUserInput ): Promise<{ userId: string; created: boolean }> { const normalized = normalizeEmail(input.email); const displayName = input.displayName.trim(); if (!displayName) throw new Error('display name is required'); if (input.password.length < 8) { throw new Error('password must be at least 8 characters'); } return db.transaction(async (tx) => { let userId: string; let created = false; const [existing] = await tx .select() .from(users) .where(eq(users.emailNormalized, normalized)) .limit(1); if (existing) { userId = existing.id; } else { const hash = await hashPassword(input.password); const [row] = await tx .insert(users) .values({ email: input.email.trim(), emailNormalized: normalized, displayName, passwordHash: hash }) .returning({ id: users.id }); userId = row.id; created = true; } const [membership] = await tx .select({ id: companyUsers.id }) .from(companyUsers) .where( and(eq(companyUsers.companyId, input.companyId), eq(companyUsers.userId, userId)) ) .limit(1); if (membership) throw new Error('user is already a member of this company'); await tx .insert(companyUsers) .values({ companyId: input.companyId, userId, role: input.role }); return { userId, created }; }); } export async function updateDisplayName( companyId: string, userId: string, displayName: string ): Promise { await assertMembership(companyId, userId); const clean = displayName.trim(); if (!clean) throw new Error('display name is required'); await db.update(users).set({ displayName: clean }).where(eq(users.id, userId)); } export async function setUserRoleInCompany( companyId: string, userId: string, role: CompanyRole ): Promise { await assertMembership(companyId, userId); const [current] = await db .select({ role: companyUsers.role }) .from(companyUsers) .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) .limit(1); if (!current) throw new Error('membership not found'); if (current.role === 'admin' && role !== 'admin') { const admins = await countAdmins(companyId); if (admins <= 1) { throw new Error('Cannot demote the last admin of this company.'); } } await db .update(companyUsers) .set({ role }) .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))); } export async function removeUserFromCompany( companyId: string, userId: string ): Promise { await assertMembership(companyId, userId); const [current] = await db .select({ role: companyUsers.role }) .from(companyUsers) .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) .limit(1); if (!current) return; if (current.role === 'admin') { const admins = await countAdmins(companyId); if (admins <= 1) { throw new Error('Cannot remove the last admin of this company.'); } } await db .delete(companyUsers) .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))); } export async function setUserActive( companyId: string, userId: string, active: boolean ): Promise { await assertMembership(companyId, userId); // Don't let the last admin deactivate themselves. if (!active) { const [m] = await db .select({ role: companyUsers.role }) .from(companyUsers) .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) .limit(1); if (m?.role === 'admin') { const admins = await countAdmins(companyId); if (admins <= 1) { throw new Error('Cannot deactivate the last admin of this company.'); } } } await db.update(users).set({ isActive: active }).where(eq(users.id, userId)); } export async function resetUserPassword( companyId: string, userId: string, newPassword: string ): Promise { await assertMembership(companyId, userId); if (newPassword.length < 8) { throw new Error('password must be at least 8 characters'); } const hash = await hashPassword(newPassword); await db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)); } async function assertMembership(companyId: string, userId: string): Promise { const [row] = await db .select({ id: companyUsers.id }) .from(companyUsers) .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) .limit(1); if (!row) throw new Error('that user is not in this company'); } // Exported for the /admin/users page to prevent deactivating yourself while // doing it from the UI (backend already enforces; this is UX helper). export function isSelf(targetUserId: string, selfUserId: string): boolean { return targetUserId === selfUserId; } // ne import kept in case we want future checks like "admins other than self"; silence unused. void ne;