mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
* Web workers for crypto functions. Crypto key storage in db. * First prototype appointment service and api * Basic flows für clients. * Staff crypto * Browser crypto functions * Get appointments from a date range * API docs * Schedule API, fixed schedule errors * Tests and bugfixes * Formatting * enhanced and unified Browser-Crypto-Utilities trying to create a new tunnel when tenant has no users now results in an error. user creation now has a confirmation state. * Staff API, getting and updating tunnel data. * Tests for staff endpoints Fixed for confirmationState Fixed tests * Moved logic to staff service * Moved logic to appoointment service * calendar route for staff members. schedule route for clients. * Fix client access to certain routes * Update src/routes/api/tenants/[id]/staff/__tests__/staff-api.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/routes/api/tenants/[id]/staff/[staffId]/public-key/__tests__/public-key.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Security fixes for staff-crypto key retrieval. * LInting errors * DB Migrations * Removed isEncrypted property --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
670 lines
20 KiB
TypeScript
670 lines
20 KiB
TypeScript
import { centralDb } from "../db";
|
|
import * as centralSchema from "../db/central-schema";
|
|
import { eq, desc, gt, and, count } from "drizzle-orm";
|
|
import type { InferInsertModel } from "drizzle-orm";
|
|
import z from "zod/v4";
|
|
import { NotFoundError, ValidationError } from "../utils/errors";
|
|
import { uuidv7 } from "uuidv7";
|
|
import { addMinutes } from "date-fns";
|
|
import logger from "$lib/logger";
|
|
import {
|
|
generateRecoveryPassphrase,
|
|
hashPassphrase,
|
|
validatePassphraseStrength,
|
|
} from "../utils/passphrase";
|
|
import { sendConfirmationEmail } from "../email/email-service";
|
|
import type { SelectTenant } from "../db/central-schema";
|
|
import { TenantAdminService } from "./tenant-admin-service";
|
|
|
|
export type InsertUser = InferInsertModel<typeof centralSchema.user>;
|
|
export type InsertUserPasskey = InferInsertModel<typeof centralSchema.userPasskey>;
|
|
|
|
const userCreationSchema = z.object({
|
|
name: z.string().min(5),
|
|
email: z.email(),
|
|
role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(),
|
|
tenantId: z.string().uuid().optional(),
|
|
passphrase: z.string().min(12).optional(),
|
|
token: z.uuidv7().optional(),
|
|
tokenValidUntil: z.date().optional(),
|
|
language: z.enum(["de", "en"]).optional().default("de"),
|
|
});
|
|
|
|
type UserCreation = z.infer<typeof userCreationSchema>;
|
|
|
|
/**
|
|
* Create a system tenant object for confirmation emails
|
|
* Since central users don't belong to a specific tenant, we use generic branding
|
|
*/
|
|
function createSystemTenant(): SelectTenant {
|
|
return {
|
|
id: "system",
|
|
shortName: "open-reception",
|
|
longName: "Open Reception",
|
|
descriptions: { en: "Secure appointment booking platform" },
|
|
languages: ["en"],
|
|
logo: null,
|
|
databaseUrl: "",
|
|
setupState: "FIRST_CHANNEL_CREATED",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get tenant data for a user, fallback to system tenant if no tenant assigned
|
|
*/
|
|
async function getTenantForUser(user: { tenantId?: string | null }): Promise<SelectTenant> {
|
|
if (!user.tenantId) {
|
|
return createSystemTenant();
|
|
}
|
|
|
|
try {
|
|
const tenantService = await TenantAdminService.getTenantById(user.tenantId);
|
|
return tenantService.tenantData || createSystemTenant();
|
|
} catch {
|
|
// If tenant not found, use system tenant as fallback
|
|
return createSystemTenant();
|
|
}
|
|
}
|
|
|
|
export class UserService {
|
|
/**
|
|
* Create a new user
|
|
*/
|
|
static async createUser(userData: UserCreation, requestUrl?: URL) {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Creating new user account", {
|
|
email: userData.email,
|
|
name: userData.name,
|
|
role: userData.role,
|
|
hasPassphrase: !!userData.passphrase,
|
|
});
|
|
|
|
const validated = userCreationSchema.safeParse(userData);
|
|
if (!validated.success) {
|
|
log.warn("User creation failed: Invalid data", {
|
|
email: userData.email,
|
|
errors: validated.error,
|
|
});
|
|
throw new ValidationError("Invalid user data");
|
|
}
|
|
|
|
// Validate passphrase strength if provided
|
|
if (userData.passphrase && !validatePassphraseStrength(userData.passphrase)) {
|
|
throw new ValidationError("Passphrase must be at least 12 characters long");
|
|
}
|
|
|
|
userData.token = uuidv7();
|
|
userData.tokenValidUntil = addMinutes(new Date(), 10);
|
|
|
|
// Prepare user data for database
|
|
const userDataForDb: InsertUser = {
|
|
name: userData.name,
|
|
email: userData.email,
|
|
role: userData.role,
|
|
tenantId: userData.tenantId,
|
|
token: userData.token,
|
|
tokenValidUntil: userData.tokenValidUntil,
|
|
language: userData.language || "de",
|
|
confirmationState: "INVITED",
|
|
isActive: false,
|
|
};
|
|
|
|
if (userData.role === "GLOBAL_ADMIN") {
|
|
userDataForDb.confirmationState = "ACCESS_GRANTED"; // Admin account is active immediately after email confirmation
|
|
userDataForDb.isActive = true;
|
|
}
|
|
|
|
// Handle passphrase or generate recovery passphrase
|
|
if (userData.passphrase) {
|
|
// User provided a passphrase, hash it
|
|
userDataForDb.passphraseHash = await hashPassphrase(userData.passphrase);
|
|
} else if (userData.role === "GLOBAL_ADMIN") {
|
|
// No passphrase provided, generate a recovery passphrase
|
|
userDataForDb.recoveryPassphrase = generateRecoveryPassphrase();
|
|
}
|
|
|
|
try {
|
|
const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
|
|
|
|
log.debug("User account created successfully", {
|
|
userId: result[0].id,
|
|
email: result[0].email,
|
|
tokenValidUntil: result[0].tokenValidUntil,
|
|
hasPassphrase: !!result[0].passphraseHash,
|
|
hasRecoveryPassphrase: !!result[0].recoveryPassphrase,
|
|
});
|
|
|
|
// Send confirmation email to user (token is used as confirmation code)
|
|
try {
|
|
if (result[0].email && result[0].token) {
|
|
const tenant = await getTenantForUser(result[0]);
|
|
await sendConfirmationEmail(
|
|
result[0],
|
|
tenant,
|
|
result[0].token,
|
|
10, // 10 minutes expiration to match tokenValidUntil
|
|
requestUrl,
|
|
);
|
|
log.debug("Confirmation email sent successfully", {
|
|
userId: result[0].id,
|
|
email: result[0].email,
|
|
tenantId: result[0].tenantId,
|
|
});
|
|
}
|
|
} catch (emailError) {
|
|
log.warn("Failed to send confirmation email", {
|
|
userId: result[0].id,
|
|
email: result[0].email,
|
|
error: String(emailError),
|
|
});
|
|
// Don't throw - user creation succeeded, email is just a bonus
|
|
}
|
|
|
|
return result[0];
|
|
} catch (error) {
|
|
log.error("Failed to create user account", { email: userData.email, error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resend the confirmation email for a user
|
|
* @param email - Email of the user to confirm
|
|
* @param requestUrl - Optional request URL for generating correct baseUrl
|
|
*/
|
|
static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise<void> {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Resending confirmation email", { email });
|
|
|
|
const token = uuidv7();
|
|
const tokenValidUntil = addMinutes(new Date(), 10);
|
|
|
|
try {
|
|
const result = await centralDb
|
|
.update(centralSchema.user)
|
|
.set({ token, tokenValidUntil })
|
|
.where(eq(centralSchema.user.email, email))
|
|
.returning();
|
|
|
|
if (result.length !== 1) {
|
|
log.warn("Failed to resend confirmation email: User not found", { email });
|
|
throw new NotFoundError(`Could not resend confirmation mail for unknown user ${email}`);
|
|
}
|
|
|
|
const user = result[0];
|
|
log.debug("Confirmation email resent successfully", { email, tokenValidUntil });
|
|
|
|
// Send confirmation email with new token - use tenant-specific branding if available
|
|
try {
|
|
const tenant = await getTenantForUser(user);
|
|
await sendConfirmationEmail(
|
|
user,
|
|
tenant,
|
|
token,
|
|
10, // 10 minutes expiration to match tokenValidUntil
|
|
requestUrl,
|
|
);
|
|
log.debug("Confirmation email sent successfully", {
|
|
userId: user.id,
|
|
email: user.email,
|
|
tenantId: user.tenantId,
|
|
});
|
|
} catch (emailError) {
|
|
log.error("Failed to send confirmation email", {
|
|
userId: user.id,
|
|
email: user.email,
|
|
error: String(emailError),
|
|
});
|
|
throw emailError; // In this case, we do want to propagate the error
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
log.error("Failed to resend confirmation email", { email, error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirm and activate user after confirmation link was clicked
|
|
* @param linkToken - The token from the link
|
|
*/
|
|
static async confirm(
|
|
linkToken: string,
|
|
): Promise<{ recoveryPassphrase?: string; isSetup: boolean }> {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." });
|
|
|
|
try {
|
|
// First, get the user data to check for recovery passphrase
|
|
const userData = await centralDb
|
|
.select({
|
|
id: centralSchema.user.id,
|
|
recoveryPassphrase: centralSchema.user.recoveryPassphrase,
|
|
tenantId: centralSchema.user.tenantId,
|
|
})
|
|
.from(centralSchema.user)
|
|
.where(
|
|
and(
|
|
eq(centralSchema.user.token, linkToken),
|
|
gt(centralSchema.user.tokenValidUntil, new Date()),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
if (userData.length === 0) {
|
|
log.warn("User confirmation failed: Invalid or expired token", {
|
|
token: linkToken.substring(0, 8) + "...",
|
|
});
|
|
throw new NotFoundError("Invalid or timed-out token");
|
|
}
|
|
|
|
const user = userData[0];
|
|
|
|
// Check if this is the first tenant admin for the tenant
|
|
let shouldGrantAccess = false;
|
|
if (user.tenantId) {
|
|
const existingTenantAdmins = await centralDb
|
|
.select({ count: count() })
|
|
.from(centralSchema.user)
|
|
.where(
|
|
and(
|
|
eq(centralSchema.user.tenantId, user.tenantId),
|
|
eq(centralSchema.user.role, "TENANT_ADMIN"),
|
|
),
|
|
);
|
|
|
|
shouldGrantAccess = existingTenantAdmins[0].count === 0;
|
|
}
|
|
|
|
const confirmationState = shouldGrantAccess ? "ACCESS_GRANTED" : "CONFIRMED";
|
|
// Update the user to confirmed and active, and clear the recovery passphrase
|
|
const result = await centralDb
|
|
.update(centralSchema.user)
|
|
.set({
|
|
confirmationState,
|
|
isActive: true,
|
|
recoveryPassphrase: null, // Clear it after showing it once
|
|
})
|
|
.where(eq(centralSchema.user.id, user.id))
|
|
.execute();
|
|
|
|
if (result.count != 1) {
|
|
throw new NotFoundError("Failed to confirm user");
|
|
}
|
|
|
|
const countResult = await centralDb.select({ count: count() }).from(centralSchema.user);
|
|
|
|
log.debug("User account confirmed successfully", {
|
|
userId: user.id,
|
|
token: linkToken.substring(0, 8) + "...",
|
|
hadRecoveryPassphrase: !!user.recoveryPassphrase,
|
|
});
|
|
|
|
return {
|
|
recoveryPassphrase: user.recoveryPassphrase || undefined,
|
|
isSetup: countResult[0].count === 1,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
log.error("Failed to confirm user account", {
|
|
token: linkToken.substring(0, 8) + "...",
|
|
error: String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add additional WebAuthn passkey to existing user
|
|
*/
|
|
static async addAdditionalPasskey(userId: string, passkeyData: InsertUserPasskey): Promise<void> {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Adding additional passkey to user", { userId, passkeyId: passkeyData.id });
|
|
|
|
try {
|
|
// Check if user exists and is active
|
|
const user = await centralDb
|
|
.select({
|
|
id: centralSchema.user.id,
|
|
confirmationState: centralSchema.user.confirmationState,
|
|
})
|
|
.from(centralSchema.user)
|
|
.where(eq(centralSchema.user.id, userId))
|
|
.limit(1);
|
|
|
|
if (user.length === 0) {
|
|
throw new NotFoundError("User not found");
|
|
}
|
|
|
|
if (user[0].confirmationState === "INVITED") {
|
|
throw new ValidationError(
|
|
"User account must be confirmed before adding additional passkeys",
|
|
);
|
|
}
|
|
|
|
// Add the passkey
|
|
await centralDb.insert(centralSchema.userPasskey).values({
|
|
...passkeyData,
|
|
userId,
|
|
});
|
|
|
|
// Note: Crypto keypairs for STAFF/TENANT_ADMIN are generated in the browser
|
|
// and stored via separate API calls, not automatically here
|
|
|
|
log.debug("Additional passkey added successfully", { userId, passkeyId: passkeyData.id });
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError || error instanceof ValidationError) throw error;
|
|
log.error("Failed to add additional passkey", { userId, error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get admin by email
|
|
*/
|
|
static async getUserByEmail(email: string) {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Getting admin by email", { email });
|
|
|
|
try {
|
|
const result = await centralDb
|
|
.select()
|
|
.from(centralSchema.user)
|
|
.where(eq(centralSchema.user.email, email))
|
|
.limit(1);
|
|
|
|
if (!result[0]) {
|
|
log.warn("User not found by email", { email });
|
|
throw new NotFoundError(`No user account for ${email}.`);
|
|
}
|
|
|
|
log.debug("User found by email", {
|
|
email,
|
|
userId: result[0].id,
|
|
confirmationState: result[0].confirmationState,
|
|
});
|
|
return result[0];
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
log.error("Failed to get user by email", { email, error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all admins
|
|
*/
|
|
static async getAllAdmins() {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Getting all admins");
|
|
|
|
try {
|
|
const result = await centralDb
|
|
.select()
|
|
.from(centralSchema.user)
|
|
.orderBy(desc(centralSchema.user.createdAt))
|
|
.where(eq(centralSchema.user.role, "GLOBAL_ADMIN"));
|
|
|
|
log.debug("Retrieved all admins", { count: result.length });
|
|
return result;
|
|
} catch (error) {
|
|
log.error("Failed to get all admins", { error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all admins
|
|
*/
|
|
static async getAllUsers() {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Getting all users");
|
|
|
|
try {
|
|
const result = await centralDb
|
|
.select()
|
|
.from(centralSchema.user)
|
|
.orderBy(desc(centralSchema.user.createdAt));
|
|
|
|
log.debug("Retrieved all users", { count: result.length });
|
|
return result;
|
|
} catch (error) {
|
|
log.error("Failed to get all users", { error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a user's data
|
|
*/
|
|
static async updateUser(
|
|
userId: string,
|
|
updateData: Partial<Omit<InsertUser, "id" | "createdAt">>,
|
|
) {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Updating user", { userId, updateFields: Object.keys(updateData) });
|
|
|
|
try {
|
|
const result = await centralDb
|
|
.update(centralSchema.user)
|
|
.set({
|
|
...updateData,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(centralSchema.user.id, userId))
|
|
.returning();
|
|
|
|
if (result[0]) {
|
|
log.debug("User updated successfully", { userId, updateFields: Object.keys(updateData) });
|
|
} else {
|
|
log.warn("User update failed: User not found", { userId });
|
|
}
|
|
|
|
return result[0] || null;
|
|
} catch (error) {
|
|
log.error("Failed to update user", { userId, error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Permanently delete admin and all associated passkeys
|
|
*/
|
|
static async deleteUser(userId: string) {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Deleting user and associated passkeys", { userId });
|
|
|
|
try {
|
|
// Delete associated passkeys first
|
|
const passkeyResult = await centralDb
|
|
.delete(centralSchema.userPasskey)
|
|
.where(eq(centralSchema.userPasskey.userId, userId));
|
|
|
|
log.debug("Deleted user passkeys", { userId, deletedCount: passkeyResult.count || 0 });
|
|
|
|
// Delete admin
|
|
const result = await centralDb
|
|
.delete(centralSchema.user)
|
|
.where(eq(centralSchema.user.id, userId))
|
|
.returning();
|
|
|
|
if (result[0]) {
|
|
log.debug("User deleted successfully", { userId, email: result[0].email });
|
|
} else {
|
|
log.warn("User deletion failed: User not found", { userId });
|
|
}
|
|
|
|
return result[0] || null;
|
|
} catch (error) {
|
|
log.error("Failed to delete user", { userId, error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update last login timestamp
|
|
*/
|
|
static async updateLastLogin(userId: string) {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Updating last login timestamp", { userId });
|
|
|
|
return await this.updateUser(userId, { lastLoginAt: new Date() });
|
|
}
|
|
|
|
/**
|
|
* Add a passkey for a user
|
|
*/
|
|
static async addPasskey(
|
|
userId: string,
|
|
passkeyData: Omit<InsertUserPasskey, "userId" | "createdAt" | "updatedAt">,
|
|
) {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Adding passkey for user", {
|
|
userId,
|
|
passkeyId: passkeyData.id,
|
|
deviceName: passkeyData.deviceName,
|
|
});
|
|
|
|
try {
|
|
// Verify user exists
|
|
const user = await centralDb
|
|
.select({ id: centralSchema.user.id })
|
|
.from(centralSchema.user)
|
|
.where(eq(centralSchema.user.id, userId))
|
|
.limit(1);
|
|
|
|
if (user.length === 0) {
|
|
throw new NotFoundError("User not found");
|
|
}
|
|
|
|
// Add the passkey to the database
|
|
const result = await centralDb
|
|
.insert(centralSchema.userPasskey)
|
|
.values({
|
|
...passkeyData,
|
|
userId,
|
|
})
|
|
.returning();
|
|
|
|
// Note: Crypto keypairs for STAFF/TENANT_ADMIN are generated in the browser
|
|
// and stored via separate API calls, not automatically here
|
|
|
|
log.debug("Passkey added successfully", {
|
|
userId,
|
|
passkeyId: result[0].id,
|
|
deviceName: result[0].deviceName,
|
|
});
|
|
return result[0];
|
|
} catch (error) {
|
|
log.error("Failed to add passkey", {
|
|
userId,
|
|
passkeyId: passkeyData.id,
|
|
error: String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all passkeys for an admin
|
|
*/
|
|
static async getUserPasskeys(userId: string) {
|
|
return await centralDb
|
|
.select()
|
|
.from(centralSchema.userPasskey)
|
|
.where(eq(centralSchema.userPasskey.userId, userId))
|
|
.orderBy(desc(centralSchema.userPasskey.createdAt));
|
|
}
|
|
|
|
/**
|
|
* Update passkey (e.g., counter, last used time)
|
|
*/
|
|
static async updatePasskey(
|
|
passkeyId: string,
|
|
updateData: Partial<Omit<InsertUserPasskey, "id" | "userId" | "createdAt">>,
|
|
) {
|
|
const result = await centralDb
|
|
.update(centralSchema.userPasskey)
|
|
.set({
|
|
...updateData,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(centralSchema.userPasskey.id, passkeyId))
|
|
.returning();
|
|
|
|
return result[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Delete a passkey
|
|
*/
|
|
static async deletePasskey(passkeyId: string) {
|
|
const result = await centralDb
|
|
.delete(centralSchema.userPasskey)
|
|
.where(eq(centralSchema.userPasskey.id, passkeyId))
|
|
.returning();
|
|
|
|
return result[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Update passkey last used timestamp and counter
|
|
*/
|
|
static async updatePasskeyUsage(passkeyId: string, newCounter: number) {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Updating passkey usage", { passkeyId, newCounter });
|
|
|
|
return await this.updatePasskey(passkeyId, {
|
|
counter: newCounter,
|
|
lastUsedAt: new Date(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if any admin exists in the system
|
|
*/
|
|
static async adminExists(): Promise<boolean> {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Checking if any admin exists");
|
|
|
|
try {
|
|
const result = await centralDb
|
|
.select({ id: centralSchema.user.id })
|
|
.from(centralSchema.user)
|
|
.where(eq(centralSchema.user.role, "GLOBAL_ADMIN"))
|
|
.limit(1);
|
|
|
|
const exists = result.length > 0;
|
|
log.debug("Admin existence check completed", { exists });
|
|
return exists;
|
|
} catch (error) {
|
|
log.error("Failed to check admin existence", { error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get total count of admins in the system
|
|
*/
|
|
static async getAdminCount(): Promise<number> {
|
|
const log = logger.setContext("UserService");
|
|
log.debug("Getting admin count");
|
|
|
|
try {
|
|
const result = await centralDb
|
|
.select()
|
|
.from(centralSchema.user)
|
|
.where(eq(centralSchema.user.role, "GLOBAL_ADMIN"));
|
|
|
|
const count = result.length;
|
|
log.debug("Admin count retrieved", { count });
|
|
return count;
|
|
} catch (error) {
|
|
log.error("Failed to get admin count", { error: String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|