/** * 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 */ 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})`); } if (filters.dateFrom) { conditions.push(`(CreatedAt,gte,${filters.dateFrom})`); } if (filters.dateTo) { conditions.push(`(CreatedAt,lte,${filters.dateTo})`); } 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') : ''; } /** * 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 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); // Add pagination if (pagination.limit) { params.append('limit', pagination.limit.toString()); } if (pagination.offset) { params.append('offset', pagination.offset.toString()); } const queryString = params.toString(); const endpoint = queryString ? `?${queryString}` : ''; return this.request>(endpoint); } /** * 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 { const filters: OrderFilters = {}; if (dateFrom) filters.dateFrom = dateFrom; if (dateTo) filters.dateTo = dateTo; // Get all orders with filters (no pagination for stats calculation) const response = await this.getOrders(filters, { limit: 10000 }); const orders = response.list; // Calculate stats const totalOrders = orders.length; const totalRevenue = orders.reduce((sum, order) => sum + (order.totalAmount || 0), 0); const totalCO2Offset = orders.reduce((sum, order) => sum + (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> { const filters: OrderFilters = {}; if (dateFrom) filters.dateFrom = dateFrom; if (dateTo) filters.dateTo = dateTo; const response = await this.getOrders(filters, { limit: 10000 }); const orders = response.list; // 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 + (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 { const whereClause = this.buildWhereClause(filters); const params = whereClause ? `?where=${whereClause}` : ''; const countUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records/count${params}`; const response = await this.request<{ count: number }>(countUrl); return response.count; } } // Export singleton instance export const nocodbClient = new NocoDBClient(); // Export types for use in other files export type { OrderFilters, PaginationParams, NocoDBResponse, OrderStats };