puffin-app/api/nocodbClient.ts

335 lines
9.0 KiB
TypeScript
Raw Normal View History

/**
* 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
*/
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<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();
// 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<NocoDBResponse<any>>(endpoint);
}
/**
* 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> {
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<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 }>> {
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<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,
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<number> {
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 };