/** * NocoDB Client - Clean abstraction layer for NocoDB REST API * Hides query syntax complexity behind simple TypeScript functions */ interface NocoDBConfig { baseUrl: string; baseId: string; apiKey: string; ordersTableId: string; } interface OrderFilters { status?: string; vesselName?: string; imoNumber?: string; dateFrom?: string; dateTo?: string; minAmount?: number; maxAmount?: number; } interface PaginationParams { limit?: number; offset?: number; sortBy?: string; sortOrder?: 'asc' | 'desc'; } interface NocoDBResponse { list: T[]; pageInfo: { totalRows: number; page: number; pageSize: number; isFirstPage: boolean; isLastPage: boolean; }; } interface OrderStats { totalOrders: number; totalRevenue: number; totalCO2Offset: number; fulfillmentRate: number; ordersByStatus: { pending: number; paid: number; fulfilled: number; cancelled: number; }; } export class NocoDBClient { private config: NocoDBConfig; private baseUrl: string; constructor() { this.config = { baseUrl: process.env.NOCODB_BASE_URL || '', baseId: process.env.NOCODB_BASE_ID || '', apiKey: process.env.NOCODB_API_KEY || '', ordersTableId: process.env.NOCODB_ORDERS_TABLE_ID || '', }; if (!this.config.baseUrl || !this.config.baseId || !this.config.apiKey || !this.config.ordersTableId) { console.warn('NocoDB configuration incomplete. Some features may not work.'); } this.baseUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records`; } /** * Build NocoDB where clause from filters * Note: Date filtering is done client-side since NocoDB columns are strings */ private buildWhereClause(filters: OrderFilters): string { const conditions: string[] = []; if (filters.status) { conditions.push(`(status,eq,${filters.status})`); } if (filters.vesselName) { conditions.push(`(vesselName,like,%${filters.vesselName}%)`); } if (filters.imoNumber) { conditions.push(`(imoNumber,eq,${filters.imoNumber})`); } // Date filtering removed from WHERE clause - done client-side instead // NocoDB rejects date comparisons when columns are stored as strings if (filters.minAmount !== undefined) { conditions.push(`(totalAmount,gte,${filters.minAmount})`); } if (filters.maxAmount !== undefined) { conditions.push(`(totalAmount,lte,${filters.maxAmount})`); } return conditions.length > 0 ? conditions.join('~and') : ''; } /** * Filter orders by date range (client-side) */ private filterOrdersByDate(orders: any[], dateFrom?: string, dateTo?: string): any[] { // Check for empty strings or undefined/null const hasDateFrom = dateFrom && dateFrom.trim() !== ''; const hasDateTo = dateTo && dateTo.trim() !== ''; if (!hasDateFrom && !hasDateTo) return orders; return orders.filter(order => { const orderDate = new Date(order.CreatedAt); if (hasDateFrom) { const fromDate = new Date(dateFrom); fromDate.setHours(0, 0, 0, 0); if (orderDate < fromDate) return false; } if (hasDateTo) { const toDate = new Date(dateTo); toDate.setHours(23, 59, 59, 999); if (orderDate > toDate) return false; } return true; }); } /** * Build sort parameter */ private buildSortParam(sortBy?: string, sortOrder?: 'asc' | 'desc'): string { if (!sortBy) return '-CreatedAt'; // Default: newest first const prefix = sortOrder === 'asc' ? '' : '-'; return `${prefix}${sortBy}`; } /** * Make authenticated request to NocoDB */ private async request(endpoint: string, options: RequestInit = {}): Promise { const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'xc-token': this.config.apiKey, 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`NocoDB request failed: ${response.status} - ${errorText}`); } return response.json(); } /** * Get list of orders with filtering, sorting, and pagination */ async getOrders( filters: OrderFilters = {}, pagination: PaginationParams = {} ): Promise> { const params = new URLSearchParams(); // Add where clause if filters exist (excludes date filters) const whereClause = this.buildWhereClause(filters); if (whereClause) { params.append('where', whereClause); } // Add sorting const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder); params.append('sort', sort); // Fetch all records for client-side date filtering (up to 10000) params.append('limit', '10000'); const queryString = params.toString(); const endpoint = queryString ? `?${queryString}` : ''; const response = await this.request>(endpoint); // Apply client-side date filtering const filteredOrders = this.filterOrdersByDate( response.list, filters.dateFrom, filters.dateTo ); // Apply pagination to filtered results const requestedLimit = pagination.limit || 25; const requestedOffset = pagination.offset || 0; const paginatedOrders = filteredOrders.slice( requestedOffset, requestedOffset + requestedLimit ); // Update response with filtered and paginated results return { list: paginatedOrders, pageInfo: { totalRows: filteredOrders.length, page: Math.floor(requestedOffset / requestedLimit) + 1, pageSize: requestedLimit, isFirstPage: requestedOffset === 0, isLastPage: requestedOffset + requestedLimit >= filteredOrders.length, }, }; } /** * Get single order by ID */ async getOrderById(recordId: string): Promise { return this.request(`/${recordId}`); } /** * Search orders by text (searches vessel name, IMO, order ID) */ async searchOrders( searchTerm: string, pagination: PaginationParams = {} ): Promise> { // Search in multiple fields using OR conditions const searchConditions = [ `(vesselName,like,%${searchTerm}%)`, `(imoNumber,like,%${searchTerm}%)`, `(orderId,like,%${searchTerm}%)`, ].join('~or'); const params = new URLSearchParams(); params.append('where', searchConditions); const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder); params.append('sort', sort); if (pagination.limit) { params.append('limit', pagination.limit.toString()); } if (pagination.offset) { params.append('offset', pagination.offset.toString()); } return this.request>(`?${params.toString()}`); } /** * Get order statistics for dashboard */ async getStats(dateFrom?: string, dateTo?: string): Promise { // Fetch all orders without date filtering const response = await this.getOrders({}, { limit: 10000 }); // Apply client-side date filtering const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo); // Calculate stats (parse string amounts from NocoDB) const totalOrders = orders.length; const totalRevenue = orders.reduce((sum, order) => sum + parseFloat(order.totalAmount || '0'), 0); const totalCO2Offset = orders.reduce((sum, order) => sum + parseFloat(order.co2Tons || '0'), 0); const ordersByStatus = { pending: orders.filter((o) => o.status === 'pending').length, paid: orders.filter((o) => o.status === 'paid').length, fulfilled: orders.filter((o) => o.status === 'fulfilled').length, cancelled: orders.filter((o) => o.status === 'cancelled').length, }; const fulfillmentRate = totalOrders > 0 ? (ordersByStatus.fulfilled / totalOrders) * 100 : 0; return { totalOrders, totalRevenue, totalCO2Offset, fulfillmentRate, ordersByStatus, }; } /** * Update order status */ async updateOrderStatus(recordId: string, status: string): Promise { return this.request(`/${recordId}`, { method: 'PATCH', body: JSON.stringify({ status }), }); } /** * Update order fields */ async updateOrder(recordId: string, data: Record): Promise { return this.request(`/${recordId}`, { method: 'PATCH', body: JSON.stringify(data), }); } /** * Get orders grouped by time period (for charts) */ async getOrdersTimeline( period: 'day' | 'week' | 'month', dateFrom?: string, dateTo?: string ): Promise> { // Fetch all orders without date filtering const response = await this.getOrders({}, { limit: 10000 }); // Apply client-side date filtering const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo); // Group orders by time period const grouped = new Map(); orders.forEach((order) => { const date = new Date(order.CreatedAt); let key: string; switch (period) { case 'day': key = date.toISOString().split('T')[0]; break; case 'week': const weekStart = new Date(date); weekStart.setDate(date.getDate() - date.getDay()); key = weekStart.toISOString().split('T')[0]; break; case 'month': key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; break; } const existing = grouped.get(key) || { count: 0, revenue: 0 }; grouped.set(key, { count: existing.count + 1, revenue: existing.revenue + parseFloat(order.totalAmount || '0'), }); }); return Array.from(grouped.entries()) .map(([date, data]) => ({ date, ...data })) .sort((a, b) => a.date.localeCompare(b.date)); } /** * Get count of records matching filters */ async getCount(filters: OrderFilters = {}): Promise { // Fetch all orders and count after client-side filtering const response = await this.getOrders(filters, { limit: 10000 }); return response.list.length; } } // Export singleton instance export const nocodbClient = new NocoDBClient(); // Export types for use in other files export type { OrderFilters, PaginationParams, NocoDBResponse, OrderStats };