diff --git a/package.json b/package.json index 394677b4..b5abb422 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "next": "15.3.1", "next-themes": "^0.4.6", "pg": "^8.20.0", + "pino": "^9.5.0", "puppeteer": "^24.42.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", @@ -78,6 +79,7 @@ "react-intersection-observer": "^10.0.3", "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.9", + "reacts-cli": "^1.0.2", "recharts": "^2.15.4", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", diff --git a/src/app/api/exports/execute/route.ts b/src/app/api/exports/execute/route.ts index cb4269ec..96d4279c 100644 --- a/src/app/api/exports/execute/route.ts +++ b/src/app/api/exports/execute/route.ts @@ -4,31 +4,84 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { schedulerService, ExportOptions } from '@/lib/export-scheduler'; +import { createLogger } from '@/lib/logging'; +import { createCounterMetric } from '@/lib/logging/performance'; +import { withRequestLogging } from '@/middleware/logger'; +import { + ExportExecutionOptions, + ExportFilter, + ExportProgressState, + ExportSort, + prepareExportData, +} from '@/lib/export'; +import { exportData, fetchDataForTemplate, getTemplate } from '@/lib/export-scheduler'; -// Mock user ID - in production, get from auth session -const getCurrentUserId = (): string => 'user-123'; +const routeLogger = createLogger('exports.execute'); + +function asFilters(value: unknown): ExportFilter[] | undefined { + return Array.isArray(value) ? (value as ExportFilter[]) : undefined; +} + +function asSort(value: unknown): ExportSort[] | undefined { + return Array.isArray(value) ? (value as ExportSort[]) : undefined; +} + +function asColumns(value: unknown): string[] | undefined { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined; +} export async function POST(request: NextRequest) { try { - const userId = getCurrentUserId(); - const body = await request.json(); + return await withRequestLogging(request, 'exports.execute', async (requestId) => { + const body = await request.json(); + const templateId = typeof body.templateId === 'string' ? body.templateId : ''; + + if (!templateId) { + return NextResponse.json({ error: 'Template ID is required' }, { status: 400 }); + } + + const template = await getTemplate(templateId); + if (!template) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }); + } - const options: ExportOptions = { - templateId: body.templateId, - scheduleId: body.scheduleId, - immediate: true, - }; + const progress: ExportProgressState[] = []; + const options: ExportExecutionOptions = { + filters: asFilters(body.filters), + sort: asSort(body.sort), + columns: asColumns(body.columns) ?? template.columns, + onProgress: (state) => { + progress.push(state); + }, + }; - const result = await schedulerService.executeExport(options, userId); + const sourceData = await fetchDataForTemplate(template); + const preparedData = prepareExportData(sourceData, options); + const { blob, fileName } = await exportData(template, sourceData, options); - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }); - } + routeLogger.info('Export generated on demand', { + requestId, + context: { + templateId, + fileName, + rows: sourceData.rows.length, + }, + metrics: [createCounterMetric('export.requests', 1, { format: template.format })], + }); - return NextResponse.json({ result }); + return NextResponse.json({ + result: { + success: true, + fileName, + fileSize: blob.size, + contentType: blob.type, + rowCount: preparedData.rows.length, + progress, + }, + }); + }); } catch (error) { - console.error('Error executing export:', error); + routeLogger.error('Failed to execute export', { error }); return NextResponse.json({ error: 'Failed to execute export' }, { status: 500 }); } } diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx new file mode 100644 index 00000000..4abed717 --- /dev/null +++ b/src/components/ExportButton.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import { apiClient } from '@/lib/api'; +import { ExportFilter, ExportProgressState, ExportSort } from '@/lib/export'; + +interface ExportButtonResult { + success: boolean; + fileName: string; + fileSize: number; + contentType: string; + rowCount: number; + progress?: ExportProgressState[]; +} + +interface ExportButtonProps { + templateId: string; + label?: string; + className?: string; + filters?: ExportFilter[]; + sort?: ExportSort[]; + columns?: string[]; + onComplete?: (result: ExportButtonResult) => void; +} + +export function ExportButton({ + templateId, + label = 'Run Export', + className = '', + filters, + sort, + columns, + onComplete, +}: ExportButtonProps) { + const [isRunning, setIsRunning] = useState(false); + const [progress, setProgress] = useState(null); + const [message, setMessage] = useState(null); + + const handleClick = async () => { + setIsRunning(true); + setMessage(null); + setProgress({ + stage: 'preparing', + percent: 10, + message: 'Preparing export request', + }); + + try { + const response = await apiClient.post<{ result: ExportButtonResult }>( + '/api/exports/execute', + { + templateId, + filters, + sort, + columns, + }, + ); + + const finalProgress = + response.result.progress?.[response.result.progress.length - 1] ?? { + stage: 'completed' as const, + percent: 100, + message: 'Export completed', + }; + + setProgress(finalProgress); + setMessage( + `${response.result.fileName} ready (${response.result.rowCount} rows, ${( + response.result.fileSize / 1024 + ).toFixed(2)} KB)`, + ); + onComplete?.(response.result); + } catch (error) { + setProgress({ + stage: 'completed', + percent: 100, + message: 'Export failed', + }); + setMessage(error instanceof Error ? error.message : 'Export failed'); + } finally { + setIsRunning(false); + } + }; + + return ( +
+ + + {progress && ( +
+
+ {progress.message} + {progress.percent}% +
+
+
+
+
+ )} + + {message &&

{message}

} +
+ ); +} + +export default ExportButton; diff --git a/src/lib/apiInterceptors.ts b/src/lib/apiInterceptors.ts index 01bf3a58..ecdaf980 100644 --- a/src/lib/apiInterceptors.ts +++ b/src/lib/apiInterceptors.ts @@ -4,8 +4,16 @@ import { API_TIMEOUT_SEARCH, STORAGE_KEYS, } from '@/constants/app.constants'; -import { apiClient, RequestInterceptor, ResponseInterceptor, ErrorInterceptor } from './api'; -import { RequestConfig } from './api'; + +import { + apiClient, + RequestInterceptor, + ResponseInterceptor, + ErrorInterceptor, + RequestConfig, +} from './api'; + +import { createLogger } from '@/lib/logging'; declare global { interface Window { @@ -13,67 +21,66 @@ declare global { } } -/** - * Request logging interceptor - logs outgoing requests - */ -export const loggingRequestInterceptor: RequestInterceptor = async (config: RequestConfig) => { +const apiLogger = createLogger('api-client'); + +function safeBody(body: RequestConfig['body']): unknown { + if (!body || typeof body !== 'string') return undefined; + + try { + return JSON.parse(body); + } catch { + return body; + } +} + +export const loggingRequestInterceptor: RequestInterceptor = async (config) => { if (process.env.NODE_ENV === 'development') { - console.log(`[API Request] ${config.method} ${config.url}`, { - headers: config.headers, - body: config.body ? JSON.parse(config.body as string) : undefined, + apiLogger.debug('API request started', { + context: { + method: config.method, + url: config.url, + headers: config.headers as Record, + body: safeBody(config.body), + }, }); } return config; }; -/** - * Response logging interceptor - logs successful responses - */ -export const loggingResponseInterceptor: ResponseInterceptor = async (response: unknown) => { +export const loggingResponseInterceptor: ResponseInterceptor = async (response) => { if (process.env.NODE_ENV === 'development') { - console.log('[API Response]', response); + apiLogger.debug('API response received', { + context: { response }, + }); } return response; }; -/** - * Error logging interceptor - logs errors - */ export const loggingErrorInterceptor: ErrorInterceptor = async (error: Error) => { - console.error('[API Error]', error.message, error); + apiLogger.error('API request failed', { error }); }; -/** - * Authentication refresh interceptor - handles 401 and refreshes token - */ export const authRefreshInterceptor: ErrorInterceptor = async (error: Error) => { - // Only handle authentication errors - if (error.message && error.message.includes('401')) { - // Clear invalid token - if (typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); - // Optionally redirect to login - window.location.href = '/login'; - } + const isUnauthorized = + error.message?.includes('401') || error.message?.includes('Unauthorized'); + + if (isUnauthorized && typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN); + window.location.href = '/login'; } }; -/** - * Request timeout customization interceptor - * Allows per-request timeout overrides - */ -export const timeoutInterceptor: RequestInterceptor = async (config: RequestConfig) => { - // You can customize timeout per endpoint - const urlPatterns: Array<{ pattern: string | RegExp; timeout: number }> = [ +export const timeoutInterceptor: RequestInterceptor = async (config) => { + const urlPatterns: Array<{ pattern: RegExp; timeout: number }> = [ { pattern: /\/upload/, timeout: API_TIMEOUT_UPLOAD }, { pattern: /\/download/, timeout: API_TIMEOUT_DOWNLOAD }, { pattern: /\/search/, timeout: API_TIMEOUT_SEARCH }, ]; const url = config.url; + for (const { pattern, timeout } of urlPatterns) { - const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; - if (regex.test(url)) { + if (pattern.test(url)) { config.timeout = timeout; break; } @@ -82,39 +89,26 @@ export const timeoutInterceptor: RequestInterceptor = async (config: RequestConf return config; }; -/** - * Request header enhancement interceptor - * Adds custom headers to all requests - */ -export const headerEnhancementInterceptor: RequestInterceptor = async (config: RequestConfig) => { - const headers = (config.headers as Record) || {}; +export const headerEnhancementInterceptor: RequestInterceptor = async (config) => { + const headers: Record = (config.headers as Record) || {}; - // Add request ID for tracing - headers['X-Request-ID'] = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + headers['X-Request-ID'] = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - // Add client version if available - if (typeof window !== 'undefined' && (window as any).__APP_VERSION__) { - headers['X-Client-Version'] = (window as any).__APP_VERSION__ as string; + if (typeof window !== 'undefined' && window.__APP_VERSION__) { + headers['X-Client-Version'] = window.__APP_VERSION__; } config.headers = headers; return config; }; -/** - * Setup all default interceptors - * Call this during app initialization - */ export function setupApiInterceptors(): void { - // Add request interceptors apiClient.addRequestInterceptor(loggingRequestInterceptor); apiClient.addRequestInterceptor(timeoutInterceptor); apiClient.addRequestInterceptor(headerEnhancementInterceptor); - // Add response interceptors apiClient.addResponseInterceptor(loggingResponseInterceptor); - // Add error interceptors apiClient.addErrorInterceptor(loggingErrorInterceptor); apiClient.addErrorInterceptor(authRefreshInterceptor); -} +} \ No newline at end of file diff --git a/src/lib/export-scheduler/__tests__/exporter.test.ts b/src/lib/export-scheduler/__tests__/exporter.test.ts index c935e132..35adc54a 100644 --- a/src/lib/export-scheduler/__tests__/exporter.test.ts +++ b/src/lib/export-scheduler/__tests__/exporter.test.ts @@ -20,8 +20,8 @@ describe('Data Exporter', () => { const mockData = { headers: ['id', 'name', 'value'], rows: [ - { id: 1, name: 'Item 1', value: 100 }, - { id: 2, name: 'Item 2', value: 200 }, + { id: 1, name: 'Item 2', value: 200 }, + { id: 2, name: 'Item 1', value: 100 }, ], }; @@ -55,6 +55,20 @@ describe('Data Exporter', () => { expect(result.fileName).toContain('.pdf'); }); + it('should apply filter and sort rules before exporting', async () => { + const progress: string[] = []; + const result = await exportData(mockTemplate, mockData, { + filters: [{ field: 'value', operator: 'gte', value: 150 }], + sort: [{ field: 'name', direction: 'asc' }], + onProgress: (state) => { + progress.push(state.stage); + }, + }); + expect(progress).toEqual(['preparing', 'filtering', 'formatting', 'completed']); + expect(result.blob.size).toBeGreaterThan(0); + expect(result.fileName).toContain('.csv'); + }); + it('should throw error for unsupported format', async () => { const template = { ...mockTemplate, format: 'invalid' as any }; await expect(exportData(template, mockData)).rejects.toThrow(); diff --git a/src/lib/export-scheduler/exporter.ts b/src/lib/export-scheduler/exporter.ts index 4f85a834..82842f11 100644 --- a/src/lib/export-scheduler/exporter.ts +++ b/src/lib/export-scheduler/exporter.ts @@ -3,6 +3,9 @@ * Handles actual data export in various formats */ +import { createLogger } from '@/lib/logging'; +import { createCounterMetric, measureAsync } from '@/lib/logging/performance'; +import { ExportExecutionOptions, emitProgress, prepareExportData } from '@/lib/export'; import { ExportFormat, ExportTemplate } from './types'; export interface ExportData { @@ -10,43 +13,82 @@ export interface ExportData { rows: Array>; } +const exportLogger = createLogger('export-engine'); + export async function exportData( template: ExportTemplate, data: ExportData, + options: ExportExecutionOptions = {}, ): Promise<{ blob: Blob; fileName: string }> { const timestamp = new Date().toISOString().split('T')[0]; const fileName = `${template.name.replace(/\s+/g, '-').toLowerCase()}-${timestamp}`; - switch (template.format) { - case 'csv': - return { - blob: await exportToCSV(data), - fileName: `${fileName}.csv`, - }; - case 'json': - return { - blob: await exportToJSON(data), - fileName: `${fileName}.json`, - }; - case 'xlsx': - return { - blob: await exportToXLSX(data), - fileName: `${fileName}.xlsx`, - }; - case 'pdf': - return { - blob: await exportToPDF(data, template.name), - fileName: `${fileName}.pdf`, - }; - default: - throw new Error(`Unsupported export format: ${template.format}`); - } + emitProgress(options.onProgress, { + stage: 'preparing', + percent: 15, + message: 'Preparing export dataset', + }); + + const preparedData = prepareExportData(data, options); + + emitProgress(options.onProgress, { + stage: 'filtering', + percent: 50, + message: 'Applying filters and sorting', + }); + + const { result: blob, metric } = await measureAsync( + `export.${template.format}`, + async () => { + switch (template.format) { + case 'csv': + return exportToCSV(preparedData); + case 'json': + return exportToJSON(preparedData); + case 'xlsx': + return exportToXLSX(preparedData); + case 'pdf': + return exportToPDF(preparedData, template.name); + default: + throw new Error(`Unsupported export format: ${template.format}`); + } + }, + { + format: template.format, + templateId: template.id, + }, + ); + + emitProgress(options.onProgress, { + stage: 'formatting', + percent: 85, + message: 'Formatting export output', + }); + + exportLogger.info('Export data prepared', { + context: { + templateId: template.id, + format: template.format, + rows: preparedData.rows.length, + }, + metrics: [metric, createCounterMetric('export.jobs', 1, { format: template.format })], + }); + + emitProgress(options.onProgress, { + stage: 'completed', + percent: 100, + message: 'Export completed', + }); + + return { + blob, + fileName: `${fileName}.${extensionForFormat(template.format)}`, + }; } async function exportToCSV(data: ExportData): Promise { const { headers, rows } = data; - // Escape CSV values const escape = (value: unknown): string => { const str = String(value ?? ''); if (str.includes(',') || str.includes('"') || str.includes('\n')) { @@ -55,7 +97,6 @@ async function exportToCSV(data: ExportData): Promise { return str; }; - // Build CSV content const csvLines: string[] = []; csvLines.push(headers.map(escape).join(',')); @@ -64,33 +105,31 @@ async function exportToCSV(data: ExportData): Promise { csvLines.push(values.join(',')); } - const csvContent = csvLines.join('\n'); - return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + return new Blob([csvLines.join('\n')], { type: 'text/csv;charset=utf-8;' }); } async function exportToJSON(data: ExportData): Promise { - const jsonContent = JSON.stringify(data.rows, null, 2); - return new Blob([jsonContent], { type: 'application/json;charset=utf-8;' }); + return new Blob([JSON.stringify(data.rows, null, 2)], { + type: 'application/json;charset=utf-8;', + }); } async function exportToXLSX(data: ExportData): Promise { - // Simple XLSX generation (in production, use a library like 'xlsx' or 'exceljs') - // For now, we'll create a basic XML structure const { headers, rows } = data; let xml = '\n'; - xml += '\n'; + xml += '${escapeXml(header)}\n`; } + xml += ' \n'; - // Data rows for (const row of rows) { xml += ' \n'; for (const header of headers) { @@ -109,8 +148,6 @@ async function exportToXLSX(data: ExportData): Promise { } async function exportToPDF(data: ExportData, title: string): Promise { - // Simple PDF generation (in production, use a library like 'jspdf' or 'pdfkit') - // For now, we'll create a basic HTML that can be printed to PDF const { headers, rows } = data; let html = ` @@ -178,15 +215,22 @@ function escapeHtml(str: string): string { .replace(/'/g, '''); } -// Mock data fetcher - in production, this would fetch from your actual data sources -export async function fetchDataForTemplate(template: ExportTemplate): Promise { - // This is a mock implementation - // In production, you would: - // 1. Query your database based on template.dataSource - // 2. Apply template.filters - // 3. Select template.columns - // 4. Return the formatted data +function extensionForFormat(format: ExportFormat): string { + switch (format) { + case 'csv': + return 'csv'; + case 'json': + return 'json'; + case 'xlsx': + return 'xlsx'; + case 'pdf': + return 'pdf'; + default: + return format; + } +} +export async function fetchDataForTemplate(template: ExportTemplate): Promise { const mockData: ExportData = { headers: template.columns || ['id', 'name', 'date', 'value'], rows: [ diff --git a/src/lib/export/index.ts b/src/lib/export/index.ts new file mode 100644 index 00000000..6d5a6ef4 --- /dev/null +++ b/src/lib/export/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './utils'; diff --git a/src/lib/export/types.ts b/src/lib/export/types.ts new file mode 100644 index 00000000..3d21235e --- /dev/null +++ b/src/lib/export/types.ts @@ -0,0 +1,32 @@ +export type ExportFilterOperator = + | 'eq' + | 'neq' + | 'contains' + | 'gt' + | 'gte' + | 'lt' + | 'lte'; + +export interface ExportFilter { + field: string; + operator: ExportFilterOperator; + value: unknown; +} + +export interface ExportSort { + field: string; + direction: 'asc' | 'desc'; +} + +export interface ExportProgressState { + stage: 'preparing' | 'filtering' | 'formatting' | 'completed'; + percent: number; + message: string; +} + +export interface ExportExecutionOptions { + filters?: ExportFilter[]; + sort?: ExportSort[]; + columns?: string[]; + onProgress?: (state: ExportProgressState) => void; +} diff --git a/src/lib/export/utils.test.ts b/src/lib/export/utils.test.ts new file mode 100644 index 00000000..2e1aea64 --- /dev/null +++ b/src/lib/export/utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { defaultSort, normalizeFilters, prepareExportData } from './utils'; + +describe('export utilities', () => { + const dataset = { + headers: ['id', 'name', 'status', 'date', 'value'], + rows: [ + { id: 1, name: 'Gamma', status: 'inactive', date: '2024-01-01', value: 10 }, + { id: 2, name: 'Alpha', status: 'active', date: '2024-03-01', value: 30 }, + { id: 3, name: 'Beta', status: 'active', date: '2024-02-01', value: 20 }, + ], + }; + + it('filters and sorts rows before export', () => { + const prepared = prepareExportData(dataset, { + filters: [{ field: 'status', operator: 'eq', value: 'active' }], + sort: [{ field: 'name', direction: 'asc' }], + columns: ['id', 'name'], + }); + + expect(prepared.headers).toEqual(['id', 'name']); + expect(prepared.rows).toEqual([ + { id: 2, name: 'Alpha' }, + { id: 3, name: 'Beta' }, + ]); + }); + + it('normalizes object filters and creates a sensible default sort', () => { + expect(normalizeFilters({ status: 'active' })).toEqual([ + { field: 'status', operator: 'eq', value: 'active' }, + ]); + expect(defaultSort(['id', 'createdDate'])).toEqual([ + { field: 'createdDate', direction: 'desc' }, + ]); + }); +}); diff --git a/src/lib/export/utils.ts b/src/lib/export/utils.ts new file mode 100644 index 00000000..48e316ae --- /dev/null +++ b/src/lib/export/utils.ts @@ -0,0 +1,113 @@ +import { ExportExecutionOptions, ExportFilter, ExportProgressState, ExportSort } from './types'; + +export interface ExportDataset { + headers: string[]; + rows: Array>; +} + +function compareValues(left: unknown, right: unknown): number { + if (left === right) return 0; + if (left == null) return -1; + if (right == null) return 1; + + const leftDate = Date.parse(String(left)); + const rightDate = Date.parse(String(right)); + if (!Number.isNaN(leftDate) && !Number.isNaN(rightDate)) { + return leftDate - rightDate; + } + + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + + return String(left).localeCompare(String(right)); +} + +function matchesFilter(row: Record, filter: ExportFilter): boolean { + const value = row[filter.field]; + switch (filter.operator) { + case 'eq': + return value === filter.value; + case 'neq': + return value !== filter.value; + case 'contains': + return String(value ?? '').toLowerCase().includes(String(filter.value ?? '').toLowerCase()); + case 'gt': + return compareValues(value, filter.value) > 0; + case 'gte': + return compareValues(value, filter.value) >= 0; + case 'lt': + return compareValues(value, filter.value) < 0; + case 'lte': + return compareValues(value, filter.value) <= 0; + default: + return true; + } +} + +export function emitProgress( + onProgress: ExportExecutionOptions['onProgress'], + state: ExportProgressState, +): void { + onProgress?.(state); +} + +export function normalizeFilters(input?: Record): ExportFilter[] { + if (!input) { + return []; + } + + return Object.entries(input).map(([field, value]) => ({ + field, + operator: 'eq', + value, + })); +} + +export function prepareExportData( + data: ExportDataset, + options: Pick = {}, +): ExportDataset { + const filters = options.filters ?? []; + const sort = options.sort ?? []; + const columns = options.columns && options.columns.length > 0 ? options.columns : data.headers; + + let rows = [...data.rows]; + + if (filters.length > 0) { + rows = rows.filter((row) => filters.every((filter) => matchesFilter(row, filter))); + } + + if (sort.length > 0) { + rows.sort((left, right) => { + for (const rule of sort) { + const comparison = compareValues(left[rule.field], right[rule.field]); + if (comparison !== 0) { + return rule.direction === 'desc' ? comparison * -1 : comparison; + } + } + + return 0; + }); + } + + return { + headers: columns, + rows: rows.map((row) => + Object.fromEntries(columns.map((column) => [column, row[column] ?? ''])), + ), + }; +} + +export function defaultSort(columns?: string[]): ExportSort[] { + if (!columns || columns.length === 0) { + return []; + } + + const dateColumn = columns.find((column) => /date|created|updated/i.test(column)); + if (dateColumn) { + return [{ field: dateColumn, direction: 'desc' }]; + } + + return [{ field: columns[0], direction: 'asc' }]; +} diff --git a/src/lib/logging/index.ts b/src/lib/logging/index.ts new file mode 100644 index 00000000..3bf88cf5 --- /dev/null +++ b/src/lib/logging/index.ts @@ -0,0 +1,147 @@ +import pino from 'pino'; +import { createCounterMetric } from './performance'; +import { HttpLogTransport, InMemoryLogTransport, queryLogRecords } from './transports'; +import { LogLevel, LogQuery, LogRecord, LogTransport, PerformanceMetric } from './types'; + +const LOG_LEVELS: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +const configuredLevel = ((process.env.LOG_LEVEL || + process.env.NEXT_PUBLIC_LOG_LEVEL || + 'info') as LogLevel) || 'info'; + +const pinoLogger = pino({ + level: configuredLevel, + timestamp: pino.stdTimeFunctions.isoTime, + base: undefined, + enabled: process.env.NODE_ENV !== 'test', +}); + +const transports: LogTransport[] = [new InMemoryLogTransport()]; +const aggregationEndpoint = + process.env.LOG_AGGREGATION_URL || process.env.NEXT_PUBLIC_LOG_AGGREGATION_URL; + +if (aggregationEndpoint) { + transports.push(new HttpLogTransport(aggregationEndpoint)); +} + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[configuredLevel]; +} + +function normalizeError(error: unknown): LogRecord['error'] | undefined { + if (!error) { + return undefined; + } + + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return { + message: String(error), + }; +} + +export interface LogPayload { + requestId?: string; + context?: Record; + metrics?: PerformanceMetric[]; + error?: unknown; +} + +export interface AppLogger { + debug(message: string, payload?: LogPayload): void; + info(message: string, payload?: LogPayload): void; + warn(message: string, payload?: LogPayload): void; + error(message: string, payload?: LogPayload): void; + child(scope: string, context?: Record): AppLogger; +} + +class Logger implements AppLogger { + constructor( + private readonly scope: string, + private readonly baseContext: Record = {}, + ) {} + + debug(message: string, payload: LogPayload = {}): void { + this.write('debug', message, payload); + } + + info(message: string, payload: LogPayload = {}): void { + this.write('info', message, payload); + } + + warn(message: string, payload: LogPayload = {}): void { + this.write('warn', message, payload); + } + + error(message: string, payload: LogPayload = {}): void { + this.write('error', message, payload); + } + + child(scope: string, context: Record = {}): AppLogger { + return new Logger(scope, { ...this.baseContext, ...context }); + } + + private write(level: LogLevel, message: string, payload: LogPayload): void { + if (!shouldLog(level)) { + return; + } + + const record: LogRecord = { + level, + message, + scope: this.scope, + timestamp: new Date().toISOString(), + requestId: payload.requestId, + context: { + ...this.baseContext, + ...(payload.context ?? {}), + }, + metrics: payload.metrics, + error: normalizeError(payload.error), + }; + + pinoLogger[level]( + { + scope: record.scope, + requestId: record.requestId, + context: record.context, + metrics: record.metrics, + error: record.error, + }, + message, + ); + + for (const transport of transports) { + void Promise.resolve(transport.write(record)); + } + + createCounterMetric('logs.total', 1, { + level, + scope: this.scope, + }); + } +} + +export function createLogger( + scope: string, + context: Record = {}, +): AppLogger { + return new Logger(scope, context); +} + +export function queryLogs(query: LogQuery): LogRecord[] { + return queryLogRecords(query); +} + +export const logger = createLogger('app'); diff --git a/src/lib/logging/logger.test.ts b/src/lib/logging/logger.test.ts new file mode 100644 index 00000000..53861e07 --- /dev/null +++ b/src/lib/logging/logger.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createLogger, queryLogs } from './index'; + +describe('structured logging', () => { + beforeEach(() => { + globalThis.__TEACHLINK_LOG_RECORDS__ = []; + globalThis.__TEACHLINK_METRICS__ = []; + }); + + it('stores structured records that can be filtered by level', () => { + const log = createLogger('tests.logging', { feature: 'monitoring' }); + + log.info('hello world', { context: { ticket: 244 } }); + log.error('something failed', { error: new Error('boom') }); + + const infoLogs = queryLogs({ level: 'info' }); + const errorLogs = queryLogs({ level: 'error' }); + + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.context).toMatchObject({ feature: 'monitoring', ticket: 244 }); + expect(errorLogs).toHaveLength(1); + expect(errorLogs[0]?.error?.message).toContain('boom'); + }); + + it('supports search filtering for aggregated logs', () => { + const log = createLogger('tests.logging'); + log.warn('export stalled', { context: { templateId: 'template-1' } }); + log.info('background heartbeat'); + + const results = queryLogs({ search: 'template-1' }); + expect(results).toHaveLength(1); + expect(results[0]?.message).toBe('export stalled'); + }); +}); diff --git a/src/lib/logging/performance.ts b/src/lib/logging/performance.ts new file mode 100644 index 00000000..89dce040 --- /dev/null +++ b/src/lib/logging/performance.ts @@ -0,0 +1,62 @@ +import { PerformanceMetric } from './types'; + +declare global { + var __TEACHLINK_METRICS__: PerformanceMetric[] | undefined; +} + +function getMetricStore(): PerformanceMetric[] { + if (!globalThis.__TEACHLINK_METRICS__) { + globalThis.__TEACHLINK_METRICS__ = []; + } + + return globalThis.__TEACHLINK_METRICS__; +} + +export function recordMetric(metric: PerformanceMetric): PerformanceMetric { + const store = getMetricStore(); + store.push(metric); + + if (store.length > 200) { + store.splice(0, store.length - 200); + } + + return metric; +} + +export function getRecordedMetrics(limit: number = 50): PerformanceMetric[] { + const store = getMetricStore(); + return store.slice(-limit); +} + +export function createCounterMetric( + name: string, + value: number = 1, + tags?: PerformanceMetric['tags'], +): PerformanceMetric { + return recordMetric({ + name, + value, + unit: 'count', + timestamp: Date.now(), + tags, + }); +} + +export async function measureAsync( + name: string, + operation: () => Promise, + tags?: PerformanceMetric['tags'], +): Promise<{ result: T; metric: PerformanceMetric }> { + const start = globalThis.performance?.now?.() ?? Date.now(); + const result = await operation(); + const end = globalThis.performance?.now?.() ?? Date.now(); + const metric = recordMetric({ + name, + value: Number((end - start).toFixed(2)), + unit: 'ms', + timestamp: Date.now(), + tags, + }); + + return { result, metric }; +} diff --git a/src/lib/logging/transports.ts b/src/lib/logging/transports.ts new file mode 100644 index 00000000..81b0fdeb --- /dev/null +++ b/src/lib/logging/transports.ts @@ -0,0 +1,93 @@ +import { LogQuery, LogRecord, LogTransport } from './types'; + +declare global { + var __TEACHLINK_LOG_RECORDS__: LogRecord[] | undefined; +} + +function getStore(): LogRecord[] { + if (!globalThis.__TEACHLINK_LOG_RECORDS__) { + globalThis.__TEACHLINK_LOG_RECORDS__ = []; + } + + return globalThis.__TEACHLINK_LOG_RECORDS__; +} + +function matchesQuery(record: LogRecord, query: LogQuery): boolean { + if (query.level) { + const levels = Array.isArray(query.level) ? query.level : [query.level]; + if (!levels.includes(record.level)) { + return false; + } + } + + if (query.scope && record.scope !== query.scope) { + return false; + } + + if (query.requestId && record.requestId !== query.requestId) { + return false; + } + + if (query.since && new Date(record.timestamp).getTime() < query.since) { + return false; + } + + if (query.search) { + const haystack = JSON.stringify(record).toLowerCase(); + if (!haystack.includes(query.search.toLowerCase())) { + return false; + } + } + + return true; +} + +export class InMemoryLogTransport implements LogTransport { + name = 'in-memory'; + + constructor(private readonly maxEntries: number = 500) {} + + write(record: LogRecord): void { + const store = getStore(); + store.push(record); + + if (store.length > this.maxEntries) { + store.splice(0, store.length - this.maxEntries); + } + } + + query(query: LogQuery): LogRecord[] { + const store = getStore(); + const filtered = store.filter((record) => matchesQuery(record, query)); + const limit = query.limit ?? filtered.length; + return filtered.slice(-limit); + } +} + +export class HttpLogTransport implements LogTransport { + name = 'http'; + + constructor(private readonly endpoint: string) {} + + async write(record: LogRecord): Promise { + if (!this.endpoint || typeof fetch !== 'function') { + return; + } + + try { + await fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(record), + }); + } catch { + // Avoid breaking the app because a remote transport is unavailable. + } + } +} + +export function queryLogRecords(query: LogQuery): LogRecord[] { + return new InMemoryLogTransport().query(query); +} diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts new file mode 100644 index 00000000..966d9247 --- /dev/null +++ b/src/lib/logging/types.ts @@ -0,0 +1,40 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface PerformanceMetric { + name: string; + value: number; + unit: 'ms' | 'count'; + timestamp: number; + tags?: Record; +} + +export interface LogRecord { + level: LogLevel; + message: string; + scope: string; + timestamp: string; + requestId?: string; + context?: Record; + metrics?: PerformanceMetric[]; + error?: { + name?: string; + message: string; + stack?: string; + }; +} + +export interface LogQuery { + level?: LogLevel | LogLevel[]; + scope?: string; + requestId?: string; + since?: number; + search?: string; + limit?: number; +} + +export interface LogTransport { + name: string; + write(record: LogRecord): void | Promise; + query?(query: LogQuery): LogRecord[] | Promise; + flush?(): void | Promise; +} diff --git a/src/lib/monitoring/metrics.ts b/src/lib/monitoring/metrics.ts index 745c405a..9e6898a6 100644 --- a/src/lib/monitoring/metrics.ts +++ b/src/lib/monitoring/metrics.ts @@ -12,8 +12,10 @@ export function useMetrics() { setMetrics(data); }; - fetchMetrics(); - const interval = setInterval(fetchMetrics, 5000); // real-time updates + void fetchMetrics(); + const interval = setInterval(() => { + void fetchMetrics(); + }, 5000); return () => clearInterval(interval); }, []); diff --git a/src/lib/monitoring/provider.ts b/src/lib/monitoring/provider.ts index 26baabd3..de801b8a 100644 --- a/src/lib/monitoring/provider.ts +++ b/src/lib/monitoring/provider.ts @@ -1,32 +1,36 @@ +import { getRecordedMetrics } from '@/lib/logging/performance'; + export type Metric = { name: string; value: number; timestamp: number; + unit?: string; + tags?: Record; }; export interface MonitoringProvider { getMetrics(): Promise; } -// Simple local provider (can swap with Datadog/New Relic later) export class LocalMonitoringProvider implements MonitoringProvider { async getMetrics(): Promise { - const baseMetrics: Metric[] = [ - { - name: 'response_time', - value: Math.random() * 500, - timestamp: Date.now(), - }, - { - name: 'error_rate', - value: Math.random() * 5, - timestamp: Date.now(), - }, - ]; + const baseMetrics: Metric[] = getRecordedMetrics(20).map((metric) => ({ + name: metric.name, + value: metric.value, + timestamp: metric.timestamp, + unit: metric.unit, + tags: metric.tags, + })); try { const response = await fetch('/api/performance/db-metrics'); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const result = await response.json(); + if (result.success && Array.isArray(result.data)) { return [...baseMetrics, ...result.data]; } @@ -36,4 +40,4 @@ export class LocalMonitoringProvider implements MonitoringProvider { return baseMetrics; } -} +} \ No newline at end of file diff --git a/src/middleware/logger.ts b/src/middleware/logger.ts new file mode 100644 index 00000000..2f1ddb1b --- /dev/null +++ b/src/middleware/logger.ts @@ -0,0 +1,94 @@ +import { NextRequest } from 'next/server'; +import { createCounterMetric, recordMetric } from '@/lib/logging/performance'; +import { createLogger } from '@/lib/logging'; + +function createRequestId(): string { + return `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function getResponseStatus(result: unknown): number | undefined { + if (typeof Response !== 'undefined' && result instanceof Response) { + return result.status; + } + + return undefined; +} + +export function createRequestLogger( + request: NextRequest, + scope: string, + context: Record = {}, +) { + const requestId = request.headers.get('x-request-id') ?? createRequestId(); + + return createLogger(scope, { + ...context, + requestId, + method: request.method, + path: request.nextUrl.pathname, + }); +} + +export async function withRequestLogging( + request: NextRequest, + scope: string, + handler: (requestId: string) => Promise, +): Promise { + const requestId = request.headers.get('x-request-id') ?? createRequestId(); + const log = createLogger(scope, { + requestId, + method: request.method, + path: request.nextUrl.pathname, + }); + const start = globalThis.performance?.now?.() ?? Date.now(); + + log.info('Request started', { requestId }); + + try { + const result = await handler(requestId); + const duration = Number( + ((globalThis.performance?.now?.() ?? Date.now()) - start).toFixed(2), + ); + const status = getResponseStatus(result); + const metric = recordMetric({ + name: 'http.request.duration', + value: duration, + unit: 'ms', + timestamp: Date.now(), + tags: { + method: request.method, + path: request.nextUrl.pathname, + status: status ?? 'unknown', + }, + }); + + log.info('Request completed', { + requestId, + context: { status }, + metrics: [metric, createCounterMetric('http.requests', 1, { status: status ?? 'unknown' })], + }); + + return result; + } catch (error) { + const duration = Number( + ((globalThis.performance?.now?.() ?? Date.now()) - start).toFixed(2), + ); + const metric = recordMetric({ + name: 'http.request.failure_duration', + value: duration, + unit: 'ms', + timestamp: Date.now(), + tags: { + method: request.method, + path: request.nextUrl.pathname, + }, + }); + + log.error('Request failed', { + requestId, + error, + metrics: [metric, createCounterMetric('http.request.errors')], + }); + throw error; + } +} diff --git a/src/pages/exports/index.tsx b/src/pages/exports/index.tsx index a2800bff..c48284c5 100644 --- a/src/pages/exports/index.tsx +++ b/src/pages/exports/index.tsx @@ -3,10 +3,12 @@ * Main page for managing export templates and schedules */ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import { ExportTemplate, ExportSchedule, ExportHistory } from '@/lib/export-scheduler'; +import ExportButton from '@/components/ExportButton'; import { apiClient } from '@/lib/api'; +import { defaultSort, normalizeFilters } from '@/lib/export'; +import { ExportHistory, ExportSchedule, ExportTemplate } from '@/lib/export-scheduler'; export default function ExportsPage() { const router = useRouter(); @@ -17,7 +19,7 @@ export default function ExportsPage() { const [loading, setLoading] = useState(true); useEffect(() => { - loadData(); + void loadData(); }, [activeTab]); const loadData = async () => { @@ -44,39 +46,11 @@ export default function ExportsPage() { } }; - const handleCreateTemplate = () => { - router.push('/exports/templates/new'); - }; - - const handleCreateSchedule = () => { - router.push('/exports/schedules/new'); - }; - - const handleExecuteExport = async (templateId: string) => { - try { - await apiClient.post('/api/exports/execute', { templateId }); - alert('Export started! You will receive an email when it completes.'); - loadData(); - } catch (error) { - console.error('Error executing export:', error); - alert('Failed to start export'); - } - }; - - const handleToggleSchedule = async (scheduleId: string, enabled: boolean) => { - try { - await apiClient.patch(`/api/exports/schedules/${scheduleId}`, { enabled }); - loadData(); - } catch (error) { - console.error('Error toggling schedule:', error); - } - }; - const handleDeleteTemplate = async (id: string) => { if (!confirm('Are you sure you want to delete this template?')) return; try { await apiClient.delete(`/api/exports/templates/${id}`); - loadData(); + void loadData(); } catch (error) { console.error('Error deleting template:', error); } @@ -86,48 +60,58 @@ export default function ExportsPage() { if (!confirm('Are you sure you want to delete this schedule?')) return; try { await apiClient.delete(`/api/exports/schedules/${id}`); - loadData(); + void loadData(); } catch (error) { console.error('Error deleting schedule:', error); } }; + const handleToggleSchedule = async (scheduleId: string, enabled: boolean) => { + try { + await apiClient.patch(`/api/exports/schedules/${scheduleId}`, { enabled }); + void loadData(); + } catch (error) { + console.error('Error toggling schedule:', error); + } + }; + return (
-

Data Exports

-

Manage export templates, schedules, and history

+

Data Exports

+

+ Manage export templates, schedules, history, and on-demand filtered exports. +

- {/* Tabs */} -
+
- {/* Content */} {loading ? ( -
-
+
+

Loading...

) : ( <> {activeTab === 'templates' && (
-
-

Export Templates

+
+
+

Export Templates

+

+ Exports keep template filters and apply a deterministic sort before generating files. +

+
+ {templates.length === 0 ? ( -
+

No templates yet. Create your first template!

) : (
{templates.map((template) => ( -
-
+
+
-

{template.name}

+

{template.name}

{template.description && ( -

{template.description}

+

{template.description}

)} -
+
Format: {template.format.toUpperCase()} Source: {template.dataSource} + Columns: {(template.columns ?? []).length || 'Default'}
-
- - - + +
+ { + setActiveTab('history'); + void loadData(); + }} + className="rounded border border-blue-600 px-3 py-1 text-blue-600 hover:text-blue-800" + /> +
+ + +
@@ -203,27 +202,27 @@ export default function ExportsPage() { {activeTab === 'schedules' && (
-
+

Export Schedules

{schedules.length === 0 ? ( -
+

No schedules yet. Create your first schedule!

) : (
{schedules.map((schedule) => ( -
+
-

{schedule.name}

-
+

{schedule.name}

+
Frequency: {schedule.frequency} Next run: {new Date(schedule.nextRunAt).toLocaleString()} @@ -231,7 +230,7 @@ export default function ExportsPage() {
{schedule.emailDelivery && ( -

+

Email delivery to: {schedule.emailRecipients?.join(', ')}

)} @@ -239,23 +238,23 @@ export default function ExportsPage() {
@@ -270,9 +269,9 @@ export default function ExportsPage() { {activeTab === 'history' && (
-

Export History

+

Export History

{history.length === 0 ? ( -
+

No export history yet.

) : ( @@ -280,36 +279,36 @@ export default function ExportsPage() { - - - - - - - + {history.map((item) => ( - - + - - - -
+ File Name + Format + Status + Size + Date + Actions
{item.fileName} + {item.fileName} {item.format.toUpperCase()} + + {(item.fileSize / 1024).toFixed(2)} KB + {new Date(item.executedAt).toLocaleString()} + {item.downloadUrl && (