@7365admin1/layer-common 4.73.1-staging.464 → 4.73.1-staging.465

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.73.1-staging.464",
5
+ "version": "4.73.1-staging.465",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,144 @@
1
+ /**
2
+ * WHO A HID ENROLMENT IS FOR — the pure decisions behind the subject picker.
3
+ *
4
+ * `HidUserEnrollment.vue` can enrol a resident or a staff member. The write path
5
+ * always carried both (`subjectLink` maps them onto `person` or `member`); what
6
+ * was missing was a way to choose. These are the parts of that choice worth
7
+ * testing on their own, kept here because a `.vue` component has no test harness
8
+ * in this repo.
9
+ */
10
+
11
+ export type HidEnrolmentSubject = "resident" | "property_management";
12
+
13
+ /**
14
+ * The dropdown's options, in the order they are drawn.
15
+ *
16
+ * `title`/`value` because `AppSelect` takes that shape. A service-provider
17
+ * option is expected next; adding it here is most of the work, since the write
18
+ * path already maps `service_provider` onto `serviceProvider`.
19
+ */
20
+ export const HID_ENROLMENT_SUBJECTS: ReadonlyArray<{
21
+ title: string;
22
+ value: HidEnrolmentSubject;
23
+ }> = [
24
+ { title: "Resident", value: "resident" },
25
+ { title: "Member", value: "property_management" },
26
+ ];
27
+
28
+ /**
29
+ * What an EDIT calls the subject it is already attached to.
30
+ *
31
+ * Wider than the dropdown on purpose. An identity's category cannot change after
32
+ * it is created, so an edit states it rather than offering it — and the records
33
+ * in the wild include `service_provider`, which the dropdown does not offer yet
34
+ * but `openEdit` does resolve. Falling back to the first option would label a
35
+ * contractor "Resident", which is worse than saying nothing useful.
36
+ */
37
+ export function subjectCategoryLabel(category: unknown): string {
38
+ const labels: Record<string, string> = {
39
+ resident: "Resident",
40
+ property_management: "Member",
41
+ service_provider: "Service provider",
42
+ visitor: "Visitor",
43
+ };
44
+ return labels[String(category ?? "")] ?? "Unknown";
45
+ }
46
+
47
+ /**
48
+ * One page of `GET /api/members`, read defensively.
49
+ *
50
+ * `paginate` puts `{ items, pages }` at the root, but `items` sits under `data`
51
+ * on some of this product's endpoints, so both are read — the same allowance
52
+ * `readCandidatePage` makes. A response that carries neither is an empty page
53
+ * rather than a thrown error: an empty staff picker with a placeholder beats a
54
+ * broken dialog.
55
+ */
56
+ export function readMemberPage(response: unknown): {
57
+ items: Record<string, unknown>[];
58
+ pages: number;
59
+ total: number;
60
+ } {
61
+ const source = (response && typeof response === "object" ? response : {}) as Record<string, unknown>;
62
+ const nested = (source.data && typeof source.data === "object" ? source.data : {}) as Record<string, unknown>;
63
+ const raw = Array.isArray(source.items)
64
+ ? source.items
65
+ : Array.isArray(nested.items)
66
+ ? nested.items
67
+ : [];
68
+ const pages = Number(source.pages ?? nested.pages ?? 1);
69
+ const items = raw.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object");
70
+ /*
71
+ * `total` drives "showing 10 of 23" and the Load more button. It falls back to
72
+ * the page's own length, not to zero, so a response without it simply reads as
73
+ * "this is everything" — a missing count must never make the button offer a
74
+ * page that is not there.
75
+ */
76
+ const total = Number(source.total ?? nested.total ?? items.length);
77
+ return {
78
+ items,
79
+ pages: Number.isFinite(pages) && pages > 0 ? Math.floor(pages) : 1,
80
+ total: Number.isFinite(total) && total >= 0 ? Math.floor(total) : items.length,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Can this member actually be enrolled?
86
+ *
87
+ * Reader access is bound by USER id — `resolvePermissionUserBindings` in
88
+ * `iservice365-core` drops every subject without one — so a member with no app
89
+ * account cannot be given access and is not a candidate. The id arrives as a
90
+ * string from JSON but may be an object if a caller passes a raw document, so
91
+ * both are read rather than trusting the shape.
92
+ */
93
+ export function memberHasAccount(row: Record<string, unknown> | null | undefined): boolean {
94
+ const user = row?.user;
95
+ if (!user) return false;
96
+ if (typeof user === "object") {
97
+ const id = (user as { _id?: unknown; toString?: () => string })._id ?? user;
98
+ return Boolean(String(id ?? "").trim());
99
+ }
100
+ return Boolean(String(user).trim());
101
+ }
102
+
103
+ /**
104
+ * How a member reads in the picker: their name, and their role beneath it.
105
+ *
106
+ * THE ACCOUNT'S NAME FIRST, then the membership's. The two diverge — a
107
+ * membership is created with whatever name was typed at invite time, and the
108
+ * person may have set their own on the account since — and the account name is
109
+ * the one they are known by. `userName` comes from the members endpoint's own
110
+ * lookup into `users`; a member with no account has none, but such a row is not
111
+ * a candidate anyway (`memberHasAccount`).
112
+ *
113
+ * Every step falls back, because a row with no name at all still has to be
114
+ * selectable rather than blank, or it cannot be told apart from the next one.
115
+ */
116
+ export function memberCandidateOf(row: Record<string, unknown>): {
117
+ subjectId: string;
118
+ name: string;
119
+ subtitle: string;
120
+ } {
121
+ const text = (value: unknown) => String(value ?? "").trim();
122
+ return {
123
+ subjectId: text(row._id),
124
+ name: text(row.userName) || text(row.name) || text(row.email) || "Member",
125
+ subtitle: text(row.roleName) || text(row.email) || "",
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Why somebody the operator can see elsewhere is not in this list.
131
+ *
132
+ * Left unexplained, an absence reads as a broken screen — the same reason the
133
+ * resident cascade carries `unitResidentNote`. Returns "" when there is nothing
134
+ * to say, so the caller can render it unconditionally.
135
+ */
136
+ export function hiddenSubjectNote(count: number, noun: "resident" | "member"): string {
137
+ if (!Number.isFinite(count) || count <= 0) return "";
138
+ const whole = Math.floor(count);
139
+ const subject = whole === 1 ? `1 ${noun}` : `${whole} ${noun}s`;
140
+ const verb = whole === 1 ? "is" : "are";
141
+ const holder = whole === 1 ? "that person has" : "they have";
142
+ return `${subject} at this site ${verb} not listed:`
143
+ + ` HID enrollment needs an app account, and ${holder} none yet.`;
144
+ }