@assembly-js/app-foundation 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +1 -0
- package/bin/foundation-exports.mjs +2 -2
- package/dist/notifications.d.ts +63 -0
- package/dist/notifications.js +57 -0
- package/package.json +1 -1
- package/skills/assembly-api-contract/SKILL.md +123 -0
- package/skills/assembly-notifications/SKILL.md +44 -31
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.3.3
|
|
23
|
+
- [new] `notifyInternalUsers` (from `@assembly-js/app-foundation/server`) notifies every internal user, or a given `recipientIds` list, on behalf of the current viewer using the installer client.
|
|
24
|
+
- [guidance] The notifications skill now directs every client-triggered send to staff through `notifyInternalUsers`, since a client's own `getAssembly()` client cannot create notifications or list staff.
|
|
25
|
+
|
|
26
|
+
## 0.3.2
|
|
27
|
+
- [new] The `assembly-api-contract` skill teaches the build agent to write and maintain `.assembly/openapi.json` alongside `app/api` routes.
|
|
28
|
+
|
|
22
29
|
## 0.3.1
|
|
23
30
|
- [guidance] The build agent now upgrades an app's bridge to 2.1.0 and its foundation package instead of skipping a deep link, mounts the location reporter, and deep-links notifications to the specific record.
|
|
24
31
|
- [new] App destinations in `NavigateDestination` now accept an optional `path` for links to an app's inner page.
|
package/CLAUDE.md
CHANGED
|
@@ -68,6 +68,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
|
|
|
68
68
|
- `assembly-route-auth` — REQUIRED on every `app/api/.../[id]/route.ts` handler: `requireAuth` + `requireCompanyScope` before returning or mutating data.
|
|
69
69
|
- `assembly-scheduled-work` — REQUIRED when the prompt implies anything on a schedule or while nobody has the app open (remind, daily digest, every week, follow up in N days, at a specific time): one `*/30 * * * *` Vercel cron in `vercel.json` hitting one dispatcher route, every scheduled action a row in a schedule table, `CRON_SECRET` declared in `requiredSecrets` and verified on every tick, `getOfflineAssembly()` for platform calls. Checking for due work on page load ships a schedule that never fires.
|
|
70
70
|
- `assembly-route-errors` — REQUIRED in every route handler: use `handleRouteError` from `@assembly-js/app-foundation/server` in catch blocks — never return `err.message`. Validate values before they hit typed DB columns (e.g. `parseUuid` before a uuid-column lookup).
|
|
71
|
+
- `assembly-api-contract` — REQUIRED whenever an `app/api` route is added, removed, or changes its request or response shape: keep `.assembly/openapi.json` (OpenAPI 3.1, `servers: [{"url": "/api"}]`, paths without the `/api` prefix) in step; cron, webhook, `assembly-*` and `privateRoutes` routes stay out.
|
|
71
72
|
- `assembly-action-results` — REQUIRED when a server action refuses work the UI should explain (validation, permission, not ready to send): return `{ ok: false, message }` instead of throwing. A thrown message is redacted in production.
|
|
72
73
|
- `assembly-dev-server-logs` — the dev server's stdout/stderr (server-side errors, stack traces, console output) is readable at `/tmp/dev-server.log`; check it before guessing at a 500 or broken preview.
|
|
73
74
|
- Your own preview is `localhost:3000`; never fetch the public `sb-*.vercel.run` host from inside the sandbox (egress to it is blocked).
|
|
@@ -29,7 +29,7 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
29
29
|
'FormatAirtableRecordsOpts', 'GenerateAppClientInput', 'GeneratedAppClient', 'GmailSendArgs', 'GmailSendResult', 'IntegrationClient',
|
|
30
30
|
'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
|
|
31
31
|
'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
|
|
32
|
-
'NetworkCaptureRecord', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
|
|
32
|
+
'NetworkCaptureRecord', 'NotifyInternalUsersInput', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
|
|
33
33
|
'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'PrincipalAllow', 'PrincipalKind',
|
|
34
34
|
'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
|
|
35
35
|
'RequestSource', 'ResolveResourceOpts', 'ResourceError', 'ResourceFieldMap',
|
|
@@ -52,7 +52,7 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
52
52
|
'isDocumentRequest', 'isPaintedBeaconPath', 'isSessionExchangeEnabled', 'isUuid',
|
|
53
53
|
'isValidHexColor', 'listAll', 'logCookieOutcome', 'logFailures', 'logPaintedBeacon',
|
|
54
54
|
'logRenewOutcome', 'makeTestActionContext', 'mintBearerFromAppKey',
|
|
55
|
-
'mintBearerFromSessionToken', 'nextRunAfter', 'normalizeResourcePage', 'notFound',
|
|
55
|
+
'mintBearerFromSessionToken', 'nextRunAfter', 'normalizeResourcePage', 'notFound', 'notifyInternalUsers',
|
|
56
56
|
'parseExchangeRejection', 'parseUuid', 'principalKind', 'readCookie', 'recordNetworkCapture',
|
|
57
57
|
'redactUrl', 'replaceChildRows', 'requireAuth', 'requireCompanyScope', 'resolveFont',
|
|
58
58
|
'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeCookieProbe',
|
package/dist/notifications.d.ts
CHANGED
|
@@ -44,4 +44,67 @@ export declare function clearClientNotifications({ assembly, clientId, ctaParams
|
|
|
44
44
|
cleared: number;
|
|
45
45
|
failed: number;
|
|
46
46
|
}>;
|
|
47
|
+
interface InternalUserRecord {
|
|
48
|
+
id: string;
|
|
49
|
+
}
|
|
50
|
+
interface InstallerNotificationsClient {
|
|
51
|
+
listInternalUsers(args: {
|
|
52
|
+
nextToken?: string;
|
|
53
|
+
limit?: number;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
data?: InternalUserRecord[] | null;
|
|
56
|
+
nextToken?: string;
|
|
57
|
+
}>;
|
|
58
|
+
createNotification(args: {
|
|
59
|
+
requestBody: {
|
|
60
|
+
senderId: string;
|
|
61
|
+
senderCompanyId?: string;
|
|
62
|
+
recipientInternalUserId: string;
|
|
63
|
+
notificationSettingId?: string;
|
|
64
|
+
deliveryTargets: {
|
|
65
|
+
inProduct: {
|
|
66
|
+
title: string;
|
|
67
|
+
body: string;
|
|
68
|
+
ctaParams: Record<string, string>;
|
|
69
|
+
};
|
|
70
|
+
email?: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
}): Promise<unknown>;
|
|
74
|
+
}
|
|
75
|
+
interface NotifyInternalUsersDeps {
|
|
76
|
+
getAssembly: () => Promise<unknown>;
|
|
77
|
+
getInstallerAssembly: () => Promise<InstallerNotificationsClient>;
|
|
78
|
+
getIdentity: () => Promise<{
|
|
79
|
+
clientId?: string;
|
|
80
|
+
internalUserId?: string;
|
|
81
|
+
companyId?: string;
|
|
82
|
+
} | null>;
|
|
83
|
+
}
|
|
84
|
+
export interface NotifyInternalUsersInput {
|
|
85
|
+
title: string;
|
|
86
|
+
body: string;
|
|
87
|
+
/** Becomes the notification's inProduct ctaParams (the `/n` surface renders from these). */
|
|
88
|
+
ctaParams: Record<string, string>;
|
|
89
|
+
/** Email delivery target (subject, header, htmlBody, title); omit for in-product only. */
|
|
90
|
+
email?: Record<string, unknown>;
|
|
91
|
+
/** Per-user toggleable category declared in the manifest `notificationSettings`. */
|
|
92
|
+
notificationSettingId?: string;
|
|
93
|
+
/** Notify only these internal users; omit to notify every internal user. Limited-access staff are not filtered out: pass recipientIds when the audience must respect Client Access. */
|
|
94
|
+
recipientIds?: string[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Notifies internal users on behalf of the current viewer — the only way a
|
|
98
|
+
* client-triggered event can reach staff, since a client's own getAssembly()
|
|
99
|
+
* client can neither create notifications for them nor list them. The send and
|
|
100
|
+
* the staff list use the installer client; the viewer's client is never touched.
|
|
101
|
+
*
|
|
102
|
+
* Throws only when there is no verified viewer (an auth gate, not a send
|
|
103
|
+
* failure). List and send failures never throw: the caller's data write already
|
|
104
|
+
* committed, so they are logged and returned as counts.
|
|
105
|
+
*/
|
|
106
|
+
export declare function notifyInternalUsers({ title, body, ctaParams, email, notificationSettingId, recipientIds }: NotifyInternalUsersInput, deps?: NotifyInternalUsersDeps): Promise<{
|
|
107
|
+
sent: number;
|
|
108
|
+
failed: number;
|
|
109
|
+
}>;
|
|
47
110
|
export {};
|
package/dist/notifications.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// (lib/notify.ts): a 429-aware fan-out throttle and the client badge-clear
|
|
3
3
|
// pattern. They live in the package so the behavior is a deterministic import,
|
|
4
4
|
// not prose the build agent re-implements (and sometimes gets wrong) each time.
|
|
5
|
+
import { getAssembly, getInstallerAssembly, getSessionIdentity } from './assembly.js';
|
|
6
|
+
import { getInstallerAssemblyForCaller } from './installer-caller.js';
|
|
7
|
+
import { listAll } from './pagination.js';
|
|
5
8
|
// Platform writes are capped at 5 concurrent per token; 3 leaves headroom for
|
|
6
9
|
// the app's own unrelated in-flight writes.
|
|
7
10
|
const CONCURRENCY = 3;
|
|
@@ -81,3 +84,57 @@ export async function clearClientNotifications({ assembly, clientId, ctaParams,
|
|
|
81
84
|
return { cleared: 0, failed: 0 };
|
|
82
85
|
}
|
|
83
86
|
}
|
|
87
|
+
const defaultNotifyDeps = {
|
|
88
|
+
getAssembly,
|
|
89
|
+
getInstallerAssembly: getInstallerAssembly,
|
|
90
|
+
getIdentity: getSessionIdentity,
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Notifies internal users on behalf of the current viewer — the only way a
|
|
94
|
+
* client-triggered event can reach staff, since a client's own getAssembly()
|
|
95
|
+
* client can neither create notifications for them nor list them. The send and
|
|
96
|
+
* the staff list use the installer client; the viewer's client is never touched.
|
|
97
|
+
*
|
|
98
|
+
* Throws only when there is no verified viewer (an auth gate, not a send
|
|
99
|
+
* failure). List and send failures never throw: the caller's data write already
|
|
100
|
+
* committed, so they are logged and returned as counts.
|
|
101
|
+
*/
|
|
102
|
+
export async function notifyInternalUsers({ title, body, ctaParams, email, notificationSettingId, recipientIds }, deps = defaultNotifyDeps) {
|
|
103
|
+
// The auth gate: a missing viewer must surface, unlike the failures below.
|
|
104
|
+
await deps.getAssembly();
|
|
105
|
+
let installer;
|
|
106
|
+
try {
|
|
107
|
+
installer = (await getInstallerAssemblyForCaller(deps));
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
console.error('notifyInternalUsers could not get the installer client', err);
|
|
111
|
+
return { sent: 0, failed: 0 };
|
|
112
|
+
}
|
|
113
|
+
const identity = await deps.getIdentity();
|
|
114
|
+
const senderId = identity?.clientId ?? identity?.internalUserId;
|
|
115
|
+
if (!senderId)
|
|
116
|
+
throw new Error('notifyInternalUsers requires a verified viewer');
|
|
117
|
+
let recipients;
|
|
118
|
+
try {
|
|
119
|
+
recipients =
|
|
120
|
+
recipientIds ??
|
|
121
|
+
(await listAll((nextToken) => installer.listInternalUsers({ nextToken, limit: 100 }))).map((u) => u.id);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error('notifyInternalUsers could not list internal users', err);
|
|
125
|
+
return { sent: 0, failed: 0 };
|
|
126
|
+
}
|
|
127
|
+
const results = await sendThrottled(recipients, (recipientInternalUserId) => installer.createNotification({
|
|
128
|
+
requestBody: {
|
|
129
|
+
senderId,
|
|
130
|
+
// A multi-company client must name the company it acts under, or the platform rejects the send.
|
|
131
|
+
senderCompanyId: identity?.companyId,
|
|
132
|
+
recipientInternalUserId,
|
|
133
|
+
notificationSettingId,
|
|
134
|
+
deliveryTargets: { inProduct: { title, body, ctaParams }, ...(email && { email }) },
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
logFailures(results, 'internal user notifications');
|
|
138
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
139
|
+
return { sent: results.length - failed, failed };
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-api-contract
|
|
3
|
+
description: REQUIRED whenever you add, remove, or change the request or response shape of a route under app/api. Write and maintain .assembly/openapi.json, the app's OpenAPI 3.1 API contract — one operation per callable (method, path) with JSON Schema bodies; servers /api and paths without the /api prefix. Skip cron, webhook, assembly-* and privateRoutes routes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Keep `.assembly/openapi.json` in step with `app/api` in the same turn as the route change. It is the document other apps and tools read to learn what this app's API accepts and returns.
|
|
7
|
+
|
|
8
|
+
## When to touch it
|
|
9
|
+
|
|
10
|
+
- Route added: add an operation for each exported GET, POST, PUT, PATCH or DELETE handler; never document head or options, which are not proxied.
|
|
11
|
+
- Route removed, or a method removed from it: delete that operation (and the path entry once it has no operations).
|
|
12
|
+
- Request body, query parameter, or response field changed: update the schemas to match.
|
|
13
|
+
|
|
14
|
+
## Format
|
|
15
|
+
|
|
16
|
+
- OpenAPI `3.1.0`, JSON, at `.assembly/openapi.json`. Bodies are JSON Schema.
|
|
17
|
+
- Identity of an operation is `(method, path template)`. `operationId` is a label only; renaming it changes nothing.
|
|
18
|
+
- `servers` is `[{ "url": "/api" }]` and `paths` are written **without** the `/api` prefix, exactly as the route scanner templates them (`app/api/projects/[projectId]/tasks/route.ts` becomes `/projects/{projectId}/tasks`). Path parameters are `{name}` using the folder's bracket name.
|
|
19
|
+
- The stored document is capped at 1 MB and publishing refuses a larger one. Keep it small: describe fields, do not paste sample data.
|
|
20
|
+
|
|
21
|
+
## What stays out
|
|
22
|
+
|
|
23
|
+
A route is not callable by other apps, so it must not appear, when it is:
|
|
24
|
+
|
|
25
|
+
- under a `cron`, `webhook` or `webhooks` segment, or a segment starting `assembly-`
|
|
26
|
+
- wrapped in `cronRoute` or `automationActionRoute`
|
|
27
|
+
- matched by a glob in `privateRoutes` in `.assembly/app.json` (matched against the route template, not the file path). The scanner records these as excluded, so documenting one is reported as a drift.
|
|
28
|
+
|
|
29
|
+
## Worked example
|
|
30
|
+
|
|
31
|
+
`app/api/projects/[projectId]/tasks/route.ts` exports `GET` (list) and `POST` (create):
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"openapi": "3.1.0",
|
|
36
|
+
"info": { "title": "Project tasks", "version": "1.0.0" },
|
|
37
|
+
"servers": [{ "url": "/api" }],
|
|
38
|
+
"paths": {
|
|
39
|
+
"/projects/{projectId}/tasks": {
|
|
40
|
+
"get": {
|
|
41
|
+
"operationId": "listProjectTasks",
|
|
42
|
+
"summary": "List the tasks of a project",
|
|
43
|
+
"parameters": [
|
|
44
|
+
{ "name": "projectId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
|
45
|
+
],
|
|
46
|
+
"responses": {
|
|
47
|
+
"200": {
|
|
48
|
+
"description": "The project's tasks",
|
|
49
|
+
"content": {
|
|
50
|
+
"application/json": {
|
|
51
|
+
"schema": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"required": ["tasks"],
|
|
54
|
+
"properties": { "tasks": { "type": "array", "items": { "$ref": "#/components/schemas/Task" } } }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"404": { "$ref": "#/components/responses/Error" }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"post": {
|
|
63
|
+
"operationId": "createProjectTask",
|
|
64
|
+
"summary": "Create a task in a project",
|
|
65
|
+
"parameters": [
|
|
66
|
+
{ "name": "projectId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
|
67
|
+
],
|
|
68
|
+
"requestBody": {
|
|
69
|
+
"required": true,
|
|
70
|
+
"content": {
|
|
71
|
+
"application/json": {
|
|
72
|
+
"schema": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"required": ["title"],
|
|
75
|
+
"properties": { "title": { "type": "string", "minLength": 1 }, "dueDate": { "type": ["string", "null"], "format": "date" } }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"responses": {
|
|
81
|
+
"201": { "description": "The created task", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Task" } } } },
|
|
82
|
+
"400": { "$ref": "#/components/responses/Error" }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"components": {
|
|
88
|
+
"schemas": {
|
|
89
|
+
"Task": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"required": ["id", "title", "done"],
|
|
92
|
+
"properties": {
|
|
93
|
+
"id": { "type": "string", "format": "uuid" },
|
|
94
|
+
"title": { "type": "string" },
|
|
95
|
+
"done": { "type": "boolean" },
|
|
96
|
+
"dueDate": { "type": ["string", "null"], "format": "date" }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"responses": {
|
|
101
|
+
"Error": {
|
|
102
|
+
"description": "Error",
|
|
103
|
+
"content": {
|
|
104
|
+
"application/json": {
|
|
105
|
+
"schema": { "type": "object", "required": ["error"], "properties": { "error": { "type": "string" } } }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Document the error shape your handlers really return (see `assembly-route-errors`: `{ error }`).
|
|
115
|
+
|
|
116
|
+
## Drift
|
|
117
|
+
|
|
118
|
+
The platform may compare the contract with the scanned routes after a turn and report disagreements. Fix each kind at its source:
|
|
119
|
+
|
|
120
|
+
- Documented operation with no route in code: remove the operation, or restore the route if it was deleted by mistake. A method the file does not export counts as missing.
|
|
121
|
+
- Route in code with no operation: add the operation, unless the route is one of the excluded kinds above.
|
|
122
|
+
- Same path under a different parameter name (`{id}` in the contract, `[taskId]` in the folder): Next allows one name per dynamic segment, so rename the contract's parameter to the folder's.
|
|
123
|
+
- Shape mismatch (a response field renamed, a body field now required): update the schemas to what the handler returns and accepts.
|
|
@@ -95,6 +95,8 @@ Detect the notification surface by the route itself — code under `app/n/` IS t
|
|
|
95
95
|
|
|
96
96
|
## Creating a notification
|
|
97
97
|
|
|
98
|
+
This is the **staff-triggered** pattern: the viewer's own client sends. When a client triggers a notification to staff, use `notifyInternalUsers` instead (see "Client-triggered sends to staff").
|
|
99
|
+
|
|
98
100
|
```ts
|
|
99
101
|
const assembly = await getAssembly();
|
|
100
102
|
const id = await getSessionIdentity();
|
|
@@ -159,6 +161,24 @@ export async function GET() {
|
|
|
159
161
|
|
|
160
162
|
Send a fragment, not a document, and don't attempt layout or branding: every tag outside that list — `div`, `table`, headings, `img`, `class`, inline styles, `script` — is stripped server-side with no error, leaving its text run together unformatted, and the platform's email template supplies the frame. The plain-text alternative is derived from `htmlBody` automatically; pass `body` alongside it only to write that text yourself.
|
|
161
163
|
|
|
164
|
+
### Client-triggered sends to staff
|
|
165
|
+
|
|
166
|
+
A client's own `getAssembly()` client **cannot create notifications or list internal users** — those calls fail for a client viewer. Every send to staff that a client triggers (a submission, a decision, a message) goes through `notifyInternalUsers`, which checks the viewer is verified, then lists staff and sends with the installer client, using the viewer's `clientId` and `companyId` as sender:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { notifyInternalUsers } from '@assembly-js/app-foundation/server';
|
|
170
|
+
|
|
171
|
+
// After the client's write committed. Never throws on send failures; returns counts.
|
|
172
|
+
const { sent, failed } = await notifyInternalUsers({
|
|
173
|
+
title: 'New submission received',
|
|
174
|
+
body: 'Jane Doe submitted their brand questionnaire.',
|
|
175
|
+
ctaParams: { submissionId: submission.id },
|
|
176
|
+
// email, notificationSettingId and recipientIds (default: every internal user) are optional
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Keep `getAssembly()` for notifications a **staff member** triggers — that viewer's client can send them directly.
|
|
181
|
+
|
|
162
182
|
### Company scoping, by recipient type
|
|
163
183
|
|
|
164
184
|
Every notification is scoped to a company, and which field supplies it depends on **who the recipient is** — the part that trips up multi-company clients:
|
|
@@ -169,22 +189,19 @@ Every notification is scoped to a company, and which field supplies it depends o
|
|
|
169
189
|
The field is only *required* when the relevant party belongs to more than one company (single-company members are auto-inferred), but passing the company id when you have it (e.g. `id.companyId` from `getSessionIdentity()`) is always safe.
|
|
170
190
|
|
|
171
191
|
```ts
|
|
172
|
-
// Multi-company client (sender) notifying internal staff:
|
|
173
|
-
// recipientCompanyId does NOT apply (recipient is internal).
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
recipientInternalUserId: assignedStaffId,
|
|
180
|
-
deliveryTargets: { inProduct: { title: 'Client approved the proposal', ctaParams: { proposalId: proposal.id } } },
|
|
181
|
-
},
|
|
192
|
+
// Multi-company client (sender) notifying internal staff: notifyInternalUsers sets senderId and
|
|
193
|
+
// senderCompanyId from the viewer; recipientCompanyId does NOT apply (recipient is internal).
|
|
194
|
+
await notifyInternalUsers({
|
|
195
|
+
title: 'Client approved the proposal',
|
|
196
|
+
ctaParams: { proposalId: proposal.id },
|
|
197
|
+
body: 'The client approved the proposal.',
|
|
198
|
+
recipientIds: [assignedStaffId],
|
|
182
199
|
});
|
|
183
200
|
```
|
|
184
201
|
|
|
185
202
|
To notify multiple recipients (e.g., several internal users), make one `createNotification` call per recipient. See "Apps with multiple notification types" below.
|
|
186
203
|
|
|
187
|
-
**`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone
|
|
204
|
+
**`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone* from a staff-triggered event, collect all recipients before fanning out, or you'll silently skip later-page recipients (a client-triggered event uses `notifyInternalUsers`, which walks every page itself). `listAll` from `@assembly-js/app-foundation/client` does the walk — import it, don't rewrite it:
|
|
188
205
|
|
|
189
206
|
```ts
|
|
190
207
|
import { listAll } from '@assembly-js/app-foundation/client';
|
|
@@ -365,17 +382,15 @@ Either way, the clear is one foundation call — `clearClientNotifications({ ass
|
|
|
365
382
|
|
|
366
383
|
Each notification type is its own `createNotification` call with its own `ctaParams` shape.
|
|
367
384
|
|
|
368
|
-
For an event that needs multiple recipients (e.g., a submission notifies the assigned staff member and the requesting client),
|
|
385
|
+
For an event that needs multiple recipients (e.g., a submission notifies the assigned staff member and the requesting client), send one notification per recipient; when a client triggers it, the staff one goes through `notifyInternalUsers` (the viewer client cannot create it). Each call can have different `ctaParams` — internal users typically want a filter scoped to the client; clients want a deep link to their own record. A fixed pair like this stays under the 5-concurrent cap, so a plain `Promise.all` is fine; reach for `sendThrottled` only when the count is driven by a list.
|
|
369
386
|
|
|
370
387
|
```ts
|
|
371
388
|
await Promise.all([
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
} },
|
|
378
|
-
} }),
|
|
389
|
+
notifyInternalUsers({
|
|
390
|
+
title: 'New submission', body: `${clientName} submitted ${formName}.`,
|
|
391
|
+
ctaParams: { submissionId: submission.id, view: 'review' },
|
|
392
|
+
recipientIds: [assignedStaffId],
|
|
393
|
+
}),
|
|
379
394
|
assembly.createNotification({ requestBody: {
|
|
380
395
|
senderId: clientId, recipientClientId: clientId,
|
|
381
396
|
deliveryTargets: { inProduct: {
|
|
@@ -388,15 +403,17 @@ await Promise.all([
|
|
|
388
403
|
|
|
389
404
|
### Fanning out to internal users with Client Access
|
|
390
405
|
|
|
391
|
-
For a client- or company-scoped event whose audience is "all internal users with access," filter yourself — notifying a user about a client they can't access violates the permission model and may leak client info. Users with `isClientAccessLimited: false` see everyone; those with `true` see only clients whose company is in their `companyAccessList`. Every client has a company (real or placeholder), so the company check covers all clients.
|
|
406
|
+
For a client- or company-scoped event whose audience is "all internal users with access," filter yourself, then pass the result to `notifyInternalUsers` as `recipientIds` — notifying a user about a client they can't access violates the permission model and may leak client info. Users with `isClientAccessLimited: false` see everyone; those with `true` see only clients whose company is in their `companyAccessList`. Every client has a company (real or placeholder), so the company check covers all clients.
|
|
392
407
|
|
|
393
408
|
```ts
|
|
394
409
|
const { companyIds = [] } = await assembly.retrieveClient({ id: clientId });
|
|
395
410
|
|
|
411
|
+
// A client viewer cannot list staff, so list with the installer client.
|
|
412
|
+
const installer = await getInstallerAssembly();
|
|
396
413
|
const internalUsers = [];
|
|
397
414
|
let nextToken: string | undefined;
|
|
398
415
|
do {
|
|
399
|
-
const page = await
|
|
416
|
+
const page = await installer.listInternalUsers({ nextToken });
|
|
400
417
|
internalUsers.push(...(page.data ?? []));
|
|
401
418
|
nextToken = page.nextToken ?? undefined;
|
|
402
419
|
} while (nextToken);
|
|
@@ -409,16 +426,12 @@ const recipients = internalUsers.filter(
|
|
|
409
426
|
);
|
|
410
427
|
// Add `.filter((u) => u.role === 'admin')` when the plan is admins/staff only.
|
|
411
428
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
},
|
|
419
|
-
}),
|
|
420
|
-
);
|
|
421
|
-
logFailures(results, 'notifications');
|
|
429
|
+
await notifyInternalUsers({
|
|
430
|
+
title: 'New submission',
|
|
431
|
+
body: 'A client submitted a new request.',
|
|
432
|
+
ctaParams: { submissionId: submission.id },
|
|
433
|
+
recipientIds: recipients.map((u) => u.id),
|
|
434
|
+
});
|
|
422
435
|
```
|
|
423
436
|
|
|
424
437
|
A company-scoped event uses `companyId` directly — same filter, same shape. Workspace-wide notifications (tied to no client or company) go to all internal users, no filter.
|