diff --git a/README.md b/README.md index 240bda9..27874b6 100644 --- a/README.md +++ b/README.md @@ -986,6 +986,45 @@ return ( For RTK Query to follow tenant changes, recreate the API slice or pass a tenant-aware `getClient` resolver to `createNorbixApi`. +### Language (`Accept-Language`) + +The language is a client setting: the hooks send whatever the wrapped core client sends. Set it with the provider prop (or `norbix.setLanguage(...)`); `Accept-Language` is the only language input (the old `cultureCode` field and `CultureCode` / `norbix-language` headers are gone). + +```tsx + + +; + +// switching later: change the prop (or call norbix.setLanguage('en')), then drop +// results read in the old language +dispatch(norbixApi.util.resetApiState()); +``` + +Read every translation instead of one language with `allLanguages: true` on record and term reads, e.g. `useFindCollectionQuery({ collectionName: 'articles', allLanguages: true })`. + +### Push for end users — `norbix.notifications.push` (core SDK with `notifications.push`) + +| Hook | SDK call | Tags | +| ----------------------------------------------------------------- | --------------------- | -------------------------- | +| `useRegisterPushDeviceMutation({ provider, token, platform, … })` | `registerDevice` | invalidates `PushDevices` | +| `useListPushDevicesQuery()` | `listDevices` | provides `PushDevices` | +| `useRemovePushDeviceMutation(id)` | `removeDevice` | invalidates `PushDevices` | +| `useRemovePushDeviceByTokenMutation(token)` | `removeDeviceByToken` | invalidates `PushDevices` | +| `useListPushMessagesQuery({ unreadOnly, pageSize })` | `listMessages` | provides `PushMessages` | +| `useMarkPushMessageReadMutation(id)` | `markMessageRead` | invalidates `PushMessages` | +| `useMarkAllPushMessagesReadMutation()` | `markAllMessagesRead` | invalidates `PushMessages` | + +```tsx +const [register] = norbixApi.useRegisterPushDeviceMutation(); +await register({ + provider: 'expo', + token: expoPushToken, + platform: 'ios', + installationId, +}).unwrap(); +const { data } = norbixApi.useListPushMessagesQuery({ unreadOnly: true }); +``` + ### Multi-region projects A Norbix project spans one **primary region** plus any number of **additional regions**. Two concerns, two homes: _which region your requests target_ is configured on the wrapped `norbix` client (this package inherits it — every hook just calls the client you passed to `createNorbixApi`), while _which regions a project spans_ is managed through the two hooks below. diff --git a/package-lock.json b/package-lock.json index 7459f74..a4fc262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", - "@norbix.ai/ts": "^4.25.0", + "@norbix.ai/ts": "^4.26.0", "@reduxjs/toolkit": "^2.12.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.9", @@ -45,7 +45,7 @@ "node": ">=18" }, "peerDependencies": { - "@norbix.ai/ts": ">=4.11.0", + "@norbix.ai/ts": ">=4.26.0", "@reduxjs/toolkit": ">=2.0.0", "react": ">=18.0.0", "react-redux": ">=9.0.0" @@ -1490,9 +1490,9 @@ } }, "node_modules/@norbix.ai/ts": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.25.0.tgz", - "integrity": "sha512-izKpx/INFqGyoluT44C7M3lE+29Qelkd40OT6YBHi4TkYcWXSt5P4BUx9B+8j2dm3bAmyL2fw6p5R7zvNaxIsQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.26.0.tgz", + "integrity": "sha512-1dygy1yW+AGOVoEBhu+k501/u/7qIGpZV8gfQchQ3FsfDxc2VBt4Z3oH7Vbh4oI7Tkcc68Gr1atlnM8kGB+gtA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 2a1f28a..faee0b6 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "prepublishOnly": "npm run build" }, "peerDependencies": { - "@norbix.ai/ts": ">=4.11.0", + "@norbix.ai/ts": ">=4.26.0", "@reduxjs/toolkit": ">=2.0.0", "react": ">=18.0.0", "react-redux": ">=9.0.0" @@ -68,7 +68,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", - "@norbix.ai/ts": "^4.25.0", + "@norbix.ai/ts": "^4.26.0", "@reduxjs/toolkit": "^2.12.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.9", diff --git a/src/createNorbixApi.ts b/src/createNorbixApi.ts index 0f5c1ad..85acee2 100644 --- a/src/createNorbixApi.ts +++ b/src/createNorbixApi.ts @@ -89,6 +89,8 @@ export function createNorbixApi( 'PushTemplates', 'PushCampaigns', 'PushIntegrations', + 'PushDevices', + 'PushMessages', // Sms 'Sms', 'SmsTemplates', diff --git a/src/hooks/api/push.ts b/src/hooks/api/push.ts new file mode 100644 index 0000000..2a7944f --- /dev/null +++ b/src/hooks/api/push.ts @@ -0,0 +1,61 @@ +import type { Norbix } from '@norbix.ai/ts'; + +import type { Builder } from '../index.js'; +import type { Result } from '../types.js'; + +type Push = Norbix['notifications']['push']; +type RegisterDevice = Push['registerDevice']; +type ListDevices = Push['listDevices']; +type RemoveDevice = Push['removeDevice']; +type RemoveDeviceByToken = Push['removeDeviceByToken']; +type ListMessages = Push['listMessages']; +type MarkMessageRead = Push['markMessageRead']; +type MarkAllMessagesRead = Push['markAllMessagesRead']; + +/** + * `notifications.push` — push for the signed-in project user (API host, user + * token), 1:1 with `norbix.notifications.push.*` in the core SDK. + * + * Tags: + * - `PushDevices` — the user's registered devices. + * - `PushMessages` — the user's push messages (read state included). + */ +export const apiPush = (b: Builder) => ({ + registerPushDevice: b.mutation, Parameters[0]>({ + query: (device) => (norbix) => norbix.notifications.push.registerDevice(device), + invalidatesTags: ['PushDevices'], + }), + + listPushDevices: b.query, void>({ + query: () => (norbix) => norbix.notifications.push.listDevices(), + providesTags: ['PushDevices'], + }), + + /** Arg: the device id (`pnd_…`). */ + removePushDevice: b.mutation, string>({ + query: (id) => (norbix) => norbix.notifications.push.removeDevice(id), + invalidatesTags: ['PushDevices'], + }), + + /** Arg: the push token the device was registered with (e.g. on sign-out). */ + removePushDeviceByToken: b.mutation, string>({ + query: (token) => (norbix) => norbix.notifications.push.removeDeviceByToken(token), + invalidatesTags: ['PushDevices'], + }), + + listPushMessages: b.query, Parameters[0] | void>({ + query: (args) => (norbix) => norbix.notifications.push.listMessages(args ?? {}), + providesTags: ['PushMessages'], + }), + + /** Arg: the message id. */ + markPushMessageRead: b.mutation, string>({ + query: (id) => (norbix) => norbix.notifications.push.markMessageRead(id), + invalidatesTags: ['PushMessages'], + }), + + markAllPushMessagesRead: b.mutation, void>({ + query: () => (norbix) => norbix.notifications.push.markAllMessagesRead(), + invalidatesTags: ['PushMessages'], + }), +}); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 53c7cd2..357177e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -10,6 +10,7 @@ import { apiDatabase } from './api/database.js'; import { apiFiles } from './api/files.js'; import { apiMembership } from './api/membership.js'; import { apiPublic } from './api/public.js'; +import { apiPush } from './api/push.js'; import { hubAccount } from './hub/account.js'; import { hubAi } from './hub/ai.js'; import { hubApikeys } from './hub/apikeys.js'; @@ -88,6 +89,8 @@ export type AllTags = | 'PushTemplates' | 'PushCampaigns' | 'PushIntegrations' + | 'PushDevices' + | 'PushMessages' // ---- Sms ---- | 'Sms' | 'SmsTemplates' @@ -141,6 +144,7 @@ export function buildEndpoints(builder: Builder) { ...apiApikeys(builder), ...apiFiles(builder), ...apiPublic(builder), + ...apiPush(builder), // Hub surface (control plane) ...hubAccount(builder), ...hubAi(builder), diff --git a/src/provider.tsx b/src/provider.tsx index 7bc45fe..ea80bfc 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -14,6 +14,14 @@ const NorbixContext = createContext(null); export interface NorbixProviderProps { /** The Norbix SDK client instance. Construct it once at app startup. */ client: Norbix; + /** + * Language to ask for (`lt`, `lt-LT, en;q=0.8`), set on the client with + * `client.setLanguage(...)` so every hook's request carries it as + * `Accept-Language`. Omit it to leave the client's own setting alone. + * Cached results do not know the language they were read in: after a + * change, refetch with `dispatch(norbixApi.util.resetApiState())`. + */ + language?: string; children?: ReactNode; } @@ -43,9 +51,14 @@ export interface NorbixProviderProps { * } * ``` */ -export function NorbixProvider({ client, children }: NorbixProviderProps) { +export function NorbixProvider({ client, language, children }: NorbixProviderProps) { // Stable identity: re-render only when the client identity actually changes. const value = useMemo(() => client, [client]); + // Set during render (not in an effect) so the first queries of the + // children already go out with the language. + if (language !== undefined && client.getLanguage() !== language) { + client.setLanguage(language || undefined); + } return {children}; } diff --git a/tests/push-language.test.tsx b/tests/push-language.test.tsx new file mode 100644 index 0000000..b4b2888 --- /dev/null +++ b/tests/push-language.test.tsx @@ -0,0 +1,144 @@ +import { Norbix } from '@norbix.ai/ts'; +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { apiPush } from '../src/hooks/api/push.js'; +import { buildEndpoints } from '../src/hooks/index.js'; +import { NorbixProvider, useNorbix } from '../src/provider.js'; + +function fakeBuilder() { + return { + query: (def: Record) => ({ ...def, __kind: 'query' }), + mutation: (def: Record) => ({ ...def, __kind: 'mutation' }), + } as never; +} + +type Def = { + __kind: 'query' | 'mutation'; + query: (args: unknown) => (norbix: unknown) => unknown; + providesTags?: unknown; + invalidatesTags?: unknown; +}; + +function fakeNorbix() { + const calls: Array<{ method: string; args: unknown[] }> = []; + const push = new Proxy( + {}, + { + get: + (_t, method: string) => + (...args: unknown[]) => { + calls.push({ method, args }); + return Promise.resolve({ ok: true }); + }, + }, + ); + return { client: { notifications: { push } }, calls }; +} + +describe('push hooks (notifications.push)', () => { + const defs = apiPush(fakeBuilder()) as unknown as Record; + + it('ships every push endpoint, queries and mutations as expected', () => { + expect(Object.keys(defs).sort()).toEqual([ + 'listPushDevices', + 'listPushMessages', + 'markAllPushMessagesRead', + 'markPushMessageRead', + 'registerPushDevice', + 'removePushDevice', + 'removePushDeviceByToken', + ]); + expect(defs.listPushDevices!.__kind).toBe('query'); + expect(defs.listPushMessages!.__kind).toBe('query'); + expect(defs.registerPushDevice!.__kind).toBe('mutation'); + expect(defs.markAllPushMessagesRead!.__kind).toBe('mutation'); + }); + + it('are part of the full endpoint set', () => { + const all = buildEndpoints(fakeBuilder()) as unknown as Record; + expect(all.registerPushDevice).toBeDefined(); + expect(all.listPushMessages).toBeDefined(); + }); + + it('call the matching SDK method with the hook argument', async () => { + const { client, calls } = fakeNorbix(); + const device = { provider: 'expo', token: 't1', platform: 'ios' }; + await defs.registerPushDevice!.query(device)(client); + await defs.listPushDevices!.query(undefined)(client); + await defs.removePushDevice!.query('pnd_1')(client); + await defs.removePushDeviceByToken!.query('t1')(client); + await defs.listPushMessages!.query({ unreadOnly: true })(client); + await defs.listPushMessages!.query(undefined)(client); + await defs.markPushMessageRead!.query('m1')(client); + await defs.markAllPushMessagesRead!.query(undefined)(client); + expect(calls).toEqual([ + { method: 'registerDevice', args: [device] }, + { method: 'listDevices', args: [] }, + { method: 'removeDevice', args: ['pnd_1'] }, + { method: 'removeDeviceByToken', args: ['t1'] }, + { method: 'listMessages', args: [{ unreadOnly: true }] }, + { method: 'listMessages', args: [{}] }, + { method: 'markMessageRead', args: ['m1'] }, + { method: 'markAllMessagesRead', args: [] }, + ]); + }); + + it('device writes invalidate PushDevices, message writes PushMessages', () => { + expect(defs.listPushDevices!.providesTags).toEqual(['PushDevices']); + expect(defs.registerPushDevice!.invalidatesTags).toEqual(['PushDevices']); + expect(defs.removePushDeviceByToken!.invalidatesTags).toEqual(['PushDevices']); + expect(defs.listPushMessages!.providesTags).toEqual(['PushMessages']); + expect(defs.markPushMessageRead!.invalidatesTags).toEqual(['PushMessages']); + expect(defs.markAllPushMessagesRead!.invalidatesTags).toEqual(['PushMessages']); + }); + + it('reach the real SDK routes', async () => { + const fetchImpl = vi.fn( + async () => new globalThis.Response('{}', { status: 200 }), + ) as unknown as typeof globalThis.fetch; + const norbix = new Norbix({ + projectId: 'p1', + bearerToken: 'jwt', + apiVersion: 'v3', + baseUrl: { api: 'https://api.example.test', hub: 'https://hub.example.test' }, + fetch: fetchImpl, + }); + await defs.markAllPushMessagesRead!.query(undefined)(norbix); + const [url, init] = ( + fetchImpl as unknown as { mock: { calls: [string, { method?: string }][] } } + ).mock.calls[0]!; + expect(url).toBe('https://api.example.test/v3/notifications/push/messages/read'); + expect(init.method).toBe('POST'); + }); +}); + +describe('', () => { + function client() { + return new Norbix( + { projectId: 'p1', apiKey: 'k', fetch: vi.fn() as unknown as typeof globalThis.fetch }, + { envSource: {} }, + ); + } + + it('sets the client language so every hook request sends Accept-Language', () => { + const norbix = client(); + renderHook(() => useNorbix(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(norbix.getLanguage()).toBe('lt-LT, en;q=0.8'); + }); + + it('leaves the client language alone when the prop is omitted', () => { + const norbix = client(); + norbix.setLanguage('de'); + renderHook(() => useNorbix(), { + wrapper: ({ children }) => {children}, + }); + expect(norbix.getLanguage()).toBe('de'); + }); +});