Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions scripts/mintlify-post-processing/method-order.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,8 @@
"functions": [
"invoke",
"fetch"
],
"mobile": [
"sendNotification"
]
}
1 change: 1 addition & 0 deletions scripts/mintlify-post-processing/types-to-expose.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"ImportResult",
"IntegrationsModule",
"CoreIntegrations",
"MobileModule",
"SortField",
"SsoModule",
"UpdateManyResult"
Expand Down
2 changes: 2 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -234,6 +235,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
token,
}),
appLogs: createAppLogsModule(serviceRoleAxiosClient, appId),
mobile: createMobileModule(serviceRoleAxiosClient, appId),
cleanup: () => {
if (socket) {
socket.disconnect();
Expand Down
3 changes: 3 additions & 0 deletions src/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
*/
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions src/modules/mobile.ts
Original file line number Diff line number Diff line change
@@ -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<SendNotificationResult> {
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<SendNotificationResult>(
`/apps/${appId}/mobile/notifications`,
params
);

return response as unknown as SendNotificationResult;
},
};
}
49 changes: 49 additions & 0 deletions src/modules/mobile.types.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* 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<string, string>;
}

/**
* 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<SendNotificationResult>;
}
6 changes: 5 additions & 1 deletion tests/unit/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
});

Expand Down Expand Up @@ -604,4 +608,4 @@ describe('Service Role Authorization Headers', () => {
expect(scope.isDone()).toBe(true);
});

});
});
100 changes: 100 additions & 0 deletions tests/unit/mobile.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createClient>;
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<string, unknown>,
})
).rejects.toThrow("metadata must be an object");
});
});
Loading