UNPKG

@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
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, }); } } }