2025-11-03 10:55:40 +01:00
|
|
|
/**
|
|
|
|
|
* 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<T> {
|
|
|
|
|
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
|
2025-11-03 22:24:17 +01:00
|
|
|
* Note: Date filtering is done client-side since NocoDB columns are strings
|
2025-11-03 10:55:40 +01:00
|
|
|
*/
|
|
|
|
|
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})`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
// Date filtering removed from WHERE clause - done client-side instead
|
|
|
|
|
// NocoDB rejects date comparisons when columns are stored as strings
|
2025-11-03 10:55:40 +01:00
|
|
|
|
|
|
|
|
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') : '';
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 10:55:40 +01:00
|
|
|
/**
|
|
|
|
|
* 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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
|
|
|
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<NocoDBResponse<any>> {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
// Add where clause if filters exist (excludes date filters)
|
2025-11-03 10:55:40 +01:00
|
|
|
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);
|
|
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
// Fetch all records for client-side date filtering (up to 10000)
|
|
|
|
|
params.append('limit', '10000');
|
2025-11-03 10:55:40 +01:00
|
|
|
|
|
|
|
|
const queryString = params.toString();
|
|
|
|
|
const endpoint = queryString ? `?${queryString}` : '';
|
|
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
const response = await this.request<NocoDBResponse<any>>(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,
|
|
|
|
|
},
|
|
|
|
|
};
|
2025-11-03 10:55:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get single order by ID
|
|
|
|
|
*/
|
|
|
|
|
async getOrderById(recordId: string): Promise<any> {
|
|
|
|
|
return this.request<any>(`/${recordId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Search orders by text (searches vessel name, IMO, order ID)
|
|
|
|
|
*/
|
|
|
|
|
async searchOrders(
|
|
|
|
|
searchTerm: string,
|
|
|
|
|
pagination: PaginationParams = {}
|
|
|
|
|
): Promise<NocoDBResponse<any>> {
|
|
|
|
|
// 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<NocoDBResponse<any>>(`?${params.toString()}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get order statistics for dashboard
|
|
|
|
|
*/
|
|
|
|
|
async getStats(dateFrom?: string, dateTo?: string): Promise<OrderStats> {
|
2025-11-03 22:24:17 +01:00
|
|
|
// Fetch all orders without date filtering
|
|
|
|
|
const response = await this.getOrders({}, { limit: 10000 });
|
2025-11-03 10:55:40 +01:00
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
// Apply client-side date filtering
|
|
|
|
|
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
|
2025-11-03 10:55:40 +01:00
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
// Calculate stats (parse string amounts from NocoDB)
|
2025-11-03 10:55:40 +01:00
|
|
|
const totalOrders = orders.length;
|
2025-11-03 22:24:17 +01:00
|
|
|
const totalRevenue = orders.reduce((sum, order) => sum + parseFloat(order.totalAmount || '0'), 0);
|
|
|
|
|
const totalCO2Offset = orders.reduce((sum, order) => sum + parseFloat(order.co2Tons || '0'), 0);
|
2025-11-03 10:55:40 +01:00
|
|
|
|
|
|
|
|
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<any> {
|
|
|
|
|
return this.request<any>(`/${recordId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify({ status }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update order fields
|
|
|
|
|
*/
|
|
|
|
|
async updateOrder(recordId: string, data: Record<string, any>): Promise<any> {
|
|
|
|
|
return this.request<any>(`/${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<Array<{ date: string; count: number; revenue: number }>> {
|
2025-11-03 22:24:17 +01:00
|
|
|
// Fetch all orders without date filtering
|
|
|
|
|
const response = await this.getOrders({}, { limit: 10000 });
|
2025-11-03 10:55:40 +01:00
|
|
|
|
2025-11-03 22:24:17 +01:00
|
|
|
// Apply client-side date filtering
|
|
|
|
|
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
|
2025-11-03 10:55:40 +01:00
|
|
|
|
|
|
|
|
// Group orders by time period
|
|
|
|
|
const grouped = new Map<string, { count: number; revenue: number }>();
|
|
|
|
|
|
|
|
|
|
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,
|
2025-11-03 22:24:17 +01:00
|
|
|
revenue: existing.revenue + parseFloat(order.totalAmount || '0'),
|
2025-11-03 10:55:40 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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<number> {
|
2025-11-03 22:24:17 +01:00
|
|
|
// Fetch all orders and count after client-side filtering
|
|
|
|
|
const response = await this.getOrders(filters, { limit: 10000 });
|
|
|
|
|
return response.list.length;
|
2025-11-03 10:55:40 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Export singleton instance
|
|
|
|
|
export const nocodbClient = new NocoDBClient();
|
|
|
|
|
|
|
|
|
|
// Export types for use in other files
|
|
|
|
|
export type { OrderFilters, PaginationParams, NocoDBResponse, OrderStats };
|