@7365admin1/layer-common 4.146.0 → 4.147.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.
@@ -0,0 +1,262 @@
1
+ /*
2
+ * People lookup helpers for the visitor form's autofill, ported as is from the
3
+ * security mobile app (isecure365 `src/features/people/people.service.ts` and
4
+ * `src/features/visitors/delivery-company.ts`), so the web and the app fill a
5
+ * visitor in the same way.
6
+ *
7
+ * People records are kept per visitor type (guest, contractor, delivery, with
8
+ * walk-in kept as guest). The form looks a person up across EVERY visitor type
9
+ * - a past contractor is offered when adding a Guest, and so on - with the type
10
+ * being registered listed first, and the same person on file under several
11
+ * types shown once.
12
+ */
13
+
14
+ /** The person types a visitor registration keeps a People record for. */
15
+ export const VISITOR_PERSON_TYPES = ["guest", "contractor", "delivery"] as const;
16
+
17
+ export type VisitorPerson = {
18
+ _id?: string;
19
+ name?: string;
20
+ nric?: string;
21
+ contact?: string | string[];
22
+ plateNumber?: string | string[];
23
+ company?: string;
24
+ companyName?: string | string[];
25
+ type?: string;
26
+ /** Every record behind one result (a merged result is several). */
27
+ ids?: string[];
28
+ /** The companies of each record by its person type. */
29
+ companiesByType?: Record<string, string[]>;
30
+ contacts?: string[];
31
+ plateNumbers?: string[];
32
+ plates?: { plateNumber?: string }[];
33
+ firstName?: string;
34
+ lastName?: string;
35
+ phoneNumber?: string;
36
+ contactNumbers?: unknown[];
37
+ phoneNumbers?: unknown[];
38
+ [key: string]: unknown;
39
+ };
40
+
41
+ const listOf = (value: unknown): unknown[] =>
42
+ Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
43
+
44
+ function uniqueOf(
45
+ values: unknown[],
46
+ pick: (value: Record<string, unknown>) => unknown
47
+ ): string[] {
48
+ return Array.from(
49
+ new Set(
50
+ values
51
+ .map((value) =>
52
+ typeof value === "string"
53
+ ? value
54
+ : value && typeof value === "object"
55
+ ? pick(value as Record<string, unknown>)
56
+ : undefined
57
+ )
58
+ .filter(
59
+ (value): value is string => typeof value === "string" && Boolean(value.trim())
60
+ )
61
+ .map((value) => value.trim())
62
+ )
63
+ );
64
+ }
65
+
66
+ export function summaryName(person: VisitorPerson): string {
67
+ return (
68
+ person.name ||
69
+ [person.firstName, person.lastName].filter(Boolean).join(" ") ||
70
+ "Unknown"
71
+ );
72
+ }
73
+
74
+ export function summaryContacts(person: VisitorPerson): string[] {
75
+ return uniqueOf(
76
+ [
77
+ ...listOf(person.contact),
78
+ person.phoneNumber,
79
+ ...listOf(person.contacts),
80
+ ...listOf(person.contactNumbers),
81
+ ...listOf(person.phoneNumbers),
82
+ ],
83
+ (value) => value.contact || value.phoneNumber || value.number
84
+ );
85
+ }
86
+
87
+ export function summaryPlates(person: VisitorPerson): string[] {
88
+ return uniqueOf(
89
+ [...listOf(person.plateNumber), ...listOf(person.plates), ...listOf(person.plateNumbers)],
90
+ (value) => value.plateNumber || value.number || value.value
91
+ );
92
+ }
93
+
94
+ /** The companies on one record (`companyName`, or a single `company`). */
95
+ function companiesOf(person: VisitorPerson): string[] {
96
+ return [
97
+ ...new Set(
98
+ [...listOf(person.companyName), person.company]
99
+ .filter((name): name is string => typeof name === "string" && Boolean(name.trim()))
100
+ .map((name) => name.trim())
101
+ ),
102
+ ];
103
+ }
104
+
105
+ /**
106
+ * The companies saved for the person from registrations of `type` only:
107
+ * contractor companies on the Contractor form, delivery companies on the
108
+ * Delivery form. Walk-in is kept as guest.
109
+ */
110
+ export function companiesForType(person: VisitorPerson, type?: string): string[] {
111
+ const personType = type === "walk-in" ? "guest" : type;
112
+ if (!personType) return [];
113
+ if (person.companiesByType) return person.companiesByType[personType] ?? [];
114
+ return person.type === personType ? companiesOf(person) : [];
115
+ }
116
+
117
+ const sameText = (a?: string, b?: string) =>
118
+ Boolean(a?.trim()) && a!.trim().toLowerCase() === b?.trim().toLowerCase();
119
+
120
+ /**
121
+ * Records with the same name and the same NRIC (or, with no NRIC on either,
122
+ * the same name and a shared phone number) are one person: shown once, with
123
+ * every number, plate and company of both.
124
+ */
125
+ export function mergePeople(people: VisitorPerson[]): VisitorPerson[] {
126
+ const merged: VisitorPerson[] = [];
127
+ const seenIds = new Set<string>();
128
+ for (const person of people) {
129
+ if (person._id) {
130
+ if (seenIds.has(person._id)) continue;
131
+ seenIds.add(person._id);
132
+ }
133
+ const contacts = summaryContacts(person);
134
+ const same = merged.find((other) => {
135
+ if (!sameText(summaryName(other), summaryName(person))) return false;
136
+ if (other.nric || person.nric) return sameText(other.nric, person.nric);
137
+ const otherContacts = other.contacts ?? [];
138
+ return contacts.some((contact) => otherContacts.includes(contact));
139
+ });
140
+ if (!same) {
141
+ merged.push({
142
+ ...person,
143
+ ids: person._id ? [person._id] : [],
144
+ companiesByType: person.type ? { [person.type]: companiesOf(person) } : {},
145
+ contacts,
146
+ plateNumbers: summaryPlates(person),
147
+ });
148
+ continue;
149
+ }
150
+ if (person.type) {
151
+ const byType = { ...(same.companiesByType ?? {}) };
152
+ byType[person.type] = [
153
+ ...new Set([...(byType[person.type] ?? []), ...companiesOf(person)]),
154
+ ];
155
+ same.companiesByType = byType;
156
+ }
157
+ if (person._id) same.ids = [...(same.ids ?? []), person._id];
158
+ same.contacts = [...new Set([...(same.contacts ?? []), ...contacts])];
159
+ same.plateNumbers = [
160
+ ...new Set([...(same.plateNumbers ?? []), ...summaryPlates(person)]),
161
+ ];
162
+ same.companyName = [
163
+ ...new Set(
164
+ [...listOf(same.companyName), ...listOf(person.companyName)].filter(
165
+ (name): name is string => typeof name === "string" && Boolean(name.trim())
166
+ )
167
+ ),
168
+ ];
169
+ }
170
+ return merged;
171
+ }
172
+
173
+ /** People of the visitor types only, the type being registered first, merged. */
174
+ export function visitorPeopleResults(
175
+ found: VisitorPerson[],
176
+ preferType?: string
177
+ ): VisitorPerson[] {
178
+ const visitorTypes: readonly string[] = VISITOR_PERSON_TYPES;
179
+ return mergePeople(
180
+ found
181
+ // A server without the several-types filter returns every type.
182
+ .filter((person) => !person.type || visitorTypes.includes(person.type))
183
+ .sort((a, b) => Number(b.type === preferType) - Number(a.type === preferType))
184
+ );
185
+ }
186
+
187
+ /** Every record behind a result (a merged one is several). */
188
+ export function recordIdsOf(person: VisitorPerson): string[] {
189
+ return [...new Set([...(person.ids ?? []), ...(person._id ? [person._id] : [])])];
190
+ }
191
+
192
+ /** A stable key for "this person was already filled in from this field". */
193
+ export function personKey(person: VisitorPerson): string {
194
+ const ids = recordIdsOf(person);
195
+ if (ids.length) return ids.join(",");
196
+ return `${summaryName(person)}|${person.nric ?? ""}`;
197
+ }
198
+
199
+ export const DELETE_PERSON_TITLE = "Delete saved details?";
200
+ export const deletePersonMessage = (name: string) =>
201
+ `${name}'s saved details will no longer be offered when filling in the form. Use this when the NRIC saved for them is wrong. Their past visits are not changed, and their details are saved again on their next visit.`;
202
+ export const PERSON_DELETED_MESSAGE = "Saved details deleted.";
203
+
204
+ /*
205
+ * The delivery company list (isecure365 `delivery-company.ts`): names are
206
+ * compared ignoring case and surrounding spaces, so "grabfood" is the listed
207
+ * "GrabFood".
208
+ */
209
+ export const normalizeCompany = (value: unknown): string =>
210
+ typeof value === "string" ? value.trim() : "";
211
+
212
+ export const sameCompany = (a: string, b: string): boolean =>
213
+ a.trim().toLowerCase() === b.trim().toLowerCase();
214
+
215
+ export const isListedCompany = (list: readonly string[], name: string): boolean =>
216
+ list.some((company) => sameCompany(company, name));
217
+
218
+ /** A typed name that matches a listed company apart from case / spaces becomes the listed one. */
219
+ export function listedName(list: readonly string[], typed: unknown): string {
220
+ const name = normalizeCompany(typed);
221
+ if (!name) return "";
222
+ return list.find((company) => sameCompany(company, name)) ?? name;
223
+ }
224
+
225
+ export const withCompany = (list: readonly string[], name: string): string[] =>
226
+ isListedCompany(list, name) ? [...list] : [...list, name];
227
+
228
+ export function renamedCompany(
229
+ list: readonly string[],
230
+ original: string,
231
+ name: string
232
+ ): string[] {
233
+ const index = list.findIndex((company) => sameCompany(company, original));
234
+ if (index === -1) return [...list, name];
235
+ const next = [...list];
236
+ next[index] = name;
237
+ return next;
238
+ }
239
+
240
+ export const withoutCompany = (list: readonly string[], name: string): string[] =>
241
+ list.filter((company) => !sameCompany(company, name));
242
+
243
+ export const COMPANY_NAME_REQUIRED = "Company name is required.";
244
+ export const COMPANY_ALREADY_LISTED = "This company is already in the list.";
245
+
246
+ export function renameProblem(
247
+ list: readonly string[],
248
+ original: string,
249
+ value: unknown
250
+ ): string | null {
251
+ const name = normalizeCompany(value);
252
+ if (!name) return COMPANY_NAME_REQUIRED;
253
+ if (!sameCompany(name, original) && isListedCompany(list, name))
254
+ return COMPANY_ALREADY_LISTED;
255
+ return null;
256
+ }
257
+
258
+ /** "Pass VP01 is In Use" -> "VP01" (the pass / key the server refused). */
259
+ export function refusedPassKeyName(message: string | undefined | null): string {
260
+ const match = /\b(?:Pass|Key)\s+(\S+)\s+is\s+/i.exec(String(message ?? ""));
261
+ return match?.[1]?.replace(/[.,;:]+$/, "") ?? "";
262
+ }