diff --git a/api/nocodbClient.ts b/api/nocodbClient.ts new file mode 100644 index 0000000..369e777 --- /dev/null +++ b/api/nocodbClient.ts @@ -0,0 +1,334 @@ +/** + * 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 };