feat: [Fireflies] log cleanly (#15618)
## [0.2.2] - 2025-11-04 ### Added - **Enhanced logging system**: Introduced configurable `AppLogger` class with log level support (debug, info, warn, error, silent) - Environment-based log level configuration via `LOG_LEVEL` environment variable - Test environment detection to prevent log noise during testing - Context-aware logging with proper prefixes for better debugging - **Improved error handling**: Enhanced webhook signature verification with detailed debug logging - **Better debugging capabilities**: Added comprehensive logging throughout webhook processing pipeline ### Enhanced - **Webhook signature verification**: Improved signature validation with detailed logging for troubleshooting - **Error messages**: More descriptive error logging for failed operations and security violations - **Development experience**: Better debugging information for webhook processing and API interactions
This commit is contained in:
@@ -77,10 +77,14 @@ FIREFLIES_MAX_POLLS=5
|
||||
# Debugging & Logging
|
||||
# =============================================================================
|
||||
|
||||
# Enable debug logging (true/false)
|
||||
# When enabled, detailed logs will be output to console
|
||||
# Useful for troubleshooting webhook processing
|
||||
DEBUG_LOGS=false
|
||||
# Log level: silent, error, warn, info, debug (default: error)
|
||||
# Controls verbosity of console output
|
||||
# - silent: No console output
|
||||
# - error: Only errors (production default)
|
||||
# - warn: Warnings and errors
|
||||
# - info: Info, warnings, and errors
|
||||
# - debug: All logs including detailed debugging
|
||||
LOG_LEVEL=error
|
||||
|
||||
# =============================================================================
|
||||
# Configuration Notes
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.2] - 2025-11-04
|
||||
|
||||
### Added
|
||||
- **Enhanced logging system**: Introduced configurable `AppLogger` class with log level support (debug, info, warn, error, silent)
|
||||
- Environment-based log level configuration via `LOG_LEVEL` environment variable
|
||||
- Test environment detection to prevent log noise during testing
|
||||
- Context-aware logging with proper prefixes for better debugging
|
||||
- **Improved error handling**: Enhanced webhook signature verification with detailed debug logging
|
||||
- **Better debugging capabilities**: Added comprehensive logging throughout webhook processing pipeline
|
||||
|
||||
### Enhanced
|
||||
- **Webhook signature verification**: Improved signature validation with detailed logging for troubleshooting
|
||||
- **Error messages**: More descriptive error logging for failed operations and security violations
|
||||
- **Development experience**: Better debugging information for webhook processing and API interactions
|
||||
|
||||
|
||||
## [0.2.1] - 2025-11-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -31,10 +31,10 @@ const config: ApplicationConfig = {
|
||||
description: 'Whether to auto-create contacts for unknown participants',
|
||||
value: 'true',
|
||||
},
|
||||
DEBUG_LOGS: {
|
||||
universalIdentifier: '009510df-5125-4683-941b-cce94b113242',
|
||||
description: 'Enable verbose logging for debugging (true/false)',
|
||||
value: 'false',
|
||||
LOG_LEVEL: {
|
||||
universalIdentifier: '2b019cf1-d198-48dd-943e-110571aa541e',
|
||||
description: 'Log level: silent, error, warn, info, debug (default: error)',
|
||||
value: 'error',
|
||||
},
|
||||
FIREFLIES_SUMMARY_STRATEGY: {
|
||||
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fireflies",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+31
-47
@@ -1,10 +1,14 @@
|
||||
import { createLogger } from './logger';
|
||||
import type { FirefliesMeetingData, FirefliesParticipant, SummaryFetchConfig } from './types';
|
||||
|
||||
const logger = createLogger('fireflies-api');
|
||||
|
||||
export class FirefliesApiClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
@@ -108,26 +112,22 @@ export class FirefliesApiClient {
|
||||
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
|
||||
// immediate_only: single attempt, no retries
|
||||
if (config.strategy === 'immediate_only') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] fetching meeting ${meetingId} (strategy: immediate_only)`);
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] summary ready: ${ready}`);
|
||||
logger.debug(`summary ready: ${ready}`);
|
||||
return { data: meetingData, summaryReady: ready };
|
||||
}
|
||||
|
||||
// immediate_with_retry: retry with exponential backoff
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
|
||||
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
|
||||
|
||||
if (ready) {
|
||||
return { data: meetingData, summaryReady: true };
|
||||
@@ -135,26 +135,22 @@ export class FirefliesApiClient {
|
||||
|
||||
if (attempt < config.retryAttempts) {
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
|
||||
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] max retries reached, returning partial data`);
|
||||
logger.debug(`max retries reached, returning partial data`);
|
||||
return { data: meetingData, summaryReady: false };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[fireflies-api] attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
|
||||
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
|
||||
|
||||
if (attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] retrying in ${delayMs}ms...`);
|
||||
logger.debug(`retrying in ${delayMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
@@ -174,18 +170,12 @@ export class FirefliesApiClient {
|
||||
const participantsWithEmails: FirefliesParticipant[] = [];
|
||||
const participantsNameOnly: FirefliesParticipant[] = [];
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] === PARTICIPANT EXTRACTION DEBUG ===');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] participants field:', JSON.stringify(transcript.participants));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] speakers field:', transcript.speakers?.map((s: any) => s.name));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] meeting_attendance field:', transcript.meeting_attendance?.map((a: any) => a.name));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] organizer_email:', transcript.organizer_email);
|
||||
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
|
||||
logger.debug('participants field:', JSON.stringify(transcript.participants));
|
||||
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
|
||||
logger.debug('speakers field:', transcript.speakers?.map((s: any) => s.name));
|
||||
logger.debug('meeting_attendance field:', transcript.meeting_attendance?.map((a: any) => a.name));
|
||||
logger.debug('organizer_email:', transcript.organizer_email);
|
||||
|
||||
// Helper function to check if a string is an email
|
||||
const isEmail = (str: string): boolean => {
|
||||
@@ -214,12 +204,14 @@ export class FirefliesApiClient {
|
||||
parts.forEach(part => {
|
||||
const emailMatch = part.match(/<([^>]+)>/);
|
||||
const email = emailMatch ? emailMatch[1] : '';
|
||||
const name = part.replace(/[<>]/g, '').trim();
|
||||
// Extract name properly: if there's an email in angle brackets, get the part before it
|
||||
const name = emailMatch
|
||||
? part.substring(0, part.indexOf('<')).trim()
|
||||
: part.trim();
|
||||
|
||||
// Skip if the "name" is actually an email address
|
||||
if (isEmail(name)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] Skipping participant with email as name: "${name}"`);
|
||||
logger.debug(`Skipping participant with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -230,8 +222,7 @@ export class FirefliesApiClient {
|
||||
|
||||
// Skip duplicates
|
||||
if (isDuplicate(name, email)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] Skipping duplicate participant: "${name}" <${email}>`);
|
||||
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,8 +243,7 @@ export class FirefliesApiClient {
|
||||
|
||||
// Skip if name is actually an email
|
||||
if (isEmail(name)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] Skipping attendee with email as name: "${name}"`);
|
||||
logger.debug(`Skipping attendee with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,8 +264,7 @@ export class FirefliesApiClient {
|
||||
|
||||
// Skip if name is actually an email
|
||||
if (isEmail(name)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] Skipping speaker with email as name: "${name}"`);
|
||||
logger.debug(`Skipping speaker with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,8 +281,7 @@ export class FirefliesApiClient {
|
||||
|
||||
// Skip if name is actually an email or contains comma-separated emails
|
||||
if (isEmail(name) || name.includes(',')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies-api] Skipping attendance with email/list as name: "${name}"`);
|
||||
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -372,14 +360,10 @@ export class FirefliesApiClient {
|
||||
// Return participants with emails first, then name-only participants
|
||||
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] === EXTRACTED PARTICIPANTS ===');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies-api] Total:', allParticipants.length);
|
||||
logger.debug('=== EXTRACTED PARTICIPANTS ===');
|
||||
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
|
||||
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
|
||||
logger.debug('Total:', allParticipants.length);
|
||||
|
||||
return allParticipants;
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
||||
|
||||
export interface LoggerConfig {
|
||||
logLevel: LogLevel;
|
||||
isTestEnvironment: boolean;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
silent: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* App-level Fireflies application logger with configurable log levels.
|
||||
*/
|
||||
export class AppLogger {
|
||||
private config: LoggerConfig;
|
||||
private context: string;
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
this.config = {
|
||||
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
|
||||
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private parseLogLevel(level: string): LogLevel {
|
||||
const normalizedLevel = level.toLowerCase() as LogLevel;
|
||||
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug information (LOG_LEVEL=debug)
|
||||
*/
|
||||
debug(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log informational messages (LOG_LEVEL=info or lower)
|
||||
*/
|
||||
info(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('info')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warnings (LOG_LEVEL=warn or lower)
|
||||
*/
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('warn')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log errors (LOG_LEVEL=error or lower)
|
||||
*/
|
||||
error(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('error')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log critical errors that should ALWAYS be visible regardless of log level
|
||||
* Use sparingly - only for fatal errors, security issues, or data corruption
|
||||
*/
|
||||
critical(message: string, ...args: any[]): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create loggers with automatic context detection
|
||||
*/
|
||||
export const createLogger = (context: string): AppLogger => {
|
||||
return new AppLogger(context);
|
||||
};
|
||||
+15
-20
@@ -1,3 +1,4 @@
|
||||
import { createLogger } from './logger';
|
||||
import type {
|
||||
Contact,
|
||||
CreateMeetingResponse,
|
||||
@@ -11,6 +12,8 @@ import type {
|
||||
MeetingCreateInput,
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('20 CRM Service');
|
||||
|
||||
export class TwentyCrmService {
|
||||
private apiKey: string;
|
||||
private apiUrl: string;
|
||||
@@ -18,6 +21,7 @@ export class TwentyCrmService {
|
||||
|
||||
constructor(apiKey: string, apiUrl: string) {
|
||||
if (!apiKey) {
|
||||
logger.critical('TWENTY_API_KEY is required but not provided - this is a critical configuration error');
|
||||
throw new Error('TWENTY_API_KEY is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
@@ -225,13 +229,13 @@ export class TwentyCrmService {
|
||||
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
|
||||
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
|
||||
|
||||
// Process participants with emails (original logic)
|
||||
// Process participants with emails
|
||||
if (participantsWithEmails.length > 0) {
|
||||
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
|
||||
newContactIds.push(...emailContactIds);
|
||||
}
|
||||
|
||||
// Process participants with names only (new logic)
|
||||
// Process participants with names only
|
||||
if (participantsNameOnly.length > 0) {
|
||||
const nameContactIds = await this.createContactsNameOnly(participantsNameOnly);
|
||||
newContactIds.push(...nameContactIds);
|
||||
@@ -249,8 +253,7 @@ export class TwentyCrmService {
|
||||
if (!existing) {
|
||||
unique.push(participant);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[fireflies] Duplicate participant email detected: ${participant.email}. Using first occurrence.`);
|
||||
logger.warn(`Duplicate participant email detected: ${participant.email}. Using first occurrence.`);
|
||||
}
|
||||
return unique;
|
||||
}, []);
|
||||
@@ -281,8 +284,7 @@ export class TwentyCrmService {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (errorMessage.includes('Duplicate Emails') || errorMessage.includes('BAD_USER_INPUT')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[fireflies] Skipping contact creation for ${participant.email} due to duplicate email constraint: ${errorMessage}`);
|
||||
logger.warn(`Skipping contact creation for ${participant.email} due to duplicate email constraint: ${errorMessage}`);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
@@ -303,8 +305,7 @@ export class TwentyCrmService {
|
||||
if (!existing) {
|
||||
unique.push(participant);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[fireflies] Duplicate participant name detected: ${participant.name}. Using first occurrence.`);
|
||||
logger.warn(`Duplicate participant name detected: ${participant.name}. Using first occurrence.`);
|
||||
}
|
||||
return unique;
|
||||
}, []);
|
||||
@@ -313,8 +314,7 @@ export class TwentyCrmService {
|
||||
// Check if we already have a contact with this exact name to avoid duplicates
|
||||
const existingContact = await this.findContactByName(participant.name);
|
||||
if (existingContact) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[fireflies] Contact with name "${participant.name}" already exists. Skipping creation.`);
|
||||
logger.warn(`Contact with name "${participant.name}" already exists. Skipping creation.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -342,12 +342,10 @@ export class TwentyCrmService {
|
||||
}
|
||||
newContactIds.push(response.data.createPerson.id);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fireflies] Created contact for name-only participant: ${participant.name}`);
|
||||
logger.debug(`Created contact for name-only participant: ${participant.name}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[fireflies] Failed to create contact for ${participant.name}: ${errorMessage}`);
|
||||
logger.warn(`Failed to create contact for ${participant.name}: ${errorMessage}`);
|
||||
// Continue processing other participants instead of failing completely
|
||||
continue;
|
||||
}
|
||||
@@ -446,8 +444,7 @@ export class TwentyCrmService {
|
||||
|
||||
// Debug: log the variables being sent
|
||||
if (!this.isTestEnvironment) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies] createMeeting variables:', JSON.stringify(variables, null, 2));
|
||||
logger.debug('createMeeting variables:', JSON.stringify(variables, null, 2));
|
||||
}
|
||||
|
||||
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
|
||||
@@ -503,8 +500,7 @@ export class TwentyCrmService {
|
||||
|
||||
return json;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[twenty-crm] GraphQL request error:', error);
|
||||
logger.error('GraphQL request error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -519,8 +515,7 @@ export class TwentyCrmService {
|
||||
const variables = { data: meetingData };
|
||||
|
||||
if (!this.isTestEnvironment) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[fireflies] createFailedMeeting variables:', JSON.stringify(variables, null, 2));
|
||||
logger.debug('createFailedMeeting variables:', JSON.stringify(variables, null, 2));
|
||||
}
|
||||
|
||||
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
|
||||
|
||||
+47
-58
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
import { FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { createLogger } from './logger';
|
||||
import { TwentyCrmService } from './twenty-crm-service';
|
||||
import type { FirefliesWebhookPayload, ProcessResult } from './types';
|
||||
import { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
const logger = createLogger('fireflies');
|
||||
|
||||
export class WebhookHandler {
|
||||
private debug: string[] = [];
|
||||
private isTestEnvironment: boolean;
|
||||
@@ -29,19 +31,19 @@ export class WebhookHandler {
|
||||
};
|
||||
|
||||
try {
|
||||
this.logDebug('[fireflies] invoked');
|
||||
this.logDebug(`[fireflies] apiUrl=${getApiUrl()}`);
|
||||
logger.debug('invoked');
|
||||
logger.debug(`apiUrl=${getApiUrl()}`);
|
||||
|
||||
// 0) Validate environment configuration
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
this.logError('[fireflies] FIREFLIES_API_KEY not configured');
|
||||
logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY environment variable is required');
|
||||
}
|
||||
if (!twentyApiKey) {
|
||||
this.logError('[fireflies] TWENTY_API_KEY not configured');
|
||||
logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('TWENTY_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
@@ -49,20 +51,20 @@ export class WebhookHandler {
|
||||
const { payload, extractedHeaders } = this.parsePayload(params);
|
||||
const finalHeaders = extractedHeaders || headers;
|
||||
|
||||
this.logDebug(`[fireflies] payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
|
||||
logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
|
||||
|
||||
// 2) Verify webhook signature
|
||||
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
|
||||
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
|
||||
this.logDebug(`[fireflies] webhook secret fingerprint=${secretFingerprint}`);
|
||||
logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
|
||||
|
||||
this.verifySignature(payload, finalHeaders, webhookSecret);
|
||||
this.logDebug('[fireflies] signature verification: ok');
|
||||
logger.debug('signature verification: ok');
|
||||
|
||||
// 3) Fetch meeting data from Fireflies
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
this.logDebug(`[fireflies] summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
|
||||
this.logDebug(`[fireflies] fetching meeting data from Fireflies API`);
|
||||
logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
|
||||
logger.debug(`fetching meeting data from Fireflies API`);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
|
||||
@@ -70,7 +72,7 @@ export class WebhookHandler {
|
||||
summaryConfig
|
||||
);
|
||||
|
||||
this.logDebug(`[fireflies] meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
|
||||
logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
|
||||
|
||||
result.summaryReady = summaryReady;
|
||||
result.summaryPending = !summaryReady;
|
||||
@@ -95,27 +97,27 @@ export class WebhookHandler {
|
||||
|
||||
const existingMeeting = await twentyService.findExistingMeeting(meetingData.title);
|
||||
if (existingMeeting) {
|
||||
this.logDebug(`[fireflies] meeting already exists id=${existingMeeting.id}`);
|
||||
logger.debug(`meeting already exists id=${existingMeeting.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeeting.id;
|
||||
result.debug = this.debug;
|
||||
return result;
|
||||
}
|
||||
this.logDebug('[fireflies] no existing meeting found, proceeding');
|
||||
logger.debug('no existing meeting found, proceeding');
|
||||
|
||||
// 5) Match participants to existing contacts
|
||||
this.logDebug(`[fireflies] total participants from API: ${meetingData.participants.length}`);
|
||||
logger.debug(`total participants from API: ${meetingData.participants.length}`);
|
||||
meetingData.participants.forEach((p, idx) => {
|
||||
this.logDebug(`[fireflies] participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants
|
||||
);
|
||||
this.logDebug(`[fireflies] matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
|
||||
logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
|
||||
|
||||
unmatchedParticipants.forEach((p, idx) => {
|
||||
this.logDebug(`[fireflies] unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
// 6) Optionally create contacts
|
||||
@@ -124,7 +126,7 @@ export class WebhookHandler {
|
||||
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
result.newContacts = newContactIds;
|
||||
this.logDebug(`[fireflies] autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
|
||||
logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
|
||||
|
||||
// 7) Create note first (so we can link to it from the meeting)
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
@@ -134,13 +136,13 @@ export class WebhookHandler {
|
||||
noteBody
|
||||
);
|
||||
result.noteIds = [noteId];
|
||||
this.logDebug(`[fireflies] created note id=${noteId}`);
|
||||
logger.debug(`created note id=${noteId}`);
|
||||
|
||||
// 8) Create meeting with direct relationship to the note
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
this.logDebug(`[fireflies] meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
|
||||
logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
|
||||
result.meetingId = await twentyService.createMeeting(meetingInput);
|
||||
this.logDebug(`[fireflies] created meeting id=${result.meetingId} with noteId=${noteId}`);
|
||||
logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
|
||||
|
||||
// 9) Link note to participants (Meeting link is handled via the relation field)
|
||||
await this.linkNoteToParticipants(
|
||||
@@ -148,12 +150,12 @@ export class WebhookHandler {
|
||||
noteId,
|
||||
allContactIds
|
||||
);
|
||||
this.logDebug(`[fireflies] linked note to ${allContactIds.length} participants`);
|
||||
logger.debug(`linked note to ${allContactIds.length} participants`);
|
||||
|
||||
result.success = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logError(`[fireflies] error: ${message}`);
|
||||
logger.error(`error: ${message}`);
|
||||
result.errors?.push(message);
|
||||
|
||||
// Try to create a failed meeting record for tracking
|
||||
@@ -170,16 +172,16 @@ export class WebhookHandler {
|
||||
|
||||
// Handle string-encoded params
|
||||
if (typeof normalizedParams === 'string') {
|
||||
this.logDebug(`[fireflies] received params as string length=${normalizedParams.length}`);
|
||||
logger.debug(`received params as string length=${normalizedParams.length}`);
|
||||
try {
|
||||
const parsed = JSON.parse(normalizedParams);
|
||||
normalizedParams = parsed;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
|
||||
this.logDebug(`[fireflies] parsed params keys: ${parsedKeys.join(',') || 'none'}`);
|
||||
logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.logError(`[fireflies] error parsing string params: ${String(parseError)}`);
|
||||
logger.error(`error parsing string params: ${String(parseError)}`);
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
}
|
||||
@@ -195,14 +197,14 @@ export class WebhookHandler {
|
||||
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
|
||||
extractedHeaders = wrapper.headers as Record<string, string>;
|
||||
const headerKeys = Object.keys(extractedHeaders);
|
||||
this.logDebug(`[fireflies] extracted headers from wrapper: ${headerKeys.join(',')}`);
|
||||
logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
|
||||
}
|
||||
|
||||
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
|
||||
for (const key of wrapperKeys) {
|
||||
const candidate = wrapper[key];
|
||||
if (isValidFirefliesPayload(candidate)) {
|
||||
this.logDebug(`[fireflies] detected payload under wrapper key "${key}"`);
|
||||
logger.debug(`detected payload under wrapper key "${key}"`);
|
||||
payload = candidate as FirefliesWebhookPayload;
|
||||
break;
|
||||
}
|
||||
@@ -210,7 +212,7 @@ export class WebhookHandler {
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
this.logError('[fireflies] error: Invalid or missing webhook payload');
|
||||
logger.error('error: Invalid or missing webhook payload');
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
|
||||
@@ -218,7 +220,7 @@ export class WebhookHandler {
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadKeys = Object.keys(payloadRecord);
|
||||
if (payloadKeys.length > 0) {
|
||||
this.logDebug(`[fireflies] payload keys: ${payloadKeys.join(',')}`);
|
||||
logger.debug(`payload keys: ${payloadKeys.join(',')}`);
|
||||
}
|
||||
|
||||
return { payload, extractedHeaders };
|
||||
@@ -233,7 +235,7 @@ export class WebhookHandler {
|
||||
const normalizedHeaders = headers || {};
|
||||
const headerKeys = Object.keys(normalizedHeaders);
|
||||
if (headerKeys.length > 0) {
|
||||
this.logDebug(`[fireflies] header keys: ${headerKeys.join(',')}`);
|
||||
logger.debug(`header keys: ${headerKeys.join(',')}`);
|
||||
}
|
||||
|
||||
const headerSignature = Object.entries(normalizedHeaders).find(
|
||||
@@ -247,7 +249,7 @@ export class WebhookHandler {
|
||||
: undefined;
|
||||
|
||||
if (payloadSignature) {
|
||||
this.logDebug('[fireflies] found signature inside payload');
|
||||
logger.debug('found signature inside payload');
|
||||
}
|
||||
|
||||
const signature =
|
||||
@@ -259,20 +261,20 @@ export class WebhookHandler {
|
||||
|
||||
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
|
||||
if (!signatureCheck.isValid) {
|
||||
this.logDebug(
|
||||
`[fireflies] signature check failed. headerPresent=${Boolean(
|
||||
logger.debug(
|
||||
`signature check failed. headerPresent=${Boolean(
|
||||
headerSignature,
|
||||
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
|
||||
);
|
||||
if (signature) {
|
||||
this.logDebug(`[fireflies] provided signature=${signature}`);
|
||||
logger.debug(`provided signature=${signature}`);
|
||||
} else {
|
||||
this.logDebug('[fireflies] provided signature=undefined');
|
||||
logger.debug('provided signature=undefined');
|
||||
}
|
||||
this.logDebug(
|
||||
`[fireflies] computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
|
||||
logger.debug(
|
||||
`computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
|
||||
);
|
||||
this.logError('[fireflies] error: Invalid webhook signature');
|
||||
logger.critical('Invalid webhook signature - potential security threat detected in production');
|
||||
throw new Error('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
@@ -286,34 +288,21 @@ export class WebhookHandler {
|
||||
for (const contactId of contactIds) {
|
||||
try {
|
||||
await twentyService.createNoteTarget(noteId, contactId);
|
||||
this.logDebug(`[fireflies] linked note ${noteId} to person ${contactId}`);
|
||||
logger.debug(`linked note ${noteId} to person ${contactId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logError(`[fireflies] failed to link note to person ${contactId}: ${message}`);
|
||||
logger.error(`failed to link note to person ${contactId}: ${message}`);
|
||||
// Continue with other participants
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logDebug(message: string): void {
|
||||
this.debug.push(message);
|
||||
if (!this.isTestEnvironment) {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
private logError(message: string): void {
|
||||
this.debug.push(message);
|
||||
if (!this.isTestEnvironment) {
|
||||
console.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
|
||||
try {
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
if (!twentyApiKey) {
|
||||
this.logDebug('[fireflies] Cannot create failed meeting record: TWENTY_API_KEY not configured');
|
||||
logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,7 +322,7 @@ export class WebhookHandler {
|
||||
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
|
||||
meetingTitle = meetingData.title || meetingTitle;
|
||||
} catch (fetchError) {
|
||||
this.logDebug(`[fireflies] Could not fetch meeting title: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
|
||||
logger.debug(`Could not fetch meeting title: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,10 +335,10 @@ export class WebhookHandler {
|
||||
);
|
||||
|
||||
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
|
||||
this.logDebug(`[fireflies] Created failed meeting record: ${failedMeetingId}`);
|
||||
logger.debug(`Created failed meeting record: ${failedMeetingId}`);
|
||||
} catch (recordError) {
|
||||
// Don't throw here - we don't want to break the original error handling
|
||||
this.logError(`[fireflies] Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ process.env.FIREFLIES_WEBHOOK_SECRET = 'testsecret';
|
||||
process.env.AUTO_CREATE_CONTACTS = 'true';
|
||||
process.env.SERVER_URL = 'http://localhost:3000';
|
||||
process.env.TWENTY_API_KEY = 'test-api-key';
|
||||
process.env.DEBUG_LOGS = 'true'; // Enable debug logs in tests
|
||||
process.env.LOG_LEVEL = 'silent';
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user