@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
1,107 lines (1,104 loc) • 37.3 kB
JavaScript
import {
KrapiError,
init_error_handler,
init_krapi_error,
normalizeError
} from "./chunk-CUJMHNHY.mjs";
// src/users-service.ts
init_krapi_error();
init_error_handler();
import crypto from "crypto";
import bcrypt from "bcryptjs";
var UsersService = class {
/**
* Create a new UsersService instance
*
* @param {DatabaseConnection} databaseConnection - Database connection
* @param {Logger} logger - Logger instance
*/
constructor(databaseConnection, 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, options) {
try {
let query = "SELECT * FROM project_users WHERE project_id = $1";
const params = [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 !== void 0) {
paramCount++;
query += ` AND is_active = $${paramCount}`;
params.push(filter.is_active);
}
if (filter.search) {
paramCount++;
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;
} 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, userId) {
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] : 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, email) {
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] : 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, username) {
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] : 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, userData, createdBy) {
try {
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
}
);
}
}
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
}
);
}
}
let passwordHash;
if (userData.password) {
passwordHash = await this.hashPassword(userData.password);
}
const role = userData.role || "member";
const permissions = userData.permissions || await this.getDefaultPermissionsForRole(projectId, role);
const userId = crypto.randomUUID();
const now = (/* @__PURE__ */ new Date()).toISOString();
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
]
);
const result = await this.db.query(
"SELECT * FROM project_users WHERE id = $1",
[userId]
);
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];
} catch (error) {
if (error instanceof KrapiError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes("unique constraint") || lowerMessage.includes("duplicate key") || lowerMessage.includes("unique") || lowerMessage.includes("already exists") || lowerMessage.includes("constraint") && lowerMessage.includes("violation")) {
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, userId, updates, updatedBy) {
try {
const fields = [];
const values = [];
let paramCount = 1;
if (updates.username !== void 0) {
fields.push(`username = $${paramCount++}`);
values.push(updates.username);
}
if (updates.email !== void 0) {
fields.push(`email = $${paramCount++}`);
values.push(updates.email);
}
if (updates.external_id !== void 0) {
fields.push(`external_id = $${paramCount++}`);
values.push(updates.external_id);
}
if (updates.first_name !== void 0) {
fields.push(`first_name = $${paramCount++}`);
values.push(updates.first_name);
}
if (updates.last_name !== void 0) {
fields.push(`last_name = $${paramCount++}`);
values.push(updates.last_name);
}
if (updates.display_name !== void 0) {
fields.push(`display_name = $${paramCount++}`);
values.push(updates.display_name);
}
if (updates.avatar_url !== void 0) {
fields.push(`avatar_url = $${paramCount++}`);
values.push(updates.avatar_url);
}
if (updates.role !== void 0) {
fields.push(`role = $${paramCount++}`);
values.push(updates.role);
}
if (updates.permissions !== void 0) {
fields.push(`permissions = $${paramCount++}`);
values.push(updates.permissions);
}
if (updates.metadata !== void 0) {
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 !== void 0) {
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((/* @__PURE__ */ new Date()).toISOString());
values.push(projectId, userId);
await this.db.query(
`UPDATE project_users SET ${fields.join(", ")}
WHERE project_id = $${paramCount++} AND id = $${paramCount}`,
values
);
const result = await this.db.query(
"SELECT * FROM project_users WHERE project_id = $1 AND id = $2",
[projectId, userId]
);
if (result.rows.length > 0) {
await this.logUserActivity({
user_id: updatedBy || "system",
project_id: projectId,
action: "user_updated",
entity_type: "user",
entity_id: userId,
details: updates
});
}
return result.rows.length > 0 ? result.rows[0] : 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, userId, deletedBy) {
try {
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) {
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, userId, deletedBy) {
try {
await this.logUserActivity({
user_id: deletedBy || "system",
project_id: projectId,
action: "user_hard_deleted",
entity_type: "user",
entity_id: userId,
details: { hard_delete: true }
});
await this.db.query(
"DELETE FROM user_sessions WHERE user_id = $1 AND project_id = $2",
[userId, projectId]
);
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) {
try {
const result = await this.db.query(
"SELECT * FROM user_roles WHERE project_id = $1 ORDER BY name",
[projectId]
);
return result.rows;
} 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, roleData) {
try {
const roleId = crypto.randomUUID();
const now = (/* @__PURE__ */ new Date()).toISOString();
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
]
);
const result = await this.db.query(
"SELECT * FROM user_roles WHERE id = $1",
[roleId]
);
return result.rows[0];
} 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, roleId, updates) {
try {
const fields = [];
const values = [];
let paramCount = 1;
if (updates.display_name !== void 0) {
fields.push(`display_name = $${paramCount++}`);
values.push(updates.display_name);
}
if (updates.description !== void 0) {
fields.push(`description = $${paramCount++}`);
values.push(updates.description);
}
if (updates.permissions !== void 0) {
fields.push(`permissions = $${paramCount++}`);
values.push(updates.permissions);
}
if (updates.is_default !== void 0) {
fields.push(`is_default = $${paramCount++}`);
values.push(updates.is_default);
}
if (fields.length === 0) {
const result2 = await this.db.query(
"SELECT * FROM user_roles WHERE project_id = $1 AND id = $2",
[projectId, roleId]
);
return result2.rows.length > 0 ? result2.rows[0] : null;
}
fields.push(`updated_at = $${paramCount++}`);
values.push((/* @__PURE__ */ new Date()).toISOString());
values.push(projectId, roleId);
await this.db.query(
`UPDATE user_roles SET ${fields.join(", ")}
WHERE project_id = $${paramCount++} AND id = $${paramCount}`,
values
);
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] : 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) {
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);
}
}
/**
* 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, userId, options) {
try {
let query = "SELECT * FROM user_activities WHERE project_id = $1 AND user_id = $2";
const params = [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;
} 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) {
try {
const sevenDaysAgo = /* @__PURE__ */ new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const thirtyDaysAgo = /* @__PURE__ */ new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const firstOfMonth = /* @__PURE__ */ 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 = {};
for (const row of usersByRoleResult.rows) {
const typedRow = row;
usersByRole[typedRow.role] = parseInt(typedRow.count);
}
return {
total_users: parseInt(totalUsersResult.rows[0].count),
active_users: parseInt(activeUsersResult.rows[0].count),
users_by_role: usersByRole,
recent_logins: parseInt(recentLoginsResult.rows[0].count),
new_users_this_month: parseInt(
newUsersResult.rows[0].count
),
most_active_users: mostActiveUsersResult.rows.map((row) => {
const typedRow = row;
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
async hashPassword(password) {
try {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
} catch (error) {
this.logger.error("Failed to hash password:", error);
return `hashed_${password}`;
}
}
async getDefaultPermissionsForRole(projectId, roleName) {
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].permissions;
}
const defaultPermissions = {
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, userId, newPassword, changedBy) {
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, userId) {
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];
if (user.scopes) {
if (typeof user.scopes === "string") {
try {
return JSON.parse(user.scopes);
} catch {
return [];
}
}
if (Array.isArray(user.scopes)) {
return user.scopes;
}
}
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];
if (permissions.permissions) {
if (typeof permissions.permissions === "string") {
try {
return JSON.parse(permissions.permissions);
} 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, userId, scopes) {
try {
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"
);
}
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
}
);
}
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]
);
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
});
}
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 }
});
const updatedScopes = result.rows[0];
if (updatedScopes.scopes) {
if (typeof updatedScopes.scopes === "string") {
try {
return JSON.parse(updatedScopes.scopes);
} 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");
}
}
};
export {
UsersService
};