diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 88ad0bfb1..6efda0601 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -2,11 +2,17 @@ import type { StreamChat } from './client'; import type { APIResponse, BatchChannelDataUpdate, + ChannelBatchDataUpdateOptions, NewMemberPayload, UpdateChannelsBatchFilters, UpdateChannelsBatchResponse, } from './types'; +const isChannelBatchDataUpdateOptions = ( + update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, +): update is ChannelBatchDataUpdateOptions => + 'data' in update || 'custom_set' in update || 'custom_unset' in update; + /** * ChannelBatchUpdater - A class that provides convenience methods for batch channel operations */ @@ -194,18 +200,38 @@ export class ChannelBatchUpdater { /** * updateData - Update data on channels matching the filter * + * `data.custom` replaces the channel's whole custom object. To patch + * individual custom keys, pass an options object with `custom_set` or + * `custom_unset`. The client sends those fields at the request root rather + * than inside `data`. The options object can also include `data` to update + * channel fields in the same request. + * * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {BatchChannelDataUpdate} data Data to update + * @param {BatchChannelDataUpdate | ChannelBatchDataUpdateOptions} update Data or update options * @return {Promise} The server response */ async updateData( filter: UpdateChannelsBatchFilters, data: BatchChannelDataUpdate, + ): Promise; + async updateData( + filter: UpdateChannelsBatchFilters, + options: ChannelBatchDataUpdateOptions, + ): Promise; + async updateData( + filter: UpdateChannelsBatchFilters, + update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, ): Promise { + const { data, custom_set, custom_unset } = isChannelBatchDataUpdateOptions(update) + ? update + : { data: update }; + return await this.client.updateChannelsBatch({ operation: 'updateData', filter, - data, + ...(data && { data }), + ...(custom_set && { custom_set }), + ...(custom_unset && { custom_unset }), }); } } diff --git a/src/client.ts b/src/client.ts index dacb34e28..d000ef3ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5306,6 +5306,10 @@ export class StreamChat { /** * Update Channels Batch * + * For the `updateData` operation, `data.custom` replaces the channel's whole + * custom object, while the root-level `custom_set` / `custom_unset` patch + * individual keys and leave the rest untouched. + * * @param {UpdateChannelsBatchOptions} payload for updating channels in batch * @return {Promise} The server response */ diff --git a/src/types.ts b/src/types.ts index 38f571c13..33b161846 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4998,8 +4998,37 @@ export type UpdateChannelsBatchOptions = { filter: UpdateChannelsBatchFilters; members?: string[] | Array; data?: BatchChannelDataUpdate; + /** + * `updateData` only. Merges these keys into each matched channel's existing + * custom object, leaving every other custom key untouched — unlike + * `data.custom`, which replaces the whole object. Keys are dot-paths, so + * `a.b` sets key `b` inside object `a` (the parent object must already + * exist). Cannot be combined with `data.custom`. + * + * Lives at the request root, not inside `data`. + */ + custom_set?: Record; + /** + * `updateData` only. Deletes these keys from each matched channel's existing + * custom object, leaving every other custom key untouched. Keys are + * dot-paths; deleting a key that does not exist is a no-op. Cannot be + * combined with `data.custom`. + * + * Lives at the request root, not inside `data`. + */ + custom_unset?: string[]; }; +/** + * Options for {@link ChannelBatchUpdater.updateData}. `custom_set` and + * `custom_unset` are sent at the request root, while `data` contains the + * channel fields to update. + */ +export type ChannelBatchDataUpdateOptions = Pick< + UpdateChannelsBatchOptions, + 'data' | 'custom_set' | 'custom_unset' +>; + export type UpdateChannelsBatchFilters = QueryFilters<{ cids?: | RequireOnlyOne, '$in' | '$eq'>> diff --git a/test/typescript/unit-test.ts b/test/typescript/unit-test.ts index 61f11e118..3957f12c0 100644 --- a/test/typescript/unit-test.ts +++ b/test/typescript/unit-test.ts @@ -88,6 +88,19 @@ const singletonClient2: StreamChat = StreamChat.getInstance(apiKey, { timeout: 3000, }); +const channelBatchUpdater = client.channelBatchUpdater(); +channelBatchUpdater.updateData({}, { frozen: true }); +channelBatchUpdater.updateData({}, { custom_set: { group: 'new' } }); +channelBatchUpdater.updateData( + {}, + { + data: { frozen: true }, + custom_set: { group: 'new' }, + }, +); +// @ts-expect-error channel data fields must be nested under data in the options form +channelBatchUpdater.updateData({}, { frozen: true, custom_set: { group: 'new' } }); + const devToken: string = client.devToken('joshua'); const token: string = client.createToken('james', 3600); const authType: string = client.getAuthType(); diff --git a/test/unit/channel_batch_update.test.ts b/test/unit/channel_batch_update.test.ts new file mode 100644 index 000000000..4ed5f28ce --- /dev/null +++ b/test/unit/channel_batch_update.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { StreamChat } from '../../src/client'; +import type { + APIResponse, + UpdateChannelsBatchOptions, + UpdateChannelsBatchResponse, +} from '../../src/types'; + +describe('updateChannelsBatch', () => { + let client: StreamChat; + let putSpy: ReturnType; + + const mockResponse: APIResponse & UpdateChannelsBatchResponse = { + duration: '0.01s', + result: {}, + task_id: 'task-id', + }; + + beforeEach(() => { + client = new StreamChat('api_key', 'api_secret'); + putSpy = vi.spyOn(client, 'put').mockResolvedValue(mockResponse); + }); + + it('sends custom_set and custom_unset at the request root', async () => { + const options: UpdateChannelsBatchOptions = { + operation: 'updateData', + filter: { cids: { $in: ['messaging:a', 'messaging:b'] } }, + custom_set: { group: 'old', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }; + + await client.updateChannelsBatch(options); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter: { cids: { $in: ['messaging:a', 'messaging:b'] } }, + custom_set: { group: 'old', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }); + + // The two fields are siblings of `operation` and `filter`. `data` is an + // extra-fields sink on the v1 routes, so a `custom_set` key sent inside it + // would mean "replace custom with a key named custom_set" instead. + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(Object.keys(body)).toContain('custom_set'); + expect(Object.keys(body)).toContain('custom_unset'); + expect(body.data).toBeUndefined(); + }); + + it('omits custom_set and custom_unset when they are not provided', async () => { + const options: UpdateChannelsBatchOptions = { + operation: 'updateData', + filter: { types: { $eq: 'messaging' } }, + data: { frozen: true }, + }; + + await client.updateChannelsBatch(options); + + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(body).not.toHaveProperty('custom_set'); + expect(body).not.toHaveProperty('custom_unset'); + }); + + it('keeps the existing channelBatchUpdater().updateData data argument', async () => { + await client + .channelBatchUpdater() + .updateData({ cids: { $eq: 'messaging:a' } }, { frozen: true }); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter: { cids: { $eq: 'messaging:a' } }, + data: { frozen: true }, + }); + }); + + it('forwards a custom patch from channelBatchUpdater().updateData to the root', async () => { + await client.channelBatchUpdater().updateData( + { cids: { $eq: 'messaging:a' } }, + { + custom_set: { group: 'new' }, + custom_unset: ['location_id'], + }, + ); + + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(body.operation).toBe('updateData'); + expect(body.custom_set).toEqual({ group: 'new' }); + expect(body.custom_unset).toEqual(['location_id']); + expect(body).not.toHaveProperty('data'); + }); + + it('updates channel data and custom fields in one updateData call', async () => { + await client.channelBatchUpdater().updateData( + { cids: { $in: ['messaging:a', 'messaging:b'] } }, + { + data: { frozen: true }, + custom_set: { group: 'new', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }, + ); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter: { cids: { $in: ['messaging:a', 'messaging:b'] } }, + data: { frozen: true }, + custom_set: { group: 'new', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }); + }); + + it('does not let update options override the operation or filter', async () => { + const filter = { cids: { $eq: 'messaging:a' } } as const; + const options = { + operation: 'hide' as const, + filter: { cids: { $eq: 'messaging:b' } } as const, + data: { frozen: true }, + }; + + await client.channelBatchUpdater().updateData(filter, options); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter, + data: { frozen: true }, + }); + }); +});