diff --git a/packages/restapi/src/lib/channels/getTags.ts b/packages/restapi/src/lib/channels/getTags.ts new file mode 100644 index 000000000..5d2ede7e8 --- /dev/null +++ b/packages/restapi/src/lib/channels/getTags.ts @@ -0,0 +1,38 @@ +import { + getCAIPAddress, + getAPIBaseUrls + } from '../helpers'; + import Constants, { ENV } from '../constants'; + import { axiosGet } from '../utils/axiosUtil'; + + /** + * GET v1/channels/${channelAddressInCAIP}/tags + */ + export type GetTagsOptionsType = { + /** address of the channel */ + channel: string; + env?: ENV; + } + + /** + * Returns the list of tags associated with the channel + */ + export const getTags = async ( + options : GetTagsOptionsType + ) => { + const { + channel, + env = Constants.ENV.PROD, + } = options || {}; + + const _channel = await getCAIPAddress(env, channel, 'Channel'); + const API_BASE_URL = getAPIBaseUrls(env); + const apiEndpoint = `${API_BASE_URL}/v1/channels`; + const requestUrl = `${apiEndpoint}/${_channel}/tags`; + + return await axiosGet(requestUrl) + .then((response) => response.data?.tags) + .catch((err) => { + console.error(`[EPNS-SDK] - API ${requestUrl}: `, err); + }); + } diff --git a/packages/restapi/src/lib/channels/index.ts b/packages/restapi/src/lib/channels/index.ts index 8728fc02a..1dae8c02f 100644 --- a/packages/restapi/src/lib/channels/index.ts +++ b/packages/restapi/src/lib/channels/index.ts @@ -5,8 +5,9 @@ export * from './getChannels'; export * from './getDelegates'; export * from './getSubscribers'; export * from './search'; +export * from './getTags'; +export * from './searchTags'; export * from './subscribe'; export * from './subscribeV2'; export * from './unsubscribe'; export * from './unsubscribeV2'; - diff --git a/packages/restapi/src/lib/channels/searchTags.ts b/packages/restapi/src/lib/channels/searchTags.ts new file mode 100644 index 000000000..814e4860a --- /dev/null +++ b/packages/restapi/src/lib/channels/searchTags.ts @@ -0,0 +1,41 @@ +import { getAPIBaseUrls, getQueryParams, getLimit } from '../helpers'; +import Constants, {ENV} from '../constants'; +import { axiosGet } from '../utils/axiosUtil'; + +/** + * GET /v1/channels/search/ + * optional params: page=(1)&limit=(20{min:1}{max:30})&query=(searchquery) + */ + +export type SearchChannelTagsOptionsType = { + query: string; + env?: ENV; + page?: number; + limit?: number; +} + +export const searchTags = async ( + options: SearchChannelTagsOptionsType +) => { + const { + query, + env = Constants.ENV.LOCAL, + page = Constants.PAGINATION.INITIAL_PAGE, + limit = Constants.PAGINATION.LIMIT, + } = options || {}; + + if (!query) throw Error('"query" not provided!'); + const API_BASE_URL = getAPIBaseUrls(env); + const apiEndpoint = `${API_BASE_URL}/v1/channels/search/tags`; + const queryObj = { + page, + limit: getLimit(limit), + query: query + }; + const requestUrl = `${apiEndpoint}?${getQueryParams(queryObj)}`; + return axiosGet(requestUrl) + .then((response) => response.data.channels) + .catch((err) => { + console.error(`[Push SDK] - API ${requestUrl}: `, err); + }); +} diff --git a/packages/restapi/src/lib/config.ts b/packages/restapi/src/lib/config.ts index 235bb709f..e3a9a0599 100644 --- a/packages/restapi/src/lib/config.ts +++ b/packages/restapi/src/lib/config.ts @@ -222,7 +222,7 @@ export const CORE_CONFIG = { EPNS_CORE_CONTRACT: '0x5ab1520e2bd519bdab2e1347eee81c00a77f4946', }, [ENV.LOCAL]: { - API_BASE_URL: API_BASE_URL[ENV.DEV], + API_BASE_URL: API_BASE_URL[ENV.LOCAL], EPNS_CORE_CONTRACT: '0x5ab1520e2bd519bdab2e1347eee81c00a77f4946', }, }; diff --git a/packages/restapi/src/lib/pushNotification/PushNotificationTypes.ts b/packages/restapi/src/lib/pushNotification/PushNotificationTypes.ts index dc3c6c20f..7f9b29391 100644 --- a/packages/restapi/src/lib/pushNotification/PushNotificationTypes.ts +++ b/packages/restapi/src/lib/pushNotification/PushNotificationTypes.ts @@ -105,6 +105,7 @@ export type CreateChannelOptions = { icon: string; url: string; alias?: string; + tags?: string[]; progressHook?: (progress: ProgressHookType) => void; }; diff --git a/packages/restapi/src/lib/pushNotification/channel.ts b/packages/restapi/src/lib/pushNotification/channel.ts index cc2a48f8f..ae939e4fa 100644 --- a/packages/restapi/src/lib/pushNotification/channel.ts +++ b/packages/restapi/src/lib/pushNotification/channel.ts @@ -30,15 +30,19 @@ import { import { Alias } from './alias'; import { Delegate } from './delegate'; +import { Tags } from './tags'; import { PushNotificationBaseClass } from './pushNotificationBase'; export class Channel extends PushNotificationBaseClass { public delegate!: Delegate; public alias!: Alias; + public tags!: Tags; + constructor(signer?: SignerType, env?: ENV, account?: string) { super(signer, env, account); this.delegate = new Delegate(signer, env, account); this.alias = new Alias(signer, env, account); + this.tags = new Tags(this, signer, env, account); } /** @@ -158,6 +162,8 @@ export class Channel extends PushNotificationBaseClass { alias = null, progressHook, } = options || {}; + + let tags = options.tags; try { // create push token instance let aliasInfo; @@ -188,6 +194,17 @@ export class Channel extends PushNotificationBaseClass { aliasDetails?.address, }; } + // check for tags length + if (tags && tags.length > 5) { + tags = tags.slice(0, 5); + } + + const tagsStr = tags && tags.length > 0 ? tags.join('') : ''; + + if (tagsStr.length > 512) { + throw new Error('Tags length should not exceed 512 characters'); + } + // construct channel identity progressHook?.(PROGRESSHOOK['PUSH-CREATE-01'] as ProgressHookType); const input = { @@ -196,6 +213,7 @@ export class Channel extends PushNotificationBaseClass { url: url, icon: icon, aliasDetails: aliasInfo ?? {}, + tags }; const cid = await this.uploadToIPFSViaPushNode(JSON.stringify(input)); const allowanceAmount = await this.fetchAllownace( @@ -248,6 +266,7 @@ export class Channel extends PushNotificationBaseClass { alias = null, progressHook, } = options || {}; + let tags = options.tags; try { // create push token instance let aliasInfo; @@ -284,6 +303,14 @@ export class Channel extends PushNotificationBaseClass { aliasDetails?.address, }; } + + // check for tags length + if (tags && tags.length > 5) { + tags = tags.slice(0, 5); + } + + const tagsStr = tags && tags.length > 0 ? tags.join('') : ''; + // construct channel identity progressHook?.(PROGRESSHOOK['PUSH-UPDATE-01'] as ProgressHookType); const input = { @@ -292,6 +319,7 @@ export class Channel extends PushNotificationBaseClass { url: url, icon: icon, aliasDetails: aliasInfo ?? {}, + tags }; const cid = await this.uploadToIPFSViaPushNode(JSON.stringify(input)); // approve the tokens to core contract diff --git a/packages/restapi/src/lib/pushNotification/tags.ts b/packages/restapi/src/lib/pushNotification/tags.ts new file mode 100644 index 000000000..dbe035102 --- /dev/null +++ b/packages/restapi/src/lib/pushNotification/tags.ts @@ -0,0 +1,129 @@ +import Constants, { ENV } from '../constants'; +import { SignerType } from '../types'; +import { ChannelInfoOptions, ChannelSearchOptions } from './PushNotificationTypes'; +import * as PUSH_CHANNEL from '../channels'; +import { PushNotificationBaseClass } from './pushNotificationBase'; +import { Channel } from './channel'; + +export class Tags extends PushNotificationBaseClass { + private channel: Channel; + + constructor(channel: Channel, signer?: SignerType, env?: ENV, account?: string) { + super(signer, env, account); + this.channel = channel; + } + + /** + * @description - Get delegates of a channell + * @param {string} [options.channel] - channel in caip. defaults to account from signer with eth caip + * @returns array of delegates + */ + get = async (options?: ChannelInfoOptions) => { + try { + this.checkSignerObjectExists(); + const channel = await this.channel.info() + return await PUSH_CHANNEL.getTags({ + channel: channel, + env: this.env, + }); + } catch (error) { + throw new Error(`Push SDK Error: API : tags::get : ${error}`); + } + }; + + /** + * @description adds tags for a channel + * @param {Array} tags - tags to be added + * @returns the tags if the transaction is successfull + */ + add = async (tags: Array) => { + try { + this.checkSignerObjectExists(); + const channel = await this.channel.info() + + const resp = await this.channel.update({ + name: channel.name, + description: channel.info, + url: channel.url, + icon: channel.icon, + tags: tags + }); + + return { tags } + + } catch (error) { + throw new Error(`Push SDK Error: Contract : tags::add : ${error}`); + } + }; + + /** + * @description update tags for a channel + * @param {Array} tags - tags to be added + * @returns the tags if the transaction is successfull + */ + update = async (tags: Array) => { + try { + this.checkSignerObjectExists(); + const channel = await this.channel.info() + await this.channel.update({ + name: channel.name, + description: channel.info, + url: channel.url, + icon: channel.icon, + tags: tags + }); + + return { tags } + } catch (error) { + throw new Error(`Push SDK Error: Contract : tags::update : ${error}`); + } + }; + + /** + * @description removes tags from a channel + * @returns status of the request + */ + remove = async () => { + try { + this.checkSignerObjectExists(); + const channel = await this.channel.info() + await this.channel.update({ + name: channel.name, + description: channel.info, + url: channel.url, + icon: channel.icon, + tags: [] + }); + + return { status: "success" } + + } catch (error) { + throw new Error(`Push SDK Error: Contract : tags::remove : ${error}`); + } + }; + + /** + * @description - returns relevant information as per the query that was passed + * @param {string} query - search query + * @param {number} [options.page] - page number. default is set to Constants.PAGINATION.INITIAL_PAGE + * @param {number} [options.limit] - number of feeds per page. default is set to Constants.PAGINATION.LIMIT + * @returns Array of results relevant to the serach query + */ + search = async (query: string, options?: ChannelSearchOptions) => { + try { + const { + page = Constants.PAGINATION.INITIAL_PAGE, + limit = Constants.PAGINATION.LIMIT, + } = options || {}; + + return await PUSH_CHANNEL.searchTags({ + query: query, + page: page, + limit: limit, + env: this.env, + }); + } catch (error) { + throw new Error(`Push SDK Error: API : channel::tags::search : ${error}`); + } + } +} diff --git a/packages/restapi/tests/lib/notification/tags.test.ts b/packages/restapi/tests/lib/notification/tags.test.ts new file mode 100644 index 000000000..f191da8dd --- /dev/null +++ b/packages/restapi/tests/lib/notification/tags.test.ts @@ -0,0 +1,131 @@ +import { PushAPI } from '../../../src/lib/pushapi/PushAPI'; // Ensure correct import path +import { expect } from 'chai'; +import { ethers } from 'ethers'; +import { sepolia } from 'viem/chains'; +import { createWalletClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +// import tokenABI from './tokenABI'; +import { ENV } from '../../../src/lib/constants'; + +describe('PushAPI.tags functionality', () => { + let userAlice: PushAPI; + let userBob: PushAPI; + let userKate: PushAPI; + let signer1: any; + let account1: string; + let signer2: any; + let viemUser: any; + let account2: string; + + // accessing env dynamically using process.env + type EnvStrings = keyof typeof ENV; + const envMode = process.env.ENV as EnvStrings; + const _env = ENV[envMode]; + + beforeEach(async () => { + signer1 = new ethers.Wallet(`0x${process.env['WALLET_PRIVATE_KEY']}`); + account1 = await signer1.getAddress(); + + const provider = (ethers as any).providers + ? new (ethers as any).providers.JsonRpcProvider('https://rpc.sepolia.org') + : new (ethers as any).JsonRpcProvider('https://rpc.sepolia.org'); + + signer2 = new ethers.Wallet( + `0x${process.env['WALLET_PRIVATE_KEY']}`, + provider + ); + const signer3 = createWalletClient({ + account: privateKeyToAccount(`0x${process.env['WALLET_PRIVATE_KEY']}`), + chain: sepolia, + transport: http(), + }); + + account2 = await signer2.getAddress(); + + // initialisation with signer and provider + userKate = await PushAPI.initialize(signer2, { env: _env }); + // initialisation with signer + userAlice = await PushAPI.initialize(signer1, { env: _env }); + // initialisation without signer + userBob = await PushAPI.initialize(signer1, { env: _env }); + // initalisation with viem + viemUser = await PushAPI.initialize(signer3, { env: _env }); + }); + + describe('tags :: add', () => { + // TODO: remove skip after signer becomes optional + it('Without signer and account :: should throw error', async () => { + await expect(() => + userBob.channel.tags.add(['tag1', 'tag2', 'tag3']) + ).to.Throw; + }); + + it('With signer and without provider :: should throw error', async () => { + await expect(() => + userAlice.channel.tags.add(['tag1', 'tag2', 'tag3']) + ).to.Throw; + }); + + it('With signer and provider :: should add tags', async () => { + const tags = ['tag1', 'tag2', 'tag3'] + const res = await userKate.channel.tags.add(tags); + expect(res).not.null; + expect(res.tags).equal(tags); + }, 100000000); + + it('With viem signer and provider :: should add tags', async () => { + const tags = ['tag1', 'tag2', 'tag3'] + const res = await viemUser.channel.tags.add(tags); + expect(res).not.null; + expect(res.tags).equal(tags); + }, 100000000); + }); + + describe('tags :: update', () => { + // TODO: remove skip after signer becomes optional + it('Without signer and account :: should throw error', async () => { + await expect(() => + userBob.channel.tags.update(['tag1', 'tag2', 'tag3']) + ).to.Throw; + }); + + it('With signer and without provider :: should throw error', async () => { + await expect(() => + userAlice.channel.tags.update(['tag1', 'tag2', 'tag3']) + ).to.Throw; + }); + + it('With signer and provider :: should update tags', async () => { + const tags = ['tag1', 'tag2', 'tag3'] + const res = await userKate.channel.tags.update(tags); + expect(res.tags).equal(tags); + }, 100000000); + + it('With viem signer and provider :: should update tags', async () => { + const tags = ['tag1', 'tag2', 'tag3'] + const res = await viemUser.channel.tags.update(tags); + expect(res.tags).equal(tags); + }, 100000000); + }); + + describe('tags :: remove', () => { + // TODO: remove skip after signer becomes optional + it('Without signer and account :: should throw error', async () => { + await expect(() => + userBob.channel.tags.remove() + ).to.Throw; + }); + + it('With signer and without provider :: should throw error', async () => { + await expect(() => + userAlice.channel.tags.remove() + ).to.Throw; + }); + + it.only('With signer and provider :: should remove tags', async () => { + await userKate.channel.tags.update(['tag1', 'tag2', 'tag3']); + const res = await userKate.channel.tags.remove(); + expect(res.status).equal('success'); + }, 100000000); + }); +});