@lotics/app-sdk 0.78.0 → 0.79.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.
@@ -18,10 +18,24 @@
18
18
  * not tell which. That is why no register row ever rendered an avatar — the
19
19
  * data was absent and nothing said so.
20
20
  *
21
- * The private fields share ONE boundary, not three: an authenticated member of
22
- * the app's own org sees `email`, `image` and `groups`; an anonymous visitor to
23
- * a public app sees `id` and `name`. Absent ≠ empty — `image: null` means the
24
- * member has no photo, `image` MISSING means you were never told.
21
+ * It happened a second time, and the fix is the same: the roster returned
22
+ * neither `groups` nor `role` long after a resolved cell carried both, so an
23
+ * app reading `m.groups` off `useMembers()` got `undefined` and drew nothing.
24
+ * Read a field here and you may read it on either door.
25
+ *
26
+ * The private fields share ONE boundary, not four: an authenticated member of
27
+ * the app's own org sees `email`, `image`, `groups` and `role`; an anonymous
28
+ * visitor to a public app sees `id` and `name` (plus `archived`, which is not
29
+ * private — see the field). Absent ≠ empty — `image: null` means the member has
30
+ * no photo, `image` MISSING means you were never told, and `groups: []` means
31
+ * they are on no team while a missing `groups` means the same "not told". Never
32
+ * collapse the two: one is a fact about a colleague, the other is a fact about
33
+ * your own permissions.
34
+ *
35
+ * The one field the doors legitimately differ on is `archived`, and it is a
36
+ * difference of ROW SET rather than of shape: a resolved cell must keep naming
37
+ * whoever handled a record two years ago, while the roster answers "who may I
38
+ * assign?" and never offers a departed member at all.
25
39
  */
26
40
  export interface ResolvedMember {
27
41
  id: string;
@@ -40,6 +54,47 @@ export interface ResolvedMember {
40
54
  * department field; a member group is what an org uses to say Sale, Kế toán,
41
55
  * CSKH. `[]` when the member is in none. Omitted on public-app responses. */
42
56
  groups?: string[];
57
+ /**
58
+ * The member's ORGANIZATION role. Omitted on public-app responses, and also
59
+ * when the id did not resolve — there is no member to have a level.
60
+ *
61
+ * It arrives RAW, and it is your job to translate it: the platform ships no
62
+ * display word for it, because a server that picked one would leak English
63
+ * into every localized app. Map it yourself (`admin` → "Quản trị viên") next
64
+ * to the rest of your vocabulary.
65
+ *
66
+ * It is a PERMISSION level and not a job title. Everyone who reads "Admin"
67
+ * beside a name on a sales register will read it as rank; if the question
68
+ * your screen answers is "who is this person in the company", the answer is
69
+ * `groups`, not this.
70
+ */
71
+ role?: "owner" | "admin" | "member";
72
+ /**
73
+ * ISO timestamp of when this person joined the organization. Omitted on
74
+ * public-app responses, and when the id did not resolve — there is no
75
+ * membership to have begun.
76
+ *
77
+ * Render it at whatever precision your question needs; `@lotics/ui`'s
78
+ * `MemberProfileCard` shows month and year, because "is this the new person?"
79
+ * does not want a day.
80
+ */
81
+ joined?: string;
82
+ /**
83
+ * `true` when this person has LEFT the organization — omitted otherwise,
84
+ * never `false`, because it rides every cell of every row and current staff
85
+ * are the overwhelming case.
86
+ *
87
+ * It arrives on both audiences: a departed colleague reading as a current
88
+ * assignee is wrong on a public app too, and it discloses less than the name
89
+ * already beside it. Feed it to `inactive` on `MemberChip` /
90
+ * `MemberProfileCard` so a record that still names them reads as history
91
+ * rather than as a live assignment.
92
+ *
93
+ * You will not see it on the `useMembers` roster, and that is correct rather
94
+ * than missing: the roster answers "who may I ASSIGN?" and departed members
95
+ * are not candidates, so it never returns one.
96
+ */
97
+ archived?: true;
43
98
  }
44
99
  /**
45
100
  * Parse a `useQuery` cell value into `ResolvedMember[]`. Returns `[]` for
@@ -41,6 +41,21 @@ export function readMembers(value) {
41
41
  ? obj.groups.filter((g) => typeof g === "string")
42
42
  : [];
43
43
  }
44
+ // A CLOSED enum, so an unrecognized value is dropped rather than passed
45
+ // through: the field's whole worth is that a caller can switch on it, and a
46
+ // server that grew a fourth role must not have apps rendering the raw word
47
+ // in a UI that has no translation for it.
48
+ if (obj.role === "owner" || obj.role === "admin" || obj.role === "member")
49
+ m.role = obj.role;
50
+ // A non-empty STRING or nothing: an empty date is not a date, and letting
51
+ // "" through would render an empty labelled row rather than no row.
52
+ if (typeof obj.joined === "string" && obj.joined !== "")
53
+ m.joined = obj.joined;
54
+ // The server sends this ONLY for a departed member and only as `true`, so
55
+ // the truthy test is the whole contract — there is no `false` to carry, and
56
+ // inventing one would put the key on every current member's cell.
57
+ if (obj.archived === true)
58
+ m.archived = true;
44
59
  out.push(m);
45
60
  }
46
61
  return out;
@@ -108,13 +108,13 @@ cell's own `{ key, label }`, which renders as a neutral badge. Never hand-map op
108
108
  ## Member cells (`readMembers`)
109
109
 
110
110
  A `select_member` column's storage shape is a bare array of member ids. The server rewrites every
111
- projected `select_member` cell to `Array<{ id, name, email?, image?, groups? }>` — **the same shape
112
- `useMembers` returns**, so a member is a member wherever you got them from:
111
+ projected `select_member` cell to `Array<{ id, name, email?, image?, groups?, role?, joined?, archived? }>` —
112
+ **the same shape `useMembers` returns**, so a member is a member wherever you got them from:
113
113
 
114
- - **`email`, `image` and `groups` share ONE gate.** All three are present only for authenticated
115
- members of the app's own organization. Anonymous visitors to a public app — and members of *other*
116
- orgs viewing a publicly shared app — get `{ id, name }` only. A face and a team are org-internal
117
- exactly as an address is.
114
+ - **`email`, `image`, `groups`, `role` and `joined` share ONE gate.** All five are present only for
115
+ authenticated members of the app's own organization. Anonymous visitors to a public app — and
116
+ members of *other* orgs viewing a publicly shared app — get `{ id, name }` only. A face, a team
117
+ and a permission level are org-internal exactly as an address is.
118
118
  - **Absent is not null.** A missing `image` key means you were not told; `image: null` means the
119
119
  member genuinely has no photo. Same for `groups`: absent vs `[]`. Rendering the two the same way
120
120
  turns a permission boundary into a missing-data bug.
@@ -122,6 +122,18 @@ projected `select_member` cell to `Array<{ id, name, email?, image?, groups? }>`
122
122
  cookie-authenticated proxy, so an unsigned URL would not load inside an app iframe at all.
123
123
  - **`groups` is the platform's "department".** There is no department field; a member group is what
124
124
  an org uses to say Sale, Kế toán, CSKH. `[]` when the member is in none.
125
+ - **`role` arrives RAW (`owner` / `admin` / `member`) and you translate it.** The platform ships no
126
+ display word, because a server that picked one would leak English into every localized app — map
127
+ it next to the rest of your vocabulary. It is a PERMISSION level, not a job title: everyone reads
128
+ "Admin" beside a name as rank. If the question your screen asks is "who is this person in the
129
+ company", the answer is `groups`.
130
+ - **`joined` is an ISO timestamp of when the membership began.** Render it at whatever precision
131
+ your question needs — `@lotics/ui`'s `MemberProfileCard` shows month and year, because "is this
132
+ the new person?" does not want a day. Absent on a public response, and on an id that did not
133
+ resolve: there is no membership to have begun.
134
+ - **`archived: true` marks someone who has LEFT** — omitted otherwise, never `false`. It is the one
135
+ field that is NOT gated (a departed colleague reading as a current assignee is wrong on a public
136
+ app too). Feed it to `inactive` on `MemberChip` / `MemberProfileCard`.
125
137
  - **An id that no longer resolves** (removed member, id outside the org) comes back with
126
138
  `name: null` — an explicit missing state, never an empty string. Render a placeholder.
127
139
  - Only ids already present in the projected rows are resolved — a member cell never exposes the
@@ -139,7 +151,10 @@ const { members, loading, error } = useMembers({ group: "grp_fulfillment" });
139
151
  <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
140
152
  ```
141
153
 
142
- Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups }`.
154
+ Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups, role, joined }`.
155
+ The one field it never carries is `archived`, and that is the answer rather than a gap: this roster
156
+ answers "who may I ASSIGN?", and a departed member is not a candidate, so it never returns one. A
157
+ resolved CELL is the display door and keeps naming them.
143
158
  `image` is the avatar URL (a presigned URL valid 24 hours, or the member's external OAuth photo) and
144
159
  may be `null`. `name` may be null/empty for members without a display name — fall back to `email`. On failure the hook does not throw: it
145
160
  resolves `{ members: [], loading: false, error }`.
@@ -311,7 +326,7 @@ The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter
311
326
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
312
327
  | --- | --- | --- |
313
328
  | `readSelect` cell enrichment | ✓ | ✓ |
314
- | `readMembers` cell enrichment | ✓ (email + image + groups, same-org) | ✓ name-only |
329
+ | `readMembers` cell enrichment | ✓ (email + image + groups + role + joined, same-org) | ✓ name-only (+ `archived`) |
315
330
  | `useFieldOptions` | ✓ | ✓ |
316
331
  | `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
317
332
  | `useViewer` | member id (view-as target) | `null` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {