@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
632 lines (578 loc) • 20 kB
text/typescript
/**
* Activity Logger
*
* Provides activity logging functionality for tracking user actions and system events.
* Logs activities to the database with metadata, timestamps, and severity levels.
*
* @module activity-logger
* @example
* const logger = new ActivityLogger(dbConnection, console);
* await logger.log({
* user_id: 'user-id',
* project_id: 'project-id',
* action: 'created',
* resource_type: 'document',
* resource_id: 'doc-id',
* details: { collection: 'users' }
* });
*/
import crypto from "crypto";
import { Logger } from "./core";
/**
* Activity Log Interface
*
* @interface ActivityLog
* @property {string} id - Log entry ID
* @property {string} [user_id] - User ID who performed the action
* @property {string} [project_id] - Project ID
* @property {string} action - Action performed
* @property {string} resource_type - Type of resource affected
* @property {string} [resource_id] - Resource ID
* @property {Record<string, unknown>} details - Action details
* @property {string} [ip_address] - IP address
* @property {string} [user_agent] - User agent
* @property {Date} timestamp - Action timestamp
* @property {"info" | "warning" | "error" | "critical"} severity - Log severity
* @property {Record<string, unknown>} [metadata] - Additional metadata
*/
export interface ActivityLog {
id: string;
user_id?: string;
project_id?: string;
action: string;
resource_type: string;
resource_id?: string;
details: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
timestamp: Date;
severity: "info" | "warning" | "error" | "critical";
metadata?: Record<string, unknown>;
}
/**
* Activity Query Interface
*
* @interface ActivityQuery
* @property {string} [user_id] - Filter by user ID
* @property {string} [project_id] - Filter by project ID
* @property {string} [action] - Filter by action
* @property {string} [resource_type] - Filter by resource type
* @property {string} [resource_id] - Filter by resource ID
* @property {string} [severity] - Filter by severity
* @property {Date} [start_date] - Start date filter
* @property {Date} [end_date] - End date filter
* @property {number} [limit] - Maximum number of results
* @property {number} [offset] - Number of results to skip
*/
export interface ActivityQuery {
user_id?: string;
project_id?: string;
action?: string;
resource_type?: string;
resource_id?: string;
severity?: string;
start_date?: Date;
end_date?: Date;
limit?: number;
offset?: number;
}
/**
* Activity Logger Class
*
* Manages activity logging with database persistence.
* Provides methods for logging activities and querying activity logs.
*
* @class ActivityLogger
* @example
* const logger = new ActivityLogger(dbConnection, console);
* await logger.log({
* user_id: 'user-id',
* action: 'created',
* resource_type: 'document',
* details: {}
* });
*/
export class ActivityLogger {
private initialized = false;
constructor(
private dbConnection: {
query: (sql: string, params?: unknown[]) => Promise<{ rows?: unknown[] }>;
},
private logger: Logger = console
) {
// Don't initialize in constructor - use lazy initialization
}
/**
* Initialize the activity_logs table
*/
private async initializeActivityTable(): Promise<void> {
if (this.initialized) {
return;
}
try {
// SQLite-compatible table existence check - just try to query the tables
// If they don't exist, the CREATE TABLE IF NOT EXISTS will still work
// No need to wait 30 seconds - just try to create the table
try {
// Try to query admin_users and projects to see if they exist
// Use a simple query that will fail if table doesn't exist
await Promise.race([
Promise.all([
this.dbConnection.query("SELECT 1 FROM admin_users LIMIT 1"),
this.dbConnection.query("SELECT 1 FROM projects LIMIT 1"),
]),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Table check timeout")), 2000)
),
]);
} catch {
// Tables might not exist yet, but we'll still try to create activity_logs
// The foreign key constraints will be ignored if referenced tables don't exist
// Continue with table creation anyway
}
// SQLite-compatible table creation
// Use simpler foreign key syntax that works even if referenced tables don't exist yet
await this.dbConnection.query(`
CREATE TABLE IF NOT EXISTS activity_logs (
id TEXT PRIMARY KEY,
user_id TEXT,
project_id TEXT,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
details TEXT NOT NULL DEFAULT '{}',
ip_address TEXT,
user_agent TEXT,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
severity TEXT NOT NULL DEFAULT 'info' CHECK (severity IN ('info', 'warning', 'error', 'critical')),
metadata TEXT DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes for performance
await this.dbConnection.query(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id)
`);
await this.dbConnection.query(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_project_id ON activity_logs(project_id)
`);
await this.dbConnection.query(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action)
`);
await this.dbConnection.query(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_timestamp ON activity_logs(timestamp)
`);
await this.dbConnection.query(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_severity ON activity_logs(severity)
`);
this.initialized = true;
this.logger.info("Activity logging table initialized");
} catch (error) {
this.logger.error("Failed to initialize activity logging table:", error);
// Don't throw error, just log it - this service is not critical for basic functionality
}
}
/**
* Ensure table is initialized before any operation
* Uses a timeout to prevent hanging
*/
private async ensureInitialized(): Promise<void> {
if (this.initialized) {
return;
}
// Use Promise.race to add a timeout to initialization
try {
await Promise.race([
this.initializeActivityTable(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Activity table initialization timeout")), 5000)
),
]);
} catch (error) {
// If initialization times out or fails, mark as initialized anyway
// to prevent repeated attempts that will hang
this.initialized = true;
this.logger.warn("Activity table initialization failed or timed out, continuing without activity logging:", error);
}
}
/**
* Log an activity
*/
async log(
activity: Omit<ActivityLog, "id" | "timestamp" | "created_at">
): Promise<ActivityLog> {
// Initialize with timeout protection
await this.ensureInitialized();
try {
// Check if table exists before inserting - if not, return a mock activity
// This prevents hanging on inserts to non-existent tables
try {
await this.dbConnection.query("SELECT 1 FROM activity_logs LIMIT 1");
} catch {
// Table doesn't exist, return a mock activity instead of hanging
this.logger.warn("Activity table doesn't exist, skipping activity log");
return {
id: crypto.randomUUID(),
user_id: activity.user_id || null,
project_id: activity.project_id || null,
action: activity.action,
resource_type: activity.resource_type,
resource_id: activity.resource_id || null,
details: activity.details,
ip_address: activity.ip_address || null,
user_agent: activity.user_agent || null,
timestamp: new Date(),
severity: activity.severity,
metadata: activity.metadata || {},
created_at: new Date(),
} as ActivityLog;
}
// Handle string user IDs by making them optional for test activities
const userId =
activity.user_id &&
typeof activity.user_id === "string" &&
activity.user_id.includes("-")
? activity.user_id
: null;
// Generate log ID (SQLite doesn't support RETURNING *)
const logId = crypto.randomUUID();
const now = new Date().toISOString();
// SQLite-compatible INSERT (no RETURNING *)
await this.dbConnection.query(
`
INSERT INTO activity_logs (
id, user_id, project_id, action, resource_type, resource_id,
details, ip_address, user_agent, severity, metadata, timestamp, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`,
[
logId,
userId,
activity.project_id,
activity.action,
activity.resource_type,
activity.resource_id,
JSON.stringify(activity.details),
activity.ip_address,
activity.user_agent,
activity.severity,
JSON.stringify(activity.metadata || {}),
now,
now,
]
);
// Query back the inserted row
const result = await this.dbConnection.query(
"SELECT * FROM activity_logs WHERE id = $1",
[logId]
);
const loggedActivity = result.rows?.[0] as ActivityLog;
this.logger.info(
`Activity logged: ${activity.action} on ${activity.resource_type}`
);
return loggedActivity;
} catch (error) {
this.logger.error("Failed to log activity:", error);
// Return a mock activity instead of throwing to prevent hanging
// This allows the application to continue even if activity logging fails
return {
id: crypto.randomUUID(),
user_id: activity.user_id || null,
project_id: activity.project_id || null,
action: activity.action,
resource_type: activity.resource_type,
resource_id: activity.resource_id || null,
details: activity.details,
ip_address: activity.ip_address || null,
user_agent: activity.user_agent || null,
timestamp: new Date(),
severity: activity.severity,
metadata: activity.metadata || {},
created_at: new Date(),
} as ActivityLog;
}
}
/**
* Query activity logs
*/
async query(
query: ActivityQuery
): Promise<{ logs: ActivityLog[]; total: number }> {
// Initialize with timeout protection
await this.ensureInitialized();
try {
// Check if table exists before querying - if not, return empty results
// This prevents hanging on queries to non-existent tables
try {
await this.dbConnection.query("SELECT 1 FROM activity_logs LIMIT 1");
} catch {
// Table doesn't exist, return empty results instead of hanging
return { logs: [], total: 0 };
}
let whereClause = "WHERE 1=1";
const params: unknown[] = [];
let paramIndex = 1;
if (query.user_id) {
whereClause += ` AND user_id = $${paramIndex++}`;
params.push(query.user_id);
}
if (query.project_id) {
whereClause += ` AND project_id = $${paramIndex++}`;
params.push(query.project_id);
}
if (query.action) {
whereClause += ` AND action = $${paramIndex++}`;
params.push(query.action);
}
if (query.resource_type) {
whereClause += ` AND resource_type = $${paramIndex++}`;
params.push(query.resource_type);
}
if (query.resource_id) {
whereClause += ` AND resource_id = $${paramIndex++}`;
params.push(query.resource_id);
}
if (query.severity) {
whereClause += ` AND severity = $${paramIndex++}`;
params.push(query.severity);
}
if (query.start_date) {
whereClause += ` AND timestamp >= $${paramIndex++}`;
params.push(query.start_date);
}
if (query.end_date) {
whereClause += ` AND timestamp <= $${paramIndex++}`;
params.push(query.end_date);
}
// Get total count
const countResult = await this.dbConnection.query(
`
SELECT COUNT(*) FROM activity_logs ${whereClause}
`,
params
);
const total = parseInt(
(countResult.rows?.[0] as { count: string }).count || "0"
);
// Get logs with pagination
const limit = query.limit || 100;
const offset = query.offset || 0;
const logsResult = await this.dbConnection.query(
`
SELECT * FROM activity_logs ${whereClause}
ORDER BY timestamp DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`,
[...params, limit, offset]
);
const logs = (logsResult.rows || [] as unknown[]).map(
(row: unknown) => {
const rowData = row as Record<string, unknown>;
return {
...rowData,
timestamp: new Date(rowData.timestamp as string),
details:
typeof rowData.details === "string"
? JSON.parse(rowData.details)
: rowData.details,
metadata:
typeof rowData.metadata === "string"
? JSON.parse(rowData.metadata)
: rowData.metadata,
};
}
) as ActivityLog[];
return { logs, total };
} catch (error) {
this.logger.error("Failed to query activity logs:", error);
// Return empty results instead of throwing to prevent hanging
// This allows the application to continue even if activity logging fails
return { logs: [], total: 0 };
}
}
/**
* Get recent activity for a user or project
*/
async getRecentActivity(
userId?: string,
projectId?: string,
limit = 50
): Promise<ActivityLog[]> {
await this.ensureInitialized();
const query: ActivityQuery = { limit };
if (userId) query.user_id = userId;
if (projectId) query.project_id = projectId;
const result = await this.query(query);
return result.logs;
}
/**
* Get activity statistics
*/
async getActivityStats(
projectId?: string,
days = 30
): Promise<{
total_actions: number;
actions_by_type: Record<string, number>;
actions_by_severity: Record<string, number>;
actions_by_user: Record<string, number>;
}> {
await this.ensureInitialized();
try {
// For SQLite, calculate the date in JavaScript and pass as parameter
// datetime('now', '-' || $1 || ' days') doesn't work with parameter binding
const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
let whereClause = "WHERE timestamp >= $1";
const params: unknown[] = [cutoffDate];
if (projectId) {
whereClause += " AND project_id = $2";
params.push(projectId);
}
// Optimize queries for SQLite - return empty stats if table doesn't exist or has no data
// This prevents timeouts on empty databases
try {
// Check if table exists and has data
const checkResult = await this.dbConnection.query(
`SELECT COUNT(*) as count FROM activity_logs LIMIT 1`
);
const hasData = parseInt(
(checkResult.rows?.[0] as { count: string })?.count || "0"
) > 0;
if (!hasData) {
return {
total_actions: 0,
actions_by_type: {},
actions_by_severity: {},
actions_by_user: {},
};
}
} catch {
// Table doesn't exist, return empty stats
return {
total_actions: 0,
actions_by_type: {},
actions_by_severity: {},
actions_by_user: {},
};
}
// Total actions
const totalResult = await this.dbConnection.query(
`
SELECT COUNT(*) as count FROM activity_logs ${whereClause}
`,
params
);
const total_actions = parseInt(
(totalResult.rows?.[0] as { count: string }).count || "0"
);
// Actions by type - optimized query
const typeResult = await this.dbConnection.query(
`
SELECT action, COUNT(*) as count
FROM activity_logs ${whereClause}
GROUP BY action
ORDER BY count DESC
LIMIT 50
`,
params
);
const actions_by_type: Record<string, number> = {};
(typeResult.rows || [] as unknown[]).forEach((row: unknown) => {
const rowData = row as Record<string, unknown>;
actions_by_type[rowData.action as string] = parseInt(
(rowData.count as string) || "0"
);
});
// Actions by severity - optimized query
const severityResult = await this.dbConnection.query(
`
SELECT severity, COUNT(*) as count
FROM activity_logs ${whereClause}
GROUP BY severity
ORDER BY count DESC
LIMIT 20
`,
params
);
const actions_by_severity: Record<string, number> = {};
(severityResult.rows || [] as unknown[]).forEach((row: unknown) => {
const rowData = row as Record<string, unknown>;
actions_by_severity[rowData.severity as string] = parseInt(
(rowData.count as string) || "0"
);
});
// Actions by user - optimized query with LIMIT
const userResult = await this.dbConnection.query(
`
SELECT user_id, COUNT(*) as count
FROM activity_logs ${whereClause} AND user_id IS NOT NULL
GROUP BY user_id
ORDER BY count DESC
LIMIT 10
`,
params
);
const actions_by_user: Record<string, number> = {};
(userResult.rows || [] as unknown[]).forEach((row: unknown) => {
const rowData = row as Record<string, unknown>;
actions_by_user[rowData.user_id as string] = parseInt(
(rowData.count as string) || "0"
);
});
return {
total_actions,
actions_by_type,
actions_by_severity,
actions_by_user,
};
} catch (error) {
this.logger.error("Failed to get activity statistics:", error);
throw error;
}
}
/**
* Clean old activity logs
*/
async cleanOldLogs(daysToKeep = 90): Promise<number> {
await this.ensureInitialized();
try {
// Calculate the cutoff date in JavaScript (SQLite doesn't support INTERVAL)
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const result = await this.dbConnection.query(
`
DELETE FROM activity_logs
WHERE timestamp < $1
`,
[cutoffDate.toISOString()]
);
const deletedCount = (result as { rowCount?: number }).rowCount || 0;
this.logger.info(`Cleaned ${deletedCount} old activity logs`);
return deletedCount;
} catch (error) {
this.logger.error("Failed to clean old activity logs:", error);
throw error;
}
}
/**
* Cleanup old activity logs (alias for cleanOldLogs with different return format)
*
* @param daysToKeep - Number of days to keep (default: 90)
* @returns Cleanup result with success status and deleted count
*/
async cleanup(daysToKeep = 90): Promise<{ success: boolean; deleted_count: number }> {
try {
const deletedCount = await this.cleanOldLogs(daysToKeep);
return {
success: true,
deleted_count: deletedCount,
};
} catch (error) {
this.logger.error("Failed to cleanup activity logs:", error);
return {
success: false,
deleted_count: 0,
};
}
}
}