diff --git a/src/client.ts b/src/client.ts index 50d535f68..fc64609af 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1907,6 +1907,7 @@ export class StreamChat { } const { predefined_filter, filter_values, sort_values, ...restOptions } = options; + const normalizedSort = normalizeQuerySort(sort); // Build payload based on whether we're using a predefined filter or traditional filters const payload = predefined_filter @@ -1914,12 +1915,13 @@ export class StreamChat { predefined_filter, filter_values, sort_values, + sort: normalizedSort, ...defaultOptions, ...restOptions, } : { filter_conditions: filterConditions, - sort: normalizeQuerySort(sort), + sort: normalizedSort, ...defaultOptions, ...restOptions, }; @@ -1929,6 +1931,9 @@ export class StreamChat { payload, ); + // FIXME: In the next major release, return the full QueryChannelsAPIResponse + // instead of only `data.channels` so top-level metadata such as + // `predefined_filter` is not lost. return data.channels; } @@ -4981,9 +4986,11 @@ export class StreamChat { * * @return {Promise} The created predefined filter */ - async createPredefinedFilter(options: CreatePredefinedFilterOptions) { + async createPredefinedFilter< + F extends Record = Record, + >(options: CreatePredefinedFilterOptions) { this.validateServerSideAuth(); - return await this.post( + return await this.post>( `${this.baseURL}/predefined_filters`, options, ); @@ -4996,9 +5003,11 @@ export class StreamChat { * * @return {Promise} The predefined filter */ - async getPredefinedFilter(name: string) { + async getPredefinedFilter = Record>( + name: string, + ) { this.validateServerSideAuth(); - return await this.get( + return await this.get>( `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, ); } @@ -5011,9 +5020,11 @@ export class StreamChat { * * @return {Promise} The updated predefined filter */ - async updatePredefinedFilter(name: string, options: UpdatePredefinedFilterOptions) { + async updatePredefinedFilter< + F extends Record = Record, + >(name: string, options: UpdatePredefinedFilterOptions) { this.validateServerSideAuth(); - return await this.put( + return await this.put>( `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, options, ); @@ -5040,10 +5051,12 @@ export class StreamChat { * * @return {Promise} The list of predefined filters */ - async listPredefinedFilters(options: ListPredefinedFiltersOptions = {}) { + async listPredefinedFilters< + F extends Record = Record, + >(options: ListPredefinedFiltersOptions = {}) { this.validateServerSideAuth(); const { sort, ...paginationOptions } = options; - return await this.get( + return await this.get>( `${this.baseURL}/predefined_filters`, { ...paginationOptions, diff --git a/src/types.ts b/src/types.ts index 8d47ba55c..9dbeb49b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1060,13 +1060,29 @@ export type ChannelOptions = { user_id?: string; watch?: boolean; /** - * Name of a predefined filter to use instead of filter_conditions. - * When provided, filter_conditions and sort parameters are ignored. + * Name of a predefined filter to use instead of sending raw + * `filter_conditions`. + * + * The backend resolves the filter template by name and interpolates it using + * `filter_values`. + * + * A regular `sort` can still be passed to `queryChannels()`, but backend + * precedence rules apply: + * + * - if the predefined filter has its own stored sort template, that stored + * sort takes precedence and the request `sort` is ignored + * - if the predefined filter does not define a sort template, the request + * `sort` can still be used */ predefined_filter?: string; /** - * Values to interpolate into the predefined filter template placeholders. - * Only used when predefined_filter is provided. + * Values used to interpolate placeholders inside the predefined filter's + * `filter` template. + * + * Example: a template value like `{{user_id}}` can be resolved with + * `{ user_id: 'alice' }`. + * + * Only used when `predefined_filter` is provided. */ filter_values?: Record; /** @@ -4755,38 +4771,129 @@ export type UpdateChannelsBatchResponse = { export type PredefinedFilterOperation = 'QueryChannels'; export type PredefinedFilterSortParam = { + /** + * Field name to sort by. + * + * This may be a literal field name such as `created_at`, or a placeholder + * template such as `{{sort_field}}` that will be interpolated server-side. + */ field: string; + /** + * Sort direction. `1` means ascending and `-1` means descending. + * + * The backend defaults this to `1` when omitted. + */ direction?: AscDesc; + /** + * Optional server-side hint describing how the sort field value should be + * interpreted. + * + * This is mainly relevant for predefined-filter sort templates and is not + * part of the regular `queryChannels()` sort shape. Omitting it uses the + * backend default string behavior. Known backend values include: + * + * - `number`: cast custom-field values to numeric before sorting + * - `boolean`: cast custom-field values to boolean before sorting + * + * Other values are backend-defined. In most cases this should be omitted + * unless you are sorting by a custom field whose stored JSON value is not + * string-like. + */ type?: string; }; -export type PredefinedFilter = { +/** + * Stored predefined filter definition as returned by the server. + * + * `F` represents the raw filter template shape. It defaults to a generic record + * because predefined filters are server-managed templates and may include + * placeholders or app-specific structures. + */ +export type PredefinedFilter< + F extends Record = Record, +> = { + /** + * Unique predefined filter name within the app. + */ name: string; + /** + * Operation this predefined filter is valid for. + */ operation: PredefinedFilterOperation; - filter: Record; + /** + * Filter template stored on the server. + * + * This is not necessarily the fully interpolated runtime filter; placeholder + * values such as `{{user_id}}` may still be present. + */ + filter: F; + /** + * Server creation timestamp in ISO-8601 format. + */ created_at: string; + /** + * Server update timestamp in ISO-8601 format. + */ updated_at: string; + /** + * Optional human-readable description. + */ description?: string; + /** + * Optional sort template stored with the predefined filter. + */ sort?: PredefinedFilterSortParam[]; + /** + * Query identifier generated by the backend for the filter/sort pattern. + * + * The exact value is backend-generated and primarily useful for correlating + * predefined filters with query analysis / query performance data. + */ query_id?: number; }; -export type CreatePredefinedFilterOptions = { +export type CreatePredefinedFilterOptions< + F extends Record = Record, +> = { + /** + * Unique predefined filter name. + */ name: string; + /** + * Operation this predefined filter will be used with. + */ operation: PredefinedFilterOperation; - filter: Record; + /** + * Filter template to store on the server. + */ + filter: F; + /** + * Optional human-readable description. + */ description?: string; + /** + * Optional sort template stored with the predefined filter. + */ sort?: PredefinedFilterSortParam[]; }; -export type UpdatePredefinedFilterOptions = Omit; +export type UpdatePredefinedFilterOptions< + F extends Record = Record, +> = Omit, 'name'>; -export type PredefinedFilterResponse = APIResponse & { - predefined_filter: PredefinedFilter; +export type PredefinedFilterResponse< + F extends Record = Record, +> = APIResponse & { + predefined_filter: PredefinedFilter; }; -export type ListPredefinedFiltersResponse = APIResponse & { - predefined_filters: PredefinedFilter[]; +/** + * Paginated response returned when listing predefined filters. + */ +export type ListPredefinedFiltersResponse< + F extends Record = Record, +> = APIResponse & { + predefined_filters: PredefinedFilter[]; next?: string; prev?: string; }; @@ -4795,9 +4902,20 @@ export type ListPredefinedFiltersResponse = APIResponse & { * Contains the interpolated filter and sort from a predefined filter. * This is returned in the QueryChannels response when using a predefined filter. */ -export type ParsedPredefinedFilterResponse = { +export type ParsedPredefinedFilterResponse< + F extends Record = Record, +> = { + /** + * Name of the predefined filter that was resolved. + */ name: string; - filter: Record; + /** + * Fully interpolated filter that the backend executed. + */ + filter: F; + /** + * Fully interpolated sort parameters resolved from the predefined filter. + */ sort?: PredefinedFilterSortParam[]; }; diff --git a/test/unit/predefined_filters.test.ts b/test/unit/predefined_filters.test.ts index 3e87860e9..e01ebca02 100644 --- a/test/unit/predefined_filters.test.ts +++ b/test/unit/predefined_filters.test.ts @@ -327,6 +327,40 @@ describe('Predefined Filters', () => { ); }); + it('should include traditional sort when using a predefined filter', async () => { + const mockResponse: QueryChannelsAPIResponse = { + duration: '0.01s', + channels: [], + }; + + const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); + + await client.queryChannels({}, [{ last_message_at: -1 }, { created_at: 1 }], { + predefined_filter: 'user_messaging', + filter_values: { user_id: 'user123' }, + limit: 20, + }); + + expect(postSpy).toHaveBeenCalledWith( + `${client.baseURL}/channels`, + expect.objectContaining({ + predefined_filter: 'user_messaging', + filter_values: { user_id: 'user123' }, + sort: [ + { field: 'last_message_at', direction: -1 }, + { field: 'created_at', direction: 1 }, + ], + limit: 20, + }), + ); + expect(postSpy).toHaveBeenCalledWith( + `${client.baseURL}/channels`, + expect.not.objectContaining({ + filter_conditions: expect.anything(), + }), + ); + }); + it('should use traditional filter_conditions when no predefined_filter is provided', async () => { const mockResponse: QueryChannelsAPIResponse = { duration: '0.01s',