Import source: backend/src/services/analytics
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<string, { views: number; downloads: number }>();
|
||||||
|
|
||||||
|
// 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<string, number>();
|
||||||
|
|
||||||
|
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<string, { count: number; sizeBytes: number }> = {
|
||||||
|
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<string, { count: number; sizeBytes: number }>();
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user