UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

1,474 lines (1,368 loc) 45.3 kB
/** * Users Service for BackendSDK * * Provides comprehensive user management functionality including: * - Project-specific user management * - User authentication and authorization * - User profile management * - User roles and permissions within projects * - User activity tracking */ import crypto from "crypto"; import bcrypt from "bcryptjs"; import { DatabaseConnection, Logger } from "./core"; import { KrapiError } from "./core/krapi-error"; import { CountRow } from "./database-types"; import { normalizeError } from "./utils/error-handler"; export interface ProjectUser { id: string; project_id: string; username?: string; email?: string; external_id?: string; first_name?: string; last_name?: string; display_name?: string; avatar_url?: string; role: string; permissions: string[]; metadata: Record<string, unknown>; is_active: boolean; last_login?: string; login_count: number; created_at: string; updated_at: string; created_by?: string; password_hash?: string; } export interface UserRole { id: string; project_id: string; name: string; display_name: string; description?: string; permissions: string[]; is_default: boolean; is_system: boolean; created_at: string; updated_at: string; } export interface UserSession { id: string; user_id: string; project_id: string; session_token: string; ip_address?: string; user_agent?: string; created_at: string; last_active: string; expires_at: string; is_active: boolean; } export interface UserActivity { id: string; user_id: string; project_id: string; action: string; entity_type: string; entity_id?: string; details: Record<string, unknown>; ip_address?: string; user_agent?: string; created_at: string; } export interface CreateUserRequest { username?: string; email?: string; external_id?: string; first_name?: string; last_name?: string; display_name?: string; avatar_url?: string; role?: string; permissions?: string[]; metadata?: Record<string, unknown>; password?: string; } export interface UpdateUserRequest { username?: string; email?: string; external_id?: string; first_name?: string; last_name?: string; display_name?: string; avatar_url?: string; role?: string; permissions?: string[]; metadata?: Record<string, unknown>; is_active?: boolean; } export interface UserFilter { role?: string; is_active?: boolean; search?: string; created_after?: string; created_before?: string; last_login_after?: string; last_login_before?: string; } export interface UserStatistics { total_users: number; active_users: number; users_by_role: Record<string, number>; recent_logins: number; new_users_this_month: number; most_active_users: Array<{ user_id: string; username: string; activity_count: number; }>; } /** * Users Service for BackendSDK * * Provides comprehensive user management functionality including: * - Project-specific user management * - User authentication and authorization * - User profile management * - User roles and permissions within projects * - User activity tracking * * @class UsersService * @example * const usersService = new UsersService(dbConnection, logger); * const users = await usersService.getAllUsers('project-id', { limit: 10 }); */ export class UsersService { private db: DatabaseConnection; private logger: Logger; /** * Create a new UsersService instance * * @param {DatabaseConnection} databaseConnection - Database connection * @param {Logger} logger - Logger instance */ constructor(databaseConnection: DatabaseConnection, logger: Logger) { this.db = databaseConnection; this.logger = logger; } /** * Get all users for a project * * @param {string} projectId - Project ID * @param {Object} [options] - Query options * @param {number} [options.limit] - Maximum number of users * @param {number} [options.offset] - Number of users to skip * @param {UserFilter} [options.filter] - User filters * @returns {Promise<ProjectUser[]>} Array of project users * @throws {Error} If query fails * * @example * const users = await usersService.getAllUsers('project-id', { * limit: 10, * filter: { role: 'admin', is_active: true } * }); */ async getAllUsers( projectId: string, options?: { limit?: number; offset?: number; filter?: UserFilter; } ): Promise<ProjectUser[]> { try { let query = "SELECT * FROM project_users WHERE project_id = $1"; const params: unknown[] = [projectId]; let paramCount = 1; if (options?.filter) { const { filter } = options; if (filter.role) { paramCount++; query += ` AND role = $${paramCount}`; params.push(filter.role); } if (filter.is_active !== undefined) { paramCount++; query += ` AND is_active = $${paramCount}`; params.push(filter.is_active); } if (filter.search) { paramCount++; // SQLite uses LIKE (case-insensitive by default with NOCASE collation) query += ` AND (username LIKE $${paramCount} OR email LIKE $${paramCount} OR first_name LIKE $${paramCount} OR last_name LIKE $${paramCount} OR display_name LIKE $${paramCount})`; params.push(`%${filter.search}%`); } if (filter.created_after) { paramCount++; query += ` AND created_at >= $${paramCount}`; params.push(filter.created_after); } if (filter.created_before) { paramCount++; query += ` AND created_at <= $${paramCount}`; params.push(filter.created_before); } if (filter.last_login_after) { paramCount++; query += ` AND last_login >= $${paramCount}`; params.push(filter.last_login_after); } if (filter.last_login_before) { paramCount++; query += ` AND last_login <= $${paramCount}`; params.push(filter.last_login_before); } } query += " ORDER BY created_at DESC"; if (options?.limit) { paramCount++; query += ` LIMIT $${paramCount}`; params.push(options.limit); } if (options?.offset) { paramCount++; query += ` OFFSET $${paramCount}`; params.push(options.offset); } const result = await this.db.query(query, params); return result.rows as ProjectUser[]; } catch (error) { this.logger.error("Failed to get users:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUsers", }); } } /** * Get user by ID * * Retrieves a single project user by their ID. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @returns {Promise<ProjectUser | null>} User or null if not found * @throws {Error} If query fails * * @example * const user = await usersService.getUserById('project-id', 'user-id'); */ async getUserById( projectId: string, userId: string ): Promise<ProjectUser | null> { try { const result = await this.db.query( "SELECT * FROM project_users WHERE project_id = $1 AND id = $2", [projectId, userId] ); return result.rows.length > 0 ? (result.rows[0] as ProjectUser) : null; } catch (error) { this.logger.error("Failed to get user by ID:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUserById", }); } } /** * Get user by email * * Retrieves a project user by their email address. * * @param {string} projectId - Project ID * @param {string} email - Email address * @returns {Promise<ProjectUser | null>} User or null if not found * @throws {Error} If query fails * * @example * const user = await usersService.getUserByEmail('project-id', 'user@example.com'); */ async getUserByEmail( projectId: string, email: string ): Promise<ProjectUser | null> { try { const result = await this.db.query( "SELECT * FROM project_users WHERE project_id = $1 AND email = $2", [projectId, email] ); return result.rows.length > 0 ? (result.rows[0] as ProjectUser) : null; } catch (error) { this.logger.error("Failed to get user by email:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUserByEmail", }); } } /** * Get user by username * * Retrieves a project user by their username. * * @param {string} projectId - Project ID * @param {string} username - Username * @returns {Promise<ProjectUser | null>} User or null if not found * @throws {Error} If query fails * * @example * const user = await usersService.getUserByUsername('project-id', 'username'); */ async getUserByUsername( projectId: string, username: string ): Promise<ProjectUser | null> { try { const result = await this.db.query( "SELECT * FROM project_users WHERE project_id = $1 AND username = $2", [projectId, username] ); return result.rows.length > 0 ? (result.rows[0] as ProjectUser) : null; } catch (error) { this.logger.error("Failed to get user by username:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUserByUsername", }); } } /** * Create a new project user * * Creates a new user in a project with optional password hashing and role assignment. * * @param {string} projectId - Project ID * @param {CreateUserRequest} userData - User creation data * @param {string} [userData.username] - Username * @param {string} [userData.email] - Email address * @param {string} [userData.password] - Password (will be hashed) * @param {string} [userData.role="member"] - User role * @param {string[]} [userData.permissions] - Permission scopes * @param {string} [createdBy] - User ID who created this user * @returns {Promise<ProjectUser>} Created user * @throws {Error} If creation fails or user already exists * * @example * const user = await usersService.createUser('project-id', { * username: 'newuser', * email: 'user@example.com', * password: 'password', * role: 'admin' * }, 'creator-user-id'); */ async createUser( projectId: string, userData: CreateUserRequest, createdBy?: string ): Promise<ProjectUser> { try { // Check for duplicate email if provided if (userData.email) { const existingUserByEmail = await this.getUserByEmail( projectId, userData.email ); if (existingUserByEmail) { throw KrapiError.conflict( `User with email ${userData.email} already exists in project ${projectId}`, { resource: "project_users", operation: "createUser", email: userData.email, projectId, } ); } } // Check for duplicate username if provided if (userData.username) { const existingUserByUsername = await this.getUserByUsername( projectId, userData.username ); if (existingUserByUsername) { throw KrapiError.conflict( `User with username ${userData.username} already exists in project ${projectId}`, { resource: "project_users", operation: "createUser", username: userData.username, projectId, } ); } } // Hash password if provided let passwordHash: string | undefined; if (userData.password) { passwordHash = await this.hashPassword(userData.password); } // Get default role if not specified const role = userData.role || "member"; // Get default permissions for role const permissions = userData.permissions || (await this.getDefaultPermissionsForRole(projectId, role)); // Generate user ID (SQLite doesn't support RETURNING *) const userId = crypto.randomUUID(); const now = new Date().toISOString(); // SQLite-compatible INSERT (no RETURNING *) await this.db.query( `INSERT INTO project_users ( id, project_id, username, email, external_id, first_name, last_name, display_name, avatar_url, role, permissions, metadata, password_hash, created_by, created_at, updated_at, is_active ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, [ userId, projectId, userData.username, userData.email, userData.external_id, userData.first_name, userData.last_name, userData.display_name, userData.avatar_url, role, JSON.stringify(permissions), // SQLite stores arrays as JSON strings JSON.stringify(userData.metadata || {}), // SQLite stores objects as JSON strings passwordHash, createdBy, now, now, 1, // is_active = true ] ); // Query back the inserted row const result = await this.db.query( "SELECT * FROM project_users WHERE id = $1", [userId] ); // Log user creation activity await this.logUserActivity({ user_id: createdBy || "system", project_id: projectId, action: "user_created", entity_type: "user", entity_id: userId, details: { username: userData.username, email: userData.email, role }, }); return result.rows[0] as ProjectUser; } catch (error) { // If it's already a KrapiError (like our conflict error), re-throw it if (error instanceof KrapiError) { throw error; } // Check if it's a database constraint violation (UNIQUE constraint) const errorMessage = error instanceof Error ? error.message : String(error); const lowerMessage = errorMessage.toLowerCase(); // Detect common database constraint violation patterns if ( lowerMessage.includes("unique constraint") || lowerMessage.includes("duplicate key") || lowerMessage.includes("unique") || lowerMessage.includes("already exists") || (lowerMessage.includes("constraint") && lowerMessage.includes("violation")) ) { // Determine which field caused the conflict let conflictField = "email or username"; let conflictValue = userData.email || userData.username || "unknown"; if (lowerMessage.includes("email")) { conflictField = "email"; conflictValue = userData.email || "unknown"; } else if (lowerMessage.includes("username")) { conflictField = "username"; conflictValue = userData.username || "unknown"; } throw KrapiError.conflict( `User with ${conflictField} ${conflictValue} already exists in project ${projectId}`, { resource: "project_users", operation: "createUser", field: conflictField, value: conflictValue, projectId, originalError: errorMessage, } ); } this.logger.error("Failed to create user:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createUser", projectId, email: userData.email, username: userData.username, }); } } /** * Update a project user * * Updates user information with the provided data. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @param {UpdateUserRequest} updates - User update data * @param {string} [updatedBy] - User ID who made the update * @returns {Promise<ProjectUser | null>} Updated user or null if not found * @throws {Error} If update fails * * @example * const updated = await usersService.updateUser('project-id', 'user-id', { * display_name: 'New Name', * role: 'admin' * }, 'updater-user-id'); */ async updateUser( projectId: string, userId: string, updates: UpdateUserRequest, updatedBy?: string ): Promise<ProjectUser | null> { try { const fields: string[] = []; const values: unknown[] = []; let paramCount = 1; if (updates.username !== undefined) { fields.push(`username = $${paramCount++}`); values.push(updates.username); } if (updates.email !== undefined) { fields.push(`email = $${paramCount++}`); values.push(updates.email); } if (updates.external_id !== undefined) { fields.push(`external_id = $${paramCount++}`); values.push(updates.external_id); } if (updates.first_name !== undefined) { fields.push(`first_name = $${paramCount++}`); values.push(updates.first_name); } if (updates.last_name !== undefined) { fields.push(`last_name = $${paramCount++}`); values.push(updates.last_name); } if (updates.display_name !== undefined) { fields.push(`display_name = $${paramCount++}`); values.push(updates.display_name); } if (updates.avatar_url !== undefined) { fields.push(`avatar_url = $${paramCount++}`); values.push(updates.avatar_url); } if (updates.role !== undefined) { fields.push(`role = $${paramCount++}`); values.push(updates.role); } if (updates.permissions !== undefined) { fields.push(`permissions = $${paramCount++}`); values.push(updates.permissions); } if (updates.metadata !== undefined) { // Merge with existing metadata const currentUser = await this.getUserById(projectId, userId); if (currentUser) { const mergedMetadata = { ...currentUser.metadata, ...updates.metadata, }; fields.push(`metadata = $${paramCount++}`); values.push(mergedMetadata); } } if (updates.is_active !== undefined) { fields.push(`is_active = $${paramCount++}`); values.push(updates.is_active); } if (fields.length === 0) { return this.getUserById(projectId, userId); } fields.push(`updated_at = $${paramCount++}`); values.push(new Date().toISOString()); values.push(projectId, userId); // SQLite doesn't support RETURNING *, so update and query back separately await this.db.query( `UPDATE project_users SET ${fields.join(", ")} WHERE project_id = $${paramCount++} AND id = $${paramCount}`, values ); // Query back the updated row const result = await this.db.query( "SELECT * FROM project_users WHERE project_id = $1 AND id = $2", [projectId, userId] ); if (result.rows.length > 0) { // Log user update activity await this.logUserActivity({ user_id: updatedBy || "system", project_id: projectId, action: "user_updated", entity_type: "user", entity_id: userId, details: updates as Record<string, unknown>, }); } return result.rows.length > 0 ? (result.rows[0] as ProjectUser) : null; } catch (error) { this.logger.error("Failed to update user:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "updateUser", }); } } /** * Soft delete a project user * * Marks a user as inactive (soft delete) rather than permanently removing them. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @param {string} [deletedBy] - User ID who deleted * @returns {Promise<boolean>} True if deletion successful * @throws {Error} If deletion fails * * @example * const deleted = await usersService.deleteUser('project-id', 'user-id', 'deleter-user-id'); */ async deleteUser( projectId: string, userId: string, deletedBy?: string ): Promise<boolean> { try { // Soft delete by setting is_active to false const result = await this.db.query( `UPDATE project_users SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE project_id = $1 AND id = $2`, [projectId, userId] ); if (result.rowCount > 0) { // Log user deletion activity await this.logUserActivity({ user_id: deletedBy || "system", project_id: projectId, action: "user_deleted", entity_type: "user", entity_id: userId, details: { soft_delete: true }, }); } return result.rowCount > 0; } catch (error) { this.logger.error("Failed to delete user:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "deleteUser", }); } } /** * Permanently delete a project user * * Permanently removes a user and all associated data from the database. * This action cannot be undone. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @param {string} [deletedBy] - User ID who deleted * @returns {Promise<boolean>} True if deletion successful * @throws {Error} If deletion fails * * @example * const deleted = await usersService.hardDeleteUser('project-id', 'user-id', 'deleter-user-id'); */ async hardDeleteUser( projectId: string, userId: string, deletedBy?: string ): Promise<boolean> { try { // Log before deletion await this.logUserActivity({ user_id: deletedBy || "system", project_id: projectId, action: "user_hard_deleted", entity_type: "user", entity_id: userId, details: { hard_delete: true }, }); // Delete user sessions await this.db.query( "DELETE FROM user_sessions WHERE user_id = $1 AND project_id = $2", [userId, projectId] ); // Delete user activities (optional - you might want to keep them for audit) // await this.db.query( // "DELETE FROM user_activities WHERE user_id = $1 AND project_id = $2", // [userId, projectId] // ); // Delete the user const result = await this.db.query( "DELETE FROM project_users WHERE project_id = $1 AND id = $2", [projectId, userId] ); return result.rowCount > 0; } catch (error) { this.logger.error("Failed to hard delete user:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "hardDeleteUser", }); } } /** * Get all roles for a project * * Retrieves all user roles defined for a project. * * @param {string} projectId - Project ID * @returns {Promise<UserRole[]>} Array of user roles * @throws {Error} If query fails * * @example * const roles = await usersService.getAllRoles('project-id'); */ async getAllRoles(projectId: string): Promise<UserRole[]> { try { const result = await this.db.query( "SELECT * FROM user_roles WHERE project_id = $1 ORDER BY name", [projectId] ); return result.rows as UserRole[]; } catch (error) { this.logger.error("Failed to get roles:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getRoles", }); } } /** * Create a new user role * * Creates a new role with specified permissions for a project. * * @param {string} projectId - Project ID * @param {Object} roleData - Role data * @param {string} roleData.name - Role name (unique identifier) * @param {string} roleData.display_name - Display name * @param {string} [roleData.description] - Role description * @param {string[]} roleData.permissions - Permission scopes * @param {boolean} [roleData.is_default=false] - Whether this is the default role * @returns {Promise<UserRole>} Created role * @throws {Error} If creation fails * * @example * const role = await usersService.createRole('project-id', { * name: 'editor', * display_name: 'Editor', * description: 'Can edit content', * permissions: ['collections:read', 'collections:write', 'documents:write'] * }); */ async createRole( projectId: string, roleData: { name: string; display_name: string; description?: string; permissions: string[]; is_default?: boolean; } ): Promise<UserRole> { try { // Generate role ID (SQLite doesn't support RETURNING *) const roleId = crypto.randomUUID(); const now = new Date().toISOString(); // SQLite-compatible INSERT (no RETURNING *) await this.db.query( `INSERT INTO user_roles (id, project_id, name, display_name, description, permissions, is_default, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ roleId, projectId, roleData.name, roleData.display_name, roleData.description, JSON.stringify(roleData.permissions), // SQLite stores arrays as JSON strings roleData.is_default ? 1 : 0, // SQLite uses INTEGER 1/0 for booleans now, now, ] ); // Query back the inserted row const result = await this.db.query( "SELECT * FROM user_roles WHERE id = $1", [roleId] ); return result.rows[0] as UserRole; } catch (error) { this.logger.error("Failed to create role:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createRole", }); } } /** * Update a user role * * Updates role information including permissions. * * @param {string} projectId - Project ID * @param {string} roleId - Role ID * @param {Object} updates - Role updates * @param {string} [updates.display_name] - New display name * @param {string} [updates.description] - New description * @param {string[]} [updates.permissions] - Updated permissions * @param {boolean} [updates.is_default] - Whether this is the default role * @returns {Promise<UserRole | null>} Updated role or null if not found * @throws {Error} If update fails * * @example * const updated = await usersService.updateRole('project-id', 'role-id', { * permissions: ['collections:read', 'collections:write', 'documents:read'] * }); */ async updateRole( projectId: string, roleId: string, updates: { display_name?: string; description?: string; permissions?: string[]; is_default?: boolean; } ): Promise<UserRole | null> { try { const fields: string[] = []; const values: unknown[] = []; let paramCount = 1; if (updates.display_name !== undefined) { fields.push(`display_name = $${paramCount++}`); values.push(updates.display_name); } if (updates.description !== undefined) { fields.push(`description = $${paramCount++}`); values.push(updates.description); } if (updates.permissions !== undefined) { fields.push(`permissions = $${paramCount++}`); values.push(updates.permissions); } if (updates.is_default !== undefined) { fields.push(`is_default = $${paramCount++}`); values.push(updates.is_default); } if (fields.length === 0) { const result = await this.db.query( "SELECT * FROM user_roles WHERE project_id = $1 AND id = $2", [projectId, roleId] ); return result.rows.length > 0 ? (result.rows[0] as UserRole) : null; } fields.push(`updated_at = $${paramCount++}`); values.push(new Date().toISOString()); values.push(projectId, roleId); // SQLite doesn't support RETURNING *, so update and query back separately await this.db.query( `UPDATE user_roles SET ${fields.join(", ")} WHERE project_id = $${paramCount++} AND id = $${paramCount}`, values ); // Query back the updated row const result = await this.db.query( "SELECT * FROM user_roles WHERE project_id = $1 AND id = $2", [projectId, roleId] ); return result.rows.length > 0 ? (result.rows[0] as UserRole) : null; } catch (error) { this.logger.error("Failed to update role:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "updateRole", }); } } /** * Log user activity * * Records a user activity event for audit and analytics. * * @param {Object} activityData - Activity data * @param {string} activityData.user_id - User ID * @param {string} activityData.project_id - Project ID * @param {string} activityData.action - Action performed * @param {string} activityData.entity_type - Entity type (e.g., 'document', 'collection') * @param {string} [activityData.entity_id] - Entity ID * @param {Record<string, unknown>} activityData.details - Activity details * @param {string} [activityData.ip_address] - Client IP address * @param {string} [activityData.user_agent] - Client user agent * @returns {Promise<void>} * * @example * await usersService.logUserActivity({ * user_id: 'user-id', * project_id: 'project-id', * action: 'created', * entity_type: 'document', * entity_id: 'doc-id', * details: { collection: 'users' } * }); */ async logUserActivity(activityData: { user_id: string; project_id: string; action: string; entity_type: string; entity_id?: string; details: Record<string, unknown>; ip_address?: string; user_agent?: string; }): Promise<void> { try { await this.db.query( `INSERT INTO user_activities ( user_id, project_id, action, entity_type, entity_id, details, ip_address, user_agent ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ activityData.user_id, activityData.project_id, activityData.action, activityData.entity_type, activityData.entity_id, JSON.stringify(activityData.details), activityData.ip_address, activityData.user_agent, ] ); } catch (error) { this.logger.error("Failed to log user activity:", error); // Don't throw here as this shouldn't break the main operation } } /** * Get user activity log * * Retrieves activity log entries for a specific user with optional filtering. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @param {Object} [options] - Query options * @param {number} [options.limit] - Maximum number of entries * @param {number} [options.offset] - Number of entries to skip * @param {string} [options.action_filter] - Filter by action type * @param {string} [options.entity_type_filter] - Filter by entity type * @returns {Promise<UserActivity[]>} Array of activity log entries * @throws {Error} If query fails * * @example * const activity = await usersService.getUserActivity('project-id', 'user-id', { * limit: 50, * action_filter: 'created' * }); */ async getUserActivity( projectId: string, userId: string, options?: { limit?: number; offset?: number; action_filter?: string; entity_type_filter?: string; } ): Promise<UserActivity[]> { try { let query = "SELECT * FROM user_activities WHERE project_id = $1 AND user_id = $2"; const params: unknown[] = [projectId, userId]; let paramCount = 2; if (options?.action_filter) { paramCount++; query += ` AND action = $${paramCount}`; params.push(options.action_filter); } if (options?.entity_type_filter) { paramCount++; query += ` AND entity_type = $${paramCount}`; params.push(options.entity_type_filter); } query += " ORDER BY created_at DESC"; if (options?.limit) { paramCount++; query += ` LIMIT $${paramCount}`; params.push(options.limit); } if (options?.offset) { paramCount++; query += ` OFFSET $${paramCount}`; params.push(options.offset); } const result = await this.db.query(query, params); return result.rows as UserActivity[]; } catch (error) { this.logger.error("Failed to get user activity:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUserActivity", }); } } /** * Get user statistics for a project * * Retrieves comprehensive user statistics including counts, role distribution, * login activity, and most active users. * * @param {string} projectId - Project ID * @returns {Promise<UserStatistics>} User statistics * @throws {Error} If query fails * * @example * const stats = await usersService.getUserStatistics('project-id'); * console.log(`Total users: ${stats.total_users}`); * console.log(`Active users: ${stats.active_users}`); */ async getUserStatistics(projectId: string): Promise<UserStatistics> { try { // Calculate cutoff dates for SQLite (SQLite doesn't support INTERVAL or DATE_TRUNC) const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const firstOfMonth = new Date(); firstOfMonth.setDate(1); firstOfMonth.setHours(0, 0, 0, 0); const [ totalUsersResult, activeUsersResult, usersByRoleResult, recentLoginsResult, newUsersResult, mostActiveUsersResult, ] = await Promise.all([ this.db.query( "SELECT COUNT(*) FROM project_users WHERE project_id = $1", [projectId] ), this.db.query( "SELECT COUNT(*) FROM project_users WHERE project_id = $1 AND is_active = 1", [projectId] ), this.db.query( "SELECT role, COUNT(*) as count FROM project_users WHERE project_id = $1 GROUP BY role", [projectId] ), this.db.query( "SELECT COUNT(*) FROM project_users WHERE project_id = $1 AND last_login >= $2", [projectId, sevenDaysAgo.toISOString()] ), this.db.query( "SELECT COUNT(*) FROM project_users WHERE project_id = $1 AND created_at >= $2", [projectId, firstOfMonth.toISOString()] ), this.db.query( ` SELECT u.id as user_id, u.username, COUNT(a.id) as activity_count FROM project_users u LEFT JOIN user_activities a ON u.id = a.user_id WHERE u.project_id = $1 AND a.created_at >= $2 GROUP BY u.id, u.username ORDER BY activity_count DESC LIMIT 10 `, [projectId, thirtyDaysAgo.toISOString()] ), ]); const usersByRole: Record<string, number> = {}; for (const row of usersByRoleResult.rows) { const typedRow = row as { role: string; count: string }; usersByRole[typedRow.role] = parseInt(typedRow.count); } return { total_users: parseInt((totalUsersResult.rows[0] as CountRow).count), active_users: parseInt((activeUsersResult.rows[0] as CountRow).count), users_by_role: usersByRole, recent_logins: parseInt((recentLoginsResult.rows[0] as CountRow).count), new_users_this_month: parseInt( (newUsersResult.rows[0] as CountRow).count ), most_active_users: mostActiveUsersResult.rows.map((row) => { const typedRow = row as { user_id: string; username: string; activity_count: string; }; return { user_id: typedRow.user_id, username: typedRow.username, activity_count: parseInt(typedRow.activity_count), }; }), }; } catch (error) { this.logger.error("Failed to get user statistics:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUserStatistics", }); } } // Utility Methods private async hashPassword(password: string): Promise<string> { try { const saltRounds = 12; return await bcrypt.hash(password, saltRounds); } catch (error) { this.logger.error("Failed to hash password:", error); // Fallback for development return `hashed_${password}`; } } private async getDefaultPermissionsForRole( projectId: string, roleName: string ): Promise<string[]> { try { const result = await this.db.query( "SELECT permissions FROM user_roles WHERE project_id = $1 AND name = $2", [projectId, roleName] ); if (result.rows.length > 0) { return (result.rows[0] as { permissions: string[] }).permissions; } // Default permissions based on common roles const defaultPermissions: Record<string, string[]> = { admin: ["*"], // All permissions member: [ "projects:read", "collections:read", "collections:write", "documents:read", "documents:write", "files:read", "files:write", ], viewer: [ "projects:read", "collections:read", "documents:read", "files:read", ], guest: ["projects:read", "collections:read", "documents:read"], }; return defaultPermissions[roleName] || defaultPermissions.member || []; } catch (error) { this.logger.error("Failed to get default permissions for role:", error); return ["projects:read", "collections:read", "documents:read"]; } } /** * Change a user's password * * Updates a user's password with hashing and activity logging. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @param {string} newPassword - New password (will be hashed) * @param {string} [changedBy] - User ID who changed the password * @returns {Promise<boolean>} True if password changed successfully * @throws {Error} If change fails * * @example * const changed = await usersService.changeUserPassword('project-id', 'user-id', 'newpassword', 'admin-user-id'); */ async changeUserPassword( projectId: string, userId: string, newPassword: string, changedBy?: string ): Promise<boolean> { try { const passwordHash = await this.hashPassword(newPassword); const result = await this.db.query( `UPDATE project_users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE project_id = $2 AND id = $3`, [passwordHash, projectId, userId] ); if (result.rowCount > 0) { await this.logUserActivity({ user_id: changedBy || userId, project_id: projectId, action: "password_changed", entity_type: "user", entity_id: userId, details: { changed_by: changedBy || "self" }, }); } return result.rowCount > 0; } catch (error) { this.logger.error("Failed to change user password:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "changeUserPassword", }); } } /** * Get user scopes * * Retrieves the access scopes for a specific user in a project. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @returns {Promise<string[]>} Array of user scopes * @throws {Error} If query fails or user not found * * @example * const scopes = await usersService.getUserScopes('project-id', 'user-id'); */ async getUserScopes(projectId: string, userId: string): Promise<string[]> { try { const result = await this.db.query( "SELECT scopes FROM project_users WHERE project_id = $1 AND id = $2", [projectId, userId] ); if (result.rows.length === 0) { throw KrapiError.notFound( `User not found: ${userId} in project ${projectId}`, { userId, projectId, } ); } const user = result.rows[0] as { scopes?: string[] | null }; // Handle scopes stored as JSON string or array if (user.scopes) { if (typeof user.scopes === "string") { try { return JSON.parse(user.scopes) as string[]; } catch { return []; } } if (Array.isArray(user.scopes)) { return user.scopes; } } // If no scopes field, check permissions field as fallback const permissionsResult = await this.db.query( "SELECT permissions FROM project_users WHERE project_id = $1 AND id = $2", [projectId, userId] ); if (permissionsResult.rows.length > 0) { const permissions = permissionsResult.rows[0] as { permissions?: string[] | null; }; if (permissions.permissions) { if (typeof permissions.permissions === "string") { try { return JSON.parse(permissions.permissions) as string[]; } catch { return []; } } if (Array.isArray(permissions.permissions)) { return permissions.permissions; } } } return []; } catch (error) { this.logger.error("Failed to get user scopes:", error); throw error instanceof Error ? error : new Error("Failed to get user scopes"); } } /** * Update user scopes * * Updates the access scopes for a specific user in a project. * * @param {string} projectId - Project ID * @param {string} userId - User ID * @param {string[]} scopes - Array of scopes to set * @returns {Promise<string[]>} Updated scopes * @throws {Error} If update fails or user not found * * @example * const updatedScopes = await usersService.updateUserScopes('project-id', 'user-id', ['data:read', 'data:write']); */ async updateUserScopes( projectId: string, userId: string, scopes: string[] ): Promise<string[]> { try { // Validate scopes are an array of strings if (!Array.isArray(scopes)) { throw KrapiError.validationError( "Scopes must be an array of strings", "scopes" ); } if (!scopes.every((scope) => typeof scope === "string")) { throw KrapiError.validationError( "All scopes must be strings", "scopes" ); } // Check if user exists const userCheck = await this.db.query( "SELECT id FROM project_users WHERE project_id = $1 AND id = $2", [projectId, userId] ); if (userCheck.rows.length === 0) { throw KrapiError.notFound( `User not found: ${userId} in project ${projectId}`, { userId, projectId, } ); } // Update scopes (store as JSON string in database) // SQLite-compatible: update and query back (SQLite 3.35.0+ supports RETURNING, but we use compatible approach) await this.db.query( `UPDATE project_users SET scopes = $1, updated_at = CURRENT_TIMESTAMP WHERE project_id = $2 AND id = $3`, [JSON.stringify(scopes), projectId, userId] ); // Query back to verify update const result = await this.db.query( "SELECT scopes FROM project_users WHERE project_id = $1 AND id = $2", [projectId, userId] ); if (result.rowCount === 0) { throw KrapiError.internalError("Failed to update user scopes", { userId, projectId, }); } // Log the activity await this.logUserActivity({ user_id: "system", // Would need user context in real implementation project_id: projectId, action: "user_scopes_updated", entity_type: "user", entity_id: userId, details: { scopes }, }); // Return the updated scopes const updatedScopes = result.rows[0] as { scopes?: string | string[] }; if (updatedScopes.scopes) { if (typeof updatedScopes.scopes === "string") { try { return JSON.parse(updatedScopes.scopes) as string[]; } catch { return scopes; } } if (Array.isArray(updatedScopes.scopes)) { return updatedScopes.scopes; } } return scopes; } catch (error) { this.logger.error("Failed to update user scopes:", error); throw error instanceof Error ? error : new Error("Failed to update user scopes"); } } }