feat: comprehensive security and architecture improvements

- Add Zod validation schemas with strong password requirements (12+ chars, complexity)
- Implement rate limiting for authentication endpoints (registration, password reset)
- Remove duplicate MetricCard component, consolidate to ui/metric-card.tsx
- Update README.md to use pnpm commands consistently
- Enhance authentication security with 12-round bcrypt hashing
- Add comprehensive input validation for all API endpoints
- Fix security vulnerabilities in user registration and password reset flows

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-06-28 01:52:53 +02:00
parent 192f9497b4
commit 7f48a085bf
68 changed files with 8045 additions and 4542 deletions

View File

@ -22,7 +22,9 @@ async function fetchTranscriptContent(
});
if (!response.ok) {
console.warn(`Failed to fetch transcript from ${url}: ${response.statusText}`);
console.warn(
`Failed to fetch transcript from ${url}: ${response.statusText}`
);
return null;
}
@ -42,7 +44,7 @@ function parseTranscriptToMessages(transcriptContent: string): Array<{
content: string;
order: number;
}> {
const lines = transcriptContent.split('\n').filter(line => line.trim());
const lines = transcriptContent.split("\n").filter((line) => line.trim());
const messages: Array<{
timestamp: Date | null;
role: string;
@ -55,10 +57,10 @@ function parseTranscriptToMessages(transcriptContent: string): Array<{
for (const line of lines) {
// Try to parse lines in format: [timestamp] role: content
const match = line.match(/^\[([^\]]+)\]\s*([^:]+):\s*(.+)$/);
if (match) {
const [, timestampStr, role, content] = match;
// Try to parse the timestamp
let timestamp: Date | null = null;
try {
@ -79,12 +81,12 @@ function parseTranscriptToMessages(transcriptContent: string): Array<{
} else {
// If line doesn't match expected format, treat as content continuation
if (messages.length > 0) {
messages[messages.length - 1].content += '\n' + line;
messages[messages.length - 1].content += "\n" + line;
} else {
// First line doesn't match format, create a generic message
messages.push({
timestamp: null,
role: 'unknown',
role: "unknown",
content: line,
order: order++,
});
@ -120,7 +122,9 @@ async function fetchTranscriptsForSessions() {
return;
}
console.log(`Found ${sessionsNeedingTranscripts.length} sessions that need transcript fetching.`);
console.log(
`Found ${sessionsNeedingTranscripts.length} sessions that need transcript fetching.`
);
let successCount = 0;
let errorCount = 0;
@ -131,7 +135,7 @@ async function fetchTranscriptsForSessions() {
}
console.log(`Fetching transcript for session ${session.id}...`);
try {
// Fetch transcript content
const transcriptContent = await fetchTranscriptContent(
@ -153,7 +157,7 @@ async function fetchTranscriptsForSessions() {
// Create messages in database
await prisma.message.createMany({
data: messages.map(msg => ({
data: messages.map((msg) => ({
sessionId: session.id,
timestamp: msg.timestamp,
role: msg.role,
@ -162,10 +166,15 @@ async function fetchTranscriptsForSessions() {
})),
});
console.log(`Successfully fetched transcript for session ${session.id} (${messages.length} messages)`);
console.log(
`Successfully fetched transcript for session ${session.id} (${messages.length} messages)`
);
successCount++;
} catch (error) {
console.error(`Error fetching transcript for session ${session.id}:`, error);
console.error(
`Error fetching transcript for session ${session.id}:`,
error
);
errorCount++;
}
}

View File

@ -37,7 +37,9 @@ async function fetchTranscriptContent(
});
if (!response.ok) {
console.warn(`Failed to fetch transcript from ${url}: ${response.statusText}`);
console.warn(
`Failed to fetch transcript from ${url}: ${response.statusText}`
);
return null;
}
return await response.text();
@ -140,10 +142,19 @@ async function processTranscriptWithOpenAI(
/**
* Validates the OpenAI response against our expected schema
*/
function validateOpenAIResponse(data: any): asserts data is OpenAIProcessedData {
function validateOpenAIResponse(
data: any
): asserts data is OpenAIProcessedData {
const requiredFields = [
"language", "messages_sent", "sentiment", "escalated",
"forwarded_hr", "category", "questions", "summary", "session_id"
"language",
"messages_sent",
"sentiment",
"escalated",
"forwarded_hr",
"category",
"questions",
"summary",
"session_id",
];
for (const field of requiredFields) {
@ -153,7 +164,9 @@ function validateOpenAIResponse(data: any): asserts data is OpenAIProcessedData
}
if (typeof data.language !== "string" || !/^[a-z]{2}$/.test(data.language)) {
throw new Error("Invalid language format. Expected ISO 639-1 code (e.g., 'en')");
throw new Error(
"Invalid language format. Expected ISO 639-1 code (e.g., 'en')"
);
}
if (typeof data.messages_sent !== "number" || data.messages_sent < 0) {
@ -161,7 +174,9 @@ function validateOpenAIResponse(data: any): asserts data is OpenAIProcessedData
}
if (!["positive", "neutral", "negative"].includes(data.sentiment)) {
throw new Error("Invalid sentiment. Expected 'positive', 'neutral', or 'negative'");
throw new Error(
"Invalid sentiment. Expected 'positive', 'neutral', or 'negative'"
);
}
if (typeof data.escalated !== "boolean") {
@ -173,22 +188,39 @@ function validateOpenAIResponse(data: any): asserts data is OpenAIProcessedData
}
const validCategories = [
"Schedule & Hours", "Leave & Vacation", "Sick Leave & Recovery",
"Salary & Compensation", "Contract & Hours", "Onboarding", "Offboarding",
"Workwear & Staff Pass", "Team & Contacts", "Personal Questions",
"Access & Login", "Social questions", "Unrecognized / Other"
"Schedule & Hours",
"Leave & Vacation",
"Sick Leave & Recovery",
"Salary & Compensation",
"Contract & Hours",
"Onboarding",
"Offboarding",
"Workwear & Staff Pass",
"Team & Contacts",
"Personal Questions",
"Access & Login",
"Social questions",
"Unrecognized / Other",
];
if (!validCategories.includes(data.category)) {
throw new Error(`Invalid category. Expected one of: ${validCategories.join(", ")}`);
throw new Error(
`Invalid category. Expected one of: ${validCategories.join(", ")}`
);
}
if (!Array.isArray(data.questions)) {
throw new Error("Invalid questions. Expected array of strings");
}
if (typeof data.summary !== "string" || data.summary.length < 10 || data.summary.length > 300) {
throw new Error("Invalid summary. Expected string between 10-300 characters");
if (
typeof data.summary !== "string" ||
data.summary.length < 10 ||
data.summary.length > 300
) {
throw new Error(
"Invalid summary. Expected string between 10-300 characters"
);
}
if (typeof data.session_id !== "string") {
@ -218,18 +250,24 @@ async function processUnprocessedSessions() {
return;
}
console.log(`Found ${importsToProcess.length} SessionImport records to process.`);
console.log(
`Found ${importsToProcess.length} SessionImport records to process.`
);
let successCount = 0;
let errorCount = 0;
for (const importRecord of importsToProcess) {
if (!importRecord.fullTranscriptUrl) {
console.warn(`SessionImport ${importRecord.id} has no transcript URL, skipping.`);
console.warn(
`SessionImport ${importRecord.id} has no transcript URL, skipping.`
);
continue;
}
console.log(`Processing transcript for SessionImport ${importRecord.id}...`);
console.log(
`Processing transcript for SessionImport ${importRecord.id}...`
);
try {
// Mark as processing (status field doesn't exist in new schema)
console.log(`Processing SessionImport ${importRecord.id}...`);
@ -265,7 +303,10 @@ async function processUnprocessedSessions() {
country: importRecord.countryCode,
language: processedData.language,
messagesSent: processedData.messages_sent,
sentiment: processedData.sentiment.toUpperCase() as "POSITIVE" | "NEUTRAL" | "NEGATIVE",
sentiment: processedData.sentiment.toUpperCase() as
| "POSITIVE"
| "NEUTRAL"
| "NEGATIVE",
escalated: processedData.escalated,
forwardedHr: processedData.forwarded_hr,
fullTranscriptUrl: importRecord.fullTranscriptUrl,
@ -284,7 +325,10 @@ async function processUnprocessedSessions() {
country: importRecord.countryCode,
language: processedData.language,
messagesSent: processedData.messages_sent,
sentiment: processedData.sentiment.toUpperCase() as "POSITIVE" | "NEUTRAL" | "NEGATIVE",
sentiment: processedData.sentiment.toUpperCase() as
| "POSITIVE"
| "NEUTRAL"
| "NEGATIVE",
escalated: processedData.escalated,
forwardedHr: processedData.forwarded_hr,
fullTranscriptUrl: importRecord.fullTranscriptUrl,
@ -296,16 +340,25 @@ async function processUnprocessedSessions() {
});
// Mark SessionImport as processed (processedAt field doesn't exist in new schema)
console.log(`Successfully processed SessionImport ${importRecord.id} -> Session ${session.id}`);
console.log(
`Successfully processed SessionImport ${importRecord.id} -> Session ${session.id}`
);
console.log(`Successfully processed SessionImport ${importRecord.id} -> Session ${session.id}`);
console.log(
`Successfully processed SessionImport ${importRecord.id} -> Session ${session.id}`
);
successCount++;
} catch (error) {
console.error(`Error processing SessionImport ${importRecord.id}:`, error);
console.error(
`Error processing SessionImport ${importRecord.id}:`,
error
);
// Log error (status and errorMsg fields don't exist in new schema)
console.error(`Failed to process SessionImport ${importRecord.id}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`Failed to process SessionImport ${importRecord.id}: ${error instanceof Error ? error.message : String(error)}`
);
errorCount++;
}
}