@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
text/typescript
/**
* 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");
}
}
}