diff --git a/README.md b/README.md index 741fe2e2..2b4b9099 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ The SDK provides access to Base44's functionality through the following modules: - **[`entities`](https://docs.base44.com/developers/references/sdk/docs/interfaces/entities)**: Work with your app's data entities using CRUD operations. - **[`functions`](https://docs.base44.com/developers/references/sdk/docs/interfaces/functions)**: Execute backend functions. - **[`integrations`](https://docs.base44.com/developers/references/sdk/docs/type-aliases/integrations)**: Access pre-built and third-party integrations. +- **`mobile`**: Send AppUser mobile push notifications from service-role backend code. ## Quickstarts @@ -96,6 +97,8 @@ Deno.serve(async (req) => { }); ``` +Service-role-only modules such as `base44.asServiceRole.mobile` are available only in backend functions. Use `base44.asServiceRole.mobile.sendNotification(...)` to send AppUser push notifications from backend code after validating that the caller is allowed to trigger the notification. Do not expose mobile push sending from frontend/client-side code. + ## Learn more The best way to get started with the JavaScript SDK is to have Base44 build an app for you. Once you have an app, you can explore the generated code and experiment with the SDK to see how it works in practice. You can also ask Base44 to demonstrate specific features of the SDK. diff --git a/scripts/mintlify-post-processing/method-order.json b/scripts/mintlify-post-processing/method-order.json index 8c05055d..9cd0d5cb 100644 --- a/scripts/mintlify-post-processing/method-order.json +++ b/scripts/mintlify-post-processing/method-order.json @@ -16,5 +16,8 @@ "functions": [ "invoke", "fetch" + ], + "mobile": [ + "sendNotification" ] } diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index e90bfd28..116abf31 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -21,6 +21,7 @@ "ImportResult", "IntegrationsModule", "CoreIntegrations", + "MobileModule", "SortField", "SsoModule", "UpdateManyResult" diff --git a/src/client.ts b/src/client.ts index a028f337..a1f30d98 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,7 @@ import { createAgentsModule } from "./modules/agents.js"; import { createAppLogsModule } from "./modules/app-logs.js"; import { createUsersModule } from "./modules/users.js"; import { RoomsSocket, RoomsSocketConfig } from "./utils/socket-utils.js"; +import { createMobileModule } from "./modules/mobile.js"; import type { Base44Client, CreateClientConfig, @@ -234,6 +235,7 @@ export function createClient(config: CreateClientConfig): Base44Client { token, }), appLogs: createAppLogsModule(serviceRoleAxiosClient, appId), + mobile: createMobileModule(serviceRoleAxiosClient, appId), cleanup: () => { if (socket) { socket.disconnect(); diff --git a/src/client.types.ts b/src/client.types.ts index 6b4c9c57..10337db0 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -10,6 +10,7 @@ import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { MobileModule } from "./modules/mobile.types.js"; /** * Options for creating a Base44 client. @@ -139,6 +140,8 @@ export interface Base44Client { functions: FunctionsModule; /** {@link IntegrationsModule | Integrations module} with elevated permissions. */ integrations: IntegrationsModule; + /** {@link MobileModule | Mobile module} for service-role mobile operations. */ + mobile: MobileModule; /** {@link SsoModule | SSO module} for generating SSO tokens. * @internal */ diff --git a/src/index.ts b/src/index.ts index bc531d89..6114230d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,6 +115,12 @@ export type { CustomIntegrationCallResponse, } from "./modules/custom-integrations.types.js"; +export type { + MobileModule, + SendNotificationParams, + SendNotificationResult, +} from "./modules/mobile.types.js"; + // Auth utils types export type { GetAccessTokenOptions, diff --git a/src/modules/mobile.ts b/src/modules/mobile.ts new file mode 100644 index 00000000..be969644 --- /dev/null +++ b/src/modules/mobile.ts @@ -0,0 +1,81 @@ +import type { AxiosInstance } from "axios"; +import type { + MobileModule, + SendNotificationParams, + SendNotificationResult, +} from "./mobile.types.js"; + +function validateRequiredString( + value: unknown, + fieldName: string, + maxLength?: number +) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${fieldName} is required and must be a string`); + } + + if (maxLength !== undefined && value.length > maxLength) { + throw new Error(`${fieldName} must be at most ${maxLength} characters`); + } +} + +function validateOptionalString( + value: unknown, + fieldName: string, + maxLength?: number +) { + if (value === undefined) { + return; + } + + if (typeof value !== "string") { + throw new Error(`${fieldName} must be a string`); + } + + if (maxLength !== undefined && value.length > maxLength) { + throw new Error(`${fieldName} must be at most ${maxLength} characters`); + } +} + +/** + * Creates the service-role mobile module. + * + * @param axios - Axios instance (should be service role client) + * @param appId - Application ID + * @returns Mobile module with push notification methods + * @internal + */ +export function createMobileModule( + axios: AxiosInstance, + appId: string +): MobileModule { + return { + async sendNotification( + params: SendNotificationParams + ): Promise { + if (!params || typeof params !== "object") { + throw new Error("Notification params are required"); + } + + validateRequiredString(params.userId, "userId"); + validateRequiredString(params.title, "title", 100); + validateRequiredString(params.content, "content", 500); + validateOptionalString(params.actionLabel, "actionLabel", 50); + validateOptionalString(params.actionUrl, "actionUrl"); + + if ( + params.metadata !== undefined && + (typeof params.metadata !== "object" || Array.isArray(params.metadata)) + ) { + throw new Error("metadata must be an object"); + } + + const response = await axios.post( + `/apps/${appId}/mobile/notifications`, + params + ); + + return response as unknown as SendNotificationResult; + }, + }; +} diff --git a/src/modules/mobile.types.ts b/src/modules/mobile.types.ts new file mode 100644 index 00000000..0c3afcb7 --- /dev/null +++ b/src/modules/mobile.types.ts @@ -0,0 +1,49 @@ +/** + * Parameters for sending a mobile push notification to an app user. + */ +export interface SendNotificationParams { + /** Target AppUser ID. */ + userId: string; + /** Notification title. Maximum 100 characters. */ + title: string; + /** Notification body. Maximum 500 characters. */ + content: string; + /** Optional action label. Maximum 50 characters. */ + actionLabel?: string; + /** Optional action URL opened by the notification action. */ + actionUrl?: string; + /** Optional metadata delivered with the notification event. */ + metadata?: Record; +} + +/** + * Result returned after attempting to send a mobile push notification. + */ +export interface SendNotificationResult { + /** Channels that completed successfully. */ + successfulChannels: string[]; + /** Channel errors keyed by channel name. */ + failedChannels: Record; +} + +/** + * Service-role-only mobile module. + * + * This module is exposed only as `base44.asServiceRole.mobile` and is intended + * for Base44-hosted backend functions or validated server-side webhooks. Normal + * frontend clients do not expose `base44.mobile`. + */ +export interface MobileModule { + /** + * Sends a push notification to an app user. + * + * This method is only available through `base44.asServiceRole.mobile`. + * Do not call it from generated frontend/client-side app code. + * + * @param params - Notification target and content. + * @returns The notification delivery result. + */ + sendNotification( + params: SendNotificationParams + ): Promise; +} diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 4a7b3efa..8a84b192 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -53,6 +53,8 @@ describe('Client Creation', () => { expect(client.asServiceRole.entities).toBeDefined(); expect(client.asServiceRole.integrations).toBeDefined(); expect(client.asServiceRole.functions).toBeDefined(); + expect(client.asServiceRole.mobile).toBeDefined(); + expect(client.mobile).toBeUndefined(); // Service role should not have auth module expect(client.asServiceRole.auth).toBeUndefined(); }); @@ -73,6 +75,8 @@ describe('Client Creation', () => { expect(client.asServiceRole.entities).toBeDefined(); expect(client.asServiceRole.integrations).toBeDefined(); expect(client.asServiceRole.functions).toBeDefined(); + expect(client.asServiceRole.mobile).toBeDefined(); + expect(client.mobile).toBeUndefined(); expect(client.asServiceRole.auth).toBeUndefined(); }); @@ -604,4 +608,4 @@ describe('Service Role Authorization Headers', () => { expect(scope.isDone()).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/mobile.test.ts b/tests/unit/mobile.test.ts new file mode 100644 index 00000000..7be59d48 --- /dev/null +++ b/tests/unit/mobile.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("Mobile module – sendNotification", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const serviceToken = "service-token-123"; + let base44: ReturnType; + let scope: nock.Scope; + + beforeEach(() => { + base44 = createClient({ + serverUrl, + appId, + serviceToken, + }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test("posts a service-role mobile notification request", async () => { + const params = { + userId: "app-user-1", + title: "Order ready", + content: "Your pickup is ready.", + actionLabel: "Open", + actionUrl: "https://example.com/orders/1", + metadata: { orderId: "order-1" }, + }; + + const apiResponse = { + successfulChannels: ["mobile_push"], + failedChannels: {}, + }; + + scope + .post(`/api/apps/${appId}/mobile/notifications`, params) + .reply(200, apiResponse); + + const result = await base44.asServiceRole.mobile.sendNotification(params); + + expect(result).toEqual(apiResponse); + expect(scope.isDone()).toBe(true); + }); + + test("is only exposed through asServiceRole", () => { + expect(base44.asServiceRole.mobile).toBeDefined(); + expect((base44 as any).mobile).toBeUndefined(); + }); + + test("validates required fields and length limits", async () => { + await expect( + base44.asServiceRole.mobile.sendNotification({ + userId: "", + title: "Title", + content: "Content", + }) + ).rejects.toThrow("userId is required and must be a string"); + + await expect( + base44.asServiceRole.mobile.sendNotification({ + userId: "app-user-1", + title: "x".repeat(101), + content: "Content", + }) + ).rejects.toThrow("title must be at most 100 characters"); + + await expect( + base44.asServiceRole.mobile.sendNotification({ + userId: "app-user-1", + title: "Title", + content: "x".repeat(501), + }) + ).rejects.toThrow("content must be at most 500 characters"); + + await expect( + base44.asServiceRole.mobile.sendNotification({ + userId: "app-user-1", + title: "Title", + content: "Content", + actionLabel: "x".repeat(51), + }) + ).rejects.toThrow("actionLabel must be at most 50 characters"); + }); + + test("validates optional metadata shape", async () => { + await expect( + base44.asServiceRole.mobile.sendNotification({ + userId: "app-user-1", + title: "Title", + content: "Content", + metadata: [] as unknown as Record, + }) + ).rejects.toThrow("metadata must be an object"); + }); +});