diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts new file mode 100644 index 0000000..b06b287 --- /dev/null +++ b/backend/src/services/analytics.ts @@ -0,0 +1,264 @@ +import { newUuid, sha256 } from '../utils/crypto.js'; +import { store } from './store.js'; +import type { DatabaseShape } from '../types.js'; + +/** + * Record a view or download event directly on the provided database instance. + * Useful for batched/atomic operations within store.update(). + */ +export function recordEventInDb( + db: DatabaseShape, + linkId: string, + type: 'view' | 'download', + rawIp?: string, + userAgent?: string, + referrer?: string, +): void { + const hashedIp = rawIp ? sha256(rawIp) : undefined; + const cleanedUserAgent = userAgent || undefined; + const cleanedReferrer = referrer || undefined; + + const file = db.files.find((f) => f.linkId === linkId && f.status === 'uploaded'); + if (file) { + if (type === 'view') { + file.views += 1; + } else if (type === 'download') { + file.downloads += 1; + } + } + + db.events.push({ + id: newUuid(), + linkId, + type, + createdAt: new Date().toISOString(), + ip: hashedIp, + userAgent: cleanedUserAgent, + referrer: cleanedReferrer, + }); +} + +/** + * Standalone helper to record an event. + */ +export async function recordEvent( + linkId: string, + type: 'view' | 'download', + rawIp?: string, + userAgent?: string, + referrer?: string, +): Promise { + await store.update((db) => { + recordEventInDb(db, linkId, type, rawIp, userAgent, referrer); + }); +} + +/** + * Generates a 30-day daily breakdown of views and downloads for a specific file link. + */ +export async function getDailyBreakdown(linkId: string, days = 30) { + const db = await store.read(); + const now = new Date(); + const dateMap = new Map(); + + // Pre-fill the range of days + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); + const dateString = d.toISOString().split('T')[0]; + dateMap.set(dateString, { views: 0, downloads: 0 }); + } + + // Filter events matching the link ID + const fileEvents = db.events.filter((e) => e.linkId === linkId); + for (const event of fileEvents) { + const dateString = event.createdAt.split('T')[0]; + if (dateMap.has(dateString)) { + const counts = dateMap.get(dateString)!; + if (event.type === 'view') counts.views += 1; + if (event.type === 'download') counts.downloads += 1; + } + } + + return Array.from(dateMap.entries()).map(([date, counts]) => ({ + date, + views: counts.views, + downloads: counts.downloads, + })); +} + +/** + * Aggregates and returns top referrers for a specific file link. + */ +export async function getTopReferrers(linkId: string) { + const db = await store.read(); + const referrerCounts = new Map(); + + const fileEvents = db.events.filter((e) => e.linkId === linkId); + for (const event of fileEvents) { + const ref = event.referrer || 'Direct'; + referrerCounts.set(ref, (referrerCounts.get(ref) || 0) + 1); + } + + return Array.from(referrerCounts.entries()) + .map(([referrer, count]) => ({ referrer, count })) + .sort((a, b) => b.count - a.count); +} + +/** + * Generates the recent activity feed (views, downloads, and uploads) for a user session. + */ +export async function getActivityFeed(sessionId: string, limit = 20) { + const db = await store.read(); + const myFiles = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sessionId); + const myFileMap = new Map(myFiles.map((f) => [f.linkId, f])); + + const activities: Array<{ + id: string; + type: 'view' | 'download' | 'upload'; + fileName: string; + fileId: string; + linkId: string; + createdAt: string; + ip?: string; + userAgent?: string; + referrer?: string; + }> = []; + + // Add uploads + for (const file of myFiles) { + activities.push({ + id: file.id, + type: 'upload', + fileName: file.originalName, + fileId: file.id, + linkId: file.linkId, + createdAt: file.createdAt, + }); + } + + // Add views/downloads + for (const event of db.events) { + const file = myFileMap.get(event.linkId); + if (file) { + activities.push({ + id: event.id, + type: event.type, + fileName: file.originalName, + fileId: file.id, + linkId: event.linkId, + createdAt: event.createdAt, + ip: event.ip, + userAgent: event.userAgent, + referrer: event.referrer, + }); + } + } + + // Sort by date descending + return activities + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, limit); +} + +function getFileCategory(mimeType: string, filename: string): string { + const mime = mimeType.toLowerCase(); + const ext = filename.split('.').pop()?.toLowerCase() || ''; + + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('video/')) return 'video'; + if (mime.startsWith('audio/')) return 'audio'; + + const archives = new Set(['zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'tgz']); + const archiveMimes = new Set([ + 'application/zip', + 'application/x-tar', + 'application/x-gzip', + 'application/x-bzip2', + 'application/x-7z-compressed', + 'application/x-rar-compressed', + ]); + if (archives.has(ext) || archiveMimes.has(mime)) return 'archive'; + + const documents = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'rtf', 'html', 'css', 'json']); + const documentMimes = new Set([ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + 'text/html', + 'text/css', + 'text/csv', + 'application/rtf', + 'application/json', + ]); + if (documents.has(ext) || documentMimes.has(mime)) return 'document'; + + return 'other'; +} + +/** + * Returns a storage size and count breakdown grouped by file type category. + */ +export async function getStorageBreakdown(sessionId: string) { + const db = await store.read(); + const myFiles = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sessionId); + + const breakdown: Record = { + document: { count: 0, sizeBytes: 0 }, + image: { count: 0, sizeBytes: 0 }, + video: { count: 0, sizeBytes: 0 }, + audio: { count: 0, sizeBytes: 0 }, + archive: { count: 0, sizeBytes: 0 }, + other: { count: 0, sizeBytes: 0 }, + }; + + for (const file of myFiles) { + const category = getFileCategory(file.mimeType, file.originalName); + breakdown[category].count += 1; + breakdown[category].sizeBytes += file.sizeBytes; + } + + return Object.entries(breakdown).map(([category, stats]) => ({ + category, + count: stats.count, + sizeBytes: stats.sizeBytes, + })); +} + +/** + * Aggregates monthly upload volume (number of uploads and sizes) for the last 6 months. + */ +export async function getMonthlyUploadVolume(sessionId: string, monthsCount = 6) { + const db = await store.read(); + const myFiles = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sessionId); + + const volumeMap = new Map(); + const now = new Date(); + + // Pre-fill last N months + for (let i = monthsCount - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const monthStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + volumeMap.set(monthStr, { count: 0, sizeBytes: 0 }); + } + + for (const file of myFiles) { + const date = new Date(file.createdAt); + const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + if (volumeMap.has(monthStr)) { + const stats = volumeMap.get(monthStr)!; + stats.count += 1; + stats.sizeBytes += file.sizeBytes; + } + } + + return Array.from(volumeMap.entries()).map(([month, stats]) => ({ + month, + count: stats.count, + sizeBytes: stats.sizeBytes, + })); +}