From 496469e3186578c9d1d62df2815ec4857721d051 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Thu, 1 Jan 2026 12:00:55 +0530 Subject: [PATCH 01/12] revoke share api changes --- src/controllers/file.controller.ts | 18 ++++++++++++++++++ src/repository/file.repository.ts | 17 +++++++++++++++++ src/routes/file.routes.ts | 1 + 3 files changed, 36 insertions(+) diff --git a/src/controllers/file.controller.ts b/src/controllers/file.controller.ts index 61cc2f9..6f5e6ed 100644 --- a/src/controllers/file.controller.ts +++ b/src/controllers/file.controller.ts @@ -240,6 +240,23 @@ const shareFileOrFolder = async (c: Context) => { } } +const revokeShare = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const shareId = c.req.param('shareId'); + + await fileRepository.revokeShare(shareId, user.id); + + return res.SuccessResponse(c, 200, { message: "Share revoked successfully", data: {} }); + } catch (error: any) { + if (error.message === 'Share not found or you do not have permission to revoke it') { + return res.FailureResponse(c, 404, { message: error.message }); + } + console.log(error); + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +} + const getAllSharedFiles = async (c: Context) => { try { const user = c.get('user') as IUserAttributes; @@ -360,6 +377,7 @@ export default { restoreFileOrFolder, deleteFileOrFolder, shareFileOrFolder, + revokeShare, getAllSharedFiles, getAllSharedFilesByMe, getAllSharedFilesWithMe, diff --git a/src/repository/file.repository.ts b/src/repository/file.repository.ts index 273a239..d75b46b 100644 --- a/src/repository/file.repository.ts +++ b/src/repository/file.repository.ts @@ -128,6 +128,22 @@ const shareFileOrFolder = async (share: ShareAttributes) => { return file; }; +const revokeShare = async (shareId: string, userId: string) => { + // Only allow the owner (shared_by_user_id) to revoke the share + const deleted = await db.Share.destroy({ + where: { + id: shareId, + shared_by_user_id: userId + } + }); + + if (deleted === 0) { + throw new Error('Share not found or you do not have permission to revoke it'); + } + + return deleted; +}; + /** @@ -606,6 +622,7 @@ export default { restoreFileOrFolder, deleteFileOrFolder, shareFileOrFolder, + revokeShare, getAllSharedFiles, getAllSharedFilesByMe, getAllSharedFilesWithMe, diff --git a/src/routes/file.routes.ts b/src/routes/file.routes.ts index 5e993c9..bcc6523 100644 --- a/src/routes/file.routes.ts +++ b/src/routes/file.routes.ts @@ -22,6 +22,7 @@ export class FileRouter { this.router.patch('/folder/:id/move', validateBody(fileDtoValidation.moveFileValidation), FileController.moveFileOrFolder); this.router.post('/file', validateBody(fileDtoValidation.createFileValidation), FileController.createFile); this.router.post('/share/file/:id', validateBody(fileDtoValidation.shareFileValidation), FileController.shareFileOrFolder); + this.router.delete('/share/:shareId', FileController.revokeShare); this.router.get('/share/file/all-shared-files', FileController.getAllSharedFiles); this.router.get('/share/file/shared-by-me', FileController.getAllSharedFilesByMe); this.router.get('/share/file/shared-with-me', FileController.getAllSharedFilesWithMe); From 5af0973bafb152bff9cd499183d45f9e79160199 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Thu, 1 Jan 2026 13:08:36 +0530 Subject: [PATCH 02/12] revoke access changes --- src/controllers/file.controller.ts | 31 +++++++++++++++++++++--------- src/models/Notification.model.ts | 1 + src/repository/file.repository.ts | 6 +----- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/controllers/file.controller.ts b/src/controllers/file.controller.ts index 6f5e6ed..c99511d 100644 --- a/src/controllers/file.controller.ts +++ b/src/controllers/file.controller.ts @@ -245,8 +245,21 @@ const revokeShare = async (c: Context) => { const user = c.get('user') as IUserAttributes; const shareId = c.req.param('shareId'); - await fileRepository.revokeShare(shareId, user.id); + const deleted = await fileRepository.revokeShare(shareId, user.id); + if (deleted === 0) { + return res.FailureResponse(c, 404, { message: "Share not found or you do not have permission to revoke it" }); + } + addToNotificationQueue({ + user_id: user.id, + type: NotificationType.FILE_SHARE_REVOKED, + title: `${user.display_name} revoked a share`, + message: `Share has been revoked successfully`, + is_read: false, + created_at: new Date(), + data: { shareId, deleted }, + related_user_id: user.id, + }) return res.SuccessResponse(c, 200, { message: "Share revoked successfully", data: {} }); } catch (error: any) { if (error.message === 'Share not found or you do not have permission to revoke it') { @@ -332,17 +345,17 @@ const updateFileAccessLevel = async (c: Context) => { const value = c.get('validated'); const user = c.get('user') as IUserAttributes; const fileId = c.req.param('id'); - + const updatedCount = await fileRepository.updateFileAccessLevel(fileId, user.id, value.access_level); - - return res.SuccessResponse(c, 200, { - message: "File access level updated successfully", - data: { + + return res.SuccessResponse(c, 200, { + message: "File access level updated successfully", + data: { updatedCount, - message: updatedCount > 1 - ? `Updated ${updatedCount} items (folder and its contents)` + message: updatedCount > 1 + ? `Updated ${updatedCount} items (folder and its contents)` : 'Updated 1 item' - } + } }); } catch (error: any) { diff --git a/src/models/Notification.model.ts b/src/models/Notification.model.ts index ef45f1e..cde96a0 100644 --- a/src/models/Notification.model.ts +++ b/src/models/Notification.model.ts @@ -3,6 +3,7 @@ import { DataTypes, Model, type Optional, Sequelize } from 'sequelize'; // Updated NotificationType enum export enum NotificationType { FILE_SHARED = 'file_shared', + FILE_SHARE_REVOKED = 'file_share_revoked', FILE_UPDATED = 'file_updated', FILE_UPLOAD_COMPLETED = 'file_upload_completed', FILE_UPLOAD_FAILED = 'file_upload_failed', diff --git a/src/repository/file.repository.ts b/src/repository/file.repository.ts index d75b46b..be0cfdb 100644 --- a/src/repository/file.repository.ts +++ b/src/repository/file.repository.ts @@ -136,11 +136,7 @@ const revokeShare = async (shareId: string, userId: string) => { shared_by_user_id: userId } }); - - if (deleted === 0) { - throw new Error('Share not found or you do not have permission to revoke it'); - } - + return deleted; }; From 0193222abec33283840819a3288b24951a8b0df9 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 10 Jan 2026 08:57:30 +0530 Subject: [PATCH 03/12] backend changes for the dashboard --- ANALYTICS_SYSTEM.md | 205 ++++++++++++++++ src/config/s3.config.ts | 23 +- src/controllers/analytics.controller.ts | 102 ++++++++ src/controllers/cron.controller.ts | 49 +++- src/controllers/file.controller.ts | 63 ++++- src/controllers/upload.controller.ts | 20 +- src/core/analytics-queue.ts | 101 ++++++++ src/global/routes.ts | 2 + src/repository/analytics.repository.ts | 304 ++++++++++++++++++++++++ src/routes/analytics.routes.ts | 32 +++ src/services/analytics.service.ts | 105 ++++++++ src/services/lock.service.ts | 86 +++++++ 12 files changed, 1082 insertions(+), 10 deletions(-) create mode 100644 ANALYTICS_SYSTEM.md create mode 100644 src/controllers/analytics.controller.ts create mode 100644 src/core/analytics-queue.ts create mode 100644 src/repository/analytics.repository.ts create mode 100644 src/routes/analytics.routes.ts create mode 100644 src/services/analytics.service.ts create mode 100644 src/services/lock.service.ts diff --git a/ANALYTICS_SYSTEM.md b/ANALYTICS_SYSTEM.md new file mode 100644 index 0000000..50d40aa --- /dev/null +++ b/ANALYTICS_SYSTEM.md @@ -0,0 +1,205 @@ +# Analytics System Implementation + +## Overview +A comprehensive analytics system has been implemented for FileFlow to track user activity, storage usage, and file operations. + +## Architecture + +### 1. Queue System (`analytics-queue.ts`) +- **Queue Name**: `fileflow-analytics-queue` +- **Event Types**: + - `FILE_UPLOADED` - Track file uploads + - `FILE_DELETED` - Track file deletions + - `FILE_DOWNLOADED` - Track file downloads + - `FILE_SHARED` - Track file shares + - `PUBLIC_LINK_CREATED` - Track public link creation + - `FOLDER_CREATED` - Track folder creation + +- **Configuration**: + - 5 retry attempts with exponential backoff + - Concurrent processing with 5 workers + - Auto-cleanup of completed jobs + - Keep last 10 failed jobs for debugging + +### 2. Repository (`analytics.repository.ts`) +**Main Functions**: +- `getTodaysAnalytics()` - Get or create today's analytics record +- `calculateUserStorage()` - Calculate total storage and categorize by file type +- `recordFileUpload()` - Update analytics when file is uploaded +- `recordFileDelete()` - Update analytics when file is deleted +- `recordFolderCreate()` - Update analytics when folder is created +- `recordFileDownload()` - Track downloads +- `recordFileShare()` - Track shares +- `recordPublicLinkCreate()` - Track public links +- `getAnalyticsByDateRange()` - Get analytics for date range +- `getLatestAnalytics()` - Get most recent analytics +- `getAnalyticsSummary()` - Get 30-day summary + +### 3. Service (`analytics.service.ts`) +**Main Functions**: +- `processAnalyticsEvent()` - Process analytics events from queue +- `getAnalyticsSummary()` - Get summary for user +- `getAnalyticsByDateRange()` - Get analytics by date range +- `getCurrentStorageOverview()` - Get current storage breakdown + +### 4. Controller (`analytics.controller.ts`) +**Endpoints Implemented**: +- `GET /api/v1/analytics/summary` - Get 30-day analytics summary +- `GET /api/v1/analytics/date-range?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` - Get analytics for specific date range +- `GET /api/v1/analytics/storage` - Get current storage overview + +### 5. Routes (`analytics.routes.ts`) +All routes are protected with authentication middleware. + +## Data Model + +### StorageAnalytics Table +```typescript +{ + id: UUID + user_id: UUID + date: DATE (unique per user per day) + + // Storage metrics + total_files: INTEGER + total_folders: INTEGER + total_size: BIGINT + + // File type breakdown + images_count: INTEGER + images_size: BIGINT + videos_count: INTEGER + videos_size: BIGINT + audio_count: INTEGER + audio_size: BIGINT + documents_count: INTEGER + documents_size: BIGINT + other_count: INTEGER + other_size: BIGINT + + // Daily activity + uploads_today: INTEGER + downloads_today: INTEGER + shares_created_today: INTEGER + public_links_created_today: INTEGER + + created_at: TIMESTAMP +} +``` + +## Integration Points + +### File Controller +Analytics events are tracked when: +- Creating folders → `FOLDER_CREATED` +- Creating files → `FILE_UPLOADED` +- Deleting files → `FILE_DELETED` +- Sharing files → `FILE_SHARED` + +### Upload Controller +Analytics events are tracked when: +- Uploading files (direct) → `FILE_UPLOADED` +- Uploading files (multipart) → Handled in File controller after completion + +## API Usage Examples + +### 1. Get Storage Overview +```http +GET /api/v1/analytics/storage +Authorization: Bearer + +Response: +{ + "success": true, + "message": "Storage overview retrieved successfully", + "data": { + "storage": { + "totalFiles": 456, + "totalFolders": 23, + "totalSize": 48622632960, + "imageCount": 234, + "imageSize": 20131512320, + "videoCount": 67, + "videoSize": 9556590592, + "audioCount": 123, + "audioSize": 3442319360, + "documentCount": 32, + "documentSize": 13346765824 + }, + "todayActivity": { + "uploads": 12, + "downloads": 34, + "shares": 5, + "publicLinks": 2 + }, + "storageQuota": 107374182400, + "storageUsed": 48622632960, + "storageRemaining": 58751549440, + "storageUsedPercentage": "45.28" + } +} +``` + +### 2. Get 30-Day Summary +```http +GET /api/v1/analytics/summary +Authorization: Bearer + +Response: +{ + "success": true, + "message": "Analytics summary retrieved successfully", + "data": { + "current": { /* Today's analytics */ }, + "last30Days": [ /* Array of daily analytics */ ], + "totalUploads": 145, + "totalDownloads": 892, + "totalShares": 34 + } +} +``` + +### 3. Get Date Range Analytics +```http +GET /api/v1/analytics/date-range?startDate=2026-01-01&endDate=2026-01-31 +Authorization: Bearer + +Response: +{ + "success": true, + "message": "Analytics retrieved successfully", + "data": [ /* Array of analytics for each day */ ] +} +``` + +## File Type Categorization + +Files are automatically categorized based on MIME type: +- **Images**: `image/*` +- **Videos**: `video/*` +- **Audio**: `audio/*` +- **Documents**: PDFs, Word docs, spreadsheets, presentations, text files +- **Other**: Everything else + +## Performance Considerations + +1. **Async Processing**: All analytics updates happen asynchronously via queue +2. **Daily Aggregation**: One record per user per day reduces database size +3. **Efficient Queries**: Indexed on `user_id` and `date` +4. **Caching Ready**: Current storage can be cached and invalidated on changes + +## Monitoring + +- Queue events are logged: completed, failed, errors +- Failed jobs are retried up to 5 times +- Last 10 failed jobs are kept for debugging + +## Next Steps + +Consider adding: +1. **Cron Job**: Daily cleanup of old analytics (keep last 90 days) +2. **Real-time Dashboard**: WebSocket updates for live analytics +3. **Export Feature**: CSV/Excel export of analytics data +4. **Alerts**: Notify users when approaching storage quota +5. **Charts API**: Pre-aggregated data for frontend charts + diff --git a/src/config/s3.config.ts b/src/config/s3.config.ts index 5bac80b..6b303b0 100644 --- a/src/config/s3.config.ts +++ b/src/config/s3.config.ts @@ -56,6 +56,19 @@ export class S3Service { return `https://${config.CLOUDFLARE.CDN_DOMAIN}/${key}`; } + /** + * Sanitizes metadata values to ensure they're valid for HTTP headers. + * HTTP headers cannot contain certain characters like non-ASCII, control characters, etc. + */ + private sanitizeMetadataValue(value: string): string { + // Replace non-ASCII and control characters with safe equivalents + // Keep only alphanumeric, spaces, hyphens, underscores, and periods + return value + .replace(/[^\x20-\x7E]/g, '') // Remove non-printable ASCII + .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') // Replace invalid chars with underscore + .substring(0, 2000); // AWS metadata value limit is 2KB + } + private calculateChunkChecksum(chunkBuffer: Buffer): string { const hash = crypto.createHash('sha256'); hash.update(chunkBuffer); @@ -63,13 +76,16 @@ export class S3Service { } public async uploadFile(file: IFile): Promise { + // Sanitize original name for HTTP header compatibility + const sanitizedOriginalName = this.sanitizeMetadataValue(file.originalName); + const params: PutObjectCommandInput = { Bucket: config.S3.BUCKET_NAME, Key: `${FolderNameEnum.FILES}/${file.filename}`, Body: file.buffer, ContentType: file.mimetype, Metadata: { - originalName: file.originalName, + originalName: sanitizedOriginalName, uploadedAt: new Date().toISOString(), size: file.size.toString(), }, @@ -88,13 +104,16 @@ export class S3Service { folder: FolderNameEnum = FolderNameEnum.FILES ): Promise { const path = `${folder}/${key}`; + // Sanitize original name for HTTP header compatibility + const sanitizedOriginalName = this.sanitizeMetadataValue(fileName); + const params = { Bucket: config.S3.BUCKET_NAME, Key: path, Body: buffer, ContentType: mimeType, Metadata: { - originalName: fileName, + originalName: sanitizedOriginalName, uploadedAt: new Date().toISOString(), size: buffer.length.toString(), }, diff --git a/src/controllers/analytics.controller.ts b/src/controllers/analytics.controller.ts new file mode 100644 index 0000000..34b5244 --- /dev/null +++ b/src/controllers/analytics.controller.ts @@ -0,0 +1,102 @@ +import { type Context } from "hono"; +import res from "@/utils/response"; +import analyticsService from "@/services/analytics.service"; +import type { IUserAttributes } from "@/models/User.model"; + +/** + * Get analytics summary (last 30 days) + */ +const getAnalyticsSummary = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const summary = await analyticsService.getAnalyticsSummary(user.id); + + return res.SuccessResponse(c, 200, { + message: "Analytics summary retrieved successfully", + data: summary + }); + } catch (error: any) { + console.error('Error getting analytics summary:', error); + return res.FailureResponse(c, 500, { + message: "Failed to retrieve analytics summary", + error: error.message + }); + } +}; + +/** + * Get analytics by date range + */ +const getAnalyticsByDateRange = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const startDateStr = c.req.query('startDate'); + const endDateStr = c.req.query('endDate'); + + if (!startDateStr || !endDateStr) { + return res.FailureResponse(c, 400, { + message: "startDate and endDate are required" + }); + } + + const startDate = new Date(startDateStr); + const endDate = new Date(endDateStr); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return res.FailureResponse(c, 400, { + message: "Invalid date format. Use ISO 8601 format (YYYY-MM-DD)" + }); + } + + const analytics = await analyticsService.getAnalyticsByDateRange( + user.id, + startDate, + endDate + ); + + return res.SuccessResponse(c, 200, { + message: "Analytics retrieved successfully", + data: analytics + }); + } catch (error: any) { + console.error('Error getting analytics by date range:', error); + return res.FailureResponse(c, 500, { + message: "Failed to retrieve analytics", + error: error.message + }); + } +}; + +/** + * Get current storage overview + */ +const getStorageOverview = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const overview = await analyticsService.getCurrentStorageOverview(user.id); + + return res.SuccessResponse(c, 200, { + message: "Storage overview retrieved successfully", + data: { + ...overview, + storageQuota: user.storage_quota, + storageUsed: overview.storage.totalSize, + storageRemaining: user.storage_quota - overview.storage.totalSize, + storageUsedPercentage: ((overview.storage.totalSize / user.storage_quota) * 100).toFixed(2) + } + }); + } catch (error: any) { + console.error('Error getting storage overview:', error); + return res.FailureResponse(c, 500, { + message: "Failed to retrieve storage overview", + error: error.message + }); + } +}; + +export default { + getAnalyticsSummary, + getAnalyticsByDateRange, + getStorageOverview +}; + diff --git a/src/controllers/cron.controller.ts b/src/controllers/cron.controller.ts index b210ce8..fb9602b 100644 --- a/src/controllers/cron.controller.ts +++ b/src/controllers/cron.controller.ts @@ -1,5 +1,6 @@ import schedule from "node-schedule"; import CronDatabase from "@/repository/cron.repository"; +import LockService from "@/services/lock.service"; const getISTTime = () => { return new Date().toLocaleString("en-IN", { @@ -17,23 +18,63 @@ const getISTTime = () => { // 🕛 Daily cleanup of expired tokens (12:00 AM IST) schedule.scheduleJob({ rule: "0 0 * * *", tz: "Asia/Kolkata" }, async () => { console.log(`[${getISTTime()}] Running cron: Removing expired tokens`); - await CronDatabase.removeAllExpiredTokens(); + + const result = await LockService.executeWithLock("remove-expired-tokens", async () => { + const count = await CronDatabase.removeAllExpiredTokens(); + console.log(`[${getISTTime()}] ✅ Removed ${count} expired tokens`); + return count; + // 10 minutes TTL + }, 600); + + if (result === null) { + console.log(`[${getISTTime()}] ⏭️ Skipped: Another instance is already removing expired tokens`); + } }); // 🕛 Daily cleanup of expired shares (12:05 AM IST) schedule.scheduleJob({ rule: "5 0 * * *", tz: "Asia/Kolkata" }, async () => { console.log(`[${getISTTime()}] Running cron: Removing expired shares`); - await CronDatabase.removeAllExpiredShares(); + + const result = await LockService.executeWithLock("remove-expired-shares", + async () => { + const count = await CronDatabase.removeAllExpiredShares(); + console.log(`[${getISTTime()}] ✅ Removed ${count} expired shares`); + return count; + }, + 600 // 10 minutes TTL + ); + + if (result === null) { + console.log(`[${getISTTime()}] ⏭️ Skipped: Another instance is already removing expired shares`); + } }); // 🕐 Daily cleanup of old files in trash (1:00 AM IST) schedule.scheduleJob({ rule: "0 1 * * *", tz: "Asia/Kolkata" }, async () => { console.log(`[${getISTTime()}] Running cron: Removing files older than 30 days from trash`); - await CronDatabase.removeOldDeletedFiles(); + + const result = await LockService.executeWithLock("remove-old-deleted-files", async () => { + const deletedIds = await CronDatabase.removeOldDeletedFiles(); + console.log(`[${getISTTime()}] ✅ Removed ${deletedIds.length} old files from trash`); + return deletedIds; + }, 1800); // 30 minutes TTL (this job may take longer) + + if (result === null) { + console.log(`[${getISTTime()}] ⏭️ Skipped: Another instance is already removing old files`); + } }); // 🕛 Daily cleanup of read notifications older than 30 days (2:00 AM IST) schedule.scheduleJob({ rule: "0 2 * * *", tz: "Asia/Kolkata" }, async () => { console.log(`[${getISTTime()}] Running cron: Removing old read notifications`); - await CronDatabase.removeOldReadNotifications(); + + const result = await LockService.executeWithLock("remove-old-notifications", async () => { + const count = await CronDatabase.removeOldReadNotifications(); + console.log(`[${getISTTime()}] ✅ Removed ${count} old read notifications`); + return count; + }, 600); // 10 minutes TTL + + if (result === null) { + console.log(`[${getISTTime()}] ⏭️ Skipped: Another instance is already removing old notifications`); + } }); diff --git a/src/controllers/file.controller.ts b/src/controllers/file.controller.ts index c99511d..fc3a8a4 100644 --- a/src/controllers/file.controller.ts +++ b/src/controllers/file.controller.ts @@ -1,6 +1,6 @@ import { type Context } from "hono"; import res from "@/utils/response"; -import { AccessLevel, type FileAttributes } from "@/models/File.model"; +import { AccessLevel, type FileAttributes, File } from "@/models/File.model"; import type { InferSchemaType } from "@/utils/validation"; import fileDtoValidation from "@/validation/file.validation"; import fileRepository from "@/repository/file.repository"; @@ -10,6 +10,7 @@ import db from "@/config/database"; import type { ShareAttributes } from "@/models/Share.model"; import { addToNotificationQueue } from "@/core/notification-queue"; import { NotificationType } from "@/models/Notification.model"; +import { addToAnalyticsQueue, AnalyticsEventType } from "@/core/analytics-queue"; const createFolder = async (c: Context) => { @@ -30,6 +31,17 @@ const createFolder = async (c: Context) => { }; const createdFolder = await fileRepository.createFolder(folder); + + // Track folder creation analytics + addToAnalyticsQueue({ + userId: user.id, + eventType: AnalyticsEventType.FOLDER_CREATED, + metadata: { + folderName: value.name, + isFolder: true + } + }); + return res.SuccessResponse(c, 201, { message: "Folder created successfully", data: createdFolder }); } catch (error: any) { @@ -116,6 +128,21 @@ const createFile = async (c: Context) => { }; const createdFile = await fileRepository.createFile(file, transaction); await transaction.commit(); + + // Track file upload analytics + if (value.file_info) { + addToAnalyticsQueue({ + userId: user.id, + eventType: AnalyticsEventType.FILE_UPLOADED, + metadata: { + fileName: value.name, + fileSize: value.file_info.file_size, + fileType: value.file_info.file_type, + isFolder: false + } + }); + } + return res.SuccessResponse(c, 201, { message: "File created successfully", data: createdFile }); } catch (error) { @@ -186,6 +213,13 @@ const deleteFileOrFolder = async (c: Context) => { try { const user = c.get('user') as IUserAttributes; const fileId = c.req.param('id'); + + // Get file info before deleting for analytics + const file = await File.findOne({ + where: { id: fileId, owner_id: user.id }, + attributes: ['name', 'is_folder'] + }); + const deletedFile = await fileRepository.deleteFileOrFolder(fileId, user.id); addToNotificationQueue({ @@ -197,7 +231,20 @@ const deleteFileOrFolder = async (c: Context) => { created_at: new Date(), data: { deletedFile }, related_user_id: user.id, - }) + }); + + // Track file deletion analytics (if file was found) + if (file) { + addToAnalyticsQueue({ + userId: user.id, + eventType: AnalyticsEventType.FILE_DELETED, + metadata: { + fileName: file.name, + isFolder: file.is_folder + } + }); + } + return res.SuccessResponse(c, 200, { message: "File/folder deleted successfully", data: { deletedFile } }); } catch (error) { console.log(error); @@ -231,7 +278,17 @@ const shareFileOrFolder = async (c: Context) => { created_at: new Date(), data: { sharedFile }, related_user_id: user.id, - }) + }); + + // Track file share analytics + addToAnalyticsQueue({ + userId: user.id, + eventType: AnalyticsEventType.FILE_SHARED, + metadata: { + fileId: fileId, + sharedWithUserId: value.shared_with_user_id + } + }); return res.SuccessResponse(c, 200, { message: "File/folder shared successfully", data: { sharedFile } }); } catch (error) { diff --git a/src/controllers/upload.controller.ts b/src/controllers/upload.controller.ts index 0cae944..63cf67b 100644 --- a/src/controllers/upload.controller.ts +++ b/src/controllers/upload.controller.ts @@ -5,13 +5,14 @@ import s3Service from "@/config/s3.config" import type { IUserAttributes } from "@/models/User.model" import { NotificationType, type IFileInfo } from "@/models" import { addToNotificationQueue } from "@/core/notification-queue" +import { addToAnalyticsQueue, AnalyticsEventType } from "@/core/analytics-queue" const uploadFile = async (c: Context) => { const user = c.get("user") as IUserAttributes if (!user?.id) { return res.FailureResponse(c, 400, { message: "User not found" }) } - try { + try { const formData = await c.req.formData() const files = formData.getAll("files") as File[] @@ -32,6 +33,23 @@ const uploadFile = async (c: Context) => { data: { results }, related_user_id: user.id, }) + + // Track upload analytics for each file + files.forEach((file, index) => { + const result = results[index]; + if (result) { + addToAnalyticsQueue({ + userId: user.id, + eventType: AnalyticsEventType.FILE_UPLOADED, + metadata: { + fileName: file.name, + fileSize: result.file_size, + fileType: result.file_type, + isFolder: false + } + }); + } + }); } return res.SuccessResponse(c, 200, { message: "File(s) uploaded successfully", diff --git a/src/core/analytics-queue.ts b/src/core/analytics-queue.ts new file mode 100644 index 0000000..2596043 --- /dev/null +++ b/src/core/analytics-queue.ts @@ -0,0 +1,101 @@ +import { Queue, Worker, QueueEvents, type JobsOptions } from "bullmq"; +import config from "@/config/config"; +import analyticsService from "@/services/analytics.service" + +export enum AnalyticsEventType { + FILE_UPLOADED = 'file_uploaded', + FILE_DELETED = 'file_deleted', + FILE_DOWNLOADED = 'file_downloaded', + FILE_SHARED = 'file_shared', + PUBLIC_LINK_CREATED = 'public_link_created', + FOLDER_CREATED = 'folder_created', +} + +interface AnalyticsEvent { + userId: string; + eventType: AnalyticsEventType; + metadata: { + fileSize?: number; + fileType?: string; + fileName?: string; + isFolder?: boolean; + [key: string]: any; + }; +} + +interface AnalyticsQueue { + event: AnalyticsEvent; +} + +const QUEUE_NAME = "fileflow-analytics-queue"; + +// Shared Redis connection config +const connection = { + url: config.REDIS_URL, +}; + +const defaultJobOptions: JobsOptions = { + attempts: 5, + backoff: { + type: "exponential", + delay: 5000, // retry after 5s, then grows exponentially + }, + removeOnComplete: true, + removeOnFail: 10, // keep last 10 failed jobs for debugging + priority: 5, + lifo: false, +}; + +// Analytics queue +export const analyticsQueue = new Queue(QUEUE_NAME, { + connection, + defaultJobOptions, +}); + +export const addToAnalyticsQueue = (event: AnalyticsEvent) => { + return analyticsQueue.add('analytics-event', { event }); +}; + +// Worker for processing analytics +const analyticsWorker = new Worker( + QUEUE_NAME, + async (job) => { + const { event } = job.data; + try { + const startTime = Date.now(); + + // Process analytics event + await analyticsService.processAnalyticsEvent(event); + + const duration = Date.now() - startTime; + return { success: true, duration }; + + } catch (error: any) { + console.error(`Failed to process ${event.eventType} analytics event:`, error.message); + throw error; + } + }, + { + connection, + concurrency: 5 + } +); + +// Queue events +const queueEvents = new QueueEvents(QUEUE_NAME, { connection }); +// ✅ prevent MaxListenersExceededWarning +queueEvents.setMaxListeners(1); + +// Event handlers +queueEvents.on('completed', ({ jobId }) => { + console.log(`Analytics job ${jobId} completed successfully`); +}); + +queueEvents.on('failed', ({ jobId, failedReason }) => { + console.error(`Analytics job ${jobId} failed with error: ${failedReason}`); +}); + +analyticsWorker.on('error', (error) => { + console.error('Analytics worker error:', error); +}); + diff --git a/src/global/routes.ts b/src/global/routes.ts index 55f6da1..8dae701 100644 --- a/src/global/routes.ts +++ b/src/global/routes.ts @@ -4,6 +4,7 @@ import { FileRouter } from "@/routes/file.routes"; import { UploadRouter } from "@/routes/upload.routes"; import { NotificationRouter } from "@/routes/notification.routes"; import { ApiTokenRouter } from "@/routes/api-token.routes"; +import { AnalyticsRouter } from "@/routes/analytics.routes"; export class MainRouter { private readonly router: Hono; @@ -19,6 +20,7 @@ export class MainRouter { this.router.route("/upload", new UploadRouter().getRouter()); this.router.route("/notification", new NotificationRouter().getRouter()); this.router.route("/api-token", new ApiTokenRouter().getRouter()); + this.router.route("/analytics", new AnalyticsRouter().getRouter()); } /** Return the configured Hono instance */ diff --git a/src/repository/analytics.repository.ts b/src/repository/analytics.repository.ts new file mode 100644 index 0000000..ab7e407 --- /dev/null +++ b/src/repository/analytics.repository.ts @@ -0,0 +1,304 @@ +import { StorageAnalytics, type StorageAnalyticsAttributes } from "@/models/StorageAnalytics.model"; +import { File } from "@/models/File.model"; +import { Op } from "sequelize"; + +/** + * Get or create today's analytics record for a user + */ +const getTodaysAnalytics = async (userId: string, date: Date = new Date()): Promise => { + const today = new Date(date); + today.setHours(0, 0, 0, 0); // Start of day + + const [analytics] = await StorageAnalytics.findOrCreate({ + where: { + user_id: userId, + date: today + }, + defaults: { + user_id: userId, + date: today, + total_files: 0, + total_folders: 0, + total_size: 0, + images_count: 0, + images_size: 0, + videos_count: 0, + videos_size: 0, + audio_count: 0, + audio_size: 0, + documents_count: 0, + documents_size: 0, + other_count: 0, + other_size: 0, + uploads_today: 0, + downloads_today: 0, + shares_created_today: 0, + public_links_created_today: 0, + } + }); + + return analytics; +}; + +/** + * Calculate total storage used by user + */ +const calculateUserStorage = async (userId: string): Promise<{ + totalFiles: number; + totalFolders: number; + totalSize: number; + imageCount: number; + imageSize: number; + videoCount: number; + videoSize: number; + audioCount: number; + audioSize: number; + documentCount: number; + documentSize: number; + otherCount: number; + otherSize: number; +}> => { + const files = await File.findAll({ + where: { + owner_id: userId, + deleted_at: null + }, + attributes: ['is_folder', 'file_info'] + }); + + let totalFiles = 0; + let totalFolders = 0; + let totalSize = 0; + let imageCount = 0; + let imageSize = 0; + let videoCount = 0; + let videoSize = 0; + let audioCount = 0; + let audioSize = 0; + let documentCount = 0; + let documentSize = 0; + let otherCount = 0; + let otherSize = 0; + + files.forEach(file => { + if (file.is_folder) { + totalFolders++; + } else { + totalFiles++; + const fileInfo = file.file_info; + if (fileInfo) { + const size = fileInfo.file_size || 0; + const type = fileInfo.file_type || ''; + + totalSize += size; + + // Categorize by type + if (type.startsWith('image/')) { + imageCount++; + imageSize += size; + } else if (type.startsWith('video/')) { + videoCount++; + videoSize += size; + } else if (type.startsWith('audio/')) { + audioCount++; + audioSize += size; + } else if ( + type === 'application/pdf' || + type.includes('document') || + type.includes('text') || + type.includes('sheet') || + type.includes('presentation') + ) { + documentCount++; + documentSize += size; + } else { + otherCount++; + otherSize += size; + } + } + } + }); + + return { + totalFiles, + totalFolders, + totalSize, + imageCount, + imageSize, + videoCount, + videoSize, + audioCount, + audioSize, + documentCount, + documentSize, + otherCount, + otherSize + }; +}; + +/** + * Update analytics for file upload + */ +const recordFileUpload = async (userId: string, fileSize: number, fileType: string): Promise => { + const analytics = await getTodaysAnalytics(userId); + const storage = await calculateUserStorage(userId); + + const updates: Partial = { + uploads_today: analytics.uploads_today + 1, + total_files: storage.totalFiles, + total_folders: storage.totalFolders, + total_size: storage.totalSize, + images_count: storage.imageCount, + images_size: storage.imageSize, + videos_count: storage.videoCount, + videos_size: storage.videoSize, + audio_count: storage.audioCount, + audio_size: storage.audioSize, + documents_count: storage.documentCount, + documents_size: storage.documentSize, + other_count: storage.otherCount, + other_size: storage.otherSize, + }; + + await analytics.update(updates); +}; + +/** + * Update analytics for file deletion + */ +const recordFileDelete = async (userId: string): Promise => { + const analytics = await getTodaysAnalytics(userId); + const storage = await calculateUserStorage(userId); + + const updates: Partial = { + total_files: storage.totalFiles, + total_folders: storage.totalFolders, + total_size: storage.totalSize, + images_count: storage.imageCount, + images_size: storage.imageSize, + videos_count: storage.videoCount, + videos_size: storage.videoSize, + audio_count: storage.audioCount, + audio_size: storage.audioSize, + documents_count: storage.documentCount, + documents_size: storage.documentSize, + other_count: storage.otherCount, + other_size: storage.otherSize, + }; + + await analytics.update(updates); +}; + +/** + * Update analytics for folder creation + */ +const recordFolderCreate = async (userId: string): Promise => { + const analytics = await getTodaysAnalytics(userId); + const storage = await calculateUserStorage(userId); + + await analytics.update({ + total_folders: storage.totalFolders, + }); +}; + +/** + * Update analytics for file download + */ +const recordFileDownload = async (userId: string): Promise => { + const analytics = await getTodaysAnalytics(userId); + await analytics.increment('downloads_today'); +}; + +/** + * Update analytics for file share + */ +const recordFileShare = async (userId: string): Promise => { + const analytics = await getTodaysAnalytics(userId); + await analytics.increment('shares_created_today'); +}; + +/** + * Update analytics for public link creation + */ +const recordPublicLinkCreate = async (userId: string): Promise => { + const analytics = await getTodaysAnalytics(userId); + await analytics.increment('public_links_created_today'); +}; + +/** + * Get analytics for a specific date range + */ +const getAnalyticsByDateRange = async ( + userId: string, + startDate: Date, + endDate: Date +): Promise => { + return await StorageAnalytics.findAll({ + where: { + user_id: userId, + date: { + [Op.between]: [startDate, endDate] + } + }, + order: [['date', 'ASC']] + }); +}; + +/** + * Get latest analytics for a user + */ +const getLatestAnalytics = async (userId: string): Promise => { + return await StorageAnalytics.findOne({ + where: { + user_id: userId + }, + order: [['date', 'DESC']] + }); +}; + +/** + * Get analytics summary (last 30 days) + */ +const getAnalyticsSummary = async (userId: string): Promise<{ + current: StorageAnalytics | null; + last30Days: StorageAnalytics[]; + totalUploads: number; + totalDownloads: number; + totalShares: number; +}> => { + const today = new Date(); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const [current, last30Days] = await Promise.all([ + getTodaysAnalytics(userId), + getAnalyticsByDateRange(userId, thirtyDaysAgo, today) + ]); + + const totalUploads = last30Days.reduce((sum, day) => sum + day.uploads_today, 0); + const totalDownloads = last30Days.reduce((sum, day) => sum + day.downloads_today, 0); + const totalShares = last30Days.reduce((sum, day) => sum + day.shares_created_today, 0); + + return { + current, + last30Days, + totalUploads, + totalDownloads, + totalShares + }; +}; + +export default { + getTodaysAnalytics, + calculateUserStorage, + recordFileUpload, + recordFileDelete, + recordFolderCreate, + recordFileDownload, + recordFileShare, + recordPublicLinkCreate, + getAnalyticsByDateRange, + getLatestAnalytics, + getAnalyticsSummary +}; + diff --git a/src/routes/analytics.routes.ts b/src/routes/analytics.routes.ts new file mode 100644 index 0000000..bfd6337 --- /dev/null +++ b/src/routes/analytics.routes.ts @@ -0,0 +1,32 @@ +import { Hono } from "hono"; +import AuthMiddleware from "@/middleware/auth.middleware"; +import analyticsController from "@/controllers/analytics.controller"; + +export class AnalyticsRouter { + /** Each router owns its own Hono instance */ + private readonly router: Hono; + + constructor() { + this.router = new Hono(); + this.initializeRoutes(); + } + + private initializeRoutes() { + // Apply authentication middleware to all routes + this.router.use(AuthMiddleware.authMiddleware); + + // Get analytics summary (last 30 days) + this.router.get('/summary', analyticsController.getAnalyticsSummary); + + // Get analytics by date range + this.router.get('/date-range', analyticsController.getAnalyticsByDateRange); + + // Get current storage overview + this.router.get('/storage', analyticsController.getStorageOverview); + } + + public getRouter() { + return this.router; + } +} + diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts new file mode 100644 index 0000000..220052a --- /dev/null +++ b/src/services/analytics.service.ts @@ -0,0 +1,105 @@ +import analyticsRepository from "@/repository/analytics.repository"; +import { type AnalyticsEventType } from "@/core/analytics-queue"; + +interface AnalyticsEvent { + userId: string; + eventType: AnalyticsEventType; + metadata: { + fileSize?: number; + fileType?: string; + fileName?: string; + isFolder?: boolean; + [key: string]: any; + }; +} + +/** + * Process analytics event + */ +const processAnalyticsEvent = async (event: AnalyticsEvent): Promise => { + const { userId, eventType, metadata } = event; + + try { + switch (eventType) { + case 'file_uploaded': + if (metadata.fileSize && metadata.fileType) { + await analyticsRepository.recordFileUpload( + userId, + metadata.fileSize, + metadata.fileType + ); + } + break; + + case 'file_deleted': + await analyticsRepository.recordFileDelete(userId); + break; + + case 'folder_created': + await analyticsRepository.recordFolderCreate(userId); + break; + + case 'file_downloaded': + await analyticsRepository.recordFileDownload(userId); + break; + + case 'file_shared': + await analyticsRepository.recordFileShare(userId); + break; + + case 'public_link_created': + await analyticsRepository.recordPublicLinkCreate(userId); + break; + + default: + console.warn(`Unknown analytics event type: ${eventType}`); + } + } catch (error: any) { + console.error(`Error processing analytics event ${eventType}:`, error.message); + throw error; + } +}; + +/** + * Get analytics summary for user + */ +const getAnalyticsSummary = async (userId: string) => { + return await analyticsRepository.getAnalyticsSummary(userId); +}; + +/** + * Get analytics by date range + */ +const getAnalyticsByDateRange = async ( + userId: string, + startDate: Date, + endDate: Date +) => { + return await analyticsRepository.getAnalyticsByDateRange(userId, startDate, endDate); +}; + +/** + * Get current storage overview + */ +const getCurrentStorageOverview = async (userId: string) => { + const storage = await analyticsRepository.calculateUserStorage(userId); + const todayAnalytics = await analyticsRepository.getTodaysAnalytics(userId); + + return { + storage, + todayActivity: { + uploads: todayAnalytics.uploads_today, + downloads: todayAnalytics.downloads_today, + shares: todayAnalytics.shares_created_today, + publicLinks: todayAnalytics.public_links_created_today + } + }; +}; + +export default { + processAnalyticsEvent, + getAnalyticsSummary, + getAnalyticsByDateRange, + getCurrentStorageOverview +}; + diff --git a/src/services/lock.service.ts b/src/services/lock.service.ts new file mode 100644 index 0000000..3bbc78d --- /dev/null +++ b/src/services/lock.service.ts @@ -0,0 +1,86 @@ +import RedisConnectionManager from '@/config/redis.config'; +import LoggerService from '@/core/logger'; + +class LockService { + private readonly LOCK_PREFIX = 'cron:lock:'; + private readonly DEFAULT_TTL = 300; // 5 minutes in seconds + + /** + * Acquire a distributed lock + * @param lockKey - Unique identifier for the lock + * @param ttl - Time to live in seconds (default: 5 minutes) + * @returns true if lock was acquired, false otherwise + */ + async acquireLock(lockKey: string, ttl: number = this.DEFAULT_TTL): Promise { + try { + const client = RedisConnectionManager.getClient(); + const fullKey = `${this.LOCK_PREFIX}${lockKey}`; + + // SET with NX (only if not exists) and EX (expiration) + const result = await client.set(fullKey, Date.now().toString(), 'EX', ttl, 'NX'); + + return result === 'OK'; + } catch (error: any) { + LoggerService.loggerInstance.logAuditEvent("Lock Acquisition Error", { + userId: "system", + action: "acquireLock", + details: `Failed to acquire lock for ${lockKey}: ${error.message}` + }); + return false; + } + } + + /** + * Release a distributed lock + * @param lockKey - Unique identifier for the lock + */ + async releaseLock(lockKey: string): Promise { + try { + const client = RedisConnectionManager.getClient(); + const fullKey = `${this.LOCK_PREFIX}${lockKey}`; + + await client.del(fullKey); + } catch (error: any) { + LoggerService.loggerInstance.logAuditEvent("Lock Release Error", { + userId: "system", + action: "releaseLock", + details: `Failed to release lock for ${lockKey}: ${error.message}` + }); + } + } + + /** + * Execute a function with a distributed lock + * @param lockKey - Unique identifier for the lock + * @param fn - Function to execute + * @param ttl - Time to live in seconds (default: 5 minutes) + */ + async executeWithLock(lockKey: string, fn: () => Promise, ttl: number = this.DEFAULT_TTL): Promise { + const acquired = await this.acquireLock(lockKey, ttl); + if (!acquired) { + LoggerService.loggerInstance.logAuditEvent("Lock Already Held", { + userId: "system", + action: "executeWithLock", + details: `Lock for ${lockKey} is already held by another process` + }); + return null; + } + + try { + const result = await fn(); + return result; + } catch (error: any) { + LoggerService.loggerInstance.logAuditEvent("Lock Execution Error", { + userId: "system", + action: "executeWithLock", + details: `Error executing locked function for ${lockKey}: ${error.message}` + }); + throw error; + } finally { + await this.releaseLock(lockKey); + } + } +} + +export default new LockService(); + From a9c736c5e17f2d8113a172eb9c171dd4e75832c6 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 21 Feb 2026 23:51:23 +0530 Subject: [PATCH 04/12] get api changes --- .gitignore | 3 +- src/config/s3-secondary.config.ts | 115 ++++++++++ src/config/s3.config.ts | 56 ++++- src/controllers/upload.controller.ts | 40 +++- src/middleware/auth.middleware.ts | 2 + src/routes/upload.routes.ts | 5 + src/scripts/migrate-storage.ts | 312 +++++++++++++++++++++++++++ src/validation/upload.validation.ts | 30 ++- 8 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 src/config/s3-secondary.config.ts create mode 100644 src/scripts/migrate-storage.ts diff --git a/.gitignore b/.gitignore index 83206bb..11d848d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,8 @@ pids *.pid *.seed *.pid.lock - +*.md +*.txt # Directory for instrumented libs generated by jscoverage/JSCover lib-cov diff --git a/src/config/s3-secondary.config.ts b/src/config/s3-secondary.config.ts new file mode 100644 index 0000000..d4d431a --- /dev/null +++ b/src/config/s3-secondary.config.ts @@ -0,0 +1,115 @@ +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + DeleteObjectsCommand, + ListObjectsV2Command, + type PutObjectCommandInput, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; +import { FolderNameEnum } from "@/config/s3.config"; + +// export enum SecondaryFolderNameEnum { +// FILES = "files", +// VIDEOS = "videos", +// IMAGES = "images", +// DOCUMENTS = "documents", +// RECORDINGS = "recordings" +// } + +export class SecondaryS3Service { + private s3Client: S3Client; + private bucketName: string; + + constructor() { + this.bucketName = process.env.SECONDARY_S3_BUCKET_NAME!; + + this.s3Client = new S3Client({ + endpoint: process.env.SECONDARY_S3_ENDPOINT!, + region: process.env.SECONDARY_S3_REGION || 'auto', + credentials: { + accessKeyId: process.env.SECONDARY_S3_TOKEN_ID!, + secretAccessKey: process.env.SECONDARY_S3_SECRET_KEY!, + }, + forcePathStyle: true, + requestHandler: { + requestTimeout: 300000, // 5 minutes timeout + } as any, + }); + } + + public async listFiles(folder?: FolderNameEnum, maxKeys: number = 1000, continuationToken?: string) { + const command = new ListObjectsV2Command({ + Bucket: this.bucketName, + Prefix: folder ? `${folder}/` : undefined, + MaxKeys: maxKeys, + ContinuationToken: continuationToken, + }); + + const response = await this.s3Client.send(command); + return { + files: response.Contents?.map(obj => ({ + key: obj.Key!, + size: obj.Size!, + lastModified: obj.LastModified!, + etag: obj.ETag!, + })) || [], + isTruncated: response.IsTruncated || false, + nextContinuationToken: response.NextContinuationToken, + }; + } + + public async getFile(key: string): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + + const response = await this.s3Client.send(command); + + if (!response.Body) { + throw new Error(`No body returned for key: ${key}`); + } + + // Convert stream to buffer + const chunks: Uint8Array[] = []; + for await (const chunk of response.Body as any) { + chunks.push(chunk); + } + return Buffer.concat(chunks); + } + + public async getFileStream(key: string) { + const command = new GetObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + + const response = await this.s3Client.send(command); + + if (!response.Body) { + throw new Error(`No body returned for key: ${key}`); + } + + return response.Body; + } + + public async getMetadata(key: string) { + const command = new HeadObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + const response = await this.s3Client.send(command); + return { + contentType: response.ContentType, + contentLength: response.ContentLength, + lastModified: response.LastModified, + metadata: response.Metadata, + etag: response.ETag, + }; + } +} + +export default new SecondaryS3Service(); + diff --git a/src/config/s3.config.ts b/src/config/s3.config.ts index 6b303b0..57e2e9b 100644 --- a/src/config/s3.config.ts +++ b/src/config/s3.config.ts @@ -53,7 +53,7 @@ export class S3Service { } public buildCDNUrl(key: string): string { - return `https://${config.CLOUDFLARE.CDN_DOMAIN}/${key}`; + return `https://fileflow.fsn1.your-objectstorage.com/${key}`; } /** @@ -78,7 +78,7 @@ export class S3Service { public async uploadFile(file: IFile): Promise { // Sanitize original name for HTTP header compatibility const sanitizedOriginalName = this.sanitizeMetadataValue(file.originalName); - + const params: PutObjectCommandInput = { Bucket: config.S3.BUCKET_NAME, Key: `${FolderNameEnum.FILES}/${file.filename}`, @@ -106,7 +106,7 @@ export class S3Service { const path = `${folder}/${key}`; // Sanitize original name for HTTP header compatibility const sanitizedOriginalName = this.sanitizeMetadataValue(fileName); - + const params = { Bucket: config.S3.BUCKET_NAME, Key: path, @@ -124,6 +124,35 @@ export class S3Service { return this.buildCDNUrl(path); } + public async uploadStream( + key: string, + stream: any, + fileName: string, + mimeType: string, + size: number, + folder: FolderNameEnum = FolderNameEnum.FILES + ): Promise { + const path = `${folder}/${key}`; + const sanitizedOriginalName = this.sanitizeMetadataValue(fileName); + + const params = { + Bucket: config.S3.BUCKET_NAME, + Key: path, + Body: stream, + ContentType: mimeType, + ContentLength: size, // Required for streaming + Metadata: { + originalName: sanitizedOriginalName, + uploadedAt: new Date().toISOString(), + size: size.toString(), + }, + CacheControl: 'public, max-age=31536000', + }; + + await this.s3Client.send(new PutObjectCommand(params)); + return this.buildCDNUrl(path); + } + public async getFileUrl(key: string, expiresIn: number = 3600): Promise { const command = new GetObjectCommand({ Bucket: config.S3.BUCKET_NAME, @@ -182,13 +211,32 @@ export class S3Service { size: obj.Size!, lastModified: obj.LastModified!, etag: obj.ETag!, - cdnUrl: this.buildCDNUrl(obj.Key!) + storageClass: obj.StorageClass, + owner: obj.Owner ? { + displayName: obj.Owner.DisplayName, + id: obj.Owner.ID + } : undefined, + cdnUrl: this.buildCDNUrl(obj.Key!), + // Include all other S3 properties + ...(obj as any) })) || [], isTruncated: response.IsTruncated || false, nextContinuationToken: response.NextContinuationToken, + keyCount: response.KeyCount, + maxKeys: response.MaxKeys, + prefix: response.Prefix, + delimiter: response.Delimiter, + encodingType: response.EncodingType, + commonPrefixes: response.CommonPrefixes, }; } + public async getAllFiles(folder?: string, maxKeys: number = 100, continuationToken?: string) { + // Convert string folder name to FolderNameEnum if provided + const folderEnum = folder ? (folder as FolderNameEnum) : undefined; + return await this.listFiles(folderEnum, maxKeys, continuationToken); + } + public async initiateMultipartUpload(fileName: string, mimeType: string): Promise<{ uploadId: string | undefined; key: string }> { const safeName = slugify(fileName, { replacement: "_", // replace invalid chars with underscore diff --git a/src/controllers/upload.controller.ts b/src/controllers/upload.controller.ts index 63cf67b..6051dca 100644 --- a/src/controllers/upload.controller.ts +++ b/src/controllers/upload.controller.ts @@ -12,7 +12,7 @@ const uploadFile = async (c: Context) => { if (!user?.id) { return res.FailureResponse(c, 400, { message: "User not found" }) } - try { + try { const formData = await c.req.formData() const files = formData.getAll("files") as File[] @@ -33,7 +33,7 @@ const uploadFile = async (c: Context) => { data: { results }, related_user_id: user.id, }) - + // Track upload analytics for each file files.forEach((file, index) => { const result = results[index]; @@ -177,7 +177,7 @@ const completeUpload = async (c: Context) => { file_type: metadata.contentType || '', storage_path: key } - + if (user?.id) { const fileName = key.split('/').pop() || 'Unknown file' addToNotificationQueue({ @@ -195,7 +195,7 @@ const completeUpload = async (c: Context) => { return res.SuccessResponse(c, 200, { message: "Upload completed successfully", data: result - }) + }) } catch (error: any) { if (user?.id) { const fileName = key.split('/').pop() || 'Unknown file' @@ -287,7 +287,39 @@ const getPartsByUploadKey = async (c: Context) => { } } +const getAllFiles = async (c: Context) => { + try { + const validatedQuery = c.get('validatedQuery') as { + folder?: string; + maxKeys?: number; + continuationToken?: string + }; + + const { folder, maxKeys = 100, continuationToken } = validatedQuery || {}; + + const result = await s3Service.getAllFiles(folder, maxKeys, continuationToken); + return res.SuccessResponse(c, 200, { + message: "All files retrieved successfully", + data: { + files: result.files, + pagination: { + hasMore: result.isTruncated, + nextContinuationToken: result.nextContinuationToken || null, + maxKeys: maxKeys, + currentCount: result.files.length + } + }, + }) + } catch (error: any) { + return res.FailureResponse(c, 500, { + message: "Failed to get all files", + error: error.message, + }) + } +} + export default { + getAllFiles, getPartsByUploadKey, initiateUpload, uploadFile, diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 66b75fd..e7352b8 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -7,6 +7,7 @@ import redisConn from "@/config/redis.config"; import redisConstants from "@/global/redis-constants"; import { getValidPinSession } from "@/core/session"; import crypto from "crypto"; +import { UserRole } from "@/models/User.model"; @@ -150,6 +151,7 @@ const pinSessionMiddleware: MiddlewareHandler = async (c: Context, next: Next) = } }; + export default { authMiddleware, pinSessionMiddleware, diff --git a/src/routes/upload.routes.ts b/src/routes/upload.routes.ts index 248fe19..e75144c 100644 --- a/src/routes/upload.routes.ts +++ b/src/routes/upload.routes.ts @@ -64,6 +64,11 @@ export class UploadRouter { validateParams(uploadValidation.fileNameValidation), uploadController.deleteFile ) + this.router.get('/file/get-all-files', + Middleware.authMiddleware, + validateQuery(uploadValidation.getAllFilesValidation), + uploadController.getAllFiles + ) } public getRouter() { diff --git a/src/scripts/migrate-storage.ts b/src/scripts/migrate-storage.ts new file mode 100644 index 0000000..592f344 --- /dev/null +++ b/src/scripts/migrate-storage.ts @@ -0,0 +1,312 @@ +import 'dotenv/config'; +import secondaryS3Service from '@/config/s3-secondary.config'; +import primaryS3Service, { FolderNameEnum } from '@/config/s3.config'; + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +interface MigrationResult { + successful: string[]; + failed: Array<{ key: string; error: string }>; + skipped: string[]; + totalFiles: number; + totalSize: number; + startTime: Date; + endTime?: Date; +} + +class StorageMigration { + private result: MigrationResult = { + successful: [], + failed: [], + skipped: [], + totalFiles: 0, + totalSize: 0, + startTime: new Date(), + }; + private skipExisting: boolean; + + constructor(skipExisting: boolean = false) { + this.skipExisting = skipExisting; + } + + /** + * Migrate files from secondary storage to primary storage + * @param sourceFolder - Folder to migrate from secondary storage + * @param targetFolder - Target folder in primary storage (defaults to same as source) + */ + async migrateFolder(sourceFolder: FolderNameEnum, targetFolder: FolderNameEnum = FolderNameEnum.IMAGES) { + console.log(`\n🚀 Starting migration from secondary storage (${sourceFolder}) to primary storage (${targetFolder})`); + console.log(`⏰ Started at: ${this.result.startTime.toISOString()}`); + console.log(`💾 Available Memory: ${this.formatBytes(os.freemem())} / ${this.formatBytes(os.totalmem())}`); + console.log(`📋 Skip existing files: ${this.skipExisting ? 'Yes' : 'No'}\n`); + + let continuationToken: string | undefined; + let batchNumber = 1; + + do { + console.log(`📦 Processing batch ${batchNumber}...`); + // Process 100 files at a time + const listResult = await secondaryS3Service.listFiles(sourceFolder, 100, continuationToken); + + this.result.totalFiles += listResult.files.length; + + // Process files one at a time for large video files to avoid memory and timeout issues + const CONCURRENT_UPLOADS = 1; + for (let i = 0; i < listResult.files.length; i += CONCURRENT_UPLOADS) { + const batch = listResult.files.slice(i, i + CONCURRENT_UPLOADS); + await Promise.all( + batch.map(file => this.migrateFile(file.key, targetFolder, file.size)) + ); + } + + continuationToken = listResult.nextContinuationToken; + batchNumber++; + + console.log(`✅ Batch ${batchNumber - 1} completed. Progress: ${this.result.successful.length}/${this.result.totalFiles} successful\n`); + + } while (continuationToken); + + this.result.endTime = new Date(); + this.printSummary(); + this.saveResultsToFile(); + } + + /** + * Check if file already exists in primary storage with retry logic + */ + private async fileExists(fileName: string, targetFolder: FolderNameEnum): Promise { + const key = `${targetFolder}/${fileName}`; + const maxRetries = 3; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await primaryS3Service.getMetadata(key); + return true; + } catch (error: any) { + // If it's a 404/not found error, file doesn't exist + if (error?.name === 'NotFound' || error?.$metadata?.httpStatusCode === 404) { + return false; + } + + // For other errors (rate limit, network), retry with backoff + if (attempt < maxRetries) { + const delay = Math.pow(2, attempt) * 1000; // Exponential backoff: 2s, 4s, 8s + console.log(` ⚠️ Retry ${attempt}/${maxRetries} checking existence after ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } else { + // After all retries failed, assume file doesn't exist (safer to re-upload than skip) + console.log(` ⚠️ Could not verify existence, will attempt migration`); + return false; + } + } + } + + return false; + } + + /** + * Migrate a single file using streaming to avoid memory issues + */ + private async migrateFile(key: string, targetFolder: FolderNameEnum, fileSize: number): Promise { + const fileName = key.split('/').pop() || key; + + try { + // Check if we should skip existing files + if (this.skipExisting) { + const exists = await this.fileExists(fileName, targetFolder); + if (exists) { + this.result.skipped.push(key); + console.log(` ⏭️ Skipped (already exists): ${key}`); + return; + } + } + + console.log(` 📄 Migrating: ${key} (${this.formatBytes(fileSize)})`); + + // Check available memory before processing + const freeMem = os.freemem(); + const MIN_MEMORY = 500 * 1024 * 1024; // 500 MB minimum + + if (freeMem < MIN_MEMORY) { + throw new Error(`Insufficient memory: ${this.formatBytes(freeMem)} available`); + } + + // Add delay between files to avoid rate limiting (100ms) + await new Promise(resolve => setTimeout(resolve, 100)); + + // Get file metadata from secondary storage with retry + let metadata: Awaited> | undefined; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + metadata = await secondaryS3Service.getMetadata(key); + break; + } catch (error) { + if (attempt === 3) throw error; + const delay = attempt * 2000; + console.log(` ⚠️ Retry ${attempt}/3 getting metadata after ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + if (!metadata) { + throw new Error('Failed to get metadata after 3 attempts'); + } + + // For videos, use buffer method with timeout + console.log(` ⬇️ Downloading from R2...`); + + // Increase timeout for very large files (10 minutes for files > 400MB) + const downloadTimeout = fileSize > 400 * 1024 * 1024 ? 600000 : 300000; + const fileBuffer = await Promise.race([ + secondaryS3Service.getFile(key), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Download timeout after ${downloadTimeout/1000/60} minutes`)), downloadTimeout) + ) + ]); + + console.log(` ⬆️ Uploading to Hetzner...`); + + // Retry upload with exponential backoff + let uploadSuccess = false; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await Promise.race([ + primaryS3Service.uploadBuffer( + fileName, + fileBuffer, + fileName, + metadata.contentType || 'application/octet-stream', + targetFolder + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Upload timeout after ${downloadTimeout/1000/60} minutes`)), downloadTimeout) + ) + ]); + uploadSuccess = true; + break; + } catch (error) { + if (attempt === 3) throw error; + const delay = Math.pow(2, attempt) * 2000; + console.log(` ⚠️ Upload retry ${attempt}/3 after ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + if (!uploadSuccess) { + throw new Error('Upload failed after 3 attempts'); + } + + this.result.totalSize += fileSize; + this.result.successful.push(key); + console.log(` ✅ Success: ${key}`); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.result.failed.push({ key, error: errorMessage }); + console.error(` ❌ Failed: ${key} - ${errorMessage}`); + + // Force garbage collection hint for large file failures + if (global.gc) { + global.gc(); + } + } + } + + /** + * Print migration summary + */ + private printSummary() { + const duration = this.result.endTime && this.result.startTime + ? (this.result.endTime.getTime() - this.result.startTime.getTime()) / 1000 + : 0; + + console.log('\n' + '='.repeat(80)); + console.log('📊 MIGRATION SUMMARY'); + console.log('='.repeat(80)); + console.log(`Total Files Processed: ${this.result.totalFiles}`); + console.log(`✅ Successful: ${this.result.successful.length}`); + console.log(`⏭️ Skipped (already exist): ${this.result.skipped.length}`); + console.log(`❌ Failed: ${this.result.failed.length}`); + console.log(`💾 Total Data Migrated: ${this.formatBytes(this.result.totalSize)}`); + console.log(`⏱️ Duration: ${duration.toFixed(2)} seconds`); + console.log(`📅 Started: ${this.result.startTime.toISOString()}`); + console.log(`📅 Ended: ${this.result.endTime?.toISOString()}`); + console.log('='.repeat(80)); + + if (this.result.failed.length > 0) { + console.log('\n❌ FAILED FILES:'); + console.log('-'.repeat(80)); + this.result.failed.forEach(({ key, error }) => { + console.log(` • ${key}`); + console.log(` Error: ${error}`); + }); + } + } + + /** + * Save migration results to a JSON file + */ + private saveResultsToFile() { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `migration-report-${timestamp}.json`; + const filePath = path.join(process.cwd(), 'logs', fileName); + + // Create logs directory if it doesn't exist + const logsDir = path.join(process.cwd(), 'logs'); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + fs.writeFileSync(filePath, JSON.stringify(this.result, null, 2)); + console.log(`\n📄 Detailed report saved to: ${filePath}`); + } + + /** + * Format bytes to human-readable format + */ + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + } +} + +// Main execution +async function main() { + try { + console.log('🔧 Environment Check:'); + console.log(' Secondary S3 Endpoint:', process.env.SECONDARY_S3_ENDPOINT); + console.log(' Secondary S3 Bucket:', process.env.SECONDARY_S3_BUCKET_NAME); + console.log(' Primary S3 Endpoint:', process.env.S3_ENDPOINT); + console.log(' Primary S3 Bucket:', process.env.S3_BUCKET_NAME); + + if (!process.env.SECONDARY_S3_TOKEN_ID || + !process.env.SECONDARY_S3_SECRET_KEY || + !process.env.SECONDARY_S3_ENDPOINT || + !process.env.SECONDARY_S3_BUCKET_NAME) { + throw new Error('Missing required secondary S3 environment variables'); + } + + // Set skipExisting to true to resume from where it crashed + const SKIP_EXISTING_FILES = true; + const migration = new StorageMigration(SKIP_EXISTING_FILES); + + // Migrate videos folder from secondary (R2) to primary (Hetzner) + await migration.migrateFolder(FolderNameEnum.VIDEOS, FolderNameEnum.VIDEOS); + + console.log('\n✨ Migration completed successfully!'); + process.exit(0); + + } catch (error) { + console.error('\n💥 Migration failed:', error); + process.exit(1); + } +} + +// Run the migration +main(); + diff --git a/src/validation/upload.validation.ts b/src/validation/upload.validation.ts index 8e46fdd..cd52c0a 100644 --- a/src/validation/upload.validation.ts +++ b/src/validation/upload.validation.ts @@ -143,6 +143,33 @@ const uploadIdValidation = Joi.object({ }) }); +// Validation for getAllFiles endpoint (query parameters) +const getAllFilesValidation = Joi.object({ + folder: Joi.string() + .valid('files', 'videos', 'images', 'documents') + .optional() + .messages({ + 'any.only': 'Folder must be one of: files, videos, images, documents' + }), + maxKeys: Joi.number() + .integer() + .min(1) + .max(1000) + .default(100) + .optional() + .messages({ + 'number.base': 'maxKeys must be a number', + 'number.integer': 'maxKeys must be a whole number', + 'number.min': 'maxKeys must be at least 1', + 'number.max': 'maxKeys cannot exceed 1000' + }), + continuationToken: Joi.string() + .optional() + .messages({ + 'string.base': 'continuationToken must be a string' + }) +}); + export default { initiateUploadValidation, uploadChunkValidation, @@ -150,5 +177,6 @@ export default { abortUploadValidation, getPartsValidation, fileNameValidation, - uploadIdValidation + uploadIdValidation, + getAllFilesValidation }; From 0763c7bdb085ff1ade89023b41a60138ad11dd49 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 22 Feb 2026 08:54:52 +0530 Subject: [PATCH 05/12] bakend get all files api changes --- ANALYTICS_SYSTEM.md | 205 --------------------------- src/controllers/upload.controller.ts | 2 +- src/middleware/auth.middleware.ts | 22 ++- src/routes/upload.routes.ts | 46 +++--- src/services/user.service.ts | 1 - 5 files changed, 46 insertions(+), 230 deletions(-) delete mode 100644 ANALYTICS_SYSTEM.md diff --git a/ANALYTICS_SYSTEM.md b/ANALYTICS_SYSTEM.md deleted file mode 100644 index 50d40aa..0000000 --- a/ANALYTICS_SYSTEM.md +++ /dev/null @@ -1,205 +0,0 @@ -# Analytics System Implementation - -## Overview -A comprehensive analytics system has been implemented for FileFlow to track user activity, storage usage, and file operations. - -## Architecture - -### 1. Queue System (`analytics-queue.ts`) -- **Queue Name**: `fileflow-analytics-queue` -- **Event Types**: - - `FILE_UPLOADED` - Track file uploads - - `FILE_DELETED` - Track file deletions - - `FILE_DOWNLOADED` - Track file downloads - - `FILE_SHARED` - Track file shares - - `PUBLIC_LINK_CREATED` - Track public link creation - - `FOLDER_CREATED` - Track folder creation - -- **Configuration**: - - 5 retry attempts with exponential backoff - - Concurrent processing with 5 workers - - Auto-cleanup of completed jobs - - Keep last 10 failed jobs for debugging - -### 2. Repository (`analytics.repository.ts`) -**Main Functions**: -- `getTodaysAnalytics()` - Get or create today's analytics record -- `calculateUserStorage()` - Calculate total storage and categorize by file type -- `recordFileUpload()` - Update analytics when file is uploaded -- `recordFileDelete()` - Update analytics when file is deleted -- `recordFolderCreate()` - Update analytics when folder is created -- `recordFileDownload()` - Track downloads -- `recordFileShare()` - Track shares -- `recordPublicLinkCreate()` - Track public links -- `getAnalyticsByDateRange()` - Get analytics for date range -- `getLatestAnalytics()` - Get most recent analytics -- `getAnalyticsSummary()` - Get 30-day summary - -### 3. Service (`analytics.service.ts`) -**Main Functions**: -- `processAnalyticsEvent()` - Process analytics events from queue -- `getAnalyticsSummary()` - Get summary for user -- `getAnalyticsByDateRange()` - Get analytics by date range -- `getCurrentStorageOverview()` - Get current storage breakdown - -### 4. Controller (`analytics.controller.ts`) -**Endpoints Implemented**: -- `GET /api/v1/analytics/summary` - Get 30-day analytics summary -- `GET /api/v1/analytics/date-range?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` - Get analytics for specific date range -- `GET /api/v1/analytics/storage` - Get current storage overview - -### 5. Routes (`analytics.routes.ts`) -All routes are protected with authentication middleware. - -## Data Model - -### StorageAnalytics Table -```typescript -{ - id: UUID - user_id: UUID - date: DATE (unique per user per day) - - // Storage metrics - total_files: INTEGER - total_folders: INTEGER - total_size: BIGINT - - // File type breakdown - images_count: INTEGER - images_size: BIGINT - videos_count: INTEGER - videos_size: BIGINT - audio_count: INTEGER - audio_size: BIGINT - documents_count: INTEGER - documents_size: BIGINT - other_count: INTEGER - other_size: BIGINT - - // Daily activity - uploads_today: INTEGER - downloads_today: INTEGER - shares_created_today: INTEGER - public_links_created_today: INTEGER - - created_at: TIMESTAMP -} -``` - -## Integration Points - -### File Controller -Analytics events are tracked when: -- Creating folders → `FOLDER_CREATED` -- Creating files → `FILE_UPLOADED` -- Deleting files → `FILE_DELETED` -- Sharing files → `FILE_SHARED` - -### Upload Controller -Analytics events are tracked when: -- Uploading files (direct) → `FILE_UPLOADED` -- Uploading files (multipart) → Handled in File controller after completion - -## API Usage Examples - -### 1. Get Storage Overview -```http -GET /api/v1/analytics/storage -Authorization: Bearer - -Response: -{ - "success": true, - "message": "Storage overview retrieved successfully", - "data": { - "storage": { - "totalFiles": 456, - "totalFolders": 23, - "totalSize": 48622632960, - "imageCount": 234, - "imageSize": 20131512320, - "videoCount": 67, - "videoSize": 9556590592, - "audioCount": 123, - "audioSize": 3442319360, - "documentCount": 32, - "documentSize": 13346765824 - }, - "todayActivity": { - "uploads": 12, - "downloads": 34, - "shares": 5, - "publicLinks": 2 - }, - "storageQuota": 107374182400, - "storageUsed": 48622632960, - "storageRemaining": 58751549440, - "storageUsedPercentage": "45.28" - } -} -``` - -### 2. Get 30-Day Summary -```http -GET /api/v1/analytics/summary -Authorization: Bearer - -Response: -{ - "success": true, - "message": "Analytics summary retrieved successfully", - "data": { - "current": { /* Today's analytics */ }, - "last30Days": [ /* Array of daily analytics */ ], - "totalUploads": 145, - "totalDownloads": 892, - "totalShares": 34 - } -} -``` - -### 3. Get Date Range Analytics -```http -GET /api/v1/analytics/date-range?startDate=2026-01-01&endDate=2026-01-31 -Authorization: Bearer - -Response: -{ - "success": true, - "message": "Analytics retrieved successfully", - "data": [ /* Array of analytics for each day */ ] -} -``` - -## File Type Categorization - -Files are automatically categorized based on MIME type: -- **Images**: `image/*` -- **Videos**: `video/*` -- **Audio**: `audio/*` -- **Documents**: PDFs, Word docs, spreadsheets, presentations, text files -- **Other**: Everything else - -## Performance Considerations - -1. **Async Processing**: All analytics updates happen asynchronously via queue -2. **Daily Aggregation**: One record per user per day reduces database size -3. **Efficient Queries**: Indexed on `user_id` and `date` -4. **Caching Ready**: Current storage can be cached and invalidated on changes - -## Monitoring - -- Queue events are logged: completed, failed, errors -- Failed jobs are retried up to 5 times -- Last 10 failed jobs are kept for debugging - -## Next Steps - -Consider adding: -1. **Cron Job**: Daily cleanup of old analytics (keep last 90 days) -2. **Real-time Dashboard**: WebSocket updates for live analytics -3. **Export Feature**: CSV/Excel export of analytics data -4. **Alerts**: Notify users when approaching storage quota -5. **Charts API**: Pre-aggregated data for frontend charts - diff --git a/src/controllers/upload.controller.ts b/src/controllers/upload.controller.ts index 6051dca..89ce652 100644 --- a/src/controllers/upload.controller.ts +++ b/src/controllers/upload.controller.ts @@ -294,7 +294,6 @@ const getAllFiles = async (c: Context) => { maxKeys?: number; continuationToken?: string }; - const { folder, maxKeys = 100, continuationToken } = validatedQuery || {}; const result = await s3Service.getAllFiles(folder, maxKeys, continuationToken); @@ -311,6 +310,7 @@ const getAllFiles = async (c: Context) => { }, }) } catch (error: any) { + console.log("error", error); return res.FailureResponse(c, 500, { message: "Failed to get all files", error: error.message, diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index e7352b8..ff91218 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -7,7 +7,7 @@ import redisConn from "@/config/redis.config"; import redisConstants from "@/global/redis-constants"; import { getValidPinSession } from "@/core/session"; import crypto from "crypto"; -import { UserRole } from "@/models/User.model"; +import { UserRole, type IUserAttributes } from "@/models/User.model"; @@ -151,8 +151,28 @@ const pinSessionMiddleware: MiddlewareHandler = async (c: Context, next: Next) = } }; +function checkPermissions(roles: UserRole[]) { + return async (c: Context, next: Next) => { + const user = c.get('user') as IUserAttributes; + + if (!user) { + return res.FailureResponse(c, 401, { message: "Unauthorized." }); + } + + if (!roles.includes(user.role)) { + return res.FailureResponse(c, 403, { + message: "Forbidden, you don't have permission to perform this action." + }); + } + + await next(); // ✅ IMPORTANT + }; +} + + export default { authMiddleware, + checkPermissions, pinSessionMiddleware, }; \ No newline at end of file diff --git a/src/routes/upload.routes.ts b/src/routes/upload.routes.ts index e75144c..7031efc 100644 --- a/src/routes/upload.routes.ts +++ b/src/routes/upload.routes.ts @@ -4,6 +4,7 @@ import { uploadImageMiddleware } from '@/middleware/multer.middleware' // rewrit import uploadController from "@/controllers/upload.controller"; import { validateBody, validateQuery, validateParams } from "@/utils/validation"; import uploadValidation from "@/validation/upload.validation"; +import { UserRole } from "@/models"; export class UploadRouter { /** Each router owns its own Hono instance */ @@ -17,55 +18,56 @@ export class UploadRouter { private MultipartUpload() { // Multipart upload - this.router.post('/initiate', - Middleware.authMiddleware, - validateBody(uploadValidation.initiateUploadValidation), + this.router.post('/initiate', + Middleware.authMiddleware, + validateBody(uploadValidation.initiateUploadValidation), uploadController.initiateUpload ) - this.router.post('/chunk/file/:uploadId', - Middleware.authMiddleware, + this.router.post('/chunk/file/:uploadId', + Middleware.authMiddleware, validateParams(uploadValidation.uploadIdValidation), uploadController.uploadChunk ) - this.router.post('/complete/file/:uploadId', - Middleware.authMiddleware, + this.router.post('/complete/file/:uploadId', + Middleware.authMiddleware, validateParams(uploadValidation.uploadIdValidation), - validateBody(uploadValidation.completeUploadValidation), + validateBody(uploadValidation.completeUploadValidation), uploadController.completeUpload ) - this.router.post('/abort/file/:uploadId', - Middleware.authMiddleware, + this.router.post('/abort/file/:uploadId', + Middleware.authMiddleware, validateParams(uploadValidation.uploadIdValidation), - validateBody(uploadValidation.abortUploadValidation), + validateBody(uploadValidation.abortUploadValidation), uploadController.abortUpload ) - this.router.get('/parts/file/:uploadId', - Middleware.authMiddleware, + this.router.get('/parts/file/:uploadId', + Middleware.authMiddleware, validateParams(uploadValidation.uploadIdValidation), - validateQuery(uploadValidation.getPartsValidation), + validateQuery(uploadValidation.getPartsValidation), uploadController.getPartsByUploadKey ) } private UploadImagesOrFiles() { // Image upload (max 5 files) - this.router.post('/file', - Middleware.authMiddleware, - uploadImageMiddleware, + this.router.post('/file', + Middleware.authMiddleware, + uploadImageMiddleware, uploadController.uploadFile ) - this.router.get('/file/get-file', - Middleware.authMiddleware, + this.router.get('/file/get-file', + Middleware.authMiddleware, validateQuery(uploadValidation.fileNameValidation), uploadController.getFiles ) - this.router.delete('/file/:fileName', - Middleware.authMiddleware, + this.router.delete('/file/:fileName', + Middleware.authMiddleware, validateParams(uploadValidation.fileNameValidation), uploadController.deleteFile ) - this.router.get('/file/get-all-files', + this.router.get('/file/get-all-files', Middleware.authMiddleware, + Middleware.checkPermissions([UserRole.ADMIN]), validateQuery(uploadValidation.getAllFilesValidation), uploadController.getAllFiles ) diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 3822509..945919a 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,5 +1,4 @@ import constants from "@/global/constants"; -import jwt from "@/utils/jwt-token"; import { type IUserSessionAttributes } from "@/models/UserSession.model"; import userRepository from "@/repository/user.repository"; import { type IUserAttributes } from "@/models/User.model"; From 70651933c49d0d6c8b95acd625703c3c35cdf4d8 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 22 Feb 2026 12:18:56 +0530 Subject: [PATCH 06/12] commented the with ip code --- src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index bc0d0d9..b800d7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,10 +6,12 @@ import config from "@/config/config"; const app = new App(); const socketEngine = app.getSocketEngine(); const port = config.PORT || 3000; +const hostname = "0.0.0.0"; // Use IP from env or default to 0.0.0.0 (all interfaces) // Start Bun server Bun.serve({ port, + // hostname, idleTimeout: 30, websocket: socketEngine.handler().websocket, // <-- attach WebSocket handler async fetch(req, server) { @@ -36,4 +38,6 @@ Bun.serve({ : {}), }); -console.log(`🚀 Server running on http${config.HTTP2.SSL.ENABLED ? "s" : ""}://localhost:${port}`); +const protocol = config.HTTP2.SSL.ENABLED ? "https" : "http"; +const displayHost = hostname === "0.0.0.0" ? "localhost" : hostname; +console.log(`🚀 Server running on ${protocol}://${displayHost}:${port}`); From 8b7a9052533f0aad0a2eb8b3db4b9d63cad5e360 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 7 Mar 2026 14:33:41 +0530 Subject: [PATCH 07/12] edn url changes --- src/config/config.ts | 2 ++ src/config/s3.config.ts | 2 +- src/index.ts | 4 ++-- src/utils/cors-options.ts | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 33ace71..a9f59f0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -38,6 +38,7 @@ interface Config { ENDPOINT: string; BUCKET_NAME: string; REGION: string; + CDN_URL: string; }; CLOUDFLARE: { CDN_DOMAIN: string; @@ -78,6 +79,7 @@ const config: Config = { ENDPOINT: process.env.S3_ENDPOINT!, BUCKET_NAME: process.env.S3_BUCKET_NAME!, REGION: process.env.S3_REGION!, + CDN_URL: process.env.S3_CDN_URL!, }, CLOUDFLARE: { CDN_DOMAIN: process.env.CLOUDFLARE_CDN_DOMAIN!, diff --git a/src/config/s3.config.ts b/src/config/s3.config.ts index 57e2e9b..f5dd3cb 100644 --- a/src/config/s3.config.ts +++ b/src/config/s3.config.ts @@ -53,7 +53,7 @@ export class S3Service { } public buildCDNUrl(key: string): string { - return `https://fileflow.fsn1.your-objectstorage.com/${key}`; + return `${config.S3.CDN_URL}/${key}`; } /** diff --git a/src/index.ts b/src/index.ts index b800d7b..346e02b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,12 @@ import config from "@/config/config"; const app = new App(); const socketEngine = app.getSocketEngine(); const port = config.PORT || 3000; -const hostname = "0.0.0.0"; // Use IP from env or default to 0.0.0.0 (all interfaces) +const hostname = process.env.IP || "0.0.0.0"; // Use IP from env or default to 0.0.0.0 (all interfaces) // Start Bun server Bun.serve({ port, - // hostname, + hostname, idleTimeout: 30, websocket: socketEngine.handler().websocket, // <-- attach WebSocket handler async fetch(req, server) { diff --git a/src/utils/cors-options.ts b/src/utils/cors-options.ts index 594abbf..29a31ab 100644 --- a/src/utils/cors-options.ts +++ b/src/utils/cors-options.ts @@ -8,7 +8,8 @@ export const corsOptions = { "http://localhost:3001", "http://localhost:5000", "http://localhost:5173", - "https://fileflow.nerchuko.in" + "https://fileflow.nerchuko.in", + "http://192.168.0.8:5173", ], credentials: true, exposedHeaders: ["sessionid", "logintoken", "resettoken", "ratelimit-remaining"], From 9bdf81943131a9c7f397190adcdb929c5b17f37a Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 7 Mar 2026 14:50:28 +0530 Subject: [PATCH 08/12] code changes --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 346e02b..b800d7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,12 @@ import config from "@/config/config"; const app = new App(); const socketEngine = app.getSocketEngine(); const port = config.PORT || 3000; -const hostname = process.env.IP || "0.0.0.0"; // Use IP from env or default to 0.0.0.0 (all interfaces) +const hostname = "0.0.0.0"; // Use IP from env or default to 0.0.0.0 (all interfaces) // Start Bun server Bun.serve({ port, - hostname, + // hostname, idleTimeout: 30, websocket: socketEngine.handler().websocket, // <-- attach WebSocket handler async fetch(req, server) { From 7b41d670c350f240cf45c97dc340d82040c660e1 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 18 Apr 2026 11:35:48 +0530 Subject: [PATCH 09/12] docker compose changes --- Dockerfile | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 66e5e01..762064b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,21 @@ # ============================ -# Single-stage Bun build +# Single-stage Bun build (multi-arch: amd64 + arm64) # ============================ -FROM oven/bun:1.2.21-debian +ARG TARGETPLATFORM +FROM --platform=$TARGETPLATFORM oven/bun:1.2.21-debian WORKDIR /app +# Install curl for the HEALTHCHECK (not present in the base image) +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + # Copy package files and Bun config COPY package.json bun.lock bunfig.toml ./ -# Install all dependencies (dev + prod) -RUN bun install +# Install all dependencies (dev + prod) using the lockfile for reproducible arm64 builds +RUN bun install --frozen-lockfile # Copy the rest of the source code COPY . . @@ -25,4 +31,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:7000/health || exit 1 # Start Bun directly with TypeScript -CMD ["bun", "run" , "src/index.ts"] +CMD ["bun", "run", "src/index.ts"] From 09bb41c3a8bef2fde60c159ba83b06038d3d45a1 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 18 Apr 2026 12:14:42 +0530 Subject: [PATCH 10/12] ci-cd modified --- .github/workflows/ci-cd.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 0f4f8c2..c4a960f 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -15,10 +15,15 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/development' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub uses: docker/login-action@v3 @@ -27,10 +32,12 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true + platforms: linux/amd64,linux/arm64 + provenance: false tags: | ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE }}:latest ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} @@ -43,17 +50,15 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/development' steps: - name: Deploy to server - uses: appleboy/ssh-action@master + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USERNAME }} key: ${{ secrets.SERVER_SSH_KEY }} script: | + set -e cd /var/www/html/file-flow-backend - docker-compose down - docker-compose pull - docker-compose up -d + docker compose pull + docker compose up -d docker system prune -f - - # Run migrations via script ./migrate.sh From baf0acc0f601762f8628a2a5c68ada6da5f28ddf Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 16 May 2026 14:39:16 +0530 Subject: [PATCH 11/12] conflicts resolved --- bun.lock | 1 + src/controllers/favorite.controller.ts | 162 ++++++++++++++++++++++++ src/global/routes.ts | 4 +- src/repository/favorite.repository.ts | 165 +++++++++++++++++++++++++ src/routes/favorite.routes.ts | 30 +++++ src/validation/favorite.validation.ts | 111 +++++++++++++++++ 6 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 src/controllers/favorite.controller.ts create mode 100644 src/repository/favorite.repository.ts create mode 100644 src/routes/favorite.routes.ts create mode 100644 src/validation/favorite.validation.ts diff --git a/bun.lock b/bun.lock index 6c19c50..0820c34 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "fileflowbe", diff --git a/src/controllers/favorite.controller.ts b/src/controllers/favorite.controller.ts new file mode 100644 index 0000000..eed07f7 --- /dev/null +++ b/src/controllers/favorite.controller.ts @@ -0,0 +1,162 @@ +import { type Context } from "hono"; +import { UniqueConstraintError } from "sequelize"; +import res from "@/utils/response"; +import type { IUserAttributes } from "@/models/User.model"; +import type { InferSchemaType } from "@/utils/validation"; +import favoriteValidation from "@/validation/favorite.validation"; +import favoriteRepository from "@/repository/favorite.repository"; + +const createFavorite = async (c: Context) => { + try { + type CreateFavoriteBody = InferSchemaType; + const value = c.get('validated'); + const user = c.get('user') as IUserAttributes; + + const file = value.file_id + ? await favoriteRepository.findOwnedFileById(value.file_id, user.id) + : await favoriteRepository.findOwnedFileByStoragePath(value.storage_path, user.id); + + if (!file) { + return res.FailureResponse(c, 404, { message: "File not found or you do not have permission to favorite it" }); + } + + const favorite = await favoriteRepository.createFavorite({ + user_id: user.id, + file_id: file.id, + }); + + const favoriteWithFile = await favoriteRepository.getFavoriteById(favorite.id, user.id); + return res.SuccessResponse(c, 201, { message: "Favorite created successfully", data: { favorite: favoriteWithFile } }); + } catch (error: any) { + if (error instanceof UniqueConstraintError) { + return res.FailureResponse(c, 409, { message: "File is already in favorites" }); + } + + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const getFavorites = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const query = c.get('validatedQuery') as { + page: number; + limit: number; + search?: string; + sortBy: 'created_at' | 'name'; + sortOrder: 'ASC' | 'DESC' | 'asc' | 'desc'; + }; + + const result = await favoriteRepository.getFavorites(user.id, { + ...query, + sortOrder: query.sortOrder.toUpperCase() as 'ASC' | 'DESC', + }); + + return res.SuccessResponse(c, 200, { message: "Favorites retrieved successfully", data: result }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const getFavoriteById = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const params = c.get('validatedParams') as { favoriteId: string }; + + const favorite = await favoriteRepository.getFavoriteById(params.favoriteId, user.id); + if (!favorite) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite retrieved successfully", data: { favorite } }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const getFavoriteByFileId = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const params = c.get('validatedParams') as { fileId: string }; + + const favorite = await favoriteRepository.getFavoriteByFileId(params.fileId, user.id); + return res.SuccessResponse(c, 200, { + message: "Favorite status retrieved successfully", + data: { + is_favorite: Boolean(favorite), + favorite, + }, + }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const updateFavorite = async (c: Context) => { + try { + type UpdateFavoriteBody = InferSchemaType; + const value = c.get('validated'); + const params = c.get('validatedParams') as { favoriteId: string }; + const user = c.get('user') as IUserAttributes; + + const file = await favoriteRepository.findOwnedFileById(value.file_id, user.id); + if (!file) { + return res.FailureResponse(c, 404, { message: "File not found or you do not have permission to favorite it" }); + } + + const favorite = await favoriteRepository.updateFavorite(params.favoriteId, user.id, value.file_id); + if (!favorite) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite updated successfully", data: { favorite } }); + } catch (error: any) { + if (error instanceof UniqueConstraintError) { + return res.FailureResponse(c, 409, { message: "File is already in favorites" }); + } + + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const deleteFavorite = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const params = c.get('validatedParams') as { favoriteId: string }; + + const deletedCount = await favoriteRepository.deleteFavorite(params.favoriteId, user.id); + if (deletedCount === 0) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite deleted successfully", data: { deletedCount } }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +const deleteFavoriteByFileId = async (c: Context) => { + try { + const user = c.get('user') as IUserAttributes; + const params = c.get('validatedParams') as { fileId: string }; + + const deletedCount = await favoriteRepository.deleteFavoriteByFileId(params.fileId, user.id); + if (deletedCount === 0) { + return res.FailureResponse(c, 404, { message: "Favorite not found" }); + } + + return res.SuccessResponse(c, 200, { message: "Favorite deleted successfully", data: { deletedCount } }); + } catch (error: any) { + return res.FailureResponse(c, 500, { message: "Internal server error" }); + } +}; + +export default { + createFavorite, + getFavorites, + getFavoriteById, + getFavoriteByFileId, + updateFavorite, + deleteFavorite, + deleteFavoriteByFileId, +}; diff --git a/src/global/routes.ts b/src/global/routes.ts index 8dae701..132b4ec 100644 --- a/src/global/routes.ts +++ b/src/global/routes.ts @@ -5,6 +5,7 @@ import { UploadRouter } from "@/routes/upload.routes"; import { NotificationRouter } from "@/routes/notification.routes"; import { ApiTokenRouter } from "@/routes/api-token.routes"; import { AnalyticsRouter } from "@/routes/analytics.routes"; +import { FavoriteRouter } from "@/routes/favorite.routes"; export class MainRouter { private readonly router: Hono; @@ -21,10 +22,11 @@ export class MainRouter { this.router.route("/notification", new NotificationRouter().getRouter()); this.router.route("/api-token", new ApiTokenRouter().getRouter()); this.router.route("/analytics", new AnalyticsRouter().getRouter()); + this.router.route("/favorite", new FavoriteRouter().getRouter()); } /** Return the configured Hono instance */ public getRouter(): Hono { return this.router; } -} \ No newline at end of file +} diff --git a/src/repository/favorite.repository.ts b/src/repository/favorite.repository.ts new file mode 100644 index 0000000..140d2b0 --- /dev/null +++ b/src/repository/favorite.repository.ts @@ -0,0 +1,165 @@ +import db from "@/config/database"; +import type { FavoriteCreationAttributes } from "@/models/Favorite.model"; +import { Op, type Order } from "sequelize"; + +type FavoriteSortBy = 'created_at' | 'name'; +type SortOrder = 'ASC' | 'DESC'; + +type FavoriteListOptions = { + page: number; + limit: number; + search?: string; + sortBy: FavoriteSortBy; + sortOrder: SortOrder; +}; + +const favoriteInclude = (userId: string, search?: string) => ({ + model: db.File, + as: 'file', + required: true, + attributes: [ + 'id', + 'owner_id', + 'parent_id', + 'name', + 'is_folder', + 'access_level', + 'file_info', + 'description', + 'tags', + 'metadata', + 'last_accessed_at', + 'created_at', + 'updated_at', + ], + where: { + owner_id: userId, + deleted_at: null, + ...(search ? { name: { [Op.iLike]: `%${search}%` } } : {}), + }, +}); + +const findOwnedFileById = async (fileId: string, userId: string) => { + return db.File.findOne({ + where: { + id: fileId, + owner_id: userId, + deleted_at: null, + }, + attributes: ['id'], + }); +}; + +const findOwnedFileByStoragePath = async (storagePath: string, userId: string) => { + return db.File.findOne({ + where: { + owner_id: userId, + deleted_at: null, + file_info: { + storage_path: storagePath, + }, + }, + attributes: ['id'], + }); +}; + +const createFavorite = async (favorite: FavoriteCreationAttributes) => { + return db.Favorite.create(favorite); +}; + +const getFavorites = async (userId: string, options: FavoriteListOptions) => { + const safePage = Math.max(1, Math.trunc(options.page)); + const safeLimit = Math.min(100, Math.max(1, Math.trunc(options.limit))); + const offset = (safePage - 1) * safeLimit; + const order: Order = options.sortBy === 'name' + ? [[{ model: db.File, as: 'file' }, 'name', options.sortOrder]] + : [['created_at', options.sortOrder]]; + + const { rows, count } = await db.Favorite.findAndCountAll({ + where: { user_id: userId }, + attributes: ['id', 'user_id', 'file_id', 'created_at'], + include: [favoriteInclude(userId, options.search)], + order, + limit: safeLimit, + offset, + distinct: true, + }); + + return { + favorites: rows, + metadata: { + total: count, + page: safePage, + limit: safeLimit, + totalPages: Math.ceil(count / safeLimit), + }, + }; +}; + +const getFavoriteById = async (favoriteId: string, userId: string) => { + return db.Favorite.findOne({ + where: { + id: favoriteId, + user_id: userId, + }, + attributes: ['id', 'user_id', 'file_id', 'created_at'], + include: [favoriteInclude(userId)], + }); +}; + +const getFavoriteByFileId = async (fileId: string, userId: string) => { + return db.Favorite.findOne({ + where: { + file_id: fileId, + user_id: userId, + }, + attributes: ['id', 'user_id', 'file_id', 'created_at'], + include: [favoriteInclude(userId)], + }); +}; + +const updateFavorite = async (favoriteId: string, userId: string, fileId: string) => { + const favorite = await db.Favorite.findOne({ + where: { + id: favoriteId, + user_id: userId, + }, + }); + + if (!favorite) { + return null; + } + + await favorite.update({ file_id: fileId }); + return getFavoriteById(favoriteId, userId); +}; + +const deleteFavorite = async (favoriteId: string, userId: string) => { + return db.Favorite.destroy({ + where: { + id: favoriteId, + user_id: userId, + }, + }); +}; + +const deleteFavoriteByFileId = async (fileId: string, userId: string) => { + return db.Favorite.destroy({ + where: { + file_id: fileId, + user_id: userId, + }, + }); +}; + +export default { + findOwnedFileById, + findOwnedFileByStoragePath, + createFavorite, + getFavorites, + getFavoriteById, + getFavoriteByFileId, + updateFavorite, + deleteFavorite, + deleteFavoriteByFileId, +}; diff --git a/src/routes/favorite.routes.ts b/src/routes/favorite.routes.ts new file mode 100644 index 0000000..5193949 --- /dev/null +++ b/src/routes/favorite.routes.ts @@ -0,0 +1,30 @@ +import { Hono } from "hono"; +import AuthMiddleware from "@/middleware/auth.middleware"; +import favoriteController from "@/controllers/favorite.controller"; +import { validateBody, validateParams, validateQuery } from "@/utils/validation"; +import favoriteValidation from "@/validation/favorite.validation"; + +export class FavoriteRouter { + private readonly router: Hono; + + constructor() { + this.router = new Hono(); + this.initializeRoutes(); + } + + private initializeRoutes() { + this.router.use(AuthMiddleware.authMiddleware); + + this.router.post('/', validateBody(favoriteValidation.createFavoriteValidation), favoriteController.createFavorite); + this.router.get('/', validateQuery(favoriteValidation.getFavoritesValidation), favoriteController.getFavorites); + this.router.get('/file/:fileId', validateParams(favoriteValidation.favoriteFileIdValidation), favoriteController.getFavoriteByFileId); + this.router.delete('/file/:fileId', validateParams(favoriteValidation.favoriteFileIdValidation), favoriteController.deleteFavoriteByFileId); + this.router.get('/:favoriteId', validateParams(favoriteValidation.favoriteIdValidation), favoriteController.getFavoriteById); + this.router.patch('/:favoriteId', validateParams(favoriteValidation.favoriteIdValidation), validateBody(favoriteValidation.updateFavoriteValidation), favoriteController.updateFavorite); + this.router.delete('/:favoriteId', validateParams(favoriteValidation.favoriteIdValidation), favoriteController.deleteFavorite); + } + + public getRouter() { + return this.router; + } +} diff --git a/src/validation/favorite.validation.ts b/src/validation/favorite.validation.ts new file mode 100644 index 0000000..9f1d745 --- /dev/null +++ b/src/validation/favorite.validation.ts @@ -0,0 +1,111 @@ +import Joi from 'joi'; + +const favoriteIdValidation = Joi.object({ + favoriteId: Joi.string() + .uuid() + .required() + .messages({ + 'string.guid': 'Favorite ID must be a valid UUID', + 'any.required': 'Favorite ID is required', + }), +}); + +const favoriteFileIdValidation = Joi.object({ + fileId: Joi.string() + .uuid() + .required() + .messages({ + 'string.guid': 'File ID must be a valid UUID', + 'any.required': 'File ID is required', + }), +}); + +const createFavoriteValidation = Joi.object({ + file_id: Joi.string() + .uuid() + .optional() + .messages({ + 'string.guid': 'File ID must be a valid UUID', + }), + storage_path: Joi.string() + .trim() + .min(1) + .max(2048) + .optional() + .messages({ + 'string.empty': 'Storage path is required', + 'string.min': 'Storage path is required', + 'string.max': 'Storage path cannot exceed 2048 characters', + }), +}) + .xor('file_id', 'storage_path') + .messages({ + 'object.missing': 'Either file_id or storage_path is required', + 'object.xor': 'Provide either file_id or storage_path, not both', + }); + +const updateFavoriteValidation = Joi.object({ + file_id: Joi.string() + .uuid() + .required() + .messages({ + 'string.guid': 'File ID must be a valid UUID', + 'any.required': 'File ID is required', + }), +}); + +const getFavoritesValidation = Joi.object({ + page: Joi.number() + .integer() + .min(1) + .optional() + .default(1) + .messages({ + 'number.base': 'Page should be a number', + 'number.integer': 'Page should be an integer', + 'number.min': 'Page should be at least 1', + }), + limit: Joi.number() + .integer() + .min(1) + .max(100) + .optional() + .default(20) + .messages({ + 'number.base': 'Limit should be a number', + 'number.integer': 'Limit should be an integer', + 'number.min': 'Limit should be at least 1', + 'number.max': 'Limit cannot exceed 100', + }), + search: Joi.string() + .trim() + .min(1) + .max(255) + .optional() + .messages({ + 'string.min': 'Search text cannot be empty', + 'string.max': 'Search text cannot exceed 255 characters', + }), + sortBy: Joi.string() + .valid('created_at', 'name') + .optional() + .default('created_at') + .messages({ + 'any.only': 'Sort by must be one of created_at or name', + }), + sortOrder: Joi.string() + .valid('ASC', 'DESC', 'asc', 'desc') + .optional() + .default('DESC') + .messages({ + 'any.only': 'Sort order must be ASC or DESC', + }), +}); + +export default { + favoriteIdValidation, + favoriteFileIdValidation, + createFavoriteValidation, + updateFavoriteValidation, + getFavoritesValidation, +}; From 9d251f1f0ffe0c4a0ea41c9bd89232d7bc797450 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 16 May 2026 15:00:41 +0530 Subject: [PATCH 12/12] backend api changes --- src/controllers/favorite.controller.ts | 24 +++++++++++++------- src/repository/favorite.repository.ts | 31 +++++++++++++------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/controllers/favorite.controller.ts b/src/controllers/favorite.controller.ts index eed07f7..7e803a1 100644 --- a/src/controllers/favorite.controller.ts +++ b/src/controllers/favorite.controller.ts @@ -1,20 +1,23 @@ import { type Context } from "hono"; import { UniqueConstraintError } from "sequelize"; import res from "@/utils/response"; -import type { IUserAttributes } from "@/models/User.model"; +import { UserRole, type IUserAttributes } from "@/models/User.model"; import type { InferSchemaType } from "@/utils/validation"; import favoriteValidation from "@/validation/favorite.validation"; import favoriteRepository from "@/repository/favorite.repository"; +const canAccessAllFiles = (user: IUserAttributes) => user.role === UserRole.ADMIN; + const createFavorite = async (c: Context) => { try { type CreateFavoriteBody = InferSchemaType; const value = c.get('validated'); const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); const file = value.file_id - ? await favoriteRepository.findOwnedFileById(value.file_id, user.id) - : await favoriteRepository.findOwnedFileByStoragePath(value.storage_path, user.id); + ? await favoriteRepository.findAccessibleFileById(value.file_id, user.id, hasGlobalFileAccess) + : await favoriteRepository.findAccessibleFileByStoragePath(value.storage_path, user.id, hasGlobalFileAccess); if (!file) { return res.FailureResponse(c, 404, { message: "File not found or you do not have permission to favorite it" }); @@ -25,7 +28,7 @@ const createFavorite = async (c: Context) => { file_id: file.id, }); - const favoriteWithFile = await favoriteRepository.getFavoriteById(favorite.id, user.id); + const favoriteWithFile = await favoriteRepository.getFavoriteById(favorite.id, user.id, hasGlobalFileAccess); return res.SuccessResponse(c, 201, { message: "Favorite created successfully", data: { favorite: favoriteWithFile } }); } catch (error: any) { if (error instanceof UniqueConstraintError) { @@ -39,6 +42,7 @@ const createFavorite = async (c: Context) => { const getFavorites = async (c: Context) => { try { const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); const query = c.get('validatedQuery') as { page: number; limit: number; @@ -50,6 +54,7 @@ const getFavorites = async (c: Context) => { const result = await favoriteRepository.getFavorites(user.id, { ...query, sortOrder: query.sortOrder.toUpperCase() as 'ASC' | 'DESC', + canAccessAllFiles: hasGlobalFileAccess, }); return res.SuccessResponse(c, 200, { message: "Favorites retrieved successfully", data: result }); @@ -61,9 +66,10 @@ const getFavorites = async (c: Context) => { const getFavoriteById = async (c: Context) => { try { const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); const params = c.get('validatedParams') as { favoriteId: string }; - const favorite = await favoriteRepository.getFavoriteById(params.favoriteId, user.id); + const favorite = await favoriteRepository.getFavoriteById(params.favoriteId, user.id, hasGlobalFileAccess); if (!favorite) { return res.FailureResponse(c, 404, { message: "Favorite not found" }); } @@ -77,9 +83,10 @@ const getFavoriteById = async (c: Context) => { const getFavoriteByFileId = async (c: Context) => { try { const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); const params = c.get('validatedParams') as { fileId: string }; - const favorite = await favoriteRepository.getFavoriteByFileId(params.fileId, user.id); + const favorite = await favoriteRepository.getFavoriteByFileId(params.fileId, user.id, hasGlobalFileAccess); return res.SuccessResponse(c, 200, { message: "Favorite status retrieved successfully", data: { @@ -98,13 +105,14 @@ const updateFavorite = async (c: Context) => { const value = c.get('validated'); const params = c.get('validatedParams') as { favoriteId: string }; const user = c.get('user') as IUserAttributes; + const hasGlobalFileAccess = canAccessAllFiles(user); - const file = await favoriteRepository.findOwnedFileById(value.file_id, user.id); + const file = await favoriteRepository.findAccessibleFileById(value.file_id, user.id, hasGlobalFileAccess); if (!file) { return res.FailureResponse(c, 404, { message: "File not found or you do not have permission to favorite it" }); } - const favorite = await favoriteRepository.updateFavorite(params.favoriteId, user.id, value.file_id); + const favorite = await favoriteRepository.updateFavorite(params.favoriteId, user.id, value.file_id, hasGlobalFileAccess); if (!favorite) { return res.FailureResponse(c, 404, { message: "Favorite not found" }); } diff --git a/src/repository/favorite.repository.ts b/src/repository/favorite.repository.ts index 140d2b0..f4382ce 100644 --- a/src/repository/favorite.repository.ts +++ b/src/repository/favorite.repository.ts @@ -11,9 +11,10 @@ type FavoriteListOptions = { search?: string; sortBy: FavoriteSortBy; sortOrder: SortOrder; + canAccessAllFiles?: boolean; }; -const favoriteInclude = (userId: string, search?: string) => ({ +const favoriteInclude = (userId: string, search?: string, canAccessAllFiles: boolean = false) => ({ model: db.File, as: 'file', required: true, @@ -33,28 +34,28 @@ const favoriteInclude = (userId: string, search?: string) => ({ 'updated_at', ], where: { - owner_id: userId, deleted_at: null, + ...(!canAccessAllFiles ? { owner_id: userId } : {}), ...(search ? { name: { [Op.iLike]: `%${search}%` } } : {}), }, }); -const findOwnedFileById = async (fileId: string, userId: string) => { +const findAccessibleFileById = async (fileId: string, userId: string, canAccessAllFiles: boolean = false) => { return db.File.findOne({ where: { id: fileId, - owner_id: userId, deleted_at: null, + ...(!canAccessAllFiles ? { owner_id: userId } : {}), }, attributes: ['id'], }); }; -const findOwnedFileByStoragePath = async (storagePath: string, userId: string) => { +const findAccessibleFileByStoragePath = async (storagePath: string, userId: string, canAccessAllFiles: boolean = false) => { return db.File.findOne({ where: { - owner_id: userId, deleted_at: null, + ...(!canAccessAllFiles ? { owner_id: userId } : {}), file_info: { storage_path: storagePath, }, @@ -78,7 +79,7 @@ const getFavorites = async (userId: string, options: FavoriteListOptions) => { const { rows, count } = await db.Favorite.findAndCountAll({ where: { user_id: userId }, attributes: ['id', 'user_id', 'file_id', 'created_at'], - include: [favoriteInclude(userId, options.search)], + include: [favoriteInclude(userId, options.search, options.canAccessAllFiles)], order, limit: safeLimit, offset, @@ -96,29 +97,29 @@ const getFavorites = async (userId: string, options: FavoriteListOptions) => { }; }; -const getFavoriteById = async (favoriteId: string, userId: string) => { +const getFavoriteById = async (favoriteId: string, userId: string, canAccessAllFiles: boolean = false) => { return db.Favorite.findOne({ where: { id: favoriteId, user_id: userId, }, attributes: ['id', 'user_id', 'file_id', 'created_at'], - include: [favoriteInclude(userId)], + include: [favoriteInclude(userId, undefined, canAccessAllFiles)], }); }; -const getFavoriteByFileId = async (fileId: string, userId: string) => { +const getFavoriteByFileId = async (fileId: string, userId: string, canAccessAllFiles: boolean = false) => { return db.Favorite.findOne({ where: { file_id: fileId, user_id: userId, }, attributes: ['id', 'user_id', 'file_id', 'created_at'], - include: [favoriteInclude(userId)], + include: [favoriteInclude(userId, undefined, canAccessAllFiles)], }); }; -const updateFavorite = async (favoriteId: string, userId: string, fileId: string) => { +const updateFavorite = async (favoriteId: string, userId: string, fileId: string, canAccessAllFiles: boolean = false) => { const favorite = await db.Favorite.findOne({ where: { id: favoriteId, @@ -131,7 +132,7 @@ const updateFavorite = async (favoriteId: string, userId: string, fileId: string } await favorite.update({ file_id: fileId }); - return getFavoriteById(favoriteId, userId); + return getFavoriteById(favoriteId, userId, canAccessAllFiles); }; const deleteFavorite = async (favoriteId: string, userId: string) => { @@ -153,8 +154,8 @@ const deleteFavoriteByFileId = async (fileId: string, userId: string) => { }; export default { - findOwnedFileById, - findOwnedFileByStoragePath, + findAccessibleFileById, + findAccessibleFileByStoragePath, createFavorite, getFavorites, getFavoriteById,