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