veza/apps/web/src/utils/reportIssue.ts
senke 8efd398239 refactor(frontend): eliminate ~45 'any' types in production code
CLN-04: Replaced any with unknown, proper interfaces, or concrete
types across 17 files. Focus: error handlers, API responses,
WebSocket data, and function parameters.
2026-02-22 17:44:49 +01:00

282 lines
7.5 KiB
TypeScript

/**
* Support Issue Reporting Utility
* Formats error information for reporting to support/GitHub issues
*/
import type { ApiError } from '@/schemas/apiSchemas';
import { parseApiError } from './apiErrorHandler';
import { getErrorCategory } from './apiErrorHandler';
export interface IssueReport {
title: string;
body: string;
metadata: {
requestId?: string;
errorCode?: string | number;
statusCode?: number;
category: string;
timestamp: string;
userAgent: string;
url: string;
};
}
/**
* Formats an error into a structured issue report
* @param error - The error to report (ApiError, Error, or unknown)
* @param context - Additional context about where/why the error occurred
* @returns Formatted issue report
*/
export function formatIssueReport(
error: Error | ApiError | unknown,
context?: {
component?: string;
action?: string;
userId?: string;
additionalInfo?: Record<string, unknown>;
},
): IssueReport {
const apiError = parseApiError(error);
const category = getErrorCategory(apiError);
// Get browser/environment info
const userAgent =
typeof navigator !== 'undefined' ? navigator.userAgent : 'Unknown';
const url = typeof window !== 'undefined' ? window.location.href : 'Unknown';
const timestamp = new Date().toISOString();
// Build title
const title = `[${category.toUpperCase()}] ${apiError.message || 'Unknown error'}`;
// Build body with structured information
const sections: string[] = [];
// Error details section
sections.push('## 🐞 Error Details');
sections.push('');
sections.push(`**Message:** ${apiError.message || 'No message provided'}`);
if (apiError.code !== undefined) {
sections.push(`**Error Code:** ${apiError.code}`);
}
if (apiError.code !== undefined) {
sections.push(`**HTTP Status:** ${apiError.code}`);
}
if (apiError.request_id) {
sections.push(`**Request ID:** \`${apiError.request_id}\``);
}
sections.push(`**Category:** ${category}`);
sections.push(`**Timestamp:** ${timestamp}`);
sections.push('');
// Context section
if (context) {
sections.push('## 📍 Context');
sections.push('');
if (context.component) {
sections.push(`**Component:** ${context.component}`);
}
if (context.action) {
sections.push(`**Action:** ${context.action}`);
}
if (context.userId) {
sections.push(`**User ID:** ${context.userId}`);
}
if (
context.additionalInfo &&
Object.keys(context.additionalInfo).length > 0
) {
sections.push('**Additional Info:**');
sections.push('```json');
sections.push(JSON.stringify(context.additionalInfo, null, 2));
sections.push('```');
}
sections.push('');
}
// Error details section
if (apiError.details) {
sections.push('## 🔍 Error Details');
sections.push('');
sections.push('```json');
sections.push(JSON.stringify(apiError.details, null, 2));
sections.push('```');
sections.push('');
}
// Validation errors section
if (apiError.details && apiError.details.length > 0) {
sections.push('## ⚠️ Validation Errors');
sections.push('');
apiError.details.forEach((detail) => {
sections.push(
`- **${detail.field}:** ${detail.message}${detail.value ? ` (value: ${detail.value})` : ''}`,
);
});
sections.push('');
}
// Environment section
sections.push('## 💻 Environment');
sections.push('');
sections.push(`**URL:** ${url}`);
sections.push(`**User Agent:** ${userAgent}`);
sections.push(`**Browser:** ${getBrowserName(userAgent)}`);
sections.push(`**Platform:** ${getPlatformName(userAgent)}`);
if (typeof window !== 'undefined') {
sections.push(`**Screen:** ${window.screen.width}x${window.screen.height}`);
sections.push(`**Viewport:** ${window.innerWidth}x${window.innerHeight}`);
}
sections.push('');
// Steps to reproduce (placeholder)
sections.push('## 🔁 Steps to Reproduce');
sections.push('');
sections.push('1. [Describe step 1]');
sections.push('2. [Describe step 2]');
sections.push('3. [Describe step 3]');
sections.push('');
// Expected behavior
sections.push('## ✅ Expected Behavior');
sections.push('');
sections.push('[Describe what should have happened]');
sections.push('');
const body = sections.join('\n');
return {
title,
body,
metadata: {
requestId: apiError.request_id,
errorCode: apiError.code,
statusCode: apiError.code,
category,
timestamp,
userAgent,
url,
},
};
}
/**
* Copies issue report to clipboard
* @param report - The issue report to copy
* @returns Promise that resolves when copy is complete
*/
export async function copyIssueReportToClipboard(
report: IssueReport,
): Promise<void> {
const text = `${report.title}\n\n${report.body}`;
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
navigator.clipboard.writeText
) {
try {
await navigator.clipboard.writeText(text);
return;
} catch (err) {
// Fallback to legacy method
}
}
// Fallback: create temporary textarea
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
}
/**
* Opens GitHub issue creation page with pre-filled issue report
* @param report - The issue report to use
* @param repository - GitHub repository (default: uses current repo if available)
*/
export function openGitHubIssue(
report: IssueReport,
repository?: string,
): void {
// Try to detect repository from package.json or env
const repo = repository || getRepositoryUrl();
if (!repo) {
// Fallback: just copy to clipboard
copyIssueReportToClipboard(report);
return;
}
// GitHub issue URL format: https://github.com/owner/repo/issues/new?title=...&body=...
const params = new URLSearchParams({
title: report.title,
body: report.body,
});
const url = `${repo}/issues/new?${params.toString()}`;
window.open(url, '_blank');
}
/**
* Gets repository URL from package.json or environment
*/
function getRepositoryUrl(): string | null {
// Try to get from environment variable
if (import.meta.env.VITE_GITHUB_REPO) {
return `https://github.com/${import.meta.env.VITE_GITHUB_REPO}`;
}
// Try to parse from package.json repository field (would need to be passed or read)
// For now, return null and fallback to clipboard
return null;
}
/**
* Extracts browser name from user agent
*/
function getBrowserName(userAgent: string): string {
if (userAgent.includes('Chrome') && !userAgent.includes('Edg'))
return 'Chrome';
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Safari') && !userAgent.includes('Chrome'))
return 'Safari';
if (userAgent.includes('Edg')) return 'Edge';
if (userAgent.includes('Opera') || userAgent.includes('OPR')) return 'Opera';
return 'Unknown';
}
/**
* Extracts platform name from user agent
*/
function getPlatformName(userAgent: string): string {
if (userAgent.includes('Windows')) return 'Windows';
if (userAgent.includes('Mac')) return 'macOS';
if (userAgent.includes('Linux')) return 'Linux';
if (userAgent.includes('Android')) return 'Android';
if (
userAgent.includes('iOS') ||
userAgent.includes('iPhone') ||
userAgent.includes('iPad')
)
return 'iOS';
return 'Unknown';
}