2 * Fieldpine FD1/FD3 API Client
4 * Modern TypeScript client for Fieldpine's FD1 and FD3 protocol.
5 * Supports both HTTP POST and WebSocket connections.
7 * @see /docs/FIELDPINE_FD1_API.md
10export interface FD1QueryOptions {
11 [key: string]: boolean | string;
14export interface FD1QueryFilters {
18export interface FD1Request {
19 a: string; // Action name
20 q?: FD1QueryFilters; // Query filters
21 qo?: FD1QueryOptions; // Query options (field selection)
24 [key: string]: any; // Additional parameters
27export interface FD1Response<T = any> {
32 rows?: T[]; // Alternative format
40export interface FD1Product {
49 department_id?: number;
53export interface FD1Sale {
55 created_date?: string;
56 completed_date?: string;
57 total_inc_tax?: number;
58 total_ex_tax?: number;
67export interface FD1SaleLine {
78export interface FD1Customer {
88 loyalty_points?: number;
91export interface FD1Department {
92 department_id?: number;
98export interface FD1Location {
108export interface FD1SessionResponse {
110 session_token?: string;
112 permissions?: string[];
120 * FD1 Client Configuration
122export interface FD1ClientConfig {
123 baseUrl: string; // e.g., 'http://127.0.0.1:3490' or 'https://store.fieldpine.com'
125 sessionToken?: string;
126 protocol?: 'fd1' | 'fd3'; // fd1 for local/ws, fd3 for cloud/https
132export class FD1Client {
133 private config: FD1ClientConfig;
134 private ws?: WebSocket;
136 constructor(config: FD1ClientConfig) {
144 * Execute a generic FD1 request
146 async request<T = any>(request: FD1Request): Promise<FD1Response<T>> {
147 const url = `${this.config.baseUrl}/${this.config.protocol}/${request.a}`;
149 const headers: Record<string, string> = {
150 'Content-Type': 'application/json',
153 if (this.config.apiKey) {
154 headers['x-api-key'] = this.config.apiKey;
157 if (this.config.sessionToken) {
158 headers['x-session-token'] = this.config.sessionToken;
161 const response = await fetch(url, {
164 body: JSON.stringify(request),
168 throw new Error(`FD1 request failed: ${response.status} ${response.statusText}`);
171 return response.json();
178 filters?: FD1QueryFilters,
179 fields?: FD1QueryOptions,
182 ): Promise<FD1Product[]> {
183 const response = await this.request<FD1Product>({
187 internal_id_n: 'pid',
188 internal_guid: 'physkey',
199 return response.data?.rows || response.rows || [];
202 async getProduct(productId: number, fields?: FD1QueryOptions): Promise<FD1Product | null> {
203 const products = await this.listProducts(
206 internal_id_n: 'pid',
216 return products[0] || null;
219 async searchProducts(searchTerm: string, limit = 50): Promise<FD1Product[]> {
220 return this.listProducts(
221 { search: searchTerm },
223 internal_id_n: 'pid',
237 filters?: FD1QueryFilters,
238 fields?: FD1QueryOptions,
241 ): Promise<FD1Sale[]> {
242 const response = await this.request<FD1Sale>({
248 completed_date: true,
258 return response.data?.rows || response.rows || [];
261 async getSale(saleId: number): Promise<FD1Sale | null> {
262 const response = await this.request<FD1Sale>({
267 if (response.data?.rows && response.data.rows.length > 0) {
268 return response.data.rows[0];
274 async createSale(saleData: {
275 lines: Array<{ product_id: number; qty: number; price?: number }>;
276 customer_id?: number;
277 location_id?: number;
278 }): Promise<FD1Sale> {
279 const response = await this.request<FD1Sale>({
284 if (response.data?.rows && response.data.rows.length > 0) {
285 return response.data.rows[0];
288 throw new Error('Failed to create sale');
295 filters?: FD1QueryFilters,
296 fields?: FD1QueryOptions,
299 ): Promise<FD1Customer[]> {
300 const response = await this.request<FD1Customer>({
308 loyalty_points: true,
314 return response.data?.rows || response.rows || [];
317 async getCustomer(customerId: number): Promise<FD1Customer | null> {
318 const customers = await this.listCustomers({ customer_id: customerId }, undefined, 1);
319 return customers[0] || null;
322 async searchCustomers(searchTerm: string, limit = 50): Promise<FD1Customer[]> {
323 return this.listCustomers(
324 { search: searchTerm },
338 async listDepartments(fields?: FD1QueryOptions): Promise<FD1Department[]> {
339 const response = await this.request<FD1Department>({
340 a: 'departments.list',
349 return response.data?.rows || response.rows || [];
355 async listLocations(fields?: FD1QueryOptions): Promise<FD1Location[]> {
356 const response = await this.request<FD1Location>({
366 return response.data?.rows || response.rows || [];
372 async login(credentials: {
376 }): Promise<FD1SessionResponse> {
377 const response = await this.request<FD1SessionResponse>({
382 if (response.success && response.data?.rows?.[0]?.session_token) {
383 this.config.sessionToken = response.data.rows[0].session_token;
386 const result = response.data?.rows?.[0];
391 // If no row data, construct response from top-level response
393 success: response.success || false,
394 session_token: response.data?.rows?.[0]?.session_token,
395 user_id: response.data?.rows?.[0]?.user_id,
396 permissions: response.data?.rows?.[0]?.permissions,
397 error: response.error,
401 async logout(): Promise<void> {
406 this.config.sessionToken = undefined;
413 onMessage: (data: any) => void,
414 onError?: (error: Event) => void
416 return new Promise((resolve, reject) => {
417 const wsProtocol = this.config.baseUrl.startsWith('https') ? 'wss' : 'ws';
418 const wsUrl = `${wsProtocol}://${this.config.baseUrl.replace(/^https?:\/\//, '')}/fd1/ws`;
420 this.ws = new WebSocket(wsUrl);
422 this.ws.onopen = () => {
423 // Send authentication as first message
424 if (this.config.apiKey) {
428 api_key: this.config.apiKey,
435 this.ws.onmessage = (event) => {
437 const data = JSON.parse(event.data);
440 console.error('Failed to parse WebSocket message:', error);
444 this.ws.onerror = (error) => {
451 this.ws.onclose = () => {
452 console.log('WebSocket connection closed');
457 sendWebSocketMessage(message: FD1Request): void {
458 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
459 this.ws.send(JSON.stringify(message));
461 throw new Error('WebSocket not connected');
465 disconnectWebSocket(): void {
473 * Firehose (Real-time Events)
475 async subscribeToFirehose(topics: string[]): Promise<void> {
477 a: 'firehose.subscribe',
483 * Generic Data Query (using /fd1/data endpoint)
485 async queryData(table: string, filter?: string, fields?: string): Promise<any> {
486 const params = new URLSearchParams({
487 '_v.table': filter ? `${table}[${filter}]` : table,
491 params.append('_qo', fields);
494 const url = `${this.config.baseUrl}/fd1/data?${params.toString()}`;
496 const headers: Record<string, string> = {};
498 if (this.config.apiKey) {
499 headers['x-api-key'] = this.config.apiKey;
502 const response = await fetch(url, { headers });
505 throw new Error(`FD1 data query failed: ${response.status} ${response.statusText}`);
508 return response.json();
513 * Create FD1 client instance
515export function createFD1Client(config: FD1ClientConfig): FD1Client {
516 return new FD1Client(config);