@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
831 lines (792 loc) • 28 kB
text/typescript
/**
* Users HTTP Client for KRAPI SDK
*
* HTTP-based user management methods for frontend applications.
* Provides user CRUD operations, role management, and permissions management.
*
* @module http-clients/users-http-client
* @example
* const client = new UsersHttpClient({ baseUrl: 'https://api.example.com' });
* const users = await client.getAllUsers('project-id', { limit: 10 });
*/
import { QueryOptions } from "../core";
import { ProjectUser } from "../types";
import { ProjectUser as UsersServiceProjectUser } from "../users-service";
import { BaseHttpClient } from "./base-http-client";
import { HttpError } from "./http-error";
/**
* Users HTTP Client
*
* HTTP client for user management operations.
*
* @class UsersHttpClient
* @extends {BaseHttpClient}
* @example
* const client = new UsersHttpClient({ baseUrl: 'https://api.example.com', apiKey: 'key' });
* const user = await client.createUser('project-id', { username: 'user', email: 'user@example.com', password: 'pass' });
*/
export class UsersHttpClient extends BaseHttpClient {
// Constructor inherited from BaseHttpClient
/**
* Get all users in a project
*
* @param {string} projectId - Project ID
* @param {Object} [options] - Query options
* @param {string} [options.search] - Search term for username/email
* @param {number} [options.limit] - Maximum number of users
* @param {number} [options.page] - Page number for pagination
* @returns {Promise<ProjectUser[]>} Array of project users
*
* @example
* const users = await client.getAllUsers('project-id', { search: 'john', limit: 10 });
*/
async getAllUsers(
projectId: string,
options?: {
search?: string;
limit?: number;
page?: number;
}
): Promise<ProjectUser[]> {
try {
// BaseHttpClient response interceptor returns response.data, so response is already unwrapped
// Backend returns: { success: true, data: [...] }
// After interceptor: { success: true, data: [...] } (response.data from axios)
const response = await this.get<unknown>(
`/projects/${projectId}/users`,
options as QueryOptions
);
// Normalize response format - handle different backend response formats
// Backend can return: { success: true, data: [...] }, { data: [...] }, or directly [...]
let users: (UsersServiceProjectUser | ProjectUser)[] = [];
if (Array.isArray(response)) {
// Direct array response (unlikely but possible)
users = response as unknown as (
| UsersServiceProjectUser
| ProjectUser
)[];
} else if (response && typeof response === "object") {
// Check for wrapped format with 'data' key (most common - backend returns { success: true, data: [...] })
if ("data" in response) {
const data = (response as { data?: unknown }).data;
if (Array.isArray(data)) {
users = data as unknown as (
| UsersServiceProjectUser
| ProjectUser
)[];
} else if (data && typeof data === "object" && "documents" in data) {
// Handle nested format: { success: true, data: { documents: [...] } }
const nestedData = (data as { documents?: unknown[] }).documents;
if (Array.isArray(nestedData)) {
users = nestedData as unknown as (
| UsersServiceProjectUser
| ProjectUser
)[];
}
}
}
// If no data key but has success, might be empty response
else if (
"success" in response &&
(response as { success: boolean }).success
) {
// Empty array if success but no data
users = [];
}
}
// Map users-service ProjectUser to types.ts ProjectUser (convert is_active to status)
return users.map((user) => this.mapToProjectUser(user));
} catch (error) {
// Enhance error with context
if (error instanceof HttpError) {
throw new HttpError(
`Failed to fetch users for project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users`,
method: error.method || "GET",
}
);
}
throw error;
}
}
/**
* Map users-service ProjectUser to types.ts ProjectUser
* Converts is_active boolean to status string
*/
private mapToProjectUser(
user: UsersServiceProjectUser | ProjectUser
): ProjectUser {
// If already has status, return as-is (already in types.ts format)
if ("status" in user && user.status) {
return user as ProjectUser;
}
// Convert from users-service format to types.ts format
const serviceUser = user as UsersServiceProjectUser;
const mapped: ProjectUser = {
id: serviceUser.id,
project_id: serviceUser.project_id,
username: serviceUser.username || "",
email: serviceUser.email || "",
role: serviceUser.role as ProjectUser["role"],
status: serviceUser.is_active ? "active" : "inactive",
created_at: serviceUser.created_at,
updated_at: serviceUser.updated_at,
permissions: serviceUser.permissions || [],
};
// Add optional fields if they exist (only if defined, to avoid undefined assignment)
if (
serviceUser.last_login !== undefined &&
serviceUser.last_login !== null
) {
mapped.last_login = serviceUser.last_login;
}
if (
serviceUser.metadata &&
typeof serviceUser.metadata === "object" &&
"phone" in serviceUser.metadata
) {
mapped.phone = serviceUser.metadata.phone as string;
}
return mapped;
}
/**
* Get user by ID
*
* @param {string} projectId - Project ID
* @param {string} userId - User ID
* @returns {Promise<ProjectUser>} Project user object
*
* @example
* const user = await client.getUser('project-id', 'user-id');
*/
async getUser(projectId: string, userId: string): Promise<ProjectUser> {
try {
// BaseHttpClient response interceptor returns response.data, so response is already unwrapped
// Backend returns: { success: true, data: {...} }
// After interceptor: { success: true, data: {...} } (response.data from axios)
const response = await this.get<unknown>(
`/projects/${projectId}/users/${userId}`
);
// Normalize response format - handle different backend response formats
// Backend can return: { success: true, data: {...} }, { data: {...} }, or directly {...}
let user: UsersServiceProjectUser | ProjectUser | undefined;
if (response && typeof response === "object") {
// Check if response itself is a ProjectUser (has id, project_id) - direct response
if ("id" in response && "project_id" in response) {
user = response as unknown as UsersServiceProjectUser | ProjectUser;
}
// Check for wrapped format with 'data' key (most common - backend returns { success: true, data: {...} })
else if ("data" in response && (response as { data?: unknown }).data) {
const data = (response as { data: unknown }).data;
if (
data &&
typeof data === "object" &&
("id" in data || "project_id" in data)
) {
user = data as unknown as UsersServiceProjectUser | ProjectUser;
}
}
// Check for ApiResponse format with success flag
else if (
"success" in response &&
(response as { success: boolean }).success &&
"data" in response
) {
const data = (response as { data?: unknown }).data;
if (data && typeof data === "object") {
user = data as unknown as UsersServiceProjectUser | ProjectUser;
}
}
}
if (!user) {
throw new HttpError(
`User not found: ${userId} in project ${projectId}. Response format was unexpected.`,
{
status: 404,
method: "GET",
url: `/projects/${projectId}/users/${userId}`,
code: "USER_NOT_FOUND",
responseData: response,
}
);
}
return this.mapToProjectUser(user);
} catch (error) {
// Enhance error with context
if (error instanceof HttpError) {
// If it's already an HttpError with 404, keep it as is
if (error.status === 404) {
return Promise.reject(error);
}
// Otherwise enhance it
throw new HttpError(
`Failed to fetch user ${userId} from project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users/${userId}`,
method: error.method || "GET",
}
);
}
throw error;
}
}
/**
* Create a new user in a project
*
* @param {string} projectId - Project ID
* @param {Object} userData - User creation data
* @param {string} userData.username - Username
* @param {string} userData.email - Email address
* @param {string} userData.password - Password
* @param {string} [userData.role] - User role (optional)
* @param {string[]} [userData.permissions] - User permissions (optional)
* @param {string} [userData.first_name] - First name (optional)
* @param {string} [userData.last_name] - Last name (optional)
* @param {string} [userData.phone] - Phone number (optional)
* @returns {Promise<ProjectUser>} Created user object
*
* @example
* const user = await client.createUser('project-id', {
* username: 'johndoe',
* email: 'john@example.com',
* password: 'password123',
* role: 'user'
* });
*/
async createUser(
projectId: string,
userData: {
username: string;
email: string;
password: string;
role?: string;
permissions?: string[];
first_name?: string;
last_name?: string;
phone?: string;
}
): Promise<ProjectUser> {
try {
// Build request body, only including defined properties
const requestBody: {
username: string;
email: string;
password: string;
role?: string;
permissions?: string[];
first_name?: string;
last_name?: string;
phone?: string;
} = {
username: userData.username,
email: userData.email,
password: userData.password,
};
if (userData.role !== undefined) {
requestBody.role = userData.role;
}
if (userData.permissions !== undefined) {
requestBody.permissions = userData.permissions;
}
if (userData.first_name !== undefined) {
requestBody.first_name = userData.first_name;
}
if (userData.last_name !== undefined) {
requestBody.last_name = userData.last_name;
}
if (userData.phone !== undefined) {
requestBody.phone = userData.phone;
}
// BaseHttpClient response interceptor returns response.data, so response is already unwrapped
// Backend returns: { success: true, data: {...} } or directly {...}
const response = await this.post<unknown>(
`/projects/${projectId}/users`,
requestBody
);
// Normalize response format - handle different backend response formats
// Backend can return: ProjectUser directly, { success: true, data: {...} }, or { data: {...} }
let user: UsersServiceProjectUser | ProjectUser | undefined;
if (response && typeof response === "object") {
// Check if response itself is a ProjectUser (has id, project_id)
if ("id" in response && "project_id" in response) {
user = response as unknown as UsersServiceProjectUser | ProjectUser;
}
// Check for wrapped format with 'data' key (most common)
else if ("data" in response && (response as { data?: unknown }).data) {
const data = (response as { data: unknown }).data;
if (
data &&
typeof data === "object" &&
("id" in data || "project_id" in data)
) {
user = data as unknown as UsersServiceProjectUser | ProjectUser;
}
}
// Check for ApiResponse format with success flag
else if (
"success" in response &&
(response as { success: boolean }).success &&
"data" in response
) {
const data = (response as { data?: unknown }).data;
if (data && typeof data === "object") {
user = data as unknown as UsersServiceProjectUser | ProjectUser;
}
}
}
if (!user) {
throw new HttpError(
`Failed to create user in project ${projectId}: Invalid response format from server. Expected user object but received: ${JSON.stringify(
response
).substring(0, 200)}`,
{
status: 500,
method: "POST",
url: `/projects/${projectId}/users`,
code: "INVALID_RESPONSE",
responseData: response,
}
);
}
return this.mapToProjectUser(user);
} catch (error) {
// Enhance error with context
if (error instanceof HttpError) {
throw new HttpError(
`Failed to create user in project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users`,
method: error.method || "POST",
}
);
}
throw error;
}
}
/**
* Update a user in a project
*
* @param {string} projectId - Project ID
* @param {string} userId - User ID
* @param {Object} updates - User update data
* @param {string} [updates.email] - Email address (optional)
* @param {string} [updates.username] - Username (optional)
* @param {string} [updates.password] - Password (optional)
* @param {string} [updates.role] - User role (optional)
* @param {string[]} [updates.permissions] - User permissions (optional)
* @param {string} [updates.first_name] - First name (optional)
* @param {string} [updates.last_name] - Last name (optional)
* @param {string} [updates.phone] - Phone number (optional)
* @param {boolean} [updates.is_active] - Active status (optional)
* @returns {Promise<ProjectUser>} Updated user object
*
* @example
* const user = await client.updateUser('project-id', 'user-id', {
* email: 'newemail@example.com',
* role: 'admin'
* });
*/
async updateUser(
projectId: string,
userId: string,
updates: Partial<{
email: string;
username: string;
password: string;
role: string;
permissions: string[];
first_name: string;
last_name: string;
phone: string;
is_active: boolean;
}>
): Promise<ProjectUser> {
try {
// BaseHttpClient response interceptor returns response.data, so response is already unwrapped
// Backend returns: { success: true, data: {...} } or directly {...}
const response = await this.put<unknown>(
`/projects/${projectId}/users/${userId}`,
updates
);
// Normalize response format - handle different backend response formats
// Backend can return: ProjectUser directly, { success: true, data: {...} }, or { data: {...} }
let user: UsersServiceProjectUser | ProjectUser | undefined;
if (response && typeof response === "object") {
// Check if response itself is a ProjectUser (has id, project_id)
if ("id" in response && "project_id" in response) {
user = response as unknown as UsersServiceProjectUser | ProjectUser;
}
// Check for wrapped format with 'data' key (most common)
else if ("data" in response && (response as { data?: unknown }).data) {
const data = (response as { data: unknown }).data;
if (
data &&
typeof data === "object" &&
("id" in data || "project_id" in data)
) {
user = data as unknown as UsersServiceProjectUser | ProjectUser;
}
}
// Check for ApiResponse format with success flag
else if (
"success" in response &&
(response as { success: boolean }).success &&
"data" in response
) {
const data = (response as { data?: unknown }).data;
if (data && typeof data === "object") {
user = data as unknown as UsersServiceProjectUser | ProjectUser;
}
}
}
if (!user) {
throw new HttpError(
`Failed to update user ${userId} in project ${projectId}: Invalid response format from server. Expected user object but received: ${JSON.stringify(
response
).substring(0, 200)}`,
{
status: 500,
method: "PUT",
url: `/projects/${projectId}/users/${userId}`,
code: "INVALID_RESPONSE",
responseData: response,
}
);
}
return this.mapToProjectUser(user);
} catch (error: unknown) {
// Enhance error with context
if (error instanceof HttpError) {
throw new HttpError(
`Failed to update user ${userId} in project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users/${userId}`,
method: error.method || "PUT",
}
);
}
throw error;
}
}
/**
* Delete a user from a project
*
* @param {string} projectId - Project ID
* @param {string} userId - User ID
* @returns {Promise<void>}
*
* @example
* await client.deleteUser('project-id', 'user-id');
*/
async deleteUser(projectId: string, userId: string): Promise<void> {
try {
await this.delete<{ success: boolean; message?: string }>(
`/projects/${projectId}/users/${userId}`
);
// Delete operations typically return void or { success: true }
} catch (error) {
// Enhance error with context
if (error instanceof HttpError) {
throw new HttpError(
`Failed to delete user ${userId} from project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users/${userId}`,
method: error.method || "DELETE",
}
);
}
throw error;
}
}
/**
* Get user scopes
*
* @param {string} projectId - Project ID
* @param {string} userId - User ID
* @returns {Promise<string[]>} Array of user scopes
*
* @example
* const scopes = await client.getUserScopes('project-id', 'user-id');
*/
async getUserScopes(projectId: string, userId: string): Promise<string[]> {
try {
const response = await this.get<{ scopes?: string[] }>(
`/projects/${projectId}/users/${userId}/scopes`
);
// Handle different response formats
if (Array.isArray(response)) {
return response;
}
if (response && typeof response === "object" && "scopes" in response) {
return (response.scopes as string[]) || [];
}
if (response && typeof response === "object" && "data" in response) {
const data = (response as { data?: unknown }).data;
if (Array.isArray(data)) {
return data;
}
if (data && typeof data === "object" && "scopes" in data) {
return (data as { scopes?: string[] }).scopes || [];
}
}
return [];
} catch (error) {
if (error instanceof HttpError) {
throw new HttpError(
`Failed to get scopes for user ${userId} in project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users/${userId}/scopes`,
method: error.method || "GET",
}
);
}
throw error;
}
}
/**
* Update user scopes
*
* @param {string} projectId - Project ID
* @param {string} userId - User ID
* @param {string[]} scopes - Array of scopes to set
* @returns {Promise<string[]>} Updated scopes
*
* @example
* const updatedScopes = await client.updateUserScopes('project-id', 'user-id', ['data:read', 'data:write']);
*/
async updateUserScopes(
projectId: string,
userId: string,
scopes: string[]
): Promise<string[]> {
try {
const response = await this.put<{ scopes?: string[] }>(
`/projects/${projectId}/users/${userId}/scopes`,
{ scopes }
);
// Handle different response formats
if (Array.isArray(response)) {
return response;
}
if (response && typeof response === "object" && "scopes" in response) {
return (response.scopes as string[]) || [];
}
if (response && typeof response === "object" && "data" in response) {
const data = (response as { data?: unknown }).data;
if (Array.isArray(data)) {
return data;
}
if (data && typeof data === "object" && "scopes" in data) {
return (data as { scopes?: string[] }).scopes || [];
}
}
return scopes; // Return input if response format is unexpected
} catch (error) {
if (error instanceof HttpError) {
throw new HttpError(
`Failed to update scopes for user ${userId} in project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users/${userId}/scopes`,
method: error.method || "PUT",
}
);
}
throw error;
}
}
/**
* Get user activity log
*
* @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.start_date] - Start date filter (ISO string)
* @param {string} [options.end_date] - End date filter (ISO string)
* @returns {Promise<Array<{id: string; action: string; timestamp: string; details: Record<string, unknown>}>>} Array of activity entries
*
* @example
* const activity = await client.getUserActivity('project-id', 'user-id', { limit: 50 });
*/
async getUserActivity(
projectId: string,
userId: string,
options?: {
limit?: number;
offset?: number;
start_date?: string;
end_date?: string;
}
): Promise<Array<{
id: string;
action: string;
timestamp: string;
details: Record<string, unknown>;
}>> {
try {
const response = await this.get<unknown>(
`/projects/${projectId}/users/${userId}/activity`,
options as QueryOptions
);
// Normalize response format
let activities: Array<{
id: string;
action: string;
timestamp: string;
details: Record<string, unknown>;
}> = [];
if (Array.isArray(response)) {
activities = response.map((activity: unknown) => {
const act = activity as {
id?: string;
action?: string;
created_at?: string;
timestamp?: string;
details?: Record<string, unknown>;
};
return {
id: act.id || "",
action: act.action || "",
timestamp: act.timestamp || act.created_at || new Date().toISOString(),
details: act.details || {},
};
});
} else if (response && typeof response === "object" && "data" in response) {
const data = (response as { data?: unknown }).data;
if (Array.isArray(data)) {
activities = data.map((activity: unknown) => {
const act = activity as {
id?: string;
action?: string;
created_at?: string;
timestamp?: string;
details?: Record<string, unknown>;
};
return {
id: act.id || "",
action: act.action || "",
timestamp: act.timestamp || act.created_at || new Date().toISOString(),
details: act.details || {},
};
});
}
}
return activities;
} catch (error) {
if (error instanceof HttpError) {
throw new HttpError(
`Failed to get activity for user ${userId} in project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users/${userId}/activity`,
method: error.method || "GET",
}
);
}
throw error;
}
}
/**
* Get user statistics for a project
*
* @param {string} projectId - Project ID
* @returns {Promise<{total_users: number; active_users: number; users_by_role: Record<string, number>; recent_logins: number}>} User statistics
*
* @example
* const stats = await client.getUserStatistics('project-id');
*/
async getUserStatistics(projectId: string): Promise<{
total_users: number;
active_users: number;
users_by_role: Record<string, number>;
recent_logins: number;
}> {
try {
const response = await this.get<unknown>(
`/projects/${projectId}/users/statistics`
);
// Normalize response format
let stats: {
total_users: number;
active_users: number;
users_by_role: Record<string, number>;
recent_logins: number;
};
if (response && typeof response === "object") {
// Check if response itself has the stats
if ("total_users" in response || "data" in response) {
const data = "data" in response ? (response as { data?: unknown }).data : response;
if (data && typeof data === "object") {
const statsData = data as {
total_users?: number;
active_users?: number;
users_by_role?: Record<string, number>;
recent_logins?: number;
};
stats = {
total_users: statsData.total_users || 0,
active_users: statsData.active_users || 0,
users_by_role: statsData.users_by_role || {},
recent_logins: statsData.recent_logins || 0,
};
} else {
throw new HttpError(
`Invalid response format from server for user statistics`,
{
status: 500,
method: "GET",
url: `/projects/${projectId}/users/statistics`,
code: "INVALID_RESPONSE",
responseData: response,
}
);
}
} else {
throw new HttpError(
`Invalid response format from server for user statistics`,
{
status: 500,
method: "GET",
url: `/projects/${projectId}/users/statistics`,
code: "INVALID_RESPONSE",
responseData: response,
}
);
}
} else {
throw new HttpError(
`Invalid response format from server for user statistics`,
{
status: 500,
method: "GET",
url: `/projects/${projectId}/users/statistics`,
code: "INVALID_RESPONSE",
responseData: response,
}
);
}
return stats;
} catch (error) {
if (error instanceof HttpError) {
throw new HttpError(
`Failed to get user statistics for project ${projectId}: ${error.message}`,
{
...error,
url: error.url || `/projects/${projectId}/users/statistics`,
method: error.method || "GET",
}
);
}
throw error;
}
}
}