/**
* LiteFarm API Client
* Handles authentication and API requests to LiteFarm server
*/
import axios, { AxiosInstance, AxiosError } from "axios";
import { LITEFARM_API_URL } from "./constants.js";
import { AutoCleanCache } from "./cache.js";
import { RateLimiter } from "./rate-limiter.js";
import type {
AuthTokens,
LoginResponse,
LiteFarmApiError,
LiteFarmUser,
LiteFarmFarm,
LiteFarmCrop,
LiteFarmTask,
LiteFarmLocation
} from "./types.js";
export class LiteFarmClient {
private axiosInstance: AxiosInstance;
private tokens: AuthTokens | null = null;
private userId: string | null = null;
private farmId: string | null = null;
private email: string;
private password: string;
// Performance optimization
private cache: AutoCleanCache<any>;
private rateLimiter: RateLimiter;
constructor(email: string, password: string) {
this.email = email;
this.password = password;
// Initialize cache with 5 minute TTL
this.cache = new AutoCleanCache(300, 60000);
// Initialize rate limiter: max 5 concurrent, 10 requests/second
this.rateLimiter = new RateLimiter(5, 10);
this.axiosInstance = axios.create({
baseURL: LITEFARM_API_URL,
headers: {
"Content-Type": "application/json"
},
timeout: 30000
});
// Add request interceptor for authentication and farm context
this.axiosInstance.interceptors.request.use(
async (config) => {
if (!this.tokens && !config.url?.includes("/login") && !config.url?.includes("/sign_up")) {
await this.login();
}
if (this.tokens && config.headers) {
config.headers.Authorization = `Bearer ${this.tokens.idToken}`;
}
// Add user_id and farm_id headers (required by LiteFarm API middleware)
if (this.userId && config.headers) {
config.headers['user_id'] = this.userId;
}
if (this.farmId && config.headers) {
config.headers['farm_id'] = this.farmId;
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor for error handling
this.axiosInstance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401 && !error.config?.url?.includes("/login")) {
// Token expired, try to refresh
this.tokens = null;
return this.axiosInstance.request(error.config!);
}
return Promise.reject(error);
}
);
}
/**
* Login to LiteFarm and store authentication tokens
*/
async login(): Promise<LoginResponse> {
try {
const response = await this.axiosInstance.post<LoginResponse>("/login", {
user: {
email: this.email,
password: this.password
},
screenSize: {
screen_width: 1920,
screen_height: 1080
}
});
this.tokens = {
idToken: response.data.id_token,
accessToken: response.data.id_token, // LiteFarm uses id_token as access_token
refreshToken: '' // LiteFarm doesn't provide refresh tokens
};
// Store user_id from response
this.userId = response.data.user.user_id;
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Login failed: ${error.response?.data?.message || error.message}`);
}
throw error;
}
}
/**
* Get current user information
*/
async getCurrentUser(): Promise<LiteFarmUser> {
if (!this.userId) {
throw new Error("User ID not available. Please login first.");
}
const response = await this.axiosInstance.get<LiteFarmUser>(`/user/${this.userId}`);
return response.data;
}
/**
* Get all farms for the current user
*/
async getFarms(): Promise<LiteFarmFarm[]> {
if (!this.userId) {
throw new Error("User ID not available. Please login first.");
}
// Check cache first
const cacheKey = `farms:${this.userId}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
// Use rate limiter for API request
const farms = await this.rateLimiter.execute(async () => {
const response = await this.axiosInstance.get<any[]>(`/user_farm/user/${this.userId}`);
// The API returns userFarm objects with farm data nested inside
return response.data.map((userFarm: any) => userFarm.farm || userFarm);
});
// Cache for 5 minutes
this.cache.set(cacheKey, farms, 300);
return farms;
}
/**
* Select a farm as the active farm context
* This sets the farm_id header for all subsequent requests
*/
async selectFarm(farmId: string): Promise<void> {
// Verify the farm exists and user has access to it
const farms = await this.getFarms();
const farmExists = farms.some((farm: any) => farm.farm_id === farmId);
if (!farmExists) {
throw new Error(`Farm with ID ${farmId} not found or user does not have access`);
}
this.farmId = farmId;
console.log(`✅ Selected farm: ${farmId}`);
}
/**
* Get the currently selected farm ID
*/
getSelectedFarmId(): string | null {
return this.farmId;
}
/**
* Get specific farm by ID
*/
async getFarm(farmId: string): Promise<LiteFarmFarm> {
// Check cache first
const cacheKey = `farm:${farmId}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
// Use rate limiter for API request
const farm = await this.rateLimiter.execute(async () => {
const response = await this.axiosInstance.get<LiteFarmFarm>(`/farm/${farmId}`);
return response.data;
});
// Cache for 5 minutes
this.cache.set(cacheKey, farm, 300);
return farm;
}
/**
* Create a new farm
*/
async createFarm(farmData: Partial<LiteFarmFarm>): Promise<LiteFarmFarm> {
const farm = await this.rateLimiter.execute(async () => {
const response = await this.axiosInstance.post<LiteFarmFarm>("/farm", farmData);
return response.data;
});
// Invalidate farms cache
this.cache.invalidatePattern(/^farms:/);
return farm;
}
/**
* Update existing farm
*/
async updateFarm(farmId: string, farmData: Partial<LiteFarmFarm>): Promise<LiteFarmFarm> {
const farm = await this.rateLimiter.execute(async () => {
const response = await this.axiosInstance.patch<LiteFarmFarm>(`/farm/${farmId}`, farmData);
return response.data;
});
// Invalidate farm caches
this.cache.invalidate(`farm:${farmId}`);
this.cache.invalidatePattern(/^farms:/);
return farm;
}
/**
* Get all crops for the current farm
*/
async getCrops(params?: { farm_id?: string }): Promise<LiteFarmCrop[]> {
// If farm_id is provided in params, select it first
if (params?.farm_id) {
await this.selectFarm(params.farm_id);
}
if (!this.farmId) {
throw new Error("Farm ID not available. Please select a farm first.");
}
// Check cache first
const cacheKey = `crops:${this.farmId}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
// Use rate limiter for API request
const crops = await this.rateLimiter.execute(async () => {
const response = await this.axiosInstance.get<LiteFarmCrop[]>(`/crop/farm/${this.farmId}`);
return response.data;
});
// Cache for 10 minutes (crops change rarely)
this.cache.set(cacheKey, crops, 600);
return crops;
}
/**
* Get specific crop by ID
*/
async getCrop(cropId: string): Promise<LiteFarmCrop> {
const response = await this.axiosInstance.get<LiteFarmCrop>(`/crop/${cropId}`);
return response.data;
}
/**
* Create a new crop
*/
async createCrop(cropData: Partial<LiteFarmCrop>): Promise<LiteFarmCrop> {
const response = await this.axiosInstance.post<LiteFarmCrop>("/crop", cropData);
return response.data;
}
/**
* Get all tasks
*/
async getTasks(params?: { farm_id?: string; status?: string }): Promise<LiteFarmTask[]> {
// If farm_id is provided in params, select it first
if (params?.farm_id) {
await this.selectFarm(params.farm_id);
}
if (!this.farmId) {
throw new Error("Farm ID not available. Please select a farm first.");
}
// LiteFarm API uses /task/:farm_id endpoint
const response = await this.axiosInstance.get<LiteFarmTask[]>(`/task/${this.farmId}`, {
params: params?.status ? { status: params.status } : undefined
});
return response.data;
}
/**
* Get specific task by ID
*/
async getTask(taskId: number): Promise<LiteFarmTask> {
const response = await this.axiosInstance.get<LiteFarmTask>(`/task/${taskId}`);
return response.data;
}
/**
* Create a new task
*/
async createTask(taskData: Partial<LiteFarmTask> & { location_id?: string; notes?: string }): Promise<LiteFarmTask> {
// If farm_id is provided in taskData, select it first
if (taskData.farm_id) {
await this.selectFarm(taskData.farm_id);
}
if (!this.farmId) {
throw new Error("Farm ID not available. Please select a farm first.");
}
// Validate required location_id
if (!taskData.location_id) {
throw new Error("location_id is required to create a task");
}
// Determine task type endpoint (e.g., /task/field_work_task)
const taskType = taskData.type || 'field_work_task';
const endpoint = `/task/${taskType}`;
// Map task types to their IDs (standard LiteFarm task types)
const taskTypeMap: Record<string, number> = {
'field_work_task': 1,
'cleaning_task': 11,
'harvest_task': 8,
'soil_task': 7,
'scouting_task': 6,
'pest_control_task': 4,
'irrigation_task': 5,
'soil_amendment_task': 1,
};
// Build task payload matching LiteFarm API requirements
const payload: any = {
task_type_id: taskTypeMap[taskType] || 1, // Use mapping or default to field_work_task
due_date: taskData.due_date || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
notes: taskData.notes || '',
locations: [
{
location_id: taskData.location_id
}
]
};
// Add optional fields
if (taskData.assignee_user_id) {
payload.assignee_user_id = taskData.assignee_user_id;
}
// Add task-specific data based on type
if (taskType === 'field_work_task') {
payload.field_work_task = {};
} else if (taskType === 'cleaning_task') {
payload.cleaning_task = {};
} else if (taskType === 'harvest_task') {
payload.harvest_task = {};
}
const response = await this.axiosInstance.post<LiteFarmTask>(endpoint, payload);
return response.data;
}
/**
* Update existing task
*/
async updateTask(taskId: number, taskData: Partial<LiteFarmTask>): Promise<LiteFarmTask> {
const response = await this.axiosInstance.patch<LiteFarmTask>(`/task/${taskId}`, taskData);
return response.data;
}
/**
* Complete a task
*/
async completeTask(taskId: number, completionData?: { completion_date?: string; happiness?: number }): Promise<LiteFarmTask> {
return this.updateTask(taskId, {
status: "completed",
completion_date: completionData?.completion_date || new Date().toISOString(),
happiness: completionData?.happiness
});
}
/**
* Get all locations
*/
async getLocations(params?: { farm_id?: string }): Promise<LiteFarmLocation[]> {
// If farm_id is provided in params, select it first
if (params?.farm_id) {
await this.selectFarm(params.farm_id);
}
if (!this.farmId) {
throw new Error("Farm ID not available. Please select a farm first.");
}
// LiteFarm API uses /location/farm/:farm_id endpoint
const response = await this.axiosInstance.get<LiteFarmLocation[]>(`/location/farm/${this.farmId}`);
return response.data;
}
/**
* Get specific location by ID
*/
async getLocation(locationId: string): Promise<LiteFarmLocation> {
const response = await this.axiosInstance.get<LiteFarmLocation>(`/location/${locationId}`);
return response.data;
}
/**
* Create a new location
* Note: LiteFarm uses specific endpoints for each location type:
* - /location/field for fields
* - /location/greenhouse for greenhouses
* - /location/garden for gardens
*/
async createLocation(locationData: Partial<LiteFarmLocation>): Promise<LiteFarmLocation> {
// Determine the correct endpoint based on location type
const locationType = locationData.type || (locationData as any).figure?.type || 'field';
const endpoint = `/location/${locationType}`;
const response = await this.axiosInstance.post<LiteFarmLocation>(endpoint, locationData);
return response.data;
}
/**
* Update existing location
*/
async updateLocation(locationId: string, locationData: Partial<LiteFarmLocation>): Promise<LiteFarmLocation> {
const response = await this.axiosInstance.patch<LiteFarmLocation>(`/location/${locationId}`, locationData);
return response.data;
}
/**
* Generic GET request
*/
async get<T>(endpoint: string, params?: Record<string, unknown>): Promise<T> {
const response = await this.axiosInstance.get<T>(endpoint, { params });
return response.data;
}
/**
* Generic POST request
*/
async post<T>(endpoint: string, data?: unknown): Promise<T> {
const response = await this.axiosInstance.post<T>(endpoint, data);
return response.data;
}
/**
* Generic PATCH request
*/
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
const response = await this.axiosInstance.patch<T>(endpoint, data);
return response.data;
}
/**
* Generic DELETE request
*/
async delete<T>(endpoint: string): Promise<T> {
const response = await this.axiosInstance.delete<T>(endpoint);
return response.data;
}
}
/**
* Format Axios errors into user-friendly messages
*/
export function formatApiError(error: unknown): string {
if (axios.isAxiosError(error)) {
const apiError = error.response?.data as LiteFarmApiError | undefined;
if (apiError?.message) {
return `LiteFarm API Error: ${apiError.message}`;
}
if (error.response?.status === 401) {
return "Authentication failed. Please check your credentials.";
}
if (error.response?.status === 403) {
return "Access denied. You don't have permission for this operation.";
}
if (error.response?.status === 404) {
return "Resource not found.";
}
if (error.response?.status === 429) {
return "Rate limit exceeded. Please try again later.";
}
if (error.code === "ECONNREFUSED") {
return "Cannot connect to LiteFarm API. Please ensure the server is running.";
}
return `API request failed: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "An unknown error occurred";
}