port-nimara-client-portal/server/utils/nocodb.ts

1017 lines
38 KiB
TypeScript

import type { Interest, Berth, Expense, ExpenseFilters } from "@/utils/types";
// Data normalization functions
export const normalizePersonName = (name: string): string => {
if (!name) return 'Unknown';
// Trim whitespace and normalize case
return name.trim()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
export interface PageInfo {
pageSize: number;
totalRows: number;
isFirstPage: boolean;
isLastPage: boolean;
page: number;
}
export interface InterestsResponse {
list: Interest[];
PageInfo: PageInfo;
}
export interface BerthsResponse {
list: Berth[];
PageInfo: PageInfo;
}
export interface ExpensesResponse {
list: Expense[];
PageInfo: PageInfo;
}
export enum Table {
Interest = "mbs9hjauug4eseo",
Berth = "mczgos9hr3oa9qc",
Expense = "mxfcefkk4dqs6uq", // Expense tracking table
}
/**
* Convert date from DD-MM-YYYY format to YYYY-MM-DD format for PostgreSQL
*/
const convertDateFormat = (dateString: string): string => {
if (!dateString) return dateString;
// If it's already in ISO format or contains 'T', return as is
if (dateString.includes('T') || dateString.match(/^\d{4}-\d{2}-\d{2}/)) {
return dateString;
}
// Handle DD-MM-YYYY format
const ddmmyyyyMatch = dateString.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
if (ddmmyyyyMatch) {
const [, day, month, year] = ddmmyyyyMatch;
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
return convertedDate;
}
// Handle DD/MM/YYYY format
const ddmmyyyySlashMatch = dateString.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (ddmmyyyySlashMatch) {
const [, day, month, year] = ddmmyyyySlashMatch;
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
return convertedDate;
}
console.warn(`[convertDateFormat] Could not parse date format: ${dateString}`);
return dateString;
};
export const getNocoDbConfiguration = () => {
const config = useRuntimeConfig().nocodb;
console.log('[nocodb] Configuration URL:', config.url);
return config;
};
export const createTableUrl = (table: Table) => {
const url = `${getNocoDbConfiguration().url}/api/v2/tables/${table}/records`;
console.log('[nocodb] Table URL:', url);
return url;
};
export const getInterests = async () =>
$fetch<InterestsResponse>(createTableUrl(Table.Interest), {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
params: {
limit: 1000,
},
});
export const getInterestById = async (id: string) => {
console.log('[nocodb.getInterestById] Fetching interest ID:', id);
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,
documensoID: result['documensoID'],
documensoID_type: typeof result['documensoID'],
documensoID_value: JSON.stringify(result['documensoID']),
signatureLinks: {
client: result['Signature Link Client'],
cc: result['Signature Link CC'],
developer: result['Signature Link Developer']
}
});
return result;
};
export const updateInterest = async (id: string, data: Partial<Interest>, retryCount = 0): Promise<Interest> => {
console.log('[nocodb.updateInterest] Updating interest:', id, 'Retry:', retryCount);
console.log('[nocodb.updateInterest] Data fields:', Object.keys(data));
// First, try to verify the record exists
if (retryCount === 0) {
try {
console.log('[nocodb.updateInterest] Verifying record exists...');
const existingRecord = await getInterestById(id);
console.log('[nocodb.updateInterest] Record exists with ID:', existingRecord.Id);
} catch (verifyError: any) {
console.error('[nocodb.updateInterest] Failed to verify record:', verifyError);
if (verifyError.statusCode === 404 || verifyError.status === 404) {
console.error('[nocodb.updateInterest] Record verification failed - record not found');
}
}
}
// Create a clean data object that matches the InterestsRequest schema
// Remove any properties that are not in the schema or shouldn't be sent
const cleanData: Record<string, any> = {};
// Only include fields that are part of the InterestsRequest schema
// Removed webhook fields: "Request More Information", "Request More Info - To Sales", "EOI Send to Sales"
const allowedFields = [
"Full Name",
"Yacht Name",
"Length",
"Address",
"Email Address",
"Sales Process Level",
"Phone Number",
"Extra Comments",
"Berth Size Desired",
"LOI-NDA Document",
"Date Added",
"Width",
"Depth",
"Created At",
"Source",
"Contact Method Preferred",
"Request Form Sent",
"Berth Number",
"EOI Time Sent",
"Lead Category",
"Time LOI Sent",
"EOI Status",
"Berth Info Sent Status",
"Contract Sent Status",
"Deposit 10% Status",
"Contract Status",
// Add the EOI link fields
"EOI Client Link",
"EOI David Link",
"EOI Oscar Link",
"EOI Document",
// Add the new signature link fields
"Signature Link Client",
"Signature Link CC",
"Signature Link Developer",
// Add the embedded signature link fields
"EmbeddedSignatureLinkClient",
"EmbeddedSignatureLinkCC",
"EmbeddedSignatureLinkDeveloper",
// Add the Documenso document ID field
"documensoID"
];
// Filter the data to only include allowed fields
for (const field of allowedFields) {
if (field in data) {
const value = (data as any)[field];
// Skip webhook-type fields and other object fields that shouldn't be sent
if (value && typeof value === 'object' && !Array.isArray(value)) {
console.log(`[nocodb.updateInterest] Skipping object field: ${field}`, value);
continue;
}
// Handle clearing fields - NocoDB requires null for clearing, not undefined
if (value === undefined) {
cleanData[field] = null;
console.log(`[nocodb.updateInterest] Converting undefined to null for field: ${field}`);
} else {
cleanData[field] = value;
}
}
}
// Fix date formatting for PostgreSQL
if (cleanData['Date Added']) {
cleanData['Date Added'] = convertDateFormat(cleanData['Date Added']);
}
if (cleanData['Created At']) {
cleanData['Created At'] = convertDateFormat(cleanData['Created At']);
}
if (cleanData['EOI Time Sent']) {
cleanData['EOI Time Sent'] = convertDateFormat(cleanData['EOI Time Sent']);
}
if (cleanData['Time LOI Sent']) {
cleanData['Time LOI Sent'] = convertDateFormat(cleanData['Time LOI Sent']);
}
console.log('[nocodb.updateInterest] Clean data fields:', Object.keys(cleanData));
// PATCH requires ID in the body (not in URL)
// Ensure ID is an integer
cleanData.Id = parseInt(id);
const url = createTableUrl(Table.Interest);
console.log('[nocodb.updateInterest] URL:', url);
try {
console.log('[nocodb.updateInterest] Sending PATCH request with headers:', {
"xc-token": getNocoDbConfiguration().token ? "***" + getNocoDbConfiguration().token.slice(-4) : "not set"
});
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 $fetch<Interest>(url, {
method: "PATCH",
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
body: cleanData
});
console.log('[nocodb.updateInterest] Update successful for ID:', id);
return result;
} catch (error: any) {
console.error('[nocodb.updateInterest] Update failed:', error);
console.error('[nocodb.updateInterest] Error details:', error instanceof Error ? error.message : 'Unknown error');
// If it's a 404 error and we haven't retried too many times, wait and retry
if ((error.statusCode === 404 || error.status === 404) && retryCount < 3) {
console.error('[nocodb.updateInterest] 404 Error - Record not found. This might be a sync delay.');
console.error(`Retrying in ${(retryCount + 1) * 1000}ms... (Attempt ${retryCount + 1}/3)`);
// Wait with exponential backoff
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
// Retry the update
return updateInterest(id, data, retryCount + 1);
}
// If it's still a 404 after retries, provide detailed error
if (error.statusCode === 404 || error.status === 404) {
console.error('[nocodb.updateInterest] 404 Error - Record not found after 3 retries. This might happen if:');
console.error('1. The record ID is incorrect');
console.error('2. The record was deleted');
console.error('3. There is a synchronization issue with the database');
console.error('Attempted URL:', url);
}
throw error;
}
};
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
const cleanData: Record<string, any> = {};
// Only include fields that are part of the InterestsRequest schema
const allowedFields = [
"Full Name",
"Yacht Name",
"Length",
"Address",
"Email Address",
"Sales Process Level",
"Phone Number",
"Extra Comments",
"Berth Size Desired",
"Date Added",
"Width",
"Depth",
"Source",
"Contact Method Preferred",
"Lead Category",
"EOI Status",
"Berth Info Sent Status",
"Contract Sent Status",
"Deposit 10% Status",
"Contract Status",
];
// Filter the data to only include allowed fields
for (const field of allowedFields) {
if (field in data) {
cleanData[field] = (data as any)[field];
}
}
// Remove any computed or relation fields that shouldn't be sent
delete cleanData.Id;
delete cleanData.Berths;
delete cleanData["Berth Recommendations"];
delete cleanData.Berth;
// Fix date formatting for PostgreSQL
if (cleanData['Date Added']) {
cleanData['Date Added'] = convertDateFormat(cleanData['Date Added']);
}
if (cleanData['Created At']) {
cleanData['Created At'] = convertDateFormat(cleanData['Created At']);
}
console.log('[nocodb.createInterest] Clean data fields:', Object.keys(cleanData));
const url = createTableUrl(Table.Interest);
console.log('[nocodb.createInterest] URL:', url);
try {
const result = await $fetch<Interest>(url, {
method: "POST",
headers: {
"xc-token": getNocoDbConfiguration().token,
},
body: cleanData,
});
console.log('[nocodb.createInterest] Created interest with ID:', result.Id);
return result;
} catch (error) {
console.error('[nocodb.createInterest] Create failed:', error);
console.error('[nocodb.createInterest] Error details:', error instanceof Error ? error.message : 'Unknown error');
throw error;
}
};
export const deleteInterest = async (id: string) => {
const startTime = Date.now();
console.log('[nocodb.deleteInterest] =========================');
console.log('[nocodb.deleteInterest] DELETE operation started at:', new Date().toISOString());
console.log('[nocodb.deleteInterest] Target ID:', id);
const url = createTableUrl(Table.Interest);
console.log('[nocodb.deleteInterest] URL:', url);
const requestBody = {
"Id": parseInt(id)
};
console.log('[nocodb.deleteInterest] Request configuration:');
console.log(' Method: DELETE');
console.log(' URL:', url);
console.log(' Headers:', {
"xc-token": getNocoDbConfiguration().token ? "***" + getNocoDbConfiguration().token.slice(-4) : "not set",
"Content-Type": "application/json"
});
console.log(' Body:', JSON.stringify(requestBody, null, 2));
try {
// According to NocoDB API docs, DELETE requires ID in the body
const result = await $fetch(url, {
method: "DELETE",
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
body: requestBody
});
console.log('[nocodb.deleteInterest] DELETE successful');
console.log('[nocodb.deleteInterest] Response:', JSON.stringify(result, null, 2));
console.log('[nocodb.deleteInterest] Duration:', Date.now() - startTime, 'ms');
console.log('[nocodb.deleteInterest] =========================');
return result;
} catch (error: any) {
console.error('[nocodb.deleteInterest] =========================');
console.error('[nocodb.deleteInterest] DELETE FAILED');
console.error('[nocodb.deleteInterest] Error type:', error.constructor.name);
console.error('[nocodb.deleteInterest] Error message:', error.message);
console.error('[nocodb.deleteInterest] Error status:', error.statusCode || error.status || 'unknown');
console.error('[nocodb.deleteInterest] Error data:', error.data);
console.error('[nocodb.deleteInterest] Error stack:', error.stack || 'No stack trace');
console.error('[nocodb.deleteInterest] Full error:', JSON.stringify(error, null, 2));
console.error('[nocodb.deleteInterest] Duration:', Date.now() - startTime, 'ms');
console.error('[nocodb.deleteInterest] =========================');
throw error;
}
};
export const triggerWebhook = async (url: string, payload: any) =>
$fetch(url, {
method: "POST",
body: payload,
});
export const updateInterestEOIDocument = async (id: string, documentData: any) => {
console.log('[nocodb.updateInterestEOIDocument] Updating EOI document for interest:', id);
// Get existing EOI Document array or create new one
const interest = await getInterestById(id);
const existingDocuments = interest['EOI Document'] || [];
// Add the new document to the array
const updatedDocuments = [...existingDocuments, documentData];
// Update the interest with the new EOI Document array
return updateInterest(id, {
'EOI Document': updatedDocuments
});
};
export const getInterestByFieldAsync = async (fieldName: string, value: any): Promise<Interest | null> => {
try {
const response = await getInterests();
const interests = response.list || [];
// Find interest where the field matches the value
const interest = interests.find(i => (i as any)[fieldName] === value);
return interest || null;
} catch (error) {
console.error('Error fetching interest by field:', error);
return null;
}
};
// Berth functions
export const getBerths = async () => {
console.log('[nocodb.getBerths] Fetching berths from NocoDB...');
try {
// First try with basic query - no field expansion to avoid 404
const result = await $fetch<BerthsResponse>(createTableUrl(Table.Berth), {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
params: {
limit: 1000,
// Start with basic fields only
fields: '*'
},
});
console.log('[nocodb.getBerths] Successfully fetched berths, count:', result.list?.length || 0);
// Process each berth to populate interested parties details
if (result.list && Array.isArray(result.list)) {
console.log('[nocodb.getBerths] Processing berths to populate interested parties...');
// Count berths with interested parties (NocoDB returns count as number)
const berthsWithParties = result.list.filter(b =>
b['Interested Parties'] && (typeof b['Interested Parties'] === 'number' && b['Interested Parties'] > 0)
);
console.log('[nocodb.getBerths] Berths with interested parties:', berthsWithParties.length);
// Log first berth with interested parties for debugging
if (berthsWithParties.length > 0) {
const firstBerth = berthsWithParties[0];
if (firstBerth && firstBerth['Interested Parties']) {
console.log('[nocodb.getBerths] First berth with parties:', {
id: firstBerth.Id,
mooringNumber: firstBerth['Mooring Number'],
partiesCount: firstBerth['Interested Parties'].length,
firstParty: firstBerth['Interested Parties'][0]
});
}
}
await Promise.all(
result.list.map(async (berth) => {
// Check if berth has interested parties (as a number count)
if (berth['Interested Parties'] && typeof berth['Interested Parties'] === 'number' && berth['Interested Parties'] > 0) {
const partyCount = berth['Interested Parties'] as number;
console.log(`[nocodb.getBerths] Berth ${berth['Mooring Number']} has ${partyCount} interested parties (as count)`);
// When we have a count, fetch the linked records using the links API
try {
const config = getNocoDbConfiguration();
const berthsTableId = "mczgos9hr3oa9qc";
const interestedPartiesFieldId = "c7q2z2rb27c1cb5";
const linkUrl = `${config.url}/api/v2/tables/${berthsTableId}/links/${interestedPartiesFieldId}/records/${berth.Id}`;
console.log(`[nocodb.getBerths] Fetching linked parties from: ${linkUrl}`);
const linkedResponse = await $fetch<any>(linkUrl, {
headers: {
"xc-token": config.token,
},
params: {
limit: 100
}
});
console.log(`[nocodb.getBerths] Linked response for berth ${berth['Mooring Number']}:`, linkedResponse);
if (linkedResponse && linkedResponse.list && Array.isArray(linkedResponse.list)) {
// The links API returns limited data, so we need to fetch full records
console.log(`[nocodb.getBerths] Got ${linkedResponse.list.length} linked records, fetching full details...`);
const fullInterestDetails = await Promise.all(
linkedResponse.list.map(async (linkedParty: any) => {
try {
const partyId = linkedParty.Id || linkedParty.id;
if (partyId) {
const fullDetails = await getInterestById(partyId.toString());
return fullDetails;
}
return linkedParty;
} catch (error) {
console.error(`[nocodb.getBerths] Failed to fetch full details for party ${linkedParty.Id}:`, error);
return linkedParty;
}
})
);
berth['Interested Parties'] = fullInterestDetails;
console.log(`[nocodb.getBerths] Successfully fetched full details for ${fullInterestDetails.length} interested parties for berth ${berth['Mooring Number']}`);
} else {
// Fallback to placeholders if API call doesn't return expected format
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
Id: index + 1,
'Full Name': `Party ${index + 1}`,
'Sales Process Level': null,
'EOI Status': null,
'Contract Status': null
}));
berth['Interested Parties'] = placeholderParties as any;
console.log(`[nocodb.getBerths] Using placeholders for berth ${berth['Mooring Number']}`);
}
} catch (linkError) {
console.error(`[nocodb.getBerths] Failed to fetch linked parties for berth ${berth['Mooring Number']}:`, linkError);
// Fallback to placeholders on error
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
Id: index + 1,
'Full Name': `Party ${index + 1}`,
'Sales Process Level': null,
'EOI Status': null,
'Contract Status': null
}));
berth['Interested Parties'] = placeholderParties as any;
}
} else if (berth['Interested Parties'] && Array.isArray(berth['Interested Parties']) && berth['Interested Parties'].length > 0) {
// Handle case where we get an array (this might happen in some cases)
console.log(`[nocodb.getBerths] Processing ${berth['Interested Parties'].length} parties for berth ${berth['Mooring Number']}`);
// Extract IDs from various possible formats
const partyIds = berth['Interested Parties'].map((party: any) => {
// Handle different possible formats from NocoDB
if (typeof party === 'number') return party;
if (typeof party === 'string') return parseInt(party);
if (party && typeof party === 'object') {
// Check various possible ID field names
return party.Id || party.id || party.ID || party._id || null;
}
return null;
}).filter((id: any) => id !== null && !isNaN(id));
console.log(`[nocodb.getBerths] Extracted ${partyIds.length} valid IDs for berth ${berth['Mooring Number']}:`, partyIds);
if (partyIds.length > 0) {
const interestedPartiesDetails = await Promise.all(
partyIds.map(async (partyId: number) => {
try {
console.log(`[nocodb.getBerths] Fetching interest ${partyId} for berth ${berth['Mooring Number']}`);
const interestDetails = await getInterestById(partyId.toString());
return interestDetails;
} catch (error) {
console.error(`[nocodb.getBerths] Failed to fetch interest ${partyId}:`, error);
return { Id: partyId, 'Full Name': `Interest #${partyId}` } as any;
}
})
);
berth['Interested Parties'] = interestedPartiesDetails;
console.log(`[nocodb.getBerths] Populated ${interestedPartiesDetails.length} parties for berth ${berth['Mooring Number']}`);
} else {
console.log(`[nocodb.getBerths] No valid party IDs found for berth ${berth['Mooring Number']}`);
}
}
})
);
// Sort berths by letter zone and then by number using Mooring Number
result.list.sort((a, b) => {
const berthA = a['Mooring Number'] || '';
const berthB = b['Mooring Number'] || '';
// Extract letter and number parts
const matchA = berthA.match(/^([A-Za-z]+)(\d+)$/);
const matchB = berthB.match(/^([A-Za-z]+)(\d+)$/);
if (matchA && matchB) {
const [, letterA, numberA] = matchA;
const [, letterB, numberB] = matchB;
// First sort by letter zone
const letterCompare = letterA.localeCompare(letterB);
if (letterCompare !== 0) {
return letterCompare;
}
// Then sort by number within the same letter zone
return parseInt(numberA) - parseInt(numberB);
}
// Fallback to string comparison if pattern doesn't match
return berthA.localeCompare(berthB);
});
console.log('[nocodb.getBerths] Berths sorted by zone and number with populated interested parties');
}
return result;
} catch (error: any) {
console.error('[nocodb.getBerths] Error fetching berths:', error);
console.error('[nocodb.getBerths] Error details:', error instanceof Error ? error.message : 'Unknown error');
throw error;
}
};
export const getBerthById = async (id: string) => {
console.log('[nocodb.getBerthById] Fetching berth ID:', id);
try {
// First fetch the basic berth data
const result = await $fetch<Berth>(`${createTableUrl(Table.Berth)}/${id}`, {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
params: {
fields: '*'
}
});
console.log('[nocodb.getBerthById] Successfully fetched berth:', result.Id);
console.log('[nocodb.getBerthById] Raw Interested Parties:', JSON.stringify(result['Interested Parties'], null, 2));
// Now fetch and populate the interested parties details
if (result['Interested Parties']) {
// Handle case where interested parties is a count (number)
if (typeof result['Interested Parties'] === 'number' && result['Interested Parties'] > 0) {
const partyCount = result['Interested Parties'] as number;
console.log(`[nocodb.getBerthById] Berth has ${partyCount} interested parties (as count)`);
// Fetch the linked records using the links API
try {
const config = getNocoDbConfiguration();
const berthsTableId = "mczgos9hr3oa9qc";
const interestedPartiesFieldId = "c7q2z2rb27c1cb5";
const linkUrl = `${config.url}/api/v2/tables/${berthsTableId}/links/${interestedPartiesFieldId}/records/${result.Id}`;
console.log(`[nocodb.getBerthById] Fetching linked parties from: ${linkUrl}`);
const linkedResponse = await $fetch<any>(linkUrl, {
headers: {
"xc-token": config.token,
},
params: {
limit: 100
}
});
console.log(`[nocodb.getBerthById] Linked response:`, linkedResponse);
if (linkedResponse && linkedResponse.list && Array.isArray(linkedResponse.list)) {
// The links API returns limited data, so we need to fetch full records
console.log(`[nocodb.getBerthById] Got ${linkedResponse.list.length} linked records, fetching full details...`);
const fullInterestDetails = await Promise.all(
linkedResponse.list.map(async (linkedParty: any) => {
try {
const partyId = linkedParty.Id || linkedParty.id;
if (partyId) {
const fullDetails = await getInterestById(partyId.toString());
return fullDetails;
}
return linkedParty;
} catch (error) {
console.error(`[nocodb.getBerthById] Failed to fetch full details for party ${linkedParty.Id}:`, error);
return linkedParty;
}
})
);
result['Interested Parties'] = fullInterestDetails;
console.log(`[nocodb.getBerthById] Successfully fetched full details for ${fullInterestDetails.length} interested parties`);
} else {
// Fallback to placeholders if API call doesn't return expected format
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
Id: index + 1,
'Full Name': `Party ${index + 1}`,
'Sales Process Level': null,
'EOI Status': null,
'Contract Status': null
}));
result['Interested Parties'] = placeholderParties as any;
console.log(`[nocodb.getBerthById] Using placeholders`);
}
} catch (linkError) {
console.error(`[nocodb.getBerthById] Failed to fetch linked parties:`, linkError);
// Fallback to placeholders on error
const placeholderParties = Array.from({ length: partyCount }, (_, index) => ({
Id: index + 1,
'Full Name': `Party ${index + 1}`,
'Sales Process Level': null,
'EOI Status': null,
'Contract Status': null
}));
result['Interested Parties'] = placeholderParties as any;
}
} else if (Array.isArray(result['Interested Parties'])) {
// Handle case where we get an array (might happen in some cases)
console.log('[nocodb.getBerthById] Fetching details for interested parties:', result['Interested Parties'].length);
// Extract IDs from various possible formats
const partyIds = result['Interested Parties'].map((party: any) => {
// Handle different possible formats from NocoDB
if (typeof party === 'number') return party;
if (typeof party === 'string') return parseInt(party);
if (party && typeof party === 'object') {
// Check various possible ID field names
return party.Id || party.id || party.ID || party._id || null;
}
return null;
}).filter(id => id !== null && !isNaN(id));
console.log('[nocodb.getBerthById] Extracted party IDs:', partyIds);
// Fetch full interest records
if (partyIds.length > 0) {
const interestedPartiesDetails = await Promise.all(
partyIds.map(async (partyId: number) => {
try {
console.log('[nocodb.getBerthById] Fetching interest details for ID:', partyId);
const interestDetails = await getInterestById(partyId.toString());
return interestDetails;
} catch (error) {
console.error('[nocodb.getBerthById] Failed to fetch interest details for ID:', partyId, error);
// Return a placeholder object if fetch fails
return { Id: partyId, 'Full Name': `Interest #${partyId}` } as any;
}
})
);
result['Interested Parties'] = interestedPartiesDetails;
console.log('[nocodb.getBerthById] Populated interested parties details:', interestedPartiesDetails.length);
} else {
console.log('[nocodb.getBerthById] No valid party IDs found to populate');
}
}
}
return result;
} catch (error: any) {
console.error('[nocodb.getBerthById] Error fetching berth:', error);
console.error('[nocodb.getBerthById] Error details:', error instanceof Error ? error.message : 'Unknown error');
throw error;
}
};
export const updateBerth = async (id: string, data: Partial<Berth>): Promise<Berth> => {
console.log('[nocodb.updateBerth] Updating berth:', id);
console.log('[nocodb.updateBerth] Data fields:', Object.keys(data));
// Create a clean data object that matches the Berth schema
const cleanData: Record<string, any> = {};
// Only include fields that are part of the Berth schema (excluding formula fields)
const allowedFields = [
"Mooring Number",
"Area",
"Status",
"Nominal Boat Size",
"Water Depth",
"Length",
"Width",
"Draft", // Changed from "Depth" to "Draft"
"Side Pontoon",
"Power Capacity",
"Voltage",
"Mooring Type",
"Access",
"Cleat Type",
"Cleat Capacity",
"Bollard Type",
"Bollard Capacity",
"Price",
"Bow Facing"
];
// Filter the data to only include allowed fields
for (const field of allowedFields) {
if (field in data) {
const value = (data as any)[field];
// Handle clearing fields - NocoDB requires null for clearing, not undefined
if (value === undefined) {
cleanData[field] = null;
console.log(`[nocodb.updateBerth] Converting undefined to null for field: ${field}`);
} else {
cleanData[field] = value;
}
}
}
console.log('[nocodb.updateBerth] Clean data fields:', Object.keys(cleanData));
// PATCH requires ID in the body (not in URL)
// Ensure ID is an integer
cleanData.Id = parseInt(id);
const url = createTableUrl(Table.Berth);
console.log('[nocodb.updateBerth] URL:', url);
try {
console.log('[nocodb.updateBerth] Sending PATCH request');
const result = await $fetch<Berth>(url, {
method: "PATCH",
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
body: cleanData
});
console.log('[nocodb.updateBerth] Update successful for ID:', id);
return result;
} catch (error: any) {
console.error('[nocodb.updateBerth] Update failed:', error);
console.error('[nocodb.updateBerth] Error details:', error instanceof Error ? error.message : 'Unknown error');
throw error;
}
};
// Expense functions with resilient HTTP handling
export const getExpenses = async (filters?: ExpenseFilters) => {
console.log('[nocodb.getExpenses] Fetching expenses from NocoDB...', filters);
const startTime = Date.now();
try {
const params: any = { limit: 1000 };
// Build filter conditions (fixed date logic)
if (filters?.startDate && filters?.endDate) {
// Ensure dates are in YYYY-MM-DD format
const startDate = filters.startDate.includes('T') ? filters.startDate.split('T')[0] : filters.startDate;
const endDate = filters.endDate.includes('T') ? filters.endDate.split('T')[0] : filters.endDate;
console.log('[nocodb.getExpenses] Date filter:', { startDate, endDate });
params.where = `(Time,gte,${startDate})~and(Time,lte,${endDate})`;
} else if (filters?.startDate) {
const startDate = filters.startDate.includes('T') ? filters.startDate.split('T')[0] : filters.startDate;
params.where = `(Time,gte,${startDate})`;
} else if (filters?.endDate) {
const endDate = filters.endDate.includes('T') ? filters.endDate.split('T')[0] : filters.endDate;
params.where = `(Time,lte,${endDate})`;
}
// Add payer filter
if (filters?.payer) {
const payerFilter = `(Payer,eq,${encodeURIComponent(filters.payer)})`;
params.where = params.where ? `${params.where}~and${payerFilter}` : payerFilter;
}
// Add category filter
if (filters?.category) {
const categoryFilter = `(Category,eq,${encodeURIComponent(filters.category)})`;
params.where = params.where ? `${params.where}~and${categoryFilter}` : categoryFilter;
}
// Sort by Time descending (newest first)
params.sort = '-Time';
console.log('[nocodb.getExpenses] Request params:', params);
console.log('[nocodb.getExpenses] Request URL:', createTableUrl(Table.Expense));
// Use regular $fetch with better error handling
const result = await $fetch<ExpensesResponse>(createTableUrl(Table.Expense), {
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
params
});
console.log('[nocodb.getExpenses] Successfully fetched expenses, count:', result.list?.length || 0);
console.log('[nocodb.getExpenses] Request duration:', Date.now() - startTime, 'ms');
// Transform expenses to add computed price numbers
if (result.list && Array.isArray(result.list)) {
result.list = result.list.map(expense => ({
...expense,
// Parse price string to number for calculations
PriceNumber: parseFloat(expense.Price?.replace(/[€$,]/g, '') || '0') || 0
}));
}
return result;
} catch (error: any) {
console.error('[nocodb.getExpenses] =========================');
console.error('[nocodb.getExpenses] EXPENSE FETCH FAILED');
console.error('[nocodb.getExpenses] Duration:', Date.now() - startTime, 'ms');
console.error('[nocodb.getExpenses] Error type:', error.constructor?.name || 'Unknown');
console.error('[nocodb.getExpenses] Error status:', error.statusCode || error.status || 'Unknown');
console.error('[nocodb.getExpenses] Error message:', error.message || 'Unknown error');
console.error('[nocodb.getExpenses] Error data:', error.data);
console.error('[nocodb.getExpenses] Full error:', JSON.stringify(error, null, 2));
console.error('[nocodb.getExpenses] =========================');
// Provide more specific error messages
if (error.statusCode === 401 || error.status === 401) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication failed when accessing expense database. Please check your access permissions.'
});
} else if (error.statusCode === 403 || error.status === 403) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied to expense database. This feature requires appropriate privileges.'
});
} else if (error.statusCode === 404 || error.status === 404) {
throw createError({
statusCode: 404,
statusMessage: 'Expense database table not found. Please contact your administrator.'
});
} else if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') {
throw createError({
statusCode: 503,
statusMessage: 'Expense database is temporarily unavailable. Please try again in a moment.'
});
}
throw error;
}
};
export const getExpenseById = async (id: string) => {
console.log('[nocodb.getExpenseById] Fetching expense ID:', id);
try {
const result = await $fetch<Expense>(`${createTableUrl(Table.Expense)}/${id}`, {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
});
console.log('[nocodb.getExpenseById] Successfully fetched expense:', result.Id);
// Add computed price number
const expenseWithPrice = {
...result,
PriceNumber: parseFloat(result.Price.replace(/[€$,]/g, '')) || 0
};
return expenseWithPrice;
} catch (error: any) {
console.error('[nocodb.getExpenseById] Error fetching expense:', error);
console.error('[nocodb.getExpenseById] Error details:', error instanceof Error ? error.message : 'Unknown error');
throw error;
}
};
// Helper function to get current month expenses (default view)
export const getCurrentMonthExpenses = async () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
console.log('[nocodb.getCurrentMonthExpenses] Fetching current month expenses:', startOfMonth, 'to', endOfMonth);
return getExpenses({
startDate: startOfMonth,
endDate: endOfMonth
});
};
// Helper function to group expenses by payer
export const groupExpensesByPayer = (expenses: Expense[]) => {
const groups = expenses.reduce((acc, expense) => {
const payer = expense.Payer || 'Unknown';
if (!acc[payer]) {
acc[payer] = {
name: payer,
expenses: [],
count: 0,
total: 0
};
}
acc[payer].expenses.push(expense);
acc[payer].count++;
acc[payer].total += parseFloat(expense.Price.replace(/[€$,]/g, '')) || 0;
return acc;
}, {} as Record<string, { name: string; expenses: Expense[]; count: number; total: number }>);
return Object.values(groups);
};