/** * Copyright (c) 2025-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { makeObservable, observable, action, flow, flowResult, computed, } from 'mobx'; import { LogEvent, type GeneratorFn, type LegendUser, assertErrorThrown, ActionState, } from '@finos/legend-shared'; import { APPLICATION_EVENT } from '@finos/legend-application'; import { LEGEND_MARKETPLACE_APP_EVENT } from '../../__lib__/LegendMarketplaceAppEvent.js'; import type { LegendMarketplaceBaseStore } from '../LegendMarketplaceBaseStore.js'; import { OrderStatusCategory, type OrderSearchStatus, type TerminalProductOrder, type TerminalProductOrderResponse, type OrderSearchRequest, type OrderSearchResponse, } from '@finos/legend-server-marketplace'; import { getUserDisplayLabel, isLastDaysSearchDefaulted, ORDER_SEARCH_DEFAULT_LAST_DAYS, ORDER_SEARCH_DEFAULT_LIMIT, } from './OrderHelpers.js'; // Note: `OrderSearchResponse.total_count` is only the count of orders returned // in the current page (see `order_search_api_guide.md`), not the total match // count across all pages, so pagination here is offset-based Previous/Next // rather than a numbered pager: a full page (`orders.length === searchPageSize`) // is treated as a signal that a next page *might* exist. export enum OrderTab { OPEN = 'open', CLOSED = 'closed', } /** * Input to `OrdersStore.searchOrders`, gathered from the advanced search form. * * `orderId` is mutually exclusive with the other fields (enforced in the UI * via separate search-mode tabs): when supplied, it is sent as the `order_id` * filter on its own, without `orderedBy`/`orderedFor`/`status`/`lastDays`. */ export interface OrderSearchFormValues { orderId: string | undefined; orderedBy: LegendUser | undefined; orderedFor: LegendUser | undefined; status: OrderSearchStatus; lastDays: number | undefined; } /** A snapshot of the last-applied advanced search filters, kept for rendering the filter summary bar. */ export interface AppliedOrderSearchFilters { orderId: string | undefined; orderedByLabel: string | undefined; orderedForLabel: string | undefined; status: OrderSearchStatus; lastDays: number; isLastDaysDefaulted: boolean; } export class OrdersStore { readonly baseStore: LegendMarketplaceBaseStore; openOrders: TerminalProductOrder[] = []; closedOrders: TerminalProductOrder[] = []; totalOpen = 0; totalClosed = 0; readonly fetchOpenOrdersState = ActionState.create(); readonly fetchClosedOrdersState = ActionState.create(); readonly cancelOrderState = ActionState.create(); selectedTab: OrderTab = OrderTab.OPEN; searchResults: TerminalProductOrder[] = []; // Mirrors `OrderSearchResponse.total_count` (see note above): only reflects // the current page's count, not the true match total across all pages, so // it's intentionally not surfaced as an "of N results" total in the UI // (which instead derives its "Page X of N" text from `searchCurrentPage`/ // `hasNextSearchPage`). Kept for API-response parity and in case the // backend semantics change. searchTotalCount = 0; appliedSearchFilters: AppliedOrderSearchFilters | undefined = undefined; readonly searchOrdersState = ActionState.create(); searchOffset = 0; searchPageSize: number = ORDER_SEARCH_DEFAULT_LIMIT; // Raw form values from the last submitted search, kept so Previous/Next/page-size // changes can re-issue the same search with a different offset/limit. private lastSearchFormValues: OrderSearchFormValues | undefined = undefined; constructor(baseStore: LegendMarketplaceBaseStore) { makeObservable(this, { openOrders: observable, closedOrders: observable, totalOpen: observable, totalClosed: observable, selectedTab: observable, searchResults: observable, searchTotalCount: observable, appliedSearchFilters: observable, searchOffset: observable, searchPageSize: observable, setSelectedTab: action, clearSearch: action, fetchOpenOrders: flow, fetchClosedOrders: flow, refreshCurrentOrders: flow, cancelOrder: flow, searchOrders: flow, goToSearchOffset: flow, setSearchPageSize: flow, currentOrders: computed, currentFetchState: computed, isAdvancedSearchActive: computed, hasPreviousSearchPage: computed, hasNextSearchPage: computed, searchCurrentPage: computed, }); this.baseStore = baseStore; } setSelectedTab(tab: OrderTab): void { this.selectedTab = tab; } get isAdvancedSearchActive(): boolean { return this.appliedSearchFilters !== undefined; } get hasPreviousSearchPage(): boolean { return this.searchOffset > 0; } /** * Heuristic per the search API's documented pagination contract: a page * returning fewer orders than the requested page size means there are no * more results; a full page means there *might* be a next page (the next * fetch may come back empty, which is an accepted/expected edge case). */ get hasNextSearchPage(): boolean { return ( this.searchResults.length > 0 && this.searchResults.length === this.searchPageSize ); } /** 1-based page number implied by the current offset/page-size, for the "Page X of N" pagination label. */ get searchCurrentPage(): number { return Math.floor(this.searchOffset / this.searchPageSize) + 1; } get currentOrders(): TerminalProductOrder[] { if (this.isAdvancedSearchActive) { return this.searchResults; } return this.selectedTab === OrderTab.OPEN ? this.openOrders : this.closedOrders; } get currentFetchState(): ActionState { if (this.isAdvancedSearchActive) { return this.searchOrdersState; } return this.selectedTab === OrderTab.OPEN ? this.fetchOpenOrdersState : this.fetchClosedOrdersState; } *fetchOpenOrders(): GeneratorFn { const user = this.baseStore.applicationStore.identityService.currentUser; if (!user) { return; } this.fetchOpenOrdersState.inProgress(); try { const response: TerminalProductOrderResponse = (yield this.baseStore.marketplaceServerClient.fetchOrders( user, OrderStatusCategory.OPEN, )) as TerminalProductOrderResponse; this.openOrders = response.orders; this.totalOpen = response.total_count; this.fetchOpenOrdersState.complete(); } catch (error) { assertErrorThrown(error); this.baseStore.applicationStore.logService.error( LogEvent.create(APPLICATION_EVENT.GENERIC_FAILURE), `Failed to fetch open orders: ${error.message}`, ); this.baseStore.applicationStore.notificationService.notifyError( `Failed to fetch open orders: ${error.message}`, ); this.fetchOpenOrdersState.fail(); } } *fetchClosedOrders(): GeneratorFn { const user = this.baseStore.applicationStore.identityService.currentUser; if (!user) { return; } this.fetchClosedOrdersState.inProgress(); try { const response = (yield this.baseStore.marketplaceServerClient.fetchOrders( user, OrderStatusCategory.CLOSED, )) as TerminalProductOrderResponse; this.closedOrders = response.orders; this.totalClosed = response.total_count; this.fetchClosedOrdersState.complete(); } catch (error) { assertErrorThrown(error); this.baseStore.applicationStore.logService.error( LogEvent.create(APPLICATION_EVENT.GENERIC_FAILURE), `Failed to fetch closed orders: ${error.message}`, ); this.baseStore.applicationStore.notificationService.notifyError( `Failed to fetch closed orders: ${error.message}`, ); this.fetchClosedOrdersState.fail(); } } *refreshCurrentOrders(): GeneratorFn { // Refresh both open and closed orders since cancelled orders move from open to closed yield Promise.all([ flowResult(this.fetchOpenOrders()), flowResult(this.fetchClosedOrders()), ]); } *cancelOrder( orderId: string, processInstanceId: string, comments?: string, ): GeneratorFn { const user = this.baseStore.applicationStore.identityService.currentUser; if (!user) { this.baseStore.applicationStore.notificationService.notifyError( 'User not authenticated', ); return false; } this.cancelOrderState.inProgress(); try { yield this.baseStore.marketplaceServerClient.cancelOrder({ order_id: orderId, kerberos: user, comments: comments ?? '', process_instance_id: processInstanceId, }); this.baseStore.applicationStore.notificationService.notifySuccess( `Order #${orderId} cancelled successfully`, ); this.cancelOrderState.complete(); // Refresh orders after successful cancellation this.refreshCurrentOrders(); return true; } catch (error) { assertErrorThrown(error); this.baseStore.applicationStore.logService.error( LogEvent.create( LEGEND_MARKETPLACE_APP_EVENT.ORDER_CANCELLATION_FAILURE, ), `Failed to cancel order: ${error.message}`, ); this.baseStore.applicationStore.notificationService.notifyError( `Failed to cancel order: ${error.message}`, ); this.cancelOrderState.fail(); return false; } } *searchOrders(filters: OrderSearchFormValues, offset = 0): GeneratorFn { const orderId = filters.orderId?.trim(); const orderedById = filters.orderedBy?.id.trim(); const orderedForId = filters.orderedFor?.id.trim(); if (!orderId && !orderedById && !orderedForId) { this.baseStore.applicationStore.notificationService.notifyWarning( 'Enter a value for Ordered By and/or Ordered For to search.', ); return; } // An Order ID search targets one specific order, so the rolling // `last_days` window (which restricts results by `created_at`) is // intentionally omitted from the request here - applying it would // otherwise hide an older order that still matches by ID. const lastDays = orderId ? undefined : (filters.lastDays ?? ORDER_SEARCH_DEFAULT_LAST_DAYS); // `orderId` is mutually exclusive with `orderedBy`/`orderedFor`/`lastDays` // (see the doc comment on `OrderSearchFormValues.orderId`). That's // enforced today by the UI's separate search-mode tabs, but the request // is still built defensively here rather than trusting the caller, so a // future caller that passes `orderId` alongside the others can't // silently combine them into one ambiguous request. const request: OrderSearchRequest = orderId ? { order_id: orderId, status: filters.status, limit: this.searchPageSize, offset, } : { ...(orderedById ? { ordered_by: orderedById } : {}), ...(orderedForId ? { ordered_for: orderedForId } : {}), status: filters.status, ...(lastDays === undefined ? {} : { last_days: lastDays }), limit: this.searchPageSize, offset, }; this.lastSearchFormValues = filters; this.searchOrdersState.inProgress(); try { const response = (yield this.baseStore.marketplaceServerClient.searchOrders( request, )) as OrderSearchResponse; this.searchResults = response.orders; this.searchTotalCount = response.total_count; this.searchOffset = offset; this.appliedSearchFilters = { orderId, orderedByLabel: getUserDisplayLabel(filters.orderedBy), orderedForLabel: getUserDisplayLabel(filters.orderedFor), status: filters.status, lastDays: lastDays ?? ORDER_SEARCH_DEFAULT_LAST_DAYS, isLastDaysDefaulted: orderId ? true : isLastDaysSearchDefaulted(filters.lastDays), }; this.searchOrdersState.complete(); } catch (error) { assertErrorThrown(error); this.baseStore.applicationStore.logService.error( LogEvent.create( LEGEND_MARKETPLACE_APP_EVENT.ADVANCED_SEARCH_ORDERS_FAILURE, ), `Failed to search orders: ${error.message}`, ); this.baseStore.applicationStore.notificationService.notifyError( `Failed to search orders: ${error.message}`, ); this.searchOrdersState.fail(); } } /** Re-issues the last submitted advanced search at a different offset (Previous/Next page navigation). */ *goToSearchOffset(offset: number): GeneratorFn { if (!this.lastSearchFormValues) { return; } yield flowResult(this.searchOrders(this.lastSearchFormValues, offset)); } /** Changes the advanced search page size and, if a search is active, re-fetches from the first page. */ *setSearchPageSize(pageSize: number): GeneratorFn { this.searchPageSize = pageSize; if (this.lastSearchFormValues) { yield flowResult(this.searchOrders(this.lastSearchFormValues, 0)); } } clearSearch(): void { this.searchResults = []; this.searchTotalCount = 0; this.appliedSearchFilters = undefined; this.searchOrdersState.reset(); this.searchOffset = 0; this.lastSearchFormValues = undefined; } }