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:
Alex Galey
2025-11-05 14:16:18 +01:00
committed by GitHub
parent f21b862d52
commit 583d490cd7
9 changed files with 219 additions and 136 deletions
@@ -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",
@@ -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;
}
@@ -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);
};
@@ -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);
@@ -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();
});
});