@colixsystems/widget-sdk 0.264.0 → 0.266.0

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/README.md CHANGED
@@ -69,7 +69,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
69
69
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
70
70
  | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset, update }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()` and `update()`, also `users.write:*`; `remove()` also `users.delete:*`) |
71
71
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
72
- | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, q?, app_user_id?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| open \| all` (endpoint default `all`; `open` = `pending` or `expired`); `q` matches the invite's name or email, `app_user_id` returns the invites reserving that user id. |
72
+ | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, q?, app_user_id?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| not_sent \| open \| all` (endpoint default `all`; `open` = `pending`, `not_sent` or `expired`); `q` matches the invite's name or email, `app_user_id` returns the invites reserving that user id. `resend(inviteId)` sends the invitation — the first send of a `not_sent` one included. |
73
73
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
74
74
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: spaceType, folderId) | `{ openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.renderPdf` + `ctx.device.pdfViewer`, `ctx.filestore.files.exportPdf` — `files.write:*`. `openPdf(html, { fileName? })` renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. `exportToPdf(html, { fileName?, folderId? })` renders it and saves it as a file (`application/pdf`). Same server-side renderer on web + native. |
75
75
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
@@ -86,7 +86,32 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
86
86
 
87
87
  ## Status
88
88
 
89
- `v0.264.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
89
+ `v0.266.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
90
+
91
+ ### What's new in 0.266.0 (contract 1.216.0)
92
+
93
+ **A widget can prepare an invitation now and send it later, again.** 0.194.0 withdrew this; it is back with the same shape. Without it `useUsers().invite()` emails the invitation the moment it is created, so a widget could not line people up ahead of a launch day or let a second person check the list first. `invite()` now takes an optional `send_invitation`:
94
+
95
+ ```js
96
+ const { invite } = useUsers();
97
+ const { invites, resend } = useInvites({ status: "not_sent" });
98
+
99
+ const prepared = await invite({ email: "bo@example.com", name: "Bo", send_invitation: false });
100
+ // prepared.status === "not_sent", prepared.sent_at === null — nobody was emailed.
101
+
102
+ await resend(prepared.id); // emails it; the row is now "pending" with a fresh 7-day expiry
103
+ ```
104
+
105
+ - **`send_invitation`** defaults to `true`, so an existing `invite({ email, name, group_ids })` call emails at once exactly as before. `false` stores the invitation and emails nobody. A value that is not a boolean rejects with a `DirectoryError` coded `VALIDATION`.
106
+ - **`not_sent` joins the invite `status` values**, and every invite row carries **`sent_at`** (`null` until the first send). A `not_sent` invitation cannot be accepted yet and its `expires_at` means nothing until it is sent, so show no expiry for it. `useInvites({ status: "not_sent" })` lists just the prepared ones. Where several states apply the first of accepted, revoked, not_sent, expired, pending wins.
107
+ - **`useInvites().resend(inviteId)` is the one call that sends**, whether or not the invitation was sent before. There is no separate `send` method: on a `not_sent` row it is the first send, on a `pending` or `expired` one it sends again, and either way it stamps `sent_at`, restarts the expiry and resolves the row as `pending`. Label the button to match the row — "Send invitation" when `status === "not_sent"`, "Resend" otherwise.
108
+ - **A widget that switches on `status` should handle the new value.** A `not_sent` row falls through an `if (status === "pending")` check, so a list that offers its actions only to pending rows would leave a prepared invitation with no way to send it.
109
+
110
+ The built-in User Management widget offers the choice on its invite form ("Send the invitation later", off by default) and a "Send invitation" action on each row that has not been sent. No new scope: both calls sit under `users.write:*`, as before.
111
+
112
+ ### What's new in 0.265.0 (contract 1.215.0)
113
+
114
+ - **`usePdfExport` appends stored PDFs.** `openPdf(html, { appendFileIds })` and `exportToPdf(html, { appendFileIds })` take up to 20 Filestore file ids; every page of each PDF follows the rendered HTML, in order, on the web Player and in the native export alike (the merge runs server-side). An id the user cannot download, or a file that is not a readable PDF (password-protected, malformed), rejects the call with `err.details.file_id` naming it: 404 `FILE_NOT_FOUND`, 403 `FILE_DOWNLOAD_NOT_PERMITTED`, 422 `PDF_NOT_MERGEABLE`.
90
115
 
91
116
  ### What's new in 0.264.0 (contract 1.214.0)
92
117
 
@@ -1307,7 +1332,7 @@ An installed widget is not rebuilt or republished for this. The web Player hands
1307
1332
 
1308
1333
  ### What's new in 0.183.0 (contract 1.144.0)
1309
1334
 
1310
- Send-later invitations (`send_invitation`, the `not_sent` status, `sent_at`). Withdrawn in 0.194.0.
1335
+ Send-later invitations (`send_invitation`, the `not_sent` status, `sent_at`). Withdrawn in 0.194.0, reinstated in 0.266.0.
1311
1336
 
1312
1337
  ### What's new in 0.182.0 (contract 1.143.0)
1313
1338
 
@@ -3345,11 +3370,13 @@ The matching manifest declares the scopes:
3345
3370
  }
3346
3371
  ```
3347
3372
 
3373
+ `invite()` emails the invitation at once. Pass `send_invitation: false` to store it without emailing anyone — the row comes back with `status: "not_sent"` and `sent_at: null` — and send it later with `useInvites().resend(inviteId)`.
3374
+
3348
3375
  The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
3349
3376
 
3350
3377
  ## Listing pending invitations from a widget
3351
3378
 
3352
- `useInvites(query?)` lists the workspace's invites and resends / revokes them:
3379
+ `useInvites(query?)` lists the workspace's invites and sends / revokes them:
3353
3380
 
3354
3381
  ```jsx
3355
3382
  import { useInvites, View, Text, Pressable } from '@colixsystems/widget-sdk';
@@ -3364,7 +3391,9 @@ function PendingInvites() {
3364
3391
  {invites.map((i) => (
3365
3392
  <View key={i.id}>
3366
3393
  <Text>{i.email} · {i.status}</Text>
3367
- <Pressable onPress={() => resend(i.id).catch(() => {})}><Text>Resend</Text></Pressable>
3394
+ <Pressable onPress={() => resend(i.id).catch(() => {})}>
3395
+ <Text>{i.status === 'not_sent' ? 'Send invitation' : 'Resend'}</Text>
3396
+ </Pressable>
3368
3397
  <Pressable onPress={() => revoke(i.id).catch(() => {})}><Text>Revoke</Text></Pressable>
3369
3398
  </View>
3370
3399
  ))}
@@ -3373,9 +3402,16 @@ function PendingInvites() {
3373
3402
  }
3374
3403
  ```
3375
3404
 
3376
- Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, expires_at,
3377
- accepted_at, revoked_at, created_at }`. Trust the server-computed `status` rather than
3378
- comparing `expires_at` against the device clock.
3405
+ Rows are snake_case: `{ id, app_user_id, email, name, group_ids, status, sent_at,
3406
+ expires_at, accepted_at, revoked_at, created_at }`. `status` is one of `pending`,
3407
+ `accepted`, `revoked`, `expired` or `not_sent`; trust the server-computed value rather
3408
+ than comparing `expires_at` against the device clock.
3409
+
3410
+ A `not_sent` row is an invitation created with `send_invitation: false`: nobody has
3411
+ been emailed and `sent_at` is `null`. `resend(inviteId)` is the one call that sends
3412
+ an invitation, a first send and a repeat send alike — it emails the invitee, stamps
3413
+ `sent_at`, restarts the 7-day expiry and resolves the row as `pending`. Pass
3414
+ `{ status: 'not_sent' }` to list only the prepared ones.
3379
3415
 
3380
3416
  `query` also takes `q` (a case-insensitive substring match on the invite's name or
3381
3417
  email) and `app_user_id`. Each row's `app_user_id` is the id the invitee's user account
@@ -3383,7 +3419,7 @@ will have once they accept. A `USER` column may hold that id before the person h
3383
3419
  joined — it then reads back as `{ id, name, invited: true }`, with no `email` — and
3384
3420
  `useInvites({ app_user_id: value.id, status: 'open', limit: 1 })` returns the invite
3385
3421
  behind it. `status: 'open'` is every invite that is neither accepted nor revoked
3386
- (`pending` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
3422
+ (`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
3387
3423
 
3388
3424
  **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
3389
3425
  someone who is not a member yet, so the backend gates the entire invite surface —
package/dist/contract.cjs CHANGED
@@ -1539,7 +1539,7 @@ const HOOKS = [
1539
1539
  optionalArgs: ["spaceType", "folderId"],
1540
1540
  description:
1541
1541
  "Render an HTML string to a PDF server-side, then either OPEN it or SAVE " +
1542
- "it. `openPdf(html, { fileName? })` is THE way to give the user a PDF " +
1542
+ "it. `openPdf(html, { fileName?, appendFileIds? })` is THE way to give the user a PDF " +
1543
1543
  "to view, print, download or share: it renders the HTML and opens the " +
1544
1544
  "result — the browser's PDF viewer in a new tab on the web Player, the " +
1545
1545
  "OS print / share sheet in the native export — and SAVES NOTHING, so " +
@@ -1555,19 +1555,27 @@ const HOOKS = [
1555
1555
  "the end-user's Filestore space. The widget passes the SPACE " +
1556
1556
  "(`{ spaceType, folderId? }`); the hook resolves owner_id from the host " +
1557
1557
  "context and POSTs JSON through ctx.filestore.files.exportPdf. " +
1558
- "`exportToPdf(html, { fileName?, folderId? })` resolves to the created " +
1558
+ "`exportToPdf(html, { fileName?, folderId?, appendFileIds? })` resolves to the created " +
1559
1559
  "file row (mime_type `application/pdf`) or throws the wire error; " +
1560
1560
  "`err.status` 403/404 means the destination denied the add (403 " +
1561
1561
  "when the caller can see the position, 404 when it cannot), a 413 " +
1562
1562
  "that the rendered " +
1563
1563
  "PDF exceeded the size cap. The HTML is rendered by the host's PDF " +
1564
1564
  "service (headless Chromium) — the SAME server-side pipeline on the web " +
1565
- "Player and the native export, so no browser-only PDF library is used.",
1565
+ "Player and the native export, so no browser-only PDF library is used. " +
1566
+ "To put a record's attached PDFs INSIDE the document, pass their Filestore " +
1567
+ "file ids as `appendFileIds` (at most 20) to either call: every page of " +
1568
+ "each file follows the rendered HTML, in the order given. Do not try to " +
1569
+ "embed a PDF through an <img>, <iframe> or link. Each id must be a PDF the " +
1570
+ "user may download, or the call rejects naming it in `err.details.file_id`: " +
1571
+ "404 FILE_NOT_FOUND, 403 FILE_DOWNLOAD_NOT_PERMITTED, or 422 " +
1572
+ "PDF_NOT_MERGEABLE (not a PDF, password-protected, or unreadable). On a " +
1573
+ "422, drop that id and call again so the rest of the report still opens.",
1566
1574
  returnShape: {
1567
- openPdf: "(html, { fileName? }) => Promise<void>",
1575
+ openPdf: "(html, { fileName?, appendFileIds? }) => Promise<void>",
1568
1576
  opening: "boolean",
1569
1577
  canOpenPdf: "boolean",
1570
- exportToPdf: "(html, { fileName?, folderId? }) => Promise<FilestoreFile>",
1578
+ exportToPdf: "(html, { fileName?, folderId?, appendFileIds? }) => Promise<FilestoreFile>",
1571
1579
  exporting: "boolean",
1572
1580
  error: "Error | null",
1573
1581
  lastExported: "FilestoreFile | null",
@@ -2353,8 +2361,9 @@ const HOOKS = [
2353
2361
  // deactivate / reactivate / sendPasswordReset / update) additionally need
2354
2362
  // `users.write:*`, and the
2355
2363
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
2356
- // call accepts `{ email, name, groupIds? }` and returns the resulting
2357
- // AppUserInvite row (the email is sent by the host). Mutating users from
2364
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
2365
+ // the resulting AppUserInvite row (the host sends the email, unless
2366
+ // `send_invitation` is false). Mutating users from
2358
2367
  // a widget requires the corresponding SystemAcl capability grant in the
2359
2368
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
2360
2369
  // that only call read methods need only `users.read:*`.
@@ -2374,8 +2383,13 @@ const HOOKS = [
2374
2383
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
2375
2384
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
2376
2385
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
2377
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
2378
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
2386
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
2387
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
2388
+ "The host emails the invitation at once unless send_invitation is " +
2389
+ "false: then nothing is emailed, the row comes back with sent_at null " +
2390
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
2391
+ "later. A send_invitation that is not a boolean rejects with " +
2392
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
2379
2393
  "standard self-serve reset link to that user's own registered address " +
2380
2394
  "and resolves { sent, email_masked } — never the token or the link, so " +
2381
2395
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -2414,7 +2428,7 @@ const HOOKS = [
2414
2428
  error: "DirectoryError | null // { code, reason, message, retryable }",
2415
2429
  refetch: "() => Promise<void>",
2416
2430
  invite:
2417
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
2431
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
2418
2432
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2419
2433
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2420
2434
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -2632,7 +2646,8 @@ const HOOKS = [
2632
2646
  // sc-5097 — pending AppUser invite administration. Returns
2633
2647
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
2634
2648
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
2635
- // caller's `users.write` SystemAcl capability.
2649
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
2650
+ // the first send of a `not_sent` invite — there is no separate `send`.
2636
2651
  {
2637
2652
  name: "useInvites",
2638
2653
  signature: "useInvites(query)",
@@ -2642,13 +2657,23 @@ const HOOKS = [
2642
2657
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
2643
2658
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
2644
2659
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
2645
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
2646
- "status is the server-computed pending/accepted/revoked/expired " +
2647
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2660
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
2661
+ "and status is the server-computed pending/accepted/revoked/expired/" +
2662
+ "not_sent value. not_sent is an invitation created with " +
2663
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
2664
+ "null, it cannot be accepted until it is sent, and its expires_at " +
2665
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
2666
+ "ONE call that sends an invitation — the first send of a not_sent one " +
2667
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
2668
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
2669
+ "'pending'. There is no separate send method; label the button " +
2670
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
2671
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2648
2672
  "verbatim; the endpoint defaults to status 'all', so pass " +
2649
- "{ status: 'pending' } for the usual outstanding-invite list; " +
2650
- "status 'open' is every invite neither accepted nor revoked (pending " +
2651
- "or expired). q is a " +
2673
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
2674
+ "{ status: 'not_sent' } for the prepared ones; " +
2675
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
2676
+ "not_sent or expired). q is a " +
2652
2677
  "case-insensitive substring match on the invite's name or email. " +
2653
2678
  "Each row's app_user_id is the id the invitee's user account will " +
2654
2679
  "have once they accept; a USER column may already hold it, and " +
@@ -2665,12 +2690,12 @@ const HOOKS = [
2665
2690
  "refetch on success.",
2666
2691
  returnShape: {
2667
2692
  invites:
2668
- "Array<{ id, app_user_id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
2693
+ "Array<{ id, app_user_id, email, name, group_ids, status, sent_at, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; status is pending | accepted | revoked | expired | not_sent; unwrapped from { data, meta }",
2669
2694
  loading: "boolean",
2670
2695
  error: "DirectoryError | null // { code, message, retryable }",
2671
2696
  refetch: "() => Promise<void>",
2672
2697
  resend:
2673
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
2698
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
2674
2699
  revoke:
2675
2700
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
2676
2701
  },
@@ -4164,7 +4189,7 @@ const WIDGET_CONTEXT_SHAPE = {
4164
4189
  "invites: { list(), revoke(id), resend(id) }, " +
4165
4190
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
4166
4191
  "presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
4167
- "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
4192
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
4168
4193
  required: true,
4169
4194
  fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
4170
4195
  },
@@ -6435,7 +6460,13 @@ const CONTRACT = deepFreeze({
6435
6460
  // 1.214.0: additive (sc-13746) — a datastoreTemplate column may be a
6436
6461
  // `UUID` (validated, stored lowercase); `"@uuid"` defaults it to a new
6437
6462
  // random id per row and may be `defaultLocked`.
6438
- version: "1.214.0",
6463
+ // 1.215.0: additive (sc-13938) — `usePdfExport` `openPdf` / `exportToPdf`
6464
+ // take `appendFileIds`: stored PDFs whose pages follow the rendered HTML.
6465
+ // 1.216.0: additive (sc-14096) — reinstates 1.144.0, withdrawn in 1.155.0:
6466
+ // `useUsers().invite` takes `send_invitation` (false stores the invite
6467
+ // unsent), invite rows carry `sent_at`, `not_sent` is a status, and
6468
+ // `useInvites().resend` performs the first send.
6469
+ version: "1.216.0",
6439
6470
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6440
6471
  hooks: HOOKS,
6441
6472
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1536,7 +1536,7 @@ const HOOKS = [
1536
1536
  optionalArgs: ["spaceType", "folderId"],
1537
1537
  description:
1538
1538
  "Render an HTML string to a PDF server-side, then either OPEN it or SAVE " +
1539
- "it. `openPdf(html, { fileName? })` is THE way to give the user a PDF " +
1539
+ "it. `openPdf(html, { fileName?, appendFileIds? })` is THE way to give the user a PDF " +
1540
1540
  "to view, print, download or share: it renders the HTML and opens the " +
1541
1541
  "result — the browser's PDF viewer in a new tab on the web Player, the " +
1542
1542
  "OS print / share sheet in the native export — and SAVES NOTHING, so " +
@@ -1552,19 +1552,27 @@ const HOOKS = [
1552
1552
  "the end-user's Filestore space. The widget passes the SPACE " +
1553
1553
  "(`{ spaceType, folderId? }`); the hook resolves owner_id from the host " +
1554
1554
  "context and POSTs JSON through ctx.filestore.files.exportPdf. " +
1555
- "`exportToPdf(html, { fileName?, folderId? })` resolves to the created " +
1555
+ "`exportToPdf(html, { fileName?, folderId?, appendFileIds? })` resolves to the created " +
1556
1556
  "file row (mime_type `application/pdf`) or throws the wire error; " +
1557
1557
  "`err.status` 403/404 means the destination denied the add (403 " +
1558
1558
  "when the caller can see the position, 404 when it cannot), a 413 " +
1559
1559
  "that the rendered " +
1560
1560
  "PDF exceeded the size cap. The HTML is rendered by the host's PDF " +
1561
1561
  "service (headless Chromium) — the SAME server-side pipeline on the web " +
1562
- "Player and the native export, so no browser-only PDF library is used.",
1562
+ "Player and the native export, so no browser-only PDF library is used. " +
1563
+ "To put a record's attached PDFs INSIDE the document, pass their Filestore " +
1564
+ "file ids as `appendFileIds` (at most 20) to either call: every page of " +
1565
+ "each file follows the rendered HTML, in the order given. Do not try to " +
1566
+ "embed a PDF through an <img>, <iframe> or link. Each id must be a PDF the " +
1567
+ "user may download, or the call rejects naming it in `err.details.file_id`: " +
1568
+ "404 FILE_NOT_FOUND, 403 FILE_DOWNLOAD_NOT_PERMITTED, or 422 " +
1569
+ "PDF_NOT_MERGEABLE (not a PDF, password-protected, or unreadable). On a " +
1570
+ "422, drop that id and call again so the rest of the report still opens.",
1563
1571
  returnShape: {
1564
- openPdf: "(html, { fileName? }) => Promise<void>",
1572
+ openPdf: "(html, { fileName?, appendFileIds? }) => Promise<void>",
1565
1573
  opening: "boolean",
1566
1574
  canOpenPdf: "boolean",
1567
- exportToPdf: "(html, { fileName?, folderId? }) => Promise<FilestoreFile>",
1575
+ exportToPdf: "(html, { fileName?, folderId?, appendFileIds? }) => Promise<FilestoreFile>",
1568
1576
  exporting: "boolean",
1569
1577
  error: "Error | null",
1570
1578
  lastExported: "FilestoreFile | null",
@@ -2350,8 +2358,9 @@ const HOOKS = [
2350
2358
  // deactivate / reactivate / sendPasswordReset / update) additionally need
2351
2359
  // `users.write:*`, and the
2352
2360
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
2353
- // call accepts `{ email, name, groupIds? }` and returns the resulting
2354
- // AppUserInvite row (the email is sent by the host). Mutating users from
2361
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
2362
+ // the resulting AppUserInvite row (the host sends the email, unless
2363
+ // `send_invitation` is false). Mutating users from
2355
2364
  // a widget requires the corresponding SystemAcl capability grant in the
2356
2365
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
2357
2366
  // that only call read methods need only `users.read:*`.
@@ -2371,8 +2380,13 @@ const HOOKS = [
2371
2380
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
2372
2381
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
2373
2382
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
2374
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
2375
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
2383
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
2384
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
2385
+ "The host emails the invitation at once unless send_invitation is " +
2386
+ "false: then nothing is emailed, the row comes back with sent_at null " +
2387
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
2388
+ "later. A send_invitation that is not a boolean rejects with " +
2389
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
2376
2390
  "standard self-serve reset link to that user's own registered address " +
2377
2391
  "and resolves { sent, email_masked } — never the token or the link, so " +
2378
2392
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -2411,7 +2425,7 @@ const HOOKS = [
2411
2425
  error: "DirectoryError | null // { code, reason, message, retryable }",
2412
2426
  refetch: "() => Promise<void>",
2413
2427
  invite:
2414
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
2428
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
2415
2429
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2416
2430
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2417
2431
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -2629,7 +2643,8 @@ const HOOKS = [
2629
2643
  // sc-5097 — pending AppUser invite administration. Returns
2630
2644
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
2631
2645
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
2632
- // caller's `users.write` SystemAcl capability.
2646
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
2647
+ // the first send of a `not_sent` invite — there is no separate `send`.
2633
2648
  {
2634
2649
  name: "useInvites",
2635
2650
  signature: "useInvites(query)",
@@ -2639,13 +2654,23 @@ const HOOKS = [
2639
2654
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
2640
2655
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
2641
2656
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
2642
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
2643
- "status is the server-computed pending/accepted/revoked/expired " +
2644
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2657
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
2658
+ "and status is the server-computed pending/accepted/revoked/expired/" +
2659
+ "not_sent value. not_sent is an invitation created with " +
2660
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
2661
+ "null, it cannot be accepted until it is sent, and its expires_at " +
2662
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
2663
+ "ONE call that sends an invitation — the first send of a not_sent one " +
2664
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
2665
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
2666
+ "'pending'. There is no separate send method; label the button " +
2667
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
2668
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2645
2669
  "verbatim; the endpoint defaults to status 'all', so pass " +
2646
- "{ status: 'pending' } for the usual outstanding-invite list; " +
2647
- "status 'open' is every invite neither accepted nor revoked (pending " +
2648
- "or expired). q is a " +
2670
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
2671
+ "{ status: 'not_sent' } for the prepared ones; " +
2672
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
2673
+ "not_sent or expired). q is a " +
2649
2674
  "case-insensitive substring match on the invite's name or email. " +
2650
2675
  "Each row's app_user_id is the id the invitee's user account will " +
2651
2676
  "have once they accept; a USER column may already hold it, and " +
@@ -2662,12 +2687,12 @@ const HOOKS = [
2662
2687
  "refetch on success.",
2663
2688
  returnShape: {
2664
2689
  invites:
2665
- "Array<{ id, app_user_id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
2690
+ "Array<{ id, app_user_id, email, name, group_ids, status, sent_at, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; status is pending | accepted | revoked | expired | not_sent; unwrapped from { data, meta }",
2666
2691
  loading: "boolean",
2667
2692
  error: "DirectoryError | null // { code, message, retryable }",
2668
2693
  refetch: "() => Promise<void>",
2669
2694
  resend:
2670
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
2695
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
2671
2696
  revoke:
2672
2697
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
2673
2698
  },
@@ -4161,7 +4186,7 @@ const WIDGET_CONTEXT_SHAPE = {
4161
4186
  "invites: { list(), revoke(id), resend(id) }, " +
4162
4187
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
4163
4188
  "presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
4164
- "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
4189
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
4165
4190
  required: true,
4166
4191
  fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
4167
4192
  },
@@ -6432,7 +6457,13 @@ const CONTRACT = deepFreeze({
6432
6457
  // 1.214.0: additive (sc-13746) — a datastoreTemplate column may be a
6433
6458
  // `UUID` (validated, stored lowercase); `"@uuid"` defaults it to a new
6434
6459
  // random id per row and may be `defaultLocked`.
6435
- version: "1.214.0",
6460
+ // 1.215.0: additive (sc-13938) — `usePdfExport` `openPdf` / `exportToPdf`
6461
+ // take `appendFileIds`: stored PDFs whose pages follow the rendered HTML.
6462
+ // 1.216.0: additive (sc-14096) — reinstates 1.144.0, withdrawn in 1.155.0:
6463
+ // `useUsers().invite` takes `send_invitation` (false stores the invite
6464
+ // unsent), invite rows carry `sent_at`, `not_sent` is a status, and
6465
+ // `useInvites().resend` performs the first send.
6466
+ version: "1.216.0",
6436
6467
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6437
6468
  hooks: HOOKS,
6438
6469
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -5171,7 +5171,7 @@ export function usePdfExport(options) {
5171
5171
  try {
5172
5172
  await viewer.open({
5173
5173
  fileName: _pdfFileName(overrides && overrides.fileName),
5174
- load: () => files.renderPdf({ html }),
5174
+ load: () => files.renderPdf(_pdfRenderBody(html, overrides)),
5175
5175
  });
5176
5176
  } catch (err) {
5177
5177
  setError(err);
@@ -5204,6 +5204,7 @@ export function usePdfExport(options) {
5204
5204
  };
5205
5205
  if (folderId) body.folder_id = folderId;
5206
5206
  if (fileName) body.file_name = fileName;
5207
+ Object.assign(body, _pdfRenderBody(html, overrides));
5207
5208
  setExporting(true);
5208
5209
  setError(null);
5209
5210
  try {
@@ -5231,6 +5232,12 @@ export function usePdfExport(options) {
5231
5232
  };
5232
5233
  }
5233
5234
 
5235
+ // `appendFileIds`: stored PDFs whose pages follow the rendered HTML.
5236
+ function _pdfRenderBody(html, overrides) {
5237
+ const ids = overrides && overrides.appendFileIds;
5238
+ return Array.isArray(ids) && ids.length > 0 ? { html, append_file_ids: ids } : { html };
5239
+ }
5240
+
5234
5241
  function _canOpenPdf(viewer) {
5235
5242
  return Boolean(
5236
5243
  viewer &&
@@ -5970,7 +5977,7 @@ export function useDirectory(query) {
5970
5977
  * invite, deactivate, reactivate, sendPasswordReset, update}` — `list` resolves to the `{ data, meta }`
5971
5978
  * envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
5972
5979
  * snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
5973
- * (e.g. `{ email, name, group_ids? }`) pass through verbatim. `query` is an
5980
+ * (e.g. `{ email, name, group_ids?, send_invitation? }`) pass through verbatim. `query` is an
5974
5981
  * optional `{ q?, role?, is_active?, group_id?, limit?, offset? }`; `group_id`
5975
5982
  * (the `id` of a `useGroups()` row) narrows the list to that group's members.
5976
5983
  * The list refetches whenever `JSON.stringify(query)` changes; the imperative methods
@@ -6221,15 +6228,20 @@ export function useGroups(query) {
6221
6228
  * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
6222
6229
  *
6223
6230
  * Rows are snake_case exactly as the backend projects them: `{ id, tenant_id,
6224
- * email, name, group_ids, status, expires_at, accepted_at, revoked_at,
6225
- * created_at, invited_by_studio_user_id }`. `status` is the server-computed
6226
- * lifecycle value (`"pending" | "accepted" | "revoked" | "expired"`) — prefer
6227
- * it over re-deriving expiry on the client, whose clock may disagree.
6231
+ * email, name, group_ids, status, sent_at, expires_at, accepted_at,
6232
+ * revoked_at, created_at, invited_by_studio_user_id }`. `status` is the
6233
+ * server-computed lifecycle value (`"pending" | "accepted" | "revoked" |
6234
+ * "expired" | "not_sent"`) — prefer it over re-deriving expiry on the client,
6235
+ * whose clock may disagree.
6236
+ *
6237
+ * `not_sent` is an invite created with `send_invitation: false`: `sent_at` is
6238
+ * null and nothing was emailed. `resend(inviteId)` is the one call that sends
6239
+ * — a first send and a repeat send alike — and resolves the row as `pending`.
6228
6240
  *
6229
6241
  * `query` is an optional `{ status?, limit?, offset? }` passed through
6230
6242
  * verbatim; `status` accepts `"pending" | "accepted" | "revoked" | "expired" |
6231
- * "all"` and the endpoint defaults to `"all"`, so pass `{ status: "pending" }`
6232
- * for the usual "who hasn't accepted yet" list.
6243
+ * "not_sent" | "all"` and the endpoint defaults to `"all"`, so pass
6244
+ * `{ status: "pending" }` for the usual "who hasn't accepted yet" list.
6233
6245
  *
6234
6246
  * The WHOLE invite surface — list included — is gated on the `users.write`
6235
6247
  * capability plus a signed `users.write:*` scope: a pending invite exposes
@@ -6296,7 +6308,8 @@ export function useInvites(query) {
6296
6308
  }, [doFetch]);
6297
6309
 
6298
6310
  // Both mutations re-list on success: revoking drops the row and resending
6299
- // moves `expires_at`, so a stale list would show the operator the old state.
6311
+ // moves `expires_at` (and a first send's status), so a stale list would
6312
+ // show the operator the old state.
6300
6313
  const resend = useCallback(
6301
6314
  async (inviteId) => {
6302
6315
  try {
package/dist/index.d.ts CHANGED
@@ -3289,13 +3289,25 @@ export interface InviteArgs {
3289
3289
  email: string;
3290
3290
  name?: string;
3291
3291
  group_ids?: string[];
3292
+ /**
3293
+ * Defaults to `true`: the invitation is emailed at once. `false` stores it
3294
+ * without emailing anyone (`status: "not_sent"`); send it later with
3295
+ * `useInvites().resend(inviteId)`.
3296
+ */
3297
+ send_invitation?: boolean;
3292
3298
  }
3293
3299
 
3300
+ /**
3301
+ * When several apply the first of accepted, revoked, not_sent, expired,
3302
+ * pending wins. `not_sent` is an invitation created with
3303
+ * `send_invitation: false` that has not been emailed yet.
3304
+ */
3294
3305
  export type AppUserInviteStatus =
3295
3306
  | "pending"
3296
3307
  | "accepted"
3297
3308
  | "revoked"
3298
- | "expired";
3309
+ | "expired"
3310
+ | "not_sent";
3299
3311
 
3300
3312
  export interface AppUserInviteRow {
3301
3313
  id: string;
@@ -3310,7 +3322,9 @@ export interface AppUserInviteRow {
3310
3322
  status: AppUserInviteStatus;
3311
3323
  name?: string;
3312
3324
  group_ids?: string[];
3313
- invited_at?: string;
3325
+ /** When the invitation email last left; `null` until it is first sent. */
3326
+ sent_at: string | null;
3327
+ /** Meaningless while `status` is `"not_sent"` — it restarts on the send. */
3314
3328
  expires_at?: string;
3315
3329
  accepted_at?: string | null;
3316
3330
  revoked_at?: string | null;
@@ -3410,8 +3424,9 @@ export function useGroups(query?: GroupsQuery): GroupsApi;
3410
3424
 
3411
3425
  export interface InvitesQuery {
3412
3426
  /**
3413
- * Defaults to "all" server-side; pass "pending" for outstanding invites.
3414
- * "open" is "pending" or "expired": neither accepted nor revoked.
3427
+ * Defaults to "all" server-side; pass "pending" for outstanding invites and
3428
+ * "not_sent" for the ones prepared but not emailed yet.
3429
+ * "open" is "pending", "not_sent" or "expired": neither accepted nor revoked.
3415
3430
  */
3416
3431
  status?: AppUserInviteStatus | "open" | "all";
3417
3432
  /** Case-insensitive substring match on the invite's name or email. */
@@ -3432,7 +3447,12 @@ export interface InvitesApi {
3432
3447
  loading: boolean;
3433
3448
  error: DirectoryError | null;
3434
3449
  refetch(): Promise<void>;
3435
- /** Re-send the invitation email. Refetches on success. */
3450
+ /**
3451
+ * Email the invitation and restart its 7-day expiry. The one call that
3452
+ * sends: the first send of a `not_sent` invitation and a repeat send of a
3453
+ * sent one alike. Resolves the row with `status: "pending"`; refetches on
3454
+ * success.
3455
+ */
3436
3456
  resend(inviteId: string): Promise<AppUserInviteRow>;
3437
3457
  /** Cancel a pending invitation. Refetches on success. */
3438
3458
  revoke(inviteId: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.264.0",
3
+ "version": "0.266.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1536,7 +1536,7 @@ const HOOKS = [
1536
1536
  optionalArgs: ["spaceType", "folderId"],
1537
1537
  description:
1538
1538
  "Render an HTML string to a PDF server-side, then either OPEN it or SAVE " +
1539
- "it. `openPdf(html, { fileName? })` is THE way to give the user a PDF " +
1539
+ "it. `openPdf(html, { fileName?, appendFileIds? })` is THE way to give the user a PDF " +
1540
1540
  "to view, print, download or share: it renders the HTML and opens the " +
1541
1541
  "result — the browser's PDF viewer in a new tab on the web Player, the " +
1542
1542
  "OS print / share sheet in the native export — and SAVES NOTHING, so " +
@@ -1552,19 +1552,27 @@ const HOOKS = [
1552
1552
  "the end-user's Filestore space. The widget passes the SPACE " +
1553
1553
  "(`{ spaceType, folderId? }`); the hook resolves owner_id from the host " +
1554
1554
  "context and POSTs JSON through ctx.filestore.files.exportPdf. " +
1555
- "`exportToPdf(html, { fileName?, folderId? })` resolves to the created " +
1555
+ "`exportToPdf(html, { fileName?, folderId?, appendFileIds? })` resolves to the created " +
1556
1556
  "file row (mime_type `application/pdf`) or throws the wire error; " +
1557
1557
  "`err.status` 403/404 means the destination denied the add (403 " +
1558
1558
  "when the caller can see the position, 404 when it cannot), a 413 " +
1559
1559
  "that the rendered " +
1560
1560
  "PDF exceeded the size cap. The HTML is rendered by the host's PDF " +
1561
1561
  "service (headless Chromium) — the SAME server-side pipeline on the web " +
1562
- "Player and the native export, so no browser-only PDF library is used.",
1562
+ "Player and the native export, so no browser-only PDF library is used. " +
1563
+ "To put a record's attached PDFs INSIDE the document, pass their Filestore " +
1564
+ "file ids as `appendFileIds` (at most 20) to either call: every page of " +
1565
+ "each file follows the rendered HTML, in the order given. Do not try to " +
1566
+ "embed a PDF through an <img>, <iframe> or link. Each id must be a PDF the " +
1567
+ "user may download, or the call rejects naming it in `err.details.file_id`: " +
1568
+ "404 FILE_NOT_FOUND, 403 FILE_DOWNLOAD_NOT_PERMITTED, or 422 " +
1569
+ "PDF_NOT_MERGEABLE (not a PDF, password-protected, or unreadable). On a " +
1570
+ "422, drop that id and call again so the rest of the report still opens.",
1563
1571
  returnShape: {
1564
- openPdf: "(html, { fileName? }) => Promise<void>",
1572
+ openPdf: "(html, { fileName?, appendFileIds? }) => Promise<void>",
1565
1573
  opening: "boolean",
1566
1574
  canOpenPdf: "boolean",
1567
- exportToPdf: "(html, { fileName?, folderId? }) => Promise<FilestoreFile>",
1575
+ exportToPdf: "(html, { fileName?, folderId?, appendFileIds? }) => Promise<FilestoreFile>",
1568
1576
  exporting: "boolean",
1569
1577
  error: "Error | null",
1570
1578
  lastExported: "FilestoreFile | null",
@@ -2350,8 +2358,9 @@ const HOOKS = [
2350
2358
  // deactivate / reactivate / sendPasswordReset / update) additionally need
2351
2359
  // `users.write:*`, and the
2352
2360
  // destructive `remove` needs `users.delete:*` (SC-902). The `invite`
2353
- // call accepts `{ email, name, groupIds? }` and returns the resulting
2354
- // AppUserInvite row (the email is sent by the host). Mutating users from
2361
+ // call accepts `{ email, name, group_ids?, send_invitation? }` and returns
2362
+ // the resulting AppUserInvite row (the host sends the email, unless
2363
+ // `send_invitation` is false). Mutating users from
2355
2364
  // a widget requires the corresponding SystemAcl capability grant in the
2356
2365
  // tenant (`users.write` for edits, `users.delete` for removal); widgets
2357
2366
  // that only call read methods need only `users.read:*`.
@@ -2371,8 +2380,13 @@ const HOOKS = [
2371
2380
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
2372
2381
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
2373
2382
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
2374
- "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
2375
- "(the email is sent by the host). sendPasswordReset(userId) mails the " +
2383
+ "{ email, name, group_ids?, send_invitation? } and returns the resulting " +
2384
+ "AppUserInvite row { id, email, name, expires_at, sent_at, status }. " +
2385
+ "The host emails the invitation at once unless send_invitation is " +
2386
+ "false: then nothing is emailed, the row comes back with sent_at null " +
2387
+ "and status 'not_sent', and useInvites().resend(inviteId) sends it " +
2388
+ "later. A send_invitation that is not a boolean rejects with " +
2389
+ "DirectoryError code VALIDATION. sendPasswordReset(userId) mails the " +
2376
2390
  "standard self-serve reset link to that user's own registered address " +
2377
2391
  "and resolves { sent, email_masked } — never the token or the link, so " +
2378
2392
  "it cannot be used to sign in as them. It rejects with a DirectoryError " +
@@ -2411,7 +2425,7 @@ const HOOKS = [
2411
2425
  error: "DirectoryError | null // { code, reason, message, retryable }",
2412
2426
  refetch: "() => Promise<void>",
2413
2427
  invite:
2414
- "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
2428
+ "({ email, name, group_ids?, send_invitation? }) => Promise<Invite> // send_invitation: false stores it unsent (status 'not_sent'); rejects with DirectoryError",
2415
2429
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2416
2430
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
2417
2431
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
@@ -2629,7 +2643,8 @@ const HOOKS = [
2629
2643
  // sc-5097 — pending AppUser invite administration. Returns
2630
2644
  // `{ invites, loading, error, refetch, resend, revoke }`. Reads need
2631
2645
  // `invites.read:*`; resend/revoke need `invites.write:*` plus the
2632
- // caller's `users.write` SystemAcl capability.
2646
+ // caller's `users.write` SystemAcl capability. sc-9714: `resend` is also
2647
+ // the first send of a `not_sent` invite — there is no separate `send`.
2633
2648
  {
2634
2649
  name: "useInvites",
2635
2650
  signature: "useInvites(query)",
@@ -2639,13 +2654,23 @@ const HOOKS = [
2639
2654
  "directory-client at ctx.directory.invites.{list,resend,revoke}. " +
2640
2655
  "Returns { invites, loading, error, refetch, resend, revoke }. list " +
2641
2656
  "returns the { data, meta } envelope verbatim — the hook unwraps " +
2642
- "res.data; rows are snake_case (email, status, expires_at, …) and " +
2643
- "status is the server-computed pending/accepted/revoked/expired " +
2644
- "value. query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2657
+ "res.data; rows are snake_case (email, status, sent_at, expires_at, …) " +
2658
+ "and status is the server-computed pending/accepted/revoked/expired/" +
2659
+ "not_sent value. not_sent is an invitation created with " +
2660
+ "send_invitation: false that has not been emailed yet: its sent_at is " +
2661
+ "null, it cannot be accepted until it is sent, and its expires_at " +
2662
+ "means nothing yet, so show no expiry for it. resend(inviteId) is the " +
2663
+ "ONE call that sends an invitation — the first send of a not_sent one " +
2664
+ "and a repeat send of a sent one alike: it emails the invitee, stamps " +
2665
+ "sent_at, restarts the 7-day expiry and resolves the row with status " +
2666
+ "'pending'. There is no separate send method; label the button " +
2667
+ "'Send invitation' on a not_sent row and 'Resend' on a pending one. " +
2668
+ "query is { status?, q?, app_user_id?, limit?, offset? } passed " +
2645
2669
  "verbatim; the endpoint defaults to status 'all', so pass " +
2646
- "{ status: 'pending' } for the usual outstanding-invite list; " +
2647
- "status 'open' is every invite neither accepted nor revoked (pending " +
2648
- "or expired). q is a " +
2670
+ "{ status: 'pending' } for the usual outstanding-invite list and " +
2671
+ "{ status: 'not_sent' } for the prepared ones; " +
2672
+ "status 'open' is every invite neither accepted nor revoked (pending, " +
2673
+ "not_sent or expired). q is a " +
2649
2674
  "case-insensitive substring match on the invite's name or email. " +
2650
2675
  "Each row's app_user_id is the id the invitee's user account will " +
2651
2676
  "have once they accept; a USER column may already hold it, and " +
@@ -2662,12 +2687,12 @@ const HOOKS = [
2662
2687
  "refetch on success.",
2663
2688
  returnShape: {
2664
2689
  invites:
2665
- "Array<{ id, app_user_id, email, name, group_ids, status, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; unwrapped from { data, meta }",
2690
+ "Array<{ id, app_user_id, email, name, group_ids, status, sent_at, expires_at, accepted_at, revoked_at, created_at }> // snake_case rows; status is pending | accepted | revoked | expired | not_sent; unwrapped from { data, meta }",
2666
2691
  loading: "boolean",
2667
2692
  error: "DirectoryError | null // { code, message, retryable }",
2668
2693
  refetch: "() => Promise<void>",
2669
2694
  resend:
2670
- "(inviteId) => Promise<Invite> // refetches; rejects with DirectoryError",
2695
+ "(inviteId) => Promise<Invite> // sends it (first send of a not_sent invite too); refetches; rejects with DirectoryError",
2671
2696
  revoke:
2672
2697
  "(inviteId) => Promise<void> // refetches; rejects with DirectoryError",
2673
2698
  },
@@ -4161,7 +4186,7 @@ const WIDGET_CONTEXT_SHAPE = {
4161
4186
  "invites: { list(), revoke(id), resend(id) }, " +
4162
4187
  "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
4163
4188
  "presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
4164
- "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
4189
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke invites — resend also sends one created with send_invitation: false — gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
4165
4190
  required: true,
4166
4191
  fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
4167
4192
  },
@@ -6432,7 +6457,13 @@ const CONTRACT = deepFreeze({
6432
6457
  // 1.214.0: additive (sc-13746) — a datastoreTemplate column may be a
6433
6458
  // `UUID` (validated, stored lowercase); `"@uuid"` defaults it to a new
6434
6459
  // random id per row and may be `defaultLocked`.
6435
- version: "1.214.0",
6460
+ // 1.215.0: additive (sc-13938) — `usePdfExport` `openPdf` / `exportToPdf`
6461
+ // take `appendFileIds`: stored PDFs whose pages follow the rendered HTML.
6462
+ // 1.216.0: additive (sc-14096) — reinstates 1.144.0, withdrawn in 1.155.0:
6463
+ // `useUsers().invite` takes `send_invitation` (false stores the invite
6464
+ // unsent), invite rows carry `sent_at`, `not_sent` is a status, and
6465
+ // `useInvites().resend` performs the first send.
6466
+ version: "1.216.0",
6436
6467
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6437
6468
  hooks: HOOKS,
6438
6469
  primitives: PRIMITIVES,