REVERT Network Updates
This commit is contained in:
@@ -1,160 +0,0 @@
|
||||
interface CleanupTask {
|
||||
name: string;
|
||||
cleanup: () => void | Promise<void>;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
class CleanupManager {
|
||||
private static instance: CleanupManager;
|
||||
private tasks: Map<string, CleanupTask> = new Map();
|
||||
private intervals: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
private constructor() {
|
||||
// Register default cleanup tasks
|
||||
this.registerDefaultTasks();
|
||||
}
|
||||
|
||||
static getInstance(): CleanupManager {
|
||||
if (!CleanupManager.instance) {
|
||||
CleanupManager.instance = new CleanupManager();
|
||||
}
|
||||
return CleanupManager.instance;
|
||||
}
|
||||
|
||||
private registerDefaultTasks() {
|
||||
// Clean up old temporary files
|
||||
this.registerTask({
|
||||
name: 'temp-files-cleanup',
|
||||
cleanup: async () => {
|
||||
console.log('[Cleanup] Running temporary files cleanup...');
|
||||
// Implementation would go here
|
||||
},
|
||||
interval: 3600000 // Every hour
|
||||
});
|
||||
|
||||
// Clean up expired cache entries
|
||||
this.registerTask({
|
||||
name: 'cache-cleanup',
|
||||
cleanup: async () => {
|
||||
console.log('[Cleanup] Running cache cleanup...');
|
||||
// Implementation would go here
|
||||
},
|
||||
interval: 1800000 // Every 30 minutes
|
||||
});
|
||||
}
|
||||
|
||||
registerTask(task: CleanupTask) {
|
||||
this.tasks.set(task.name, task);
|
||||
|
||||
if (task.interval) {
|
||||
// Clear existing interval if any
|
||||
const existingInterval = this.intervals.get(task.name);
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval);
|
||||
}
|
||||
|
||||
// Set up new interval
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await task.cleanup();
|
||||
} catch (error) {
|
||||
console.error(`[Cleanup] Task '${task.name}' failed:`, error);
|
||||
}
|
||||
}, task.interval);
|
||||
|
||||
this.intervals.set(task.name, interval);
|
||||
}
|
||||
}
|
||||
|
||||
async runTask(taskName: string) {
|
||||
const task = this.tasks.get(taskName);
|
||||
if (!task) {
|
||||
throw new Error(`Cleanup task '${taskName}' not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
await task.cleanup();
|
||||
console.log(`[Cleanup] Task '${taskName}' completed successfully`);
|
||||
} catch (error) {
|
||||
console.error(`[Cleanup] Task '${taskName}' failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runAllTasks() {
|
||||
console.log('[Cleanup] Running all cleanup tasks...');
|
||||
const results: { task: string; success: boolean; error?: any }[] = [];
|
||||
|
||||
for (const [name, task] of this.tasks) {
|
||||
try {
|
||||
await task.cleanup();
|
||||
results.push({ task: name, success: true });
|
||||
} catch (error) {
|
||||
results.push({ task: name, success: false, error });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
stopAllIntervals() {
|
||||
for (const [name, interval] of this.intervals) {
|
||||
clearInterval(interval);
|
||||
console.log(`[Cleanup] Stopped interval for task '${name}'`);
|
||||
}
|
||||
this.intervals.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanupManager = CleanupManager.getInstance();
|
||||
|
||||
// Utility functions for common cleanup operations
|
||||
export const cleanupUtils = {
|
||||
// Clean up old files in a directory
|
||||
async cleanupOldFiles(directory: string, maxAgeMs: number) {
|
||||
// Implementation would check file ages and remove old ones
|
||||
console.log(`[Cleanup] Would clean files older than ${maxAgeMs}ms in ${directory}`);
|
||||
},
|
||||
|
||||
// Clean up unused stream handles
|
||||
cleanupStreams(streams: any[]) {
|
||||
streams.forEach(stream => {
|
||||
if (stream && typeof stream.destroy === 'function') {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Clean up IMAP connections
|
||||
cleanupIMAPConnections(connections: any[]) {
|
||||
connections.forEach(conn => {
|
||||
try {
|
||||
if (conn && typeof conn.end === 'function') {
|
||||
conn.end();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Cleanup] Failed to close IMAP connection:', error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Memory-efficient array processing
|
||||
async processInBatches<T, R>(
|
||||
items: T[],
|
||||
batchSize: number,
|
||||
processor: (batch: T[]) => Promise<R[]>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchResults = await processor(batch);
|
||||
results.push(...batchResults);
|
||||
|
||||
// Allow garbage collection between batches
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
};
|
||||
@@ -1,139 +1,153 @@
|
||||
import { resilientHttp } from './resilient-http';
|
||||
|
||||
interface DocumensoConfig {
|
||||
// Documeso API client utilities
|
||||
interface DocumesoConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export const getDocumensoConfiguration = (): DocumensoConfig => {
|
||||
const apiKey = process.env.NUXT_DOCUMENSO_API_KEY;
|
||||
const baseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
|
||||
|
||||
if (!apiKey || !baseUrl) {
|
||||
throw new Error('Documenso configuration missing');
|
||||
}
|
||||
|
||||
return { apiKey, baseUrl };
|
||||
interface DocumesoRecipient {
|
||||
id: number;
|
||||
documentId: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'SIGNER' | 'APPROVER' | 'VIEWER';
|
||||
signingOrder: number;
|
||||
token: string;
|
||||
signedAt: string | null;
|
||||
readStatus: 'NOT_OPENED' | 'OPENED';
|
||||
signingStatus: 'NOT_SIGNED' | 'SIGNED';
|
||||
sendStatus: 'NOT_SENT' | 'SENT';
|
||||
signingUrl: string;
|
||||
}
|
||||
|
||||
interface DocumesoDocument {
|
||||
id: number;
|
||||
externalId: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
title: string;
|
||||
status: 'DRAFT' | 'PENDING' | 'COMPLETED' | 'CANCELLED';
|
||||
documentDataId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
recipients: DocumesoRecipient[];
|
||||
}
|
||||
|
||||
interface DocumesoListResponse {
|
||||
documents: DocumesoDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
// Get Documeso configuration
|
||||
const getDocumesoConfig = (): DocumesoConfig => {
|
||||
return {
|
||||
apiUrl: 'https://signatures.portnimara.dev/api/v1',
|
||||
apiKey: 'Bearer api_malptg62zqyb0wrp'
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function for resilient Documenso requests
|
||||
async function documensoFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const config = getDocumensoConfiguration();
|
||||
const url = `${config.baseUrl}${endpoint}`;
|
||||
// Fetch a single document by ID
|
||||
export const getDocumesoDocument = async (documentId: number): Promise<DocumesoDocument> => {
|
||||
const config = getDocumesoConfig();
|
||||
|
||||
const response = await resilientHttp.fetchWithRetry(
|
||||
url,
|
||||
{
|
||||
...options,
|
||||
try {
|
||||
const response = await $fetch<DocumesoDocument>(`${config.apiUrl}/documents/${documentId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
'Authorization': config.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Documeso document:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Search documents by external ID (e.g., 'loi-94')
|
||||
export const searchDocumesoDocuments = async (externalId?: string): Promise<DocumesoDocument[]> => {
|
||||
const config = getDocumesoConfig();
|
||||
|
||||
try {
|
||||
const response = await $fetch<DocumesoListResponse>(`${config.apiUrl}/documents`, {
|
||||
headers: {
|
||||
'Authorization': config.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
serviceName: 'documenso'
|
||||
},
|
||||
{
|
||||
maxRetries: options.method === 'POST' || options.method === 'PUT' ? 2 : 3,
|
||||
timeout: 20000, // 20 seconds for document operations
|
||||
retryableStatuses: [408, 429, 500, 502, 503, 504]
|
||||
params: {
|
||||
perPage: 100
|
||||
}
|
||||
});
|
||||
|
||||
// If externalId is provided, filter by it
|
||||
if (externalId) {
|
||||
return response.documents.filter(doc => doc.externalId === externalId);
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Documenso request failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const checkDocumentStatus = async (documentId: string): Promise<any> => {
|
||||
console.log('[Documenso] Checking document status:', documentId);
|
||||
|
||||
try {
|
||||
const result = await documensoFetch<any>(`/api/v1/documents/${documentId}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
console.log('[Documenso] Document status retrieved:', result.status);
|
||||
return result;
|
||||
return response.documents;
|
||||
} catch (error) {
|
||||
console.error('[Documenso] Failed to check document status:', error);
|
||||
console.error('Failed to search Documeso documents:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createDocument = async (templateId: string, data: any): Promise<any> => {
|
||||
console.log('[Documenso] Creating document from template:', templateId);
|
||||
|
||||
try {
|
||||
const result = await documensoFetch<any>('/api/v1/documents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
templateId,
|
||||
...data
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[Documenso] Document created with ID:', result.id);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Documenso] Failed to create document:', error);
|
||||
throw error;
|
||||
}
|
||||
// Get document by external ID (e.g., 'loi-94')
|
||||
export const getDocumesoDocumentByExternalId = async (externalId: string): Promise<DocumesoDocument | null> => {
|
||||
const documents = await searchDocumesoDocuments(externalId);
|
||||
return documents.length > 0 ? documents[0] : null;
|
||||
};
|
||||
|
||||
export const sendDocument = async (documentId: string, signers: any[]): Promise<any> => {
|
||||
console.log('[Documenso] Sending document:', documentId);
|
||||
// Check signature status for a document
|
||||
export const checkDocumentSignatureStatus = async (documentId: number): Promise<{
|
||||
documentStatus: string;
|
||||
unsignedRecipients: DocumesoRecipient[];
|
||||
signedRecipients: DocumesoRecipient[];
|
||||
clientSigned: boolean;
|
||||
allSigned: boolean;
|
||||
}> => {
|
||||
const document = await getDocumesoDocument(documentId);
|
||||
|
||||
try {
|
||||
const result = await documensoFetch<any>(`/api/v1/documents/${documentId}/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ signers })
|
||||
});
|
||||
|
||||
console.log('[Documenso] Document sent successfully');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Documenso] Failed to send document:', error);
|
||||
throw error;
|
||||
}
|
||||
const unsignedRecipients = document.recipients.filter(r => r.signingStatus === 'NOT_SIGNED');
|
||||
const signedRecipients = document.recipients.filter(r => r.signingStatus === 'SIGNED');
|
||||
|
||||
// Check if client (signingOrder = 1) has signed
|
||||
const clientRecipient = document.recipients.find(r => r.signingOrder === 1);
|
||||
const clientSigned = clientRecipient ? clientRecipient.signingStatus === 'SIGNED' : false;
|
||||
|
||||
const allSigned = unsignedRecipients.length === 0;
|
||||
|
||||
return {
|
||||
documentStatus: document.status,
|
||||
unsignedRecipients,
|
||||
signedRecipients,
|
||||
clientSigned,
|
||||
allSigned
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteDocument = async (documentId: string): Promise<any> => {
|
||||
console.log('[Documenso] Deleting document:', documentId);
|
||||
// Get recipients who need to sign (excluding client)
|
||||
export const getRecipientsToRemind = async (documentId: number): Promise<DocumesoRecipient[]> => {
|
||||
const status = await checkDocumentSignatureStatus(documentId);
|
||||
|
||||
try {
|
||||
const result = await documensoFetch<any>(`/api/v1/documents/${documentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
console.log('[Documenso] Document deleted successfully');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Documenso] Failed to delete document:', error);
|
||||
throw error;
|
||||
// Only remind if client has signed
|
||||
if (!status.clientSigned) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return unsigned recipients with signingOrder > 1
|
||||
return status.unsignedRecipients.filter(r => r.signingOrder > 1);
|
||||
};
|
||||
|
||||
export const verifyTemplate = async (templateId: string): Promise<boolean> => {
|
||||
console.log('[Documenso] Verifying template:', templateId);
|
||||
|
||||
try {
|
||||
await documensoFetch<any>(`/api/v1/templates/${templateId}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
console.log('[Documenso] Template verified successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Documenso] Template verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
// Format recipient name for emails
|
||||
export const formatRecipientName = (recipient: DocumesoRecipient): string => {
|
||||
const firstName = recipient.name.split(' ')[0];
|
||||
return firstName;
|
||||
};
|
||||
|
||||
// Get circuit breaker status for monitoring
|
||||
export const getDocumensoHealthStatus = () => {
|
||||
const status = resilientHttp.getCircuitBreakerStatus();
|
||||
return status.documenso || { state: 'UNKNOWN', failures: 0 };
|
||||
// Get signing URL for a recipient
|
||||
export const getSigningUrl = (recipient: DocumesoRecipient): string => {
|
||||
return recipient.signingUrl;
|
||||
};
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { H3Event } from 'h3';
|
||||
|
||||
interface ServiceCheckResult {
|
||||
status: 'up' | 'down' | 'slow';
|
||||
responseTime?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class HealthChecker {
|
||||
private static instance: HealthChecker;
|
||||
private isReady: boolean = false;
|
||||
private startupChecks: Map<string, boolean> = new Map();
|
||||
private requiredServices = ['nocodb', 'directus', 'minio'];
|
||||
|
||||
private constructor() {
|
||||
this.initializeStartupChecks();
|
||||
}
|
||||
|
||||
static getInstance(): HealthChecker {
|
||||
if (!HealthChecker.instance) {
|
||||
HealthChecker.instance = new HealthChecker();
|
||||
}
|
||||
return HealthChecker.instance;
|
||||
}
|
||||
|
||||
private initializeStartupChecks() {
|
||||
this.requiredServices.forEach(service => {
|
||||
this.startupChecks.set(service, false);
|
||||
});
|
||||
}
|
||||
|
||||
async checkServiceWithRetry(
|
||||
serviceName: string,
|
||||
checkFunction: () => Promise<ServiceCheckResult>,
|
||||
maxRetries: number = 3,
|
||||
retryDelay: number = 1000
|
||||
): Promise<ServiceCheckResult> {
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await checkFunction();
|
||||
|
||||
if (result.status === 'up' || result.status === 'slow') {
|
||||
this.startupChecks.set(serviceName, true);
|
||||
return result;
|
||||
}
|
||||
|
||||
lastError = result.error;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'down',
|
||||
error: lastError || 'Max retries exceeded'
|
||||
};
|
||||
}
|
||||
|
||||
markServiceReady(serviceName: string) {
|
||||
this.startupChecks.set(serviceName, true);
|
||||
this.checkOverallReadiness();
|
||||
}
|
||||
|
||||
markServiceDown(serviceName: string) {
|
||||
this.startupChecks.set(serviceName, false);
|
||||
this.isReady = false;
|
||||
}
|
||||
|
||||
private checkOverallReadiness() {
|
||||
const allRequired = this.requiredServices.every(service =>
|
||||
this.startupChecks.get(service) === true
|
||||
);
|
||||
|
||||
if (allRequired) {
|
||||
this.isReady = true;
|
||||
console.log('[HealthChecker] All required services are ready');
|
||||
}
|
||||
}
|
||||
|
||||
isApplicationReady(): boolean {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
getStartupStatus(): { [key: string]: boolean } {
|
||||
const status: { [key: string]: boolean } = {};
|
||||
this.startupChecks.forEach((value, key) => {
|
||||
status[key] = value;
|
||||
});
|
||||
return status;
|
||||
}
|
||||
|
||||
async performStartupHealthChecks(): Promise<boolean> {
|
||||
console.log('[HealthChecker] Performing startup health checks...');
|
||||
|
||||
// Import check functions dynamically to avoid circular dependencies
|
||||
const { checkNocoDB, checkDirectus, checkMinIO } = await import('./service-checks');
|
||||
|
||||
// Check all services in parallel
|
||||
const checks = await Promise.all([
|
||||
this.checkServiceWithRetry('nocodb', checkNocoDB, 5, 2000),
|
||||
this.checkServiceWithRetry('directus', checkDirectus, 5, 2000),
|
||||
this.checkServiceWithRetry('minio', checkMinIO, 5, 2000)
|
||||
]);
|
||||
|
||||
// Log results
|
||||
checks.forEach((result, index) => {
|
||||
const serviceName = ['nocodb', 'directus', 'minio'][index];
|
||||
console.log(`[HealthChecker] ${serviceName}: ${result.status}${result.error ? ` - ${result.error}` : ''}`);
|
||||
});
|
||||
|
||||
this.checkOverallReadiness();
|
||||
return this.isReady;
|
||||
}
|
||||
}
|
||||
|
||||
export const healthChecker = HealthChecker.getInstance();
|
||||
|
||||
// Middleware to check if app is ready
|
||||
export function createReadinessMiddleware() {
|
||||
return defineEventHandler(async (event: H3Event) => {
|
||||
// Skip health check endpoint itself
|
||||
if (event.node.req.url === '/api/health') {
|
||||
return;
|
||||
}
|
||||
|
||||
// During startup, return 503 for all requests except health
|
||||
if (!healthChecker.isApplicationReady()) {
|
||||
const startupStatus = healthChecker.getStartupStatus();
|
||||
|
||||
setResponseStatus(event, 503);
|
||||
return {
|
||||
error: 'Service Unavailable',
|
||||
message: 'Application is starting up. Please wait...',
|
||||
startupStatus
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { resilientHttp } from './resilient-http';
|
||||
|
||||
export interface PageInfo {
|
||||
pageSize: number;
|
||||
totalRows: number;
|
||||
@@ -29,43 +27,24 @@ export const createTableUrl = (table: Table) => {
|
||||
return url;
|
||||
};
|
||||
|
||||
// Helper function for resilient NocoDB requests
|
||||
async function nocoDbFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await resilientHttp.fetchWithRetry(
|
||||
url,
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
'xc-token': getNocoDbConfiguration().token,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
serviceName: 'nocodb'
|
||||
export const getInterests = async () =>
|
||||
$fetch<InterestsResponse>(createTableUrl(Table.Interest), {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
{
|
||||
maxRetries: options.method === 'POST' || options.method === 'PATCH' || options.method === 'DELETE' ? 2 : 3,
|
||||
timeout: 15000
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`NocoDB request failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const getInterests = async () => {
|
||||
const url = createTableUrl(Table.Interest);
|
||||
return nocoDbFetch<InterestsResponse>(url + '?limit=1000');
|
||||
};
|
||||
params: {
|
||||
limit: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
export const getInterestById = async (id: string) => {
|
||||
console.log('[nocodb.getInterestById] Fetching interest ID:', id);
|
||||
|
||||
const url = `${createTableUrl(Table.Interest)}/${id}`;
|
||||
const result = await nocoDbFetch<any>(url);
|
||||
const result = await $fetch<Interest>(`${createTableUrl(Table.Interest)}/${id}`, {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[nocodb.getInterestById] Raw result from NocoDB:', {
|
||||
id: result.Id,
|
||||
@@ -187,9 +166,13 @@ export const updateInterest = async (id: string, data: Partial<Interest>, retryC
|
||||
console.log('[nocodb.updateInterest] Request body:', JSON.stringify(cleanData, null, 2));
|
||||
|
||||
// Try sending as a single object first (as shown in the API docs)
|
||||
const result = await nocoDbFetch<any>(url, {
|
||||
const result = await $fetch<Interest>(url, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(cleanData)
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: cleanData
|
||||
});
|
||||
console.log('[nocodb.updateInterest] Update successful for ID:', id);
|
||||
return result;
|
||||
@@ -222,7 +205,7 @@ export const updateInterest = async (id: string, data: Partial<Interest>, retryC
|
||||
}
|
||||
};
|
||||
|
||||
export const createInterest = async (data: Partial<any>) => {
|
||||
export const createInterest = async (data: Partial<Interest>) => {
|
||||
console.log('[nocodb.createInterest] Creating interest with fields:', Object.keys(data));
|
||||
|
||||
// Create a clean data object that matches the InterestsRequest schema
|
||||
@@ -270,9 +253,12 @@ export const createInterest = async (data: Partial<any>) => {
|
||||
console.log('[nocodb.createInterest] URL:', url);
|
||||
|
||||
try {
|
||||
const result = await nocoDbFetch<any>(url, {
|
||||
const result = await $fetch<Interest>(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(cleanData)
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
body: cleanData,
|
||||
});
|
||||
console.log('[nocodb.createInterest] Created interest with ID:', result.Id);
|
||||
return result;
|
||||
@@ -307,9 +293,13 @@ export const deleteInterest = async (id: string) => {
|
||||
|
||||
try {
|
||||
// According to NocoDB API docs, DELETE requires ID in the body
|
||||
const result = await nocoDbFetch<any>(url, {
|
||||
const result = await $fetch(url, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(requestBody)
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: requestBody
|
||||
});
|
||||
|
||||
console.log('[nocodb.deleteInterest] DELETE successful');
|
||||
@@ -333,22 +323,11 @@ export const deleteInterest = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const triggerWebhook = async (url: string, payload: any) => {
|
||||
// Webhooks might not need the same resilience as data operations
|
||||
const response = await fetch(url, {
|
||||
export const triggerWebhook = async (url: string, payload: any) =>
|
||||
$fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateInterestEOIDocument = async (id: string, documentData: any) => {
|
||||
console.log('[nocodb.updateInterestEOIDocument] Updating EOI document for interest:', id);
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
interface QueuedRequest<T> {
|
||||
id: string;
|
||||
priority: 'high' | 'normal' | 'low';
|
||||
execute: () => Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: any) => void;
|
||||
timestamp: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
interface QueueOptions {
|
||||
maxConcurrent: number;
|
||||
maxQueueSize: number;
|
||||
requestTimeout: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
class RequestQueue {
|
||||
private static instances: Map<string, RequestQueue> = new Map();
|
||||
private queue: QueuedRequest<any>[] = [];
|
||||
private activeRequests: number = 0;
|
||||
private requestId: number = 0;
|
||||
|
||||
constructor(private name: string, private options: QueueOptions) {
|
||||
this.options = {
|
||||
maxConcurrent: 5,
|
||||
maxQueueSize: 100,
|
||||
requestTimeout: 30000,
|
||||
maxRetries: 2,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
static getInstance(name: string, options?: Partial<QueueOptions>): RequestQueue {
|
||||
if (!RequestQueue.instances.has(name)) {
|
||||
RequestQueue.instances.set(name, new RequestQueue(name, options as QueueOptions));
|
||||
}
|
||||
return RequestQueue.instances.get(name)!;
|
||||
}
|
||||
|
||||
async add<T>(
|
||||
execute: () => Promise<T>,
|
||||
priority: 'high' | 'normal' | 'low' = 'normal'
|
||||
): Promise<T> {
|
||||
if (this.queue.length >= this.options.maxQueueSize) {
|
||||
throw new Error(`Queue ${this.name} is full (${this.queue.length} items)`);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const request: QueuedRequest<T> = {
|
||||
id: `${this.name}-${++this.requestId}`,
|
||||
priority,
|
||||
execute,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
retries: 0
|
||||
};
|
||||
|
||||
// Insert based on priority
|
||||
if (priority === 'high') {
|
||||
// Find the first non-high priority item
|
||||
const index = this.queue.findIndex(item => item.priority !== 'high');
|
||||
if (index === -1) {
|
||||
this.queue.push(request);
|
||||
} else {
|
||||
this.queue.splice(index, 0, request);
|
||||
}
|
||||
} else if (priority === 'low') {
|
||||
this.queue.push(request);
|
||||
} else {
|
||||
// Normal priority - insert before low priority items
|
||||
const index = this.queue.findIndex(item => item.priority === 'low');
|
||||
if (index === -1) {
|
||||
this.queue.push(request);
|
||||
} else {
|
||||
this.queue.splice(index, 0, request);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Queue ${this.name}] Added request ${request.id} (priority: ${priority}, queue size: ${this.queue.length})`);
|
||||
|
||||
// Process queue
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
while (this.queue.length > 0 && this.activeRequests < this.options.maxConcurrent) {
|
||||
const request = this.queue.shift();
|
||||
if (!request) continue;
|
||||
|
||||
this.activeRequests++;
|
||||
|
||||
// Process request with timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Request timeout')), this.options.requestTimeout);
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`[Queue ${this.name}] Processing request ${request.id} (active: ${this.activeRequests}/${this.options.maxConcurrent})`);
|
||||
|
||||
const result = await Promise.race([
|
||||
request.execute(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
request.resolve(result);
|
||||
console.log(`[Queue ${this.name}] Request ${request.id} completed successfully`);
|
||||
} catch (error) {
|
||||
console.error(`[Queue ${this.name}] Request ${request.id} failed:`, error);
|
||||
|
||||
// Retry logic
|
||||
if (request.retries < this.options.maxRetries) {
|
||||
request.retries++;
|
||||
console.log(`[Queue ${this.name}] Retrying request ${request.id} (attempt ${request.retries + 1}/${this.options.maxRetries + 1})`);
|
||||
|
||||
// Re-queue with same priority
|
||||
this.queue.unshift(request);
|
||||
} else {
|
||||
request.reject(error);
|
||||
}
|
||||
} finally {
|
||||
this.activeRequests--;
|
||||
|
||||
// Continue processing
|
||||
if (this.queue.length > 0) {
|
||||
setImmediate(() => this.processQueue());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
name: this.name,
|
||||
queueLength: this.queue.length,
|
||||
activeRequests: this.activeRequests,
|
||||
maxConcurrent: this.options.maxConcurrent,
|
||||
queueByPriority: {
|
||||
high: this.queue.filter(r => r.priority === 'high').length,
|
||||
normal: this.queue.filter(r => r.priority === 'normal').length,
|
||||
low: this.queue.filter(r => r.priority === 'low').length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
const cleared = this.queue.length;
|
||||
this.queue.forEach(request => {
|
||||
request.reject(new Error('Queue cleared'));
|
||||
});
|
||||
this.queue = [];
|
||||
console.log(`[Queue ${this.name}] Cleared ${cleared} requests`);
|
||||
return cleared;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-configured queues for different operations
|
||||
export const queues = {
|
||||
documenso: RequestQueue.getInstance('documenso', {
|
||||
maxConcurrent: 3,
|
||||
maxQueueSize: 50,
|
||||
requestTimeout: 45000,
|
||||
maxRetries: 2
|
||||
}),
|
||||
|
||||
email: RequestQueue.getInstance('email', {
|
||||
maxConcurrent: 2,
|
||||
maxQueueSize: 30,
|
||||
requestTimeout: 60000,
|
||||
maxRetries: 1
|
||||
}),
|
||||
|
||||
nocodb: RequestQueue.getInstance('nocodb', {
|
||||
maxConcurrent: 5,
|
||||
maxQueueSize: 100,
|
||||
requestTimeout: 20000,
|
||||
maxRetries: 3
|
||||
})
|
||||
};
|
||||
|
||||
// Utility function to get all queue statuses
|
||||
export function getAllQueueStatuses() {
|
||||
return {
|
||||
documenso: queues.documenso.getStatus(),
|
||||
email: queues.email.getStatus(),
|
||||
nocodb: queues.nocodb.getStatus()
|
||||
};
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
interface RetryOptions {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffMultiplier?: number;
|
||||
timeout?: number;
|
||||
retryableStatuses?: number[];
|
||||
retryableErrors?: string[];
|
||||
}
|
||||
|
||||
interface CircuitBreakerOptions {
|
||||
failureThreshold?: number;
|
||||
resetTimeout?: number;
|
||||
halfOpenRequests?: number;
|
||||
}
|
||||
|
||||
enum CircuitState {
|
||||
CLOSED = 'CLOSED',
|
||||
OPEN = 'OPEN',
|
||||
HALF_OPEN = 'HALF_OPEN'
|
||||
}
|
||||
|
||||
class CircuitBreaker {
|
||||
private state: CircuitState = CircuitState.CLOSED;
|
||||
private failures: number = 0;
|
||||
private lastFailureTime: number = 0;
|
||||
private halfOpenRequests: number = 0;
|
||||
|
||||
constructor(private options: CircuitBreakerOptions = {}) {
|
||||
this.options = {
|
||||
failureThreshold: 10,
|
||||
resetTimeout: 30000, // 30 seconds
|
||||
halfOpenRequests: 3,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Check circuit state
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
const timeSinceLastFailure = Date.now() - this.lastFailureTime;
|
||||
if (timeSinceLastFailure > this.options.resetTimeout!) {
|
||||
this.state = CircuitState.HALF_OPEN;
|
||||
this.halfOpenRequests = 0;
|
||||
} else {
|
||||
throw new Error('Circuit breaker is OPEN');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
if (this.halfOpenRequests >= this.options.halfOpenRequests!) {
|
||||
throw new Error('Circuit breaker is HALF_OPEN - max requests reached');
|
||||
}
|
||||
this.halfOpenRequests++;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
|
||||
// Success - reset failures
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
this.state = CircuitState.CLOSED;
|
||||
this.failures = 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.failures++;
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
if (this.failures >= this.options.failureThreshold!) {
|
||||
this.state = CircuitState.OPEN;
|
||||
console.error(`[CircuitBreaker] Opening circuit after ${this.failures} failures`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getState(): { state: CircuitState; failures: number } {
|
||||
return {
|
||||
state: this.state,
|
||||
failures: this.failures
|
||||
};
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = CircuitState.CLOSED;
|
||||
this.failures = 0;
|
||||
this.halfOpenRequests = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResilientHttpClient {
|
||||
private circuitBreakers: Map<string, CircuitBreaker> = new Map();
|
||||
|
||||
constructor(
|
||||
private defaultRetryOptions: RetryOptions = {},
|
||||
private defaultCircuitBreakerOptions: CircuitBreakerOptions = {}
|
||||
) {
|
||||
this.defaultRetryOptions = {
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 16000,
|
||||
backoffMultiplier: 2,
|
||||
timeout: 30000,
|
||||
retryableStatuses: [408, 429, 500, 502, 503, 504],
|
||||
retryableErrors: ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNRESET'],
|
||||
...defaultRetryOptions
|
||||
};
|
||||
}
|
||||
|
||||
private getCircuitBreaker(serviceName: string): CircuitBreaker {
|
||||
if (!this.circuitBreakers.has(serviceName)) {
|
||||
this.circuitBreakers.set(
|
||||
serviceName,
|
||||
new CircuitBreaker(this.defaultCircuitBreakerOptions)
|
||||
);
|
||||
}
|
||||
return this.circuitBreakers.get(serviceName)!;
|
||||
}
|
||||
|
||||
async fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit & { serviceName?: string } = {},
|
||||
retryOptions: RetryOptions = {}
|
||||
): Promise<Response> {
|
||||
const mergedRetryOptions = { ...this.defaultRetryOptions, ...retryOptions };
|
||||
const serviceName = options.serviceName || new URL(url).hostname;
|
||||
const circuitBreaker = this.getCircuitBreaker(serviceName);
|
||||
|
||||
return circuitBreaker.execute(async () => {
|
||||
return this.executeWithRetry(url, options, mergedRetryOptions);
|
||||
});
|
||||
}
|
||||
|
||||
private async executeWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
retryOptions: RetryOptions
|
||||
): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
let delay = retryOptions.initialDelay!;
|
||||
|
||||
for (let attempt = 0; attempt <= retryOptions.maxRetries!; attempt++) {
|
||||
try {
|
||||
// Add timeout to request
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
retryOptions.timeout!
|
||||
);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Check if response is retryable
|
||||
if (
|
||||
!response.ok &&
|
||||
retryOptions.retryableStatuses!.includes(response.status) &&
|
||||
attempt < retryOptions.maxRetries!
|
||||
) {
|
||||
console.warn(
|
||||
`[ResilientHttp] Retryable status ${response.status} for ${url}, attempt ${attempt + 1}/${retryOptions.maxRetries}`
|
||||
);
|
||||
await this.delay(delay);
|
||||
delay = Math.min(delay * retryOptions.backoffMultiplier!, retryOptions.maxDelay!);
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
// Check if error is retryable
|
||||
const isRetryable = retryOptions.retryableErrors!.some(
|
||||
errType => error.message?.includes(errType) || error.code === errType
|
||||
);
|
||||
|
||||
if (isRetryable && attempt < retryOptions.maxRetries!) {
|
||||
console.warn(
|
||||
`[ResilientHttp] Retryable error for ${url}: ${error.message}, attempt ${attempt + 1}/${retryOptions.maxRetries}`
|
||||
);
|
||||
await this.delay(delay);
|
||||
delay = Math.min(delay * retryOptions.backoffMultiplier!, retryOptions.maxDelay!);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed after ${retryOptions.maxRetries} retries`);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
getCircuitBreakerStatus(): { [serviceName: string]: { state: string; failures: number } } {
|
||||
const status: { [serviceName: string]: { state: string; failures: number } } = {};
|
||||
|
||||
this.circuitBreakers.forEach((breaker, serviceName) => {
|
||||
status[serviceName] = breaker.getState();
|
||||
});
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const resilientHttp = new ResilientHttpClient(
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 8000,
|
||||
backoffMultiplier: 2,
|
||||
timeout: 30000
|
||||
},
|
||||
{
|
||||
failureThreshold: 10,
|
||||
resetTimeout: 30000,
|
||||
halfOpenRequests: 3
|
||||
}
|
||||
);
|
||||
@@ -1,162 +0,0 @@
|
||||
import { getNocoDbConfiguration } from './nocodb';
|
||||
import { getMinioClient } from './minio';
|
||||
|
||||
interface ServiceCheckResult {
|
||||
status: 'up' | 'down' | 'slow';
|
||||
responseTime?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkNocoDB(): Promise<ServiceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const config = getNocoDbConfiguration();
|
||||
|
||||
if (!config.url || !config.token) {
|
||||
return {
|
||||
status: 'down',
|
||||
error: 'Missing NocoDB configuration'
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.url}/api/v1/health`, {
|
||||
headers: {
|
||||
'xc-token': config.token
|
||||
},
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
status: responseTime > 3000 ? 'slow' : 'up',
|
||||
responseTime
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'down',
|
||||
responseTime,
|
||||
error: `HTTP ${response.status}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'down',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDirectus(): Promise<ServiceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const directusUrl = useRuntimeConfig().public.directus?.url;
|
||||
|
||||
if (!directusUrl) {
|
||||
return {
|
||||
status: 'down',
|
||||
error: 'Missing Directus configuration'
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${directusUrl}/server/health`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
status: responseTime > 3000 ? 'slow' : 'up',
|
||||
responseTime
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'down',
|
||||
responseTime,
|
||||
error: `HTTP ${response.status}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'down',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkMinIO(): Promise<ServiceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const minioClient = getMinioClient();
|
||||
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||
|
||||
const bucketExists = await minioClient.bucketExists(bucketName);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (bucketExists) {
|
||||
return {
|
||||
status: responseTime > 3000 ? 'slow' : 'up',
|
||||
responseTime
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'down',
|
||||
responseTime,
|
||||
error: 'Bucket not found'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'down',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDocumenso(): Promise<ServiceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const documensoUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
|
||||
const documensoKey = process.env.NUXT_DOCUMENSO_API_KEY;
|
||||
|
||||
if (!documensoUrl || !documensoKey) {
|
||||
return {
|
||||
status: 'down',
|
||||
error: 'Missing Documenso configuration'
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${documensoUrl}/api/health`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${documensoKey}`
|
||||
},
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (response.ok || response.status === 401) {
|
||||
return {
|
||||
status: responseTime > 3000 ? 'slow' : 'up',
|
||||
responseTime
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'down',
|
||||
responseTime,
|
||||
error: `HTTP ${response.status}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'down',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user