@assembly-js/app-foundation 0.3.2 → 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 CHANGED
@@ -19,6 +19,10 @@ 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
+
22
26
  ## 0.3.2
23
27
  - [new] The `assembly-api-contract` skill teaches the build agent to write and maintain `.assembly/openapi.json` alongside `app/api` routes.
24
28
 
@@ -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',
@@ -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 {};
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -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: senderCompanyId required,
173
- // recipientCompanyId does NOT apply (recipient is internal).
174
- const id = await getSessionIdentity();
175
- await assembly.createNotification({
176
- requestBody: {
177
- senderId: id?.clientId,
178
- senderCompanyId: id?.companyId, // the company the client is acting under
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*, collect all recipients before fanning out, or you'll silently skip later-page recipients. `listAll` from `@assembly-js/app-foundation/client` does the walk — import it, don't rewrite it:
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), make one `createNotification` call per recipient. 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.
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
- assembly.createNotification({ requestBody: {
373
- senderId: clientId, recipientInternalUserId: assignedStaffId,
374
- deliveryTargets: { inProduct: {
375
- title: 'New submission', body: `${clientName} submitted ${formName}.`,
376
- ctaParams: { submissionId: submission.id, view: 'review' },
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 assembly.listInternalUsers({ nextToken });
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
- const results = await sendThrottled(recipients, (u) =>
413
- assembly.createNotification({
414
- requestBody: {
415
- senderId: clientId,
416
- recipientInternalUserId: u.id,
417
- deliveryTargets: { inProduct: { title: 'New submission', ctaParams: { submissionId: submission.id } } },
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.