@7365admin1/layer-common 4.97.1-staging.586 → 4.97.1-staging.588

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,278 @@
1
+ /**
2
+ * Ported verbatim from the security mobile app (isecure365
3
+ * `src/features/visitors/nric-card.ts`) so the web NRIC scan reads a card
4
+ * exactly as the app does. Keep the two in step.
5
+ *
6
+ * Reading a Singapore NRIC (or FIN card) from the text the camera saw.
7
+ *
8
+ * The visitor form's "Scan" beside the NRIC field photographs the card, runs
9
+ * on-device text recognition (`modules/text-recognizer`) and hands the lines
10
+ * here. Nothing leaves the device: the photo is deleted straight after it is
11
+ * read, and only the two values below reach the form.
12
+ *
13
+ * The front of the card reads, top to bottom:
14
+ *
15
+ * REPUBLIC OF SINGAPORE
16
+ * IDENTITY CARD NO. S1234567D
17
+ * Name
18
+ * TAN AH KOW <- may wrap onto a second line
19
+ * (name in Chinese / Tamil / Jawi, when there is one)
20
+ * Race ... Date of birth ... Sex ... Country/Place of birth ...
21
+ *
22
+ * The back carries the number again (with its barcode), so the number alone
23
+ * can also be read from the back.
24
+ */
25
+
26
+ /** One line of recognised text and where it sits in the photo. */
27
+ export type CardLine = {
28
+ text: string;
29
+ x: number;
30
+ y: number;
31
+ width: number;
32
+ height: number;
33
+ };
34
+
35
+ export type NricCardRead = {
36
+ nric: string | null;
37
+ name: string | null;
38
+ };
39
+
40
+ const WEIGHTS = [2, 7, 6, 5, 4, 3, 2];
41
+
42
+ /** Check letter by remainder, per prefix family. `T` / `G` add 4 first. */
43
+ const CHECK_LETTERS: Record<string, { letters: string; offset: number }> = {
44
+ S: { letters: "JZIHGFEDCBA", offset: 0 },
45
+ T: { letters: "JZIHGFEDCBA", offset: 4 },
46
+ F: { letters: "XWUTRQPNMLK", offset: 0 },
47
+ G: { letters: "XWUTRQPNMLK", offset: 4 },
48
+ };
49
+
50
+ /**
51
+ * Whether the last letter matches the number. `null` for an M-series FIN,
52
+ * whose check letter is not checked here: the scanner instead waits for the
53
+ * same M number to be read twice in a row.
54
+ */
55
+ export function isNricChecksumValid(nric: string): boolean | null {
56
+ const value = nric.toUpperCase();
57
+ if (!/^[STFGM]\d{7}[A-Z]$/.test(value)) return false;
58
+ const rule = CHECK_LETTERS[value[0]];
59
+ if (!rule) return null;
60
+ let sum = rule.offset;
61
+ for (let i = 0; i < 7; i += 1) sum += Number(value[i + 1]) * WEIGHTS[i];
62
+ return rule.letters[sum % 11] === value[8];
63
+ }
64
+
65
+ /** Characters text recognition commonly puts where a digit is printed. */
66
+ const AS_DIGIT: Record<string, string> = {
67
+ O: "0",
68
+ D: "0",
69
+ Q: "0",
70
+ I: "1",
71
+ L: "1",
72
+ Z: "2",
73
+ S: "5",
74
+ G: "6",
75
+ B: "8",
76
+ };
77
+
78
+ /** A letter, seven digit-like characters, a letter — before any fixing. */
79
+ const NRIC_LIKE = /^[STFGM5][0-9ODQILZSGB]{7}[A-Z]$/;
80
+
81
+ function fixCandidate(raw: string): string {
82
+ const prefix = raw[0] === "5" ? "S" : raw[0];
83
+ const digits = raw
84
+ .slice(1, 8)
85
+ .split("")
86
+ .map((ch) => AS_DIGIT[ch] ?? ch)
87
+ .join("");
88
+ return `${prefix}${digits}${raw[8]}`;
89
+ }
90
+
91
+ /**
92
+ * The NRIC/FIN in the text, or null. A number whose check letter matches wins
93
+ * over one that only has the right shape (an M-series FIN).
94
+ */
95
+ export function findNric(lines: CardLine[]): string | null {
96
+ const texts = lines.map((line) => line.text);
97
+ // Each line on its own, then everything joined in case the number was split.
98
+ texts.push(lines.map((line) => line.text).join(""));
99
+
100
+ let shapeOnly: string | null = null;
101
+ for (const text of texts) {
102
+ const compact = text.toUpperCase().replace(/[^A-Z0-9]/g, "");
103
+ for (let start = 0; start + 9 <= compact.length; start += 1) {
104
+ const window = compact.slice(start, start + 9);
105
+ if (!NRIC_LIKE.test(window)) continue;
106
+ const candidate = fixCandidate(window);
107
+ const valid = isNricChecksumValid(candidate);
108
+ if (valid === true) return candidate;
109
+ if (valid === null && !shapeOnly) shapeOnly = candidate;
110
+ }
111
+ }
112
+ return shapeOnly;
113
+ }
114
+
115
+ /**
116
+ * A number with the NRIC's shape whose check letter does NOT match, or null.
117
+ *
118
+ * Never filled in on its own: the scanner only offers it ("Use anyway") once
119
+ * the same number has been read several times in a row, so a one-off misread
120
+ * never reaches the form but a worn card, or a specimen with a dummy number,
121
+ * does not leave the guard stuck.
122
+ */
123
+ export function findUncheckedNric(lines: CardLine[]): string | null {
124
+ const texts = lines.map((line) => line.text);
125
+ texts.push(lines.map((line) => line.text).join(""));
126
+ for (const text of texts) {
127
+ const compact = text.toUpperCase().replace(/[^A-Z0-9]/g, "");
128
+ for (let start = 0; start + 9 <= compact.length; start += 1) {
129
+ const window = compact.slice(start, start + 9);
130
+ if (!NRIC_LIKE.test(window)) continue;
131
+ const candidate = fixCandidate(window);
132
+ if (isNricChecksumValid(candidate) === false) return candidate;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ const NAME_LABEL = /^\s*name\b[\s:.]*/i;
139
+ /** Labels that come after the name on the card: reading stops at them. */
140
+ const AFTER_NAME = /\b(race|date|birth|sex|country|identity|republic)\b/i;
141
+
142
+ /** Printed names are capitals: letters, spaces and a little punctuation. */
143
+ function asName(text: string): string | null {
144
+ const value = text.replace(/\s+/g, " ").trim();
145
+ if (!/^[A-Za-z][A-Za-z .,'@/()-]*$/.test(value)) return null;
146
+ const letters = value.replace(/[^A-Za-z]/g, "");
147
+ if (letters.length < 2) return null;
148
+ const capitals = letters.replace(/[^A-Z]/g, "").length;
149
+ // A few lower-case misreads are fine; mostly lower case is a label, not a name.
150
+ if (capitals / letters.length < 0.8) return null;
151
+ return value.toUpperCase();
152
+ }
153
+
154
+ /**
155
+ * The labels printed right under the name ("Race", "Date of birth", "Sex",
156
+ * "Country/Place of birth"), left-aligned with it. Not "Date of issue", which
157
+ * is on the back.
158
+ */
159
+ const UNDER_NAME = /\b(race|sex|country|date of b\w*|birth)\b/i;
160
+ /** Capitals on the card that are not the holder's name. */
161
+ const NOT_A_NAME = /\b(republic|singapore|identity|card|specimen)\b/i;
162
+
163
+ /**
164
+ * Race values printed on the card, under "Race". When the name line itself is
165
+ * not read, the Race value is the next line in capitals and was being taken
166
+ * for the name.
167
+ */
168
+ const RACE_VALUES = new Set([
169
+ "CHINESE",
170
+ "MALAY",
171
+ "INDIAN",
172
+ "EURASIAN",
173
+ "CAUCASIAN",
174
+ "FILIPINO",
175
+ "JAVANESE",
176
+ "BOYANESE",
177
+ "BUGIS",
178
+ "SIKH",
179
+ "ARAB",
180
+ "JAPANESE",
181
+ "KOREAN",
182
+ "THAI",
183
+ "VIETNAMESE",
184
+ "BURMESE",
185
+ "SINHALESE",
186
+ "PAKISTANI",
187
+ "BANGLADESHI",
188
+ "NEPALESE",
189
+ "INDONESIAN",
190
+ "OTHERS",
191
+ ]);
192
+
193
+ function nameOf(line: CardLine): string | null {
194
+ const name = asName(line.text);
195
+ if (!name || NOT_A_NAME.test(name) || RACE_VALUES.has(name)) return null;
196
+ return name;
197
+ }
198
+
199
+ /**
200
+ * The name from its first line, plus the lines it wraps onto: a long name
201
+ * wraps onto a second line set tight under the first; the name in another
202
+ * script below it has a wider gap and is not Latin.
203
+ */
204
+ function joinName(first: CardLine, following: CardLine[]): string {
205
+ const parts = [first];
206
+ for (const line of following) {
207
+ if (AFTER_NAME.test(line.text)) break;
208
+ const previous = parts[parts.length - 1];
209
+ const gap = line.y - (previous.y + previous.height);
210
+ if (nameOf(line) && gap < previous.height * 0.6 && parts.length < 3) {
211
+ parts.push(line);
212
+ } else break;
213
+ }
214
+ return parts
215
+ .map((line) => nameOf(line))
216
+ .join(" ")
217
+ .replace(/\s+/g, " ")
218
+ .trim();
219
+ }
220
+
221
+ /** The name under the "Name" label, starting at the label's left edge. */
222
+ function nameUnderLabel(sorted: CardLine[]): string | null {
223
+ const labelIndex = sorted.findIndex((line) => NAME_LABEL.test(line.text));
224
+ if (labelIndex < 0) return null;
225
+ const label = sorted[labelIndex];
226
+
227
+ // "Name TAN AH KOW" read as one line.
228
+ const sameLine = asName(label.text.replace(NAME_LABEL, ""));
229
+ if (sameLine && !AFTER_NAME.test(sameLine) && !NOT_A_NAME.test(sameLine)) {
230
+ return sameLine;
231
+ }
232
+
233
+ const tolerance = Math.max(label.height * 1.5, label.width * 0.5);
234
+ const below = sorted
235
+ .slice(labelIndex + 1)
236
+ .filter(
237
+ (line) => line.y > label.y && Math.abs(line.x - label.x) <= tolerance,
238
+ );
239
+ // The first line under the label must be the name; anything else ends it.
240
+ if (!below.length || AFTER_NAME.test(below[0].text) || !nameOf(below[0])) {
241
+ return null;
242
+ }
243
+ return joinName(below[0], below.slice(1));
244
+ }
245
+
246
+ /**
247
+ * Without the "Name" label (it is printed small and is often not read): the
248
+ * name is the first line in capitals in the column above "Race" / "Date of
249
+ * birth", which are left-aligned with it.
250
+ */
251
+ function nameAboveDetails(sorted: CardLine[]): string | null {
252
+ const details = sorted.filter((line) => UNDER_NAME.test(line.text));
253
+ if (!details.length) return null;
254
+ // The top row of those labels, its left-most: "Race" or "Date of birth",
255
+ // never "Sex" beside it.
256
+ const top = details[0];
257
+ const anchor = details
258
+ .filter((line) => Math.abs(line.y - top.y) <= top.height)
259
+ .reduce((left, line) => (line.x < left.x ? line : left));
260
+
261
+ const tolerance = anchor.height * 2;
262
+ const column = sorted.filter(
263
+ (line) => line.y < anchor.y && Math.abs(line.x - anchor.x) <= tolerance,
264
+ );
265
+ const firstIndex = column.findIndex((line) => nameOf(line) !== null);
266
+ if (firstIndex < 0) return null;
267
+ return joinName(column[firstIndex], column.slice(firstIndex + 1));
268
+ }
269
+
270
+ /** The holder's name off the front of the card, or null. */
271
+ export function findName(lines: CardLine[]): string | null {
272
+ const sorted = [...lines].sort((a, b) => a.y - b.y || a.x - b.x);
273
+ return nameUnderLabel(sorted) ?? nameAboveDetails(sorted);
274
+ }
275
+
276
+ export function readNricCard(lines: CardLine[]): NricCardRead {
277
+ return { nric: findNric(lines), name: findName(lines) };
278
+ }
@@ -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
+ }