diff --git a/client/src/router.js b/client/src/router.js index 0db9443..94d18f1 100644 --- a/client/src/router.js +++ b/client/src/router.js @@ -33,6 +33,12 @@ const router = createRouter({ name: 'stats', component: () => import('./views/StatsView.vue') }, + { + path: '/timeline', + name: 'timeline', + component: () => import('./views/Timeline.vue'), + meta: { requiresAuth: true } + }, { path: '/library', name: 'library', diff --git a/client/src/views/HomeView.vue b/client/src/views/HomeView.vue index 06e47d5..13aacb1 100644 --- a/client/src/views/HomeView.vue +++ b/client/src/views/HomeView.vue @@ -29,6 +29,12 @@ Jobs + + + + + Timeline + diff --git a/client/src/views/Timeline.vue b/client/src/views/Timeline.vue new file mode 100644 index 0000000..5e31317 --- /dev/null +++ b/client/src/views/Timeline.vue @@ -0,0 +1,330 @@ + + + + Activity Timeline + + + All Events + Document Uploads + Maintenance + Warranty + + + + + + Loading timeline... + + + + + {{ date }} + + + + + + + + + {{ event.event_title }} + {{ formatTime(event.created_at) }} + + + {{ event.event_description }} + + + {{ event.user.name }} + + + + View {{ event.reference_type }} → + + + + + + + + {{ loading ? 'Loading...' : 'Load More' }} + + + + + No activity yet. Upload a document to get started! + + + + + + + + diff --git a/server/index.js b/server/index.js index da85d7d..4995ca8 100644 --- a/server/index.js +++ b/server/index.js @@ -94,6 +94,7 @@ import documentsRoutes from './routes/documents.js'; import imagesRoutes from './routes/images.js'; import statsRoutes from './routes/stats.js'; import tocRoutes from './routes/toc.js'; +import timelineRoutes from './routes/timeline.js'; // Public API endpoint for app settings (no auth required) import * as settingsService from './services/settings.service.js'; @@ -129,6 +130,7 @@ app.use('/api/documents', documentsRoutes); app.use('/api/stats', statsRoutes); app.use('/api', tocRoutes); // Handles /api/documents/:id/toc paths app.use('/api', imagesRoutes); +app.use('/api', timelineRoutes); // Client error logging endpoint (Tier 2) app.post('/api/client-log', express.json(), (req, res) => { diff --git a/server/migrations/010_activity_timeline.sql b/server/migrations/010_activity_timeline.sql new file mode 100644 index 0000000..86b51ff --- /dev/null +++ b/server/migrations/010_activity_timeline.sql @@ -0,0 +1,37 @@ +-- Activity Log for Organization Timeline +-- Tracks all events: uploads, maintenance, warranty, settings changes + +CREATE TABLE IF NOT EXISTS activity_log ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + entity_id TEXT, -- Optional: boat/yacht ID if event is entity-specific + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, -- 'document_upload', 'maintenance_log', 'warranty_claim', 'settings_change' + event_action TEXT, -- 'created', 'updated', 'deleted', 'viewed' + event_title TEXT NOT NULL, + event_description TEXT, + metadata TEXT, -- JSON blob for event-specific data + reference_id TEXT, -- ID of related resource (document_id, maintenance_id, etc.) + reference_type TEXT, -- 'document', 'maintenance', 'warranty', etc. + created_at INTEGER NOT NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +); + +-- Indexes for fast timeline queries +CREATE INDEX IF NOT EXISTS idx_activity_org_created + ON activity_log(organization_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_activity_entity + ON activity_log(entity_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_activity_type + ON activity_log(event_type); + +-- Test data (for demo) +INSERT INTO activity_log (id, organization_id, user_id, event_type, event_action, event_title, event_description, created_at) +VALUES + ('evt_demo_1', '6ce0dfc7-f754-4122-afde-85154bc4d0ae', 'bef71b0c-3427-485b-b4dd-b6399f4d4c45', + 'document_upload', 'created', 'Bilge Pump Manual Uploaded', + 'Azimut 55S Bilge Pump Manual.pdf (2.3MB)', + strftime('%s', 'now') * 1000); diff --git a/server/routes/timeline.js b/server/routes/timeline.js new file mode 100644 index 0000000..84709f3 --- /dev/null +++ b/server/routes/timeline.js @@ -0,0 +1,87 @@ +import express from 'express'; +import { getDb } from '../config/db.js'; +import { authenticateToken } from '../middleware/auth.js'; + +const router = express.Router(); + +router.get('/organizations/:orgId/timeline', authenticateToken, async (req, res) => { + const { orgId } = req.params; + const { limit = 50, offset = 0, eventType, entityId, startDate, endDate } = req.query; + + // Verify user belongs to organization + if (req.user.organizationId !== orgId) { + return res.status(403).json({ error: 'Access denied' }); + } + + const db = getDb(); + + // Build query with filters + let query = ` + SELECT + a.*, + u.name as user_name, + u.email as user_email + FROM activity_log a + LEFT JOIN users u ON a.user_id = u.id + WHERE a.organization_id = ? + `; + + const params = [orgId]; + + if (eventType) { + query += ` AND a.event_type = ?`; + params.push(eventType); + } + + if (entityId) { + query += ` AND a.entity_id = ?`; + params.push(entityId); + } + + if (startDate) { + query += ` AND a.created_at >= ?`; + params.push(parseInt(startDate)); + } + + if (endDate) { + query += ` AND a.created_at <= ?`; + params.push(parseInt(endDate)); + } + + query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?`; + params.push(parseInt(limit), parseInt(offset)); + + try { + const events = db.prepare(query).all(...params); + + // Get total count + const countQuery = query.split('ORDER BY')[0].replace('SELECT a.*, u.name as user_name, u.email as user_email', 'SELECT COUNT(*) as total'); + const { total } = db.prepare(countQuery).get(...params.slice(0, -2)); + + // Parse metadata + const parsedEvents = events.map(event => ({ + ...event, + metadata: event.metadata ? JSON.parse(event.metadata) : {}, + user: { + id: event.user_id, + name: event.user_name, + email: event.user_email + } + })); + + res.json({ + events: parsedEvents, + pagination: { + total, + limit: parseInt(limit), + offset: parseInt(offset), + hasMore: offset + events.length < total + } + }); + } catch (error) { + console.error('[Timeline] Error fetching events:', error); + res.status(500).json({ error: 'Failed to fetch timeline' }); + } +}); + +export default router; diff --git a/server/routes/upload.js b/server/routes/upload.js index 69e1c8c..73c41fe 100644 --- a/server/routes/upload.js +++ b/server/routes/upload.js @@ -14,6 +14,7 @@ import { dirname, join } from 'path'; import { getDb } from '../db/db.js'; import { validateFile, sanitizeFilename } from '../services/file-safety.js'; import { addOcrJob } from '../services/queue.js'; +import { logActivity } from '../services/activity-logger.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const router = express.Router(); @@ -165,6 +166,24 @@ router.post('/', upload.single('file'), async (req, res) => { userId }); + // Log activity to timeline + await logActivity({ + organizationId, + entityId, + userId, + eventType: 'document_upload', + eventAction: 'created', + eventTitle: title, + eventDescription: `Uploaded ${sanitizedFilename} (${(file.size / 1024).toFixed(1)}KB)`, + metadata: { + fileSize: file.size, + fileName: sanitizedFilename, + documentType: documentType + }, + referenceId: documentId, + referenceType: 'document' + }); + // Return success response res.status(201).json({ jobId, diff --git a/server/services/activity-logger.js b/server/services/activity-logger.js new file mode 100644 index 0000000..a8a372e --- /dev/null +++ b/server/services/activity-logger.js @@ -0,0 +1,59 @@ +/** + * Activity Logger Service + * Automatically logs events to organization timeline + */ +import { getDb } from '../config/db.js'; +import { v4 as uuidv4 } from 'uuid'; + +export async function logActivity({ + organizationId, + entityId = null, + userId, + eventType, + eventAction, + eventTitle, + eventDescription = '', + metadata = {}, + referenceId = null, + referenceType = null +}) { + const db = getDb(); + + const activity = { + id: `evt_${uuidv4()}`, + organization_id: organizationId, + entity_id: entityId, + user_id: userId, + event_type: eventType, + event_action: eventAction, + event_title: eventTitle, + event_description: eventDescription, + metadata: JSON.stringify(metadata), + reference_id: referenceId, + reference_type: referenceType, + created_at: Date.now() + }; + + db.prepare(` + INSERT INTO activity_log ( + id, organization_id, entity_id, user_id, event_type, event_action, + event_title, event_description, metadata, reference_id, reference_type, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + activity.id, + activity.organization_id, + activity.entity_id, + activity.user_id, + activity.event_type, + activity.event_action, + activity.event_title, + activity.event_description, + activity.metadata, + activity.reference_id, + activity.reference_type, + activity.created_at + ); + + console.log(`[Activity Log] ${eventType}: ${eventTitle}`); + return activity; +}
{{ event.event_description }}
No activity yet. Upload a document to get started!