@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
1,417 lines (1,309 loc) • 45.3 kB
text/typescript
import crypto from "crypto";
/**
* Storage Service for BackendSDK
*
* Provides comprehensive file and storage management functionality including:
* - File upload and download
* - File metadata management
* - File versioning and history
* - File permissions and access control
* - File organization (folders, tags)
* - File transformations and thumbnails
* - Storage quotas and analytics
*
* @class StorageService
* @example
* const storageService = new StorageService(dbConnection, logger);
* const fileInfo = await storageService.uploadFile(projectId, uploadRequest, fileBuffer);
*/
import { DatabaseConnection, Logger } from "./core";
import { KrapiError } from "./core/krapi-error";
import { normalizeError } from "./utils/error-handler";
export interface StoredFile {
id: string;
project_id: string;
original_name: string;
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
file_extension: string;
file_hash: string;
storage_provider: string;
storage_path: string;
storage_url?: string;
is_public: boolean;
folder_id?: string;
tags: string[];
metadata: Record<string, unknown>;
uploaded_by: string;
created_at: string;
updated_at: string;
last_accessed?: string;
access_count: number;
is_deleted: boolean;
deleted_at?: string;
deleted_by?: string;
}
export interface FileFolder {
id: string;
project_id: string;
name: string;
path: string;
parent_id?: string;
description?: string;
is_public: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface FileVersion {
id: string;
file_id: string;
version_number: number;
file_name: string;
file_path: string;
file_size: number;
file_hash: string;
storage_path: string;
uploaded_by: string;
created_at: string;
is_current: boolean;
}
export interface FilePermission {
id: string;
file_id: string;
user_id?: string;
role?: string;
permission_type: "read" | "write" | "delete" | "admin";
granted_by: string;
created_at: string;
expires_at?: string;
}
export interface UploadRequest {
original_name: string;
file_size: number;
mime_type: string;
folder_id?: string;
tags?: string[];
metadata?: Record<string, unknown>;
is_public?: boolean;
uploaded_by: string;
}
export interface FileUrlRequest {
file_id: string;
expires_in?: number;
access_type?: "download" | "preview" | "stream";
user_id?: string;
}
export interface FileFilter {
folder_id?: string;
mime_type?: string;
file_extension?: string;
tags?: string[];
uploaded_by?: string;
is_public?: boolean;
created_after?: string;
created_before?: string;
size_min?: number;
size_max?: number;
search?: string;
}
export interface StorageStatistics {
total_files: number;
total_size: number;
files_by_type: Record<string, { count: number; size: number }>;
files_by_folder: Record<string, { count: number; size: number }>;
storage_used_percentage: number;
recent_uploads: number;
most_accessed_files: Array<{
file_id: string;
file_name: string;
access_count: number;
}>;
largest_files: Array<{
file_id: string;
file_name: string;
file_size: number;
}>;
}
export interface StorageQuota {
project_id: string;
max_storage_bytes: number;
max_files: number;
max_file_size: number;
allowed_mime_types: string[];
blocked_mime_types: string[];
current_storage_bytes: number;
current_files: number;
}
interface DatabaseRow {
[key: string]: unknown;
}
interface CountRow extends DatabaseRow {
count: string;
}
interface StorageUsageRow extends DatabaseRow {
file_count: string;
total_size: string;
}
interface StorageQuotaRow extends DatabaseRow {
max_storage_bytes: string;
max_files: string;
current_storage_bytes: string;
current_files: string;
}
interface FileTypeStatsRow extends DatabaseRow {
mime_type: string;
count: string;
size: string;
}
interface FolderStatsRow extends DatabaseRow {
folder_id: string;
count: string;
size: string;
}
interface FileInfoRow extends DatabaseRow {
id: string;
original_name: string;
access_count: number;
file_size: number;
}
export class StorageService {
private db: DatabaseConnection;
private logger: Logger;
/**
* Create a new StorageService instance
*
* @param {DatabaseConnection} databaseConnection - Database connection
* @param {Logger} logger - Logger instance
*/
constructor(databaseConnection: DatabaseConnection, logger: Logger) {
this.db = databaseConnection;
this.logger = logger;
}
/**
* Get all files for a project
*
* @param {string} projectId - Project ID
* @param {Object} [options] - Query options
* @param {number} [options.limit] - Maximum number of files
* @param {number} [options.offset] - Number of files to skip
* @param {FileFilter} [options.filter] - File filters
* @param {boolean} [options.include_deleted] - Include deleted files
* @returns {Promise<StoredFile[]>} Array of stored files
* @throws {Error} If query fails
*
* @example
* const files = await storageService.getAllFiles('project-id', {
* limit: 10,
* filter: { mime_type: 'image/jpeg' }
* });
*/
async getAllFiles(
projectId: string,
options?: {
limit?: number;
offset?: number;
filter?: FileFilter;
include_deleted?: boolean;
}
): Promise<StoredFile[]> {
try {
let query = "SELECT * FROM files WHERE project_id = $1";
const params: unknown[] = [projectId];
let paramCount = 1;
if (!options?.include_deleted) {
query += " AND is_deleted = false";
}
if (options?.filter) {
const { filter } = options;
if (filter.folder_id) {
paramCount++;
query += ` AND folder_id = $${paramCount}`;
params.push(filter.folder_id);
}
if (filter.mime_type) {
paramCount++;
query += ` AND mime_type = $${paramCount}`;
params.push(filter.mime_type);
}
if (filter.file_extension) {
paramCount++;
query += ` AND file_extension = $${paramCount}`;
params.push(filter.file_extension);
}
if (filter.tags && filter.tags.length > 0) {
paramCount++;
query += ` AND tags && $${paramCount}`;
params.push(filter.tags);
}
if (filter.uploaded_by) {
paramCount++;
query += ` AND uploaded_by = $${paramCount}`;
params.push(filter.uploaded_by);
}
if (filter.is_public !== undefined) {
paramCount++;
query += ` AND is_public = $${paramCount}`;
params.push(filter.is_public);
}
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.size_min) {
paramCount++;
query += ` AND file_size >= $${paramCount}`;
params.push(filter.size_min);
}
if (filter.size_max) {
paramCount++;
query += ` AND file_size <= $${paramCount}`;
params.push(filter.size_max);
}
if (filter.search) {
paramCount++;
// SQLite uses LIKE (case-insensitive by default) instead of ILIKE
// Tags are stored as JSON strings in SQLite
query += ` AND (original_name LIKE $${paramCount} OR tags LIKE $${paramCount})`;
params.push(`%${filter.search}%`);
}
}
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 StoredFile[];
} catch (error) {
this.logger.error("Failed to get files:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getFiles",
});
}
}
/**
* Get file by ID
*
* Retrieves a single file by its ID and updates access tracking.
*
* @param {string} projectId - Project ID
* @param {string} fileId - File ID
* @returns {Promise<StoredFile | null>} File or null if not found
* @throws {Error} If query fails
*
* @example
* const file = await storageService.getFileById('project-id', 'file-id');
*/
async getFileById(
projectId: string,
fileId: string
): Promise<StoredFile | null> {
try {
// Database table uses 'filename' column, not 'file_name', and has no 'file_hash'
// Map database columns to interface fields
const result = await this.db.query(
`SELECT
id, project_id, original_name,
filename as file_name,
file_path, file_size, mime_type, file_extension,
storage_provider, storage_path, storage_url,
is_public, folder_id, tags, metadata, uploaded_by,
created_at, updated_at, last_accessed, access_count,
is_deleted, deleted_at, deleted_by
FROM files WHERE project_id = $1 AND id = $2 AND is_deleted = false`,
[projectId, fileId]
);
if (result.rows.length > 0) {
// Update access tracking
await this.updateFileAccess(fileId);
const row = result.rows[0] as Record<string, unknown>;
// Map database row to StoredFile interface
// Note: file_hash doesn't exist in database, set to empty string
const file: StoredFile = {
id: row.id as string,
project_id: row.project_id as string,
original_name: row.original_name as string,
file_name: row.file_name as string,
file_path: row.file_path as string,
file_size: row.file_size as number,
mime_type: row.mime_type as string,
file_extension: row.file_extension as string,
file_hash: "", // Database doesn't have file_hash column
storage_provider: row.storage_provider as string,
storage_path: row.storage_path as string,
...(row.storage_url ? { storage_url: row.storage_url as string } : {}),
is_public: (row.is_public as number) === 1 || (row.is_public as boolean) === true,
...(row.folder_id ? { folder_id: row.folder_id as string } : {}),
tags: typeof row.tags === 'string' ? JSON.parse(row.tags as string) : (row.tags as string[] || []),
metadata: typeof row.metadata === 'string' ? JSON.parse(row.metadata as string) : (row.metadata as Record<string, unknown> || {}),
uploaded_by: row.uploaded_by as string,
created_at: row.created_at as string,
updated_at: row.updated_at as string,
...(row.last_accessed ? { last_accessed: row.last_accessed as string } : {}),
access_count: row.access_count as number,
is_deleted: (row.is_deleted as number) === 1 || (row.is_deleted as boolean) === true,
...(row.deleted_at ? { deleted_at: row.deleted_at as string } : {}),
...(row.deleted_by ? { deleted_by: row.deleted_by as string } : {}),
};
return file;
}
return null;
} catch (error) {
this.logger.error("Failed to get file by ID:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getFileById",
});
}
}
/**
* Upload a file
*
* Uploads a file to storage with deduplication, quota checking, and versioning.
*
* @param {string} projectId - Project ID
* @param {UploadRequest} fileData - File metadata
* @param {string} fileData.original_name - Original filename
* @param {number} fileData.file_size - File size in bytes
* @param {string} fileData.mime_type - MIME type
* @param {string} [fileData.folder_id] - Optional folder ID
* @param {string[]} [fileData.tags] - Optional tags
* @param {Record<string, unknown>} [fileData.metadata] - Optional metadata
* @param {boolean} [fileData.is_public=false] - Whether file is public
* @param {string} fileData.uploaded_by - User ID who uploaded
* @param {Buffer} fileBuffer - File content buffer
* @returns {Promise<StoredFile>} Uploaded file information
* @throws {Error} If upload fails, quota exceeded, or validation fails
*
* @example
* const file = await storageService.uploadFile('project-id', {
* original_name: 'document.pdf',
* file_size: 1024000,
* mime_type: 'application/pdf',
* uploaded_by: 'user-id'
* }, fileBuffer);
*/
async uploadFile(
projectId: string,
fileData: UploadRequest,
fileBuffer: Buffer
): Promise<StoredFile> {
try {
// Check storage quota
await this.checkStorageQuota(projectId, fileData.file_size);
// Generate file hash for deduplication
const fileHash = await this.generateFileHash(fileBuffer);
// Check if file already exists (deduplication)
const existingFile = await this.findFileByHash(projectId, fileHash);
if (existingFile && !existingFile.is_deleted) {
// Return existing file instead of uploading duplicate
return existingFile;
}
// Generate unique file name
const fileExtension = this.getFileExtension(fileData.original_name);
const fileName = `${Date.now()}_${Math.random()
.toString(36)
.substring(2)}.${fileExtension}`;
// Determine storage path
const storagePath = this.generateStoragePath(projectId, fileName);
// In a real implementation, you would save to actual storage here
// const storageUrl = await this.saveToStorage(storagePath, fileBuffer);
const storageUrl = `/storage/${projectId}/${fileName}`;
// Generate file ID (SQLite doesn't support RETURNING *)
const fileId = crypto.randomUUID();
// SQLite-compatible INSERT (no RETURNING *)
// Database table uses 'filename' column, not 'file_name', and has no 'file_hash'
// Database also has 'name' column that should be populated (use same value as filename)
await this.db.query(
`INSERT INTO files (
id, project_id, original_name, name, filename, file_path, file_size,
mime_type, file_extension, storage_provider, storage_path,
storage_url, is_public, folder_id, tags, metadata, uploaded_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[
fileId,
projectId,
fileData.original_name,
fileName, // name column - use same value as filename
fileName, // filename column
storagePath,
fileData.file_size,
fileData.mime_type,
fileExtension,
"local", // In production, this could be "s3", "gcs", etc.
storagePath,
storageUrl,
fileData.is_public || false,
fileData.folder_id,
JSON.stringify(fileData.tags || []), // SQLite stores arrays as JSON strings
JSON.stringify(fileData.metadata || {}), // SQLite stores objects as JSON strings
fileData.uploaded_by,
]
);
// Query back the inserted row (SQLite doesn't support RETURNING *)
const result = await this.db.query(
"SELECT * FROM files WHERE id = $1",
[fileId]
);
// Create initial version
await this.createFileVersion(fileId, {
version_number: 1,
file_name: fileName,
file_path: storagePath,
file_size: fileData.file_size,
file_hash: fileHash,
storage_path: storagePath,
uploaded_by: fileData.uploaded_by,
is_current: true,
});
// Update storage usage
await this.updateStorageUsage(projectId);
return result.rows[0] as StoredFile;
} catch (error) {
this.logger.error("Failed to upload file:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "uploadFile",
});
}
}
/**
* Update file metadata
*
* Updates file metadata (name, folder, tags, metadata, visibility) without changing the file content.
*
* @param {string} projectId - Project ID
* @param {string} fileId - File ID
* @param {Object} updates - File updates
* @param {string} [updates.original_name] - New filename
* @param {string} [updates.folder_id] - New folder ID
* @param {string[]} [updates.tags] - Updated tags
* @param {Record<string, unknown>} [updates.metadata] - Updated metadata (merged with existing)
* @param {boolean} [updates.is_public] - Public visibility
* @param {string} _updatedBy - User ID who updated (currently unused)
* @returns {Promise<StoredFile | null>} Updated file or null if not found
* @throws {Error} If update fails
*
* @example
* const updated = await storageService.updateFile('project-id', 'file-id', {
* original_name: 'renamed.pdf',
* tags: ['important', 'document']
* }, 'user-id');
*/
async updateFile(
projectId: string,
fileId: string,
updates: {
original_name?: string;
folder_id?: string;
tags?: string[];
metadata?: Record<string, unknown>;
is_public?: boolean;
},
_updatedBy: string
): Promise<StoredFile | null> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let paramCount = 1;
if (updates.original_name !== undefined) {
fields.push(`original_name = $${paramCount++}`);
values.push(updates.original_name);
}
if (updates.folder_id !== undefined) {
fields.push(`folder_id = $${paramCount++}`);
values.push(updates.folder_id);
}
if (updates.tags !== undefined) {
fields.push(`tags = $${paramCount++}`);
values.push(updates.tags);
}
if (updates.metadata !== undefined) {
// Merge with existing metadata
const currentFile = await this.getFileById(projectId, fileId);
if (currentFile) {
const mergedMetadata = {
...currentFile.metadata,
...updates.metadata,
};
fields.push(`metadata = $${paramCount++}`);
values.push(mergedMetadata);
}
}
if (updates.is_public !== undefined) {
fields.push(`is_public = $${paramCount++}`);
values.push(updates.is_public);
}
if (fields.length === 0) {
return this.getFileById(projectId, fileId);
}
fields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(projectId, fileId);
// SQLite doesn't support RETURNING *, so update and query back separately
await this.db.query(
`UPDATE files SET ${fields.join(", ")}
WHERE project_id = $${paramCount++} AND id = $${paramCount} AND is_deleted = false`,
values
);
// Query back the updated row
const result = await this.db.query(
"SELECT * FROM files WHERE project_id = $1 AND id = $2 AND is_deleted = false",
[projectId, fileId]
);
return result.rows.length > 0 ? (result.rows[0] as StoredFile) : null;
} catch (error) {
this.logger.error("Failed to update file:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "updateFile",
});
}
}
/**
* Delete a file
*
* Deletes a file either softly (marks as deleted) or permanently (removes from storage and database).
*
* @param {string} projectId - Project ID
* @param {string} fileId - File ID
* @param {string} deletedBy - User ID who deleted
* @param {boolean} [permanent=false] - Whether to permanently delete
* @returns {Promise<boolean>} True if deletion successful
* @throws {Error} If deletion fails
*
* @example
* // Soft delete
* await storageService.deleteFile('project-id', 'file-id', 'user-id', false);
*
* // Permanent delete
* await storageService.deleteFile('project-id', 'file-id', 'user-id', true);
*/
async deleteFile(
projectId: string,
fileId: string,
deletedBy: string,
permanent = false
): Promise<boolean> {
try {
if (permanent) {
// Hard delete - remove from storage and database
const file = await this.getFileById(projectId, fileId);
if (file) {
// In real implementation, delete from actual storage
// await this.deleteFromStorage(file.storage_path);
// Delete file versions
await this.db.query("DELETE FROM file_versions WHERE file_id = $1", [
fileId,
]);
// Delete file permissions
await this.db.query(
"DELETE FROM file_permissions WHERE file_id = $1",
[fileId]
);
// Delete the file record
const result = await this.db.query(
"DELETE FROM files WHERE project_id = $1 AND id = $2",
[projectId, fileId]
);
// Update storage usage
await this.updateStorageUsage(projectId);
return result.rowCount > 0;
}
return false;
} else {
// Soft delete
const result = await this.db.query(
`UPDATE files SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP, deleted_by = $3, updated_at = CURRENT_TIMESTAMP
WHERE project_id = $1 AND id = $2 AND is_deleted = false`,
[projectId, fileId, deletedBy]
);
return result.rowCount > 0;
}
} catch (error) {
this.logger.error("Failed to delete file:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "deleteFile",
});
}
}
/**
* Restore a deleted file
*
* Restores a soft-deleted file by marking it as not deleted.
*
* @param {string} projectId - Project ID
* @param {string} fileId - File ID
* @param {string} _restoredBy - User ID who restored (currently unused)
* @returns {Promise<boolean>} True if restoration successful
* @throws {Error} If restoration fails
*
* @example
* const restored = await storageService.restoreFile('project-id', 'file-id', 'user-id');
*/
async restoreFile(
projectId: string,
fileId: string,
_restoredBy: string
): Promise<boolean> {
try {
const result = await this.db.query(
`UPDATE files SET is_deleted = false, deleted_at = NULL, deleted_by = NULL, updated_at = CURRENT_TIMESTAMP
WHERE project_id = $1 AND id = $2 AND is_deleted = true`,
[projectId, fileId]
);
return result.rowCount > 0;
} catch (error) {
this.logger.error("Failed to restore file:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "restoreFile",
});
}
}
/**
* Get all folders for a project
*
* Retrieves all file folders for a project.
*
* @param {string} projectId - Project ID
* @returns {Promise<FileFolder[]>} Array of folders
* @throws {Error} If query fails
*
* @example
* const folders = await storageService.getAllFolders('project-id');
*/
async getAllFolders(projectId: string): Promise<FileFolder[]> {
try {
const result = await this.db.query(
"SELECT * FROM file_folders WHERE project_id = $1 ORDER BY path",
[projectId]
);
return result.rows as FileFolder[];
} catch (error) {
this.logger.error("Failed to get folders:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getFolders",
});
}
}
/**
* Create a new folder
*
* Creates a new file folder with optional parent folder.
*
* @param {string} projectId - Project ID
* @param {Object} folderData - Folder data
* @param {string} folderData.name - Folder name
* @param {string} [folderData.parent_id] - Parent folder ID
* @param {string} [folderData.description] - Folder description
* @param {boolean} [folderData.is_public=false] - Whether folder is public
* @param {string} createdBy - User ID who created
* @returns {Promise<FileFolder>} Created folder
* @throws {Error} If creation fails
*
* @example
* const folder = await storageService.createFolder('project-id', {
* name: 'Documents',
* parent_id: 'parent-folder-id',
* description: 'Document folder'
* }, 'user-id');
*/
async createFolder(
projectId: string,
folderData: {
name: string;
parent_id?: string;
description?: string;
is_public?: boolean;
},
createdBy: string
): Promise<FileFolder> {
try {
// Generate folder path
let path = folderData.name;
if (folderData.parent_id) {
const parentFolder = await this.getFolderById(
projectId,
folderData.parent_id
);
if (parentFolder) {
path = `${parentFolder.path}/${folderData.name}`;
}
}
// Generate folder ID (SQLite doesn't support RETURNING *)
const folderId = crypto.randomUUID();
// SQLite-compatible INSERT (no RETURNING *)
await this.db.query(
`INSERT INTO file_folders (id, project_id, name, path, parent_id, description, is_public, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
folderId,
projectId,
folderData.name,
path,
folderData.parent_id,
folderData.description,
folderData.is_public || false,
createdBy,
]
);
// Query back the inserted row
const result = await this.db.query(
"SELECT * FROM file_folders WHERE id = $1",
[folderId]
);
return result.rows[0] as FileFolder;
} catch (error) {
this.logger.error("Failed to create folder:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "createFolder",
});
}
}
/**
* Get folder by ID
*
* Retrieves a single folder by its ID.
*
* @param {string} projectId - Project ID
* @param {string} folderId - Folder ID
* @returns {Promise<FileFolder | null>} Folder or null if not found
* @throws {Error} If query fails
*
* @example
* const folder = await storageService.getFolderById('project-id', 'folder-id');
*/
async getFolderById(
projectId: string,
folderId: string
): Promise<FileFolder | null> {
try {
const result = await this.db.query(
"SELECT * FROM file_folders WHERE project_id = $1 AND id = $2",
[projectId, folderId]
);
return result.rows.length > 0 ? (result.rows[0] as FileFolder) : null;
} catch (error) {
this.logger.error("Failed to get folder by ID:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getFolderById",
});
}
}
/**
* Delete a folder
*
* Deletes a folder and optionally all files within it.
*
* @param {string} projectId - Project ID
* @param {string} folderId - Folder ID
* @param {boolean} recursive - Whether to delete all files in the folder
* @returns {Promise<{ success: boolean; deleted_files?: number }>} Deletion result
* @throws {Error} If deletion fails
*
* @example
* const result = await storageService.deleteFolder('project-id', 'folder-id', true);
*/
async deleteFolder(
projectId: string,
folderId: string,
recursive = false
): Promise<{ success: boolean; deleted_files?: number }> {
try {
// Check if folder exists
const folder = await this.getFolderById(projectId, folderId);
if (!folder) {
throw KrapiError.notFound(`Folder '${folderId}' not found`, {
folderId,
projectId,
operation: "deleteFolder",
});
}
// Check for files in folder
const filesResult = await this.db.query(
"SELECT COUNT(*) as count FROM files WHERE project_id = $1 AND folder_id = $2",
[projectId, folderId]
);
const fileCount = parseInt(String((filesResult.rows[0] as { count: number | string }).count));
if (fileCount > 0 && !recursive) {
throw KrapiError.conflict(
`Folder contains ${fileCount} files. Use recursive=true to delete all files.`,
{
folderId,
projectId,
fileCount,
operation: "deleteFolder",
}
);
}
let deletedFiles = 0;
if (recursive && fileCount > 0) {
// Delete all files in folder
await this.db.query(
"DELETE FROM files WHERE project_id = $1 AND folder_id = $2",
[projectId, folderId]
);
deletedFiles = fileCount;
}
// Delete the folder
await this.db.query(
"DELETE FROM file_folders WHERE project_id = $1 AND id = $2",
[projectId, folderId]
);
// Update storage usage
await this.updateStorageUsage(projectId);
this.logger.info(`Folder ${folderId} deleted`, { deletedFiles });
return { success: true, deleted_files: deletedFiles };
} catch (error) {
if (error instanceof KrapiError) {
throw error;
}
this.logger.error("Failed to delete folder:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "deleteFolder",
folderId,
projectId,
});
}
}
/**
* Get file URL/path
*
* Returns the accessible path or URL for a file.
*
* @param {string} projectId - Project ID
* @param {string} fileId - File ID
* @returns {Promise<string>} File path or URL
* @throws {Error} If file not found
*
* @example
* const url = await storageService.getFileUrl('project-id', 'file-id');
*/
async getFileUrl(projectId: string, fileId: string): Promise<string> {
try {
const file = await this.getFileById(projectId, fileId);
if (!file) {
throw KrapiError.notFound(`File '${fileId}' not found`, {
fileId,
projectId,
operation: "getFileUrl",
});
}
// Return the storage path - in server mode, this is the file system path
// The actual URL generation would depend on your server's static file serving configuration
return file.storage_path || file.file_path;
} catch (error) {
if (error instanceof KrapiError) {
throw error;
}
this.logger.error("Failed to get file URL:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getFileUrl",
fileId,
projectId,
});
}
}
/**
* Create a file version
*
* Creates a new version of a file for versioning support.
*
* @param {string} fileId - File ID
* @param {Object} versionData - Version data
* @param {number} versionData.version_number - Version number
* @param {string} versionData.file_name - Version filename
* @param {string} versionData.file_path - Version file path
* @param {number} versionData.file_size - Version file size
* @param {string} versionData.file_hash - Version file hash
* @param {string} versionData.storage_path - Storage path
* @param {string} versionData.uploaded_by - User ID who uploaded
* @param {boolean} versionData.is_current - Whether this is the current version
* @returns {Promise<FileVersion>} Created file version
* @throws {Error} If creation fails
*
* @example
* const version = await storageService.createFileVersion('file-id', {
* version_number: 2,
* file_name: 'document_v2.pdf',
* file_path: '/path/to/file',
* file_size: 1024000,
* file_hash: 'abc123...',
* storage_path: '/storage/path',
* uploaded_by: 'user-id',
* is_current: true
* });
*/
async createFileVersion(
fileId: string,
versionData: {
version_number: number;
file_name: string;
file_path: string;
file_size: number;
file_hash: string;
storage_path: string;
uploaded_by: string;
is_current: boolean;
}
): Promise<FileVersion> {
try {
// If this is the current version, mark others as not current
if (versionData.is_current) {
await this.db.query(
"UPDATE file_versions SET is_current = false WHERE file_id = $1",
[fileId]
);
}
// Generate version ID (SQLite doesn't support RETURNING *)
const versionId = crypto.randomUUID();
// SQLite-compatible INSERT (no RETURNING *)
await this.db.query(
`INSERT INTO file_versions (
id, file_id, version_number, file_name, file_path, file_size,
file_hash, storage_path, uploaded_by, is_current
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
versionId,
fileId,
versionData.version_number,
versionData.file_name,
versionData.file_path,
versionData.file_size,
versionData.file_hash,
versionData.storage_path,
versionData.uploaded_by,
versionData.is_current ? 1 : 0, // SQLite uses INTEGER 1/0 for booleans
]
);
// Query back the inserted row
const result = await this.db.query(
"SELECT * FROM file_versions WHERE id = $1",
[versionId]
);
return result.rows[0] as FileVersion;
} catch (error) {
this.logger.error("Failed to create file version:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "createFileVersion",
});
}
}
/**
* Get all versions of a file
*
* Retrieves all versions of a file, ordered by version number (newest first).
*
* @param {string} fileId - File ID
* @returns {Promise<FileVersion[]>} Array of file versions
* @throws {Error} If query fails
*
* @example
* const versions = await storageService.getFileVersions('file-id');
*/
async getFileVersions(fileId: string): Promise<FileVersion[]> {
try {
const result = await this.db.query(
"SELECT * FROM file_versions WHERE file_id = $1 ORDER BY version_number DESC",
[fileId]
);
return result.rows as FileVersion[];
} catch (error) {
this.logger.error("Failed to get file versions:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getFileVersions",
});
}
}
/**
* Get storage statistics for a project
*
* Retrieves comprehensive storage statistics including file counts, sizes,
* usage by type/folder, quota information, and access analytics.
*
* @param {string} projectId - Project ID
* @returns {Promise<StorageStatistics>} Storage statistics
* @throws {Error} If query fails
*
* @example
* const stats = await storageService.getStorageStatistics('project-id');
* console.log(`Total files: ${stats.total_files}`);
* console.log(`Storage used: ${stats.total_size} bytes`);
* console.log(`Usage: ${stats.storage_used_percentage}%`);
*/
async getStorageStatistics(projectId: string): Promise<StorageStatistics> {
try {
const [
totalFilesResult,
totalSizeResult,
filesByTypeResult,
filesByFolderResult,
quotaResult,
recentUploadsResult,
mostAccessedResult,
largestFilesResult,
] = await Promise.all([
this.db.query(
"SELECT COUNT(*) FROM files WHERE project_id = $1 AND is_deleted = false",
[projectId]
),
this.db.query(
"SELECT COALESCE(SUM(file_size), 0) as total_size FROM files WHERE project_id = $1 AND is_deleted = false",
[projectId]
),
this.db.query(
"SELECT mime_type, COUNT(*) as count, COALESCE(SUM(file_size), 0) as size FROM files WHERE project_id = $1 AND is_deleted = false GROUP BY mime_type",
[projectId]
),
this.db.query(
"SELECT COALESCE(folder_id, 'root') as folder_id, COUNT(*) as count, COALESCE(SUM(file_size), 0) as size FROM files WHERE project_id = $1 AND is_deleted = false GROUP BY folder_id",
[projectId]
),
this.db.query(
"SELECT max_storage_bytes FROM storage_quotas WHERE project_id = $1",
[projectId]
),
this.db.query(
"SELECT COUNT(*) FROM files WHERE project_id = $1 AND created_at >= datetime('now', '-7 days')",
[projectId]
),
this.db.query(
"SELECT id, original_name, access_count FROM files WHERE project_id = $1 AND is_deleted = false ORDER BY access_count DESC LIMIT 10",
[projectId]
),
this.db.query(
"SELECT id, original_name, file_size FROM files WHERE project_id = $1 AND is_deleted = false ORDER BY file_size DESC LIMIT 10",
[projectId]
),
]);
const filesByType: Record<string, { count: number; size: number }> = {};
for (const row of filesByTypeResult.rows) {
const data = row as FileTypeStatsRow;
filesByType[data.mime_type] = {
count: parseInt(data.count),
size: parseInt(data.size),
};
}
const filesByFolder: Record<string, { count: number; size: number }> = {};
for (const row of filesByFolderResult.rows) {
const data = row as FolderStatsRow;
filesByFolder[data.folder_id] = {
count: parseInt(data.count),
size: parseInt(data.size),
};
}
const totalSizeRow = totalSizeResult.rows[0] as { total_size: string };
const totalSize = parseInt(totalSizeRow.total_size);
const maxStorage =
quotaResult.rows.length > 0
? parseInt(
(quotaResult.rows[0] as { max_storage_bytes: string })
.max_storage_bytes
)
: 1073741824; // 1GB default
return {
total_files: parseInt((totalFilesResult.rows[0] as CountRow).count),
total_size: totalSize,
files_by_type: filesByType,
files_by_folder: filesByFolder,
storage_used_percentage: (totalSize / maxStorage) * 100,
recent_uploads: parseInt(
(recentUploadsResult.rows[0] as CountRow).count
),
most_accessed_files: mostAccessedResult.rows.map((row) => {
const fileRow = row as FileInfoRow;
return {
file_id: fileRow.id,
file_name: fileRow.original_name,
access_count: fileRow.access_count,
};
}),
largest_files: largestFilesResult.rows.map((row) => {
const fileRow = row as FileInfoRow;
return {
file_id: fileRow.id,
file_name: fileRow.original_name,
file_size: fileRow.file_size,
};
}),
};
} catch (error) {
this.logger.error("Failed to get storage statistics:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getStorageStatistics",
});
}
}
// Utility Methods
private async generateFileHash(buffer: Buffer): Promise<string> {
try {
return crypto.createHash("sha256").update(buffer).digest("hex");
} catch {
return `hash_${Date.now()}_${Math.random().toString(36).substring(2)}`;
}
}
private getFileExtension(fileName: string): string {
const parts = fileName.split(".");
return parts.length > 1 ? parts.pop()?.toLowerCase() || "" : "";
}
private generateStoragePath(projectId: string, fileName: string): string {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${projectId}/${year}/${month}/${day}/${fileName}`;
}
private async findFileByHash(
_projectId: string,
_fileHash: string
): Promise<StoredFile | null> {
// Database table doesn't have file_hash column, so this method will always return null
// This is kept for interface compatibility but won't find files by hash
return null;
}
private async updateFileAccess(fileId: string): Promise<void> {
try {
await this.db.query(
"UPDATE files SET last_accessed = CURRENT_TIMESTAMP, access_count = access_count + 1 WHERE id = $1",
[fileId]
);
} catch (error) {
this.logger.error("Failed to update file access:", error);
// Don't throw here as this shouldn't break the main operation
}
}
private async checkStorageQuota(
projectId: string,
fileSize: number
): Promise<void> {
try {
const quotaResult = await this.db.query(
"SELECT max_storage_bytes, max_files, current_storage_bytes, current_files FROM storage_quotas WHERE project_id = $1",
[projectId]
);
if (quotaResult.rows.length > 0) {
const quota = quotaResult.rows[0] as StorageQuotaRow;
if (
parseInt(quota.current_storage_bytes) + fileSize >
parseInt(quota.max_storage_bytes)
) {
throw KrapiError.forbidden("Storage quota exceeded", {
projectId,
currentBytes: parseInt(quota.current_storage_bytes),
maxBytes: parseInt(quota.max_storage_bytes),
requestedBytes: fileSize,
});
}
if (parseInt(quota.current_files) >= parseInt(quota.max_files)) {
throw KrapiError.forbidden("File count quota exceeded", {
projectId,
currentFiles: parseInt(quota.current_files),
maxFiles: parseInt(quota.max_files),
});
}
}
} catch (error) {
if (error instanceof KrapiError) {
throw error;
}
// If quota doesn't exist, allow upload (could be created on first upload)
}
}
private async updateStorageUsage(projectId: string): Promise<void> {
try {
const result = await this.db.query(
"SELECT COUNT(*) as file_count, COALESCE(SUM(file_size), 0) as total_size FROM files WHERE project_id = $1 AND is_deleted = false",
[projectId]
);
const data = result.rows[0] as StorageUsageRow;
await this.db.query(
`INSERT INTO storage_quotas (project_id, current_files, current_storage_bytes)
VALUES ($1, $2, $3)
ON CONFLICT (project_id)
DO UPDATE SET current_files = $2, current_storage_bytes = $3`,
[projectId, parseInt(data.file_count), parseInt(data.total_size)]
);
} catch (error) {
this.logger.error("Failed to update storage usage:", error);
// Don't throw here as this shouldn't break the main operation
}
}
/**
* Get storage information for a project
*
* Retrieves basic storage information including file count, total size, usage percentage, and quota.
*
* @param {string} projectId - Project ID
* @returns {Promise<Object>} Storage information
* @returns {number} returns.total_files - Total number of files
* @returns {number} returns.total_size - Total storage used in bytes
* @returns {number} returns.storage_used_percentage - Storage usage percentage
* @returns {number} returns.quota - Storage quota in bytes
* @throws {Error} If query fails
*
* @example
* const info = await storageService.getStorageInfo('project-id');
* console.log(`Using ${info.storage_used_percentage}% of ${info.quota} bytes`);
*/
async getStorageInfo(projectId: string): Promise<{
total_files: number;
total_size: number;
storage_used_percentage: number;
quota: number;
}> {
try {
const [totalFilesResult, totalSizeResult, quotaResult] =
await Promise.all([
this.db.query(
"SELECT COUNT(*) FROM files WHERE project_id = $1 AND is_deleted = false",
[projectId]
),
this.db.query(
"SELECT COALESCE(SUM(file_size), 0) as total_size FROM files WHERE project_id = $1 AND is_deleted = false",
[projectId]
),
this.db.query(
"SELECT max_storage_bytes FROM storage_quotas WHERE project_id = $1",
[projectId]
),
]);
const totalFiles = parseInt((totalFilesResult.rows[0] as CountRow).count);
const totalSize = parseInt(
(totalSizeResult.rows[0] as { total_size: string }).total_size
);
const quota =
quotaResult.rows.length > 0
? parseInt(
(quotaResult.rows[0] as { max_storage_bytes: string })
.max_storage_bytes
)
: 1073741824; // 1GB default
return {
total_files: totalFiles,
total_size: totalSize,
storage_used_percentage: (totalSize / quota) * 100,
quota,
};
} catch (error) {
this.logger.error(
`Error getting storage info for project ${projectId}:`,
error
);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getStorageInfo",
projectId,
});
}
}
}