Files
waseel/lib/document-ocr.ts
T
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:17:55 +03:00

512 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Reading a driver's documents with Google Cloud Vision, then pulling the
// four credential fields out of the text it returns.
//
// Two things shape this file. First, Lebanese documents are trilingual: a
// driving licence carries Arabic and French on the same card, an ID card is
// Arabic with Arabic-Indic digits, and a vehicle registration mixes both. So
// every label we look for has an Arabic, a French and an English spelling, and
// digits are normalised before anything is matched.
//
// Second, OCR is a suggestion, never an answer. Everything here is best-effort
// and each field is returned independently — a licence whose number reads
// cleanly but whose expiry is smudged yields the number and leaves expiry
// empty. The driver reviews and corrects every field before submitting, and a
// human reviewer still approves the profile against the stored scan. Nothing
// downstream trusts these values because they came from a scan.
const VISION_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
export const DOCUMENT_TYPES = ["license", "id", "vehicle_reg"] as const;
export type DocumentType = (typeof DOCUMENT_TYPES)[number];
export const isDocumentType = (v: unknown): v is DocumentType =>
typeof v === "string" && (DOCUMENT_TYPES as readonly string[]).includes(v);
/** Which drivers column stores the scan for each document type. */
export const DOCUMENT_COLUMNS: Record<DocumentType, string> = {
license: "license_image_url",
id: "id_image_url",
vehicle_reg: "vehicle_reg_image_url",
};
/**
* The subset of the onboarding form a scan can fill. Every key is optional:
* a field is present only when it was actually read off the document.
*/
export type ExtractedFields = {
license_number?: string;
/** Always normalised to YYYY-MM-DD, whatever the card printed. */
license_expiry?: string;
national_id?: string;
plate_number?: string;
car_model?: string;
};
// --- Normalisation --------------------------------------------------------
/**
* Lebanese ID cards print Arabic-Indic digits (٠١٢…), and Vision returns them
* verbatim. Everything downstream — the date parser, the digit-run fallbacks,
* the form itself — expects ASCII, so fold them first. Both the Arabic-Indic
* (U+0660) and Extended Arabic-Indic (U+06F0, used by some fonts) ranges show
* up in practice.
*/
const toAsciiDigits = (text: string): string =>
text.replace(/[٠-٩۰-۹]/g, (char) => {
const code = char.charCodeAt(0);
const base = code >= 0x06f0 ? 0x06f0 : 0x0660;
return String(code - base);
});
/**
* Arabic tashkeel (short-vowel marks) and the tatweel stretcher are decorative
* and appear inconsistently in OCR output, so a label match must not depend on
* them. Latin text is uppercased so one pattern covers "Permis" and "PERMIS".
*/
const normalise = (text: string): string =>
toAsciiDigits(text)
.replace(/[ً-ٟـٰ]/g, "")
.replace(/[--]/g, "")
.toUpperCase();
const lines = (text: string): string[] =>
text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
// --- Dates ----------------------------------------------------------------
const MONTH_NAMES: Record<string, number> = {
JAN: 1,
FEV: 2,
FEB: 2,
MAR: 3,
AVR: 4,
APR: 4,
MAI: 5,
MAY: 5,
JUN: 6,
JUIN: 6,
JUL: 7,
JUIL: 7,
AOU: 8,
AUG: 8,
SEP: 9,
OCT: 10,
NOV: 11,
DEC: 12,
};
const isoDate = (year: number, month: number, day: number): string | null => {
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (year < 1900 || year > 2100) return null;
const date = new Date(Date.UTC(year, month - 1, day));
// Rejects the likes of 31/02 that survive the range checks above.
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day)
return null;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
/**
* Every date on a line, normalised to YYYY-MM-DD.
*
* Lebanese documents print day-first (the French convention), so an ambiguous
* pair like 03/04 is read as 3 April. When the second component is above 12 the
* card must be month-first after all, so that reading wins instead — which is
* how a US-formatted document still parses correctly.
*/
const datesIn = (line: string): string[] => {
const found: string[] = [];
// Year-first: 2027-03-14
for (const match of line.matchAll(
/\b(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})\b/g,
)) {
const iso = isoDate(+match[1], +match[2], +match[3]);
if (iso) found.push(iso);
}
// Day-first or month-first: 14/03/2027
for (const match of line.matchAll(
/\b(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\b/g,
)) {
const [, a, b, year] = match;
const iso = +b > 12 ? isoDate(+year, +a, +b) : isoDate(+year, +b, +a);
if (iso) found.push(iso);
}
// Spelled-out month: 14 MAR 2027
for (const match of line.matchAll(
/\b(\d{1,2})\s+([A-Z]{3,4})\.?\s+(\d{4})\b/g,
)) {
const month = MONTH_NAMES[match[2]];
const iso = month ? isoDate(+match[3], month, +match[1]) : null;
if (iso) found.push(iso);
}
return found;
};
// Label vocabularies. Arabic first because that is what an ID card leads with.
const EXPIRY_LABELS =
/صلاحية|الصلاحية|تنتهي|انتهاء|ينتهي|EXPIR|VALABLE|VALIDIT|VALID|JUSQU|UNTIL/;
const ISSUE_LABELS =
/اصدار|الاصدار|تاريخ الاصدار|DELIVR|ISSUE|ISSUED|EMIS|EMISSION/;
const BIRTH_LABELS = /ولادة|الولادة|مواليد|NAISSANCE|BIRTH|NE LE|DOB/;
/**
* The expiry date, which is the one date on a licence we actually want.
*
* A licence shows three dates — birth, issue, expiry — and picking the wrong
* one fails the driver's submission on a date they never typed. So a labelled
* expiry wins outright. Failing that, dates sitting on a birth or issue line
* are excluded, along with any the caller already identified by field code,
* and the latest remaining future date is taken — expiry is the only one of
* the three that can be in the future.
*/
const findExpiry = (
docLines: string[],
exclude: Set<string> = new Set(),
): string | undefined => {
const today = new Date().toISOString().slice(0, 10);
const unlabelled: string[] = [];
for (const line of docLines) {
const onLine = datesIn(line).filter((date) => !exclude.has(date));
if (onLine.length === 0) continue;
if (EXPIRY_LABELS.test(line)) {
// A line reading "issued 14/03/2022 expires 14/03/2027" carries both, and
// the later one is the expiry.
const future = onLine.filter((date) => date > today).sort();
if (future.length > 0) return future[future.length - 1];
return onLine.sort()[onLine.length - 1];
}
if (ISSUE_LABELS.test(line) || BIRTH_LABELS.test(line)) continue;
unlabelled.push(...onLine);
}
const future = unlabelled.filter((date) => date > today).sort();
return future.length > 0 ? future[future.length - 1] : undefined;
};
/**
* Value printed against a numbered field code.
*
* The Lebanese licence is an EU-format card (Directive 2006/126/EC), which
* means its fields are identified by a printed number rather than a word: 1 is
* the surname, 2 the given names, 3 the date of birth, 4a the issue date, 4b
* the expiry, 4c the issuing authority, 5 the licence number. Reading those
* codes is far more reliable than hunting for "expiry" in three languages,
* because the card never prints the word in any of them — the only prose on it
* is the "PERMIS DE CONDUIRE / DRIVING LICENSE" title.
*
* The value normally sits on the same line as its code; when Vision splits the
* label column from the value column, it lands on the next line instead, so
* both layouts are handled.
*
* `shape` is what makes the second layout safe. Reading a card whose codes are
* stacked ("1 / 2 / 3 / 4a / 4b / 5") followed by the values in their own
* block, "the line after code 5" is the *first* value, not the fifth — on the
* sample licence that is the surname. Requiring the value to look like the
* field it claims to be rejects that mismatch and lets the caller fall through
* to a fallback that gets it right.
*/
const ANY_FIELD_CODE = /^\d{1,2}[ABCD]?[.):]?$/;
const numberedField = (
docLines: string[],
code: string,
shape?: RegExp,
): string | undefined => {
// The leading (^|\s) is what stops code "5" matching inside "15." and code
// "3" matching inside "13B" — both of which are printed on this card.
const inline = new RegExp(`(?:^|\\s)${code}[.):\\s]\\s*(\\S.*)$`);
const bare = new RegExp(`^${code}[.):]?$`);
const fits = (value: string) =>
value.length > 0 && (!shape || shape.test(value));
for (let index = 0; index < docLines.length; index += 1) {
const match = docLines[index].match(inline);
const sameLine = match?.[1]?.trim();
if (sameLine && fits(sameLine)) return sameLine;
if (!bare.test(docLines[index])) continue;
const next = docLines[index + 1]?.trim();
// A code followed by another code is a label column; there is no value
// there to take.
if (next && !ANY_FIELD_CODE.test(next) && fits(next)) return next;
}
return undefined;
};
/** The single date in a numbered field's value, if it holds one. */
const numberedDate = (docLines: string[], code: string): string | undefined => {
const value = numberedField(
docLines,
code,
/\d{1,4}[-/.]\d{1,2}[-/.]\d{2,4}/,
);
return value ? datesIn(value)[0] : undefined;
};
// --- Field extraction -----------------------------------------------------
/**
* OCR routinely drops the separator between a label and its value, so a capture
* can arrive as "N 123456" or with trailing label text from the next column.
* Keep the leading run of value-shaped characters and drop the rest.
*/
const cleanValue = (raw: string, allowed: RegExp): string | undefined => {
const value = raw
.trim()
.replace(/^[:.\-–—\s]+/, "")
.split(/\s{2,}/)[0]
.trim();
const kept = value
.split("")
.filter((char) => allowed.test(char))
.join("")
.trim();
return kept.length >= 3 ? kept : undefined;
};
/** First capture across a list of patterns, tried in order of confidence. */
const firstMatch = (
docLines: string[],
patterns: RegExp[],
allowed: RegExp,
): string | undefined => {
for (const pattern of patterns) {
for (const line of docLines) {
const match = line.match(pattern);
const value = match?.[1] ? cleanValue(match[1], allowed) : undefined;
if (value) return value;
}
}
return undefined;
};
/**
* Last resort when no label was recognised: the longest plausible run of
* digits on the card. Dates are stripped first, otherwise "14/03/2027" reads
* as an eight-digit licence number.
*/
const longestDigitRun = (
docLines: string[],
min: number,
max: number,
): string | undefined => {
let best: string | undefined;
for (const line of docLines) {
const withoutDates = line
.replace(/\b\d{1,4}[-/.]\d{1,2}[-/.]\d{2,4}\b/g, " ")
.replace(/\b(19|20)\d{2}\b/g, " ");
for (const match of withoutDates.matchAll(/\d[\d\s-]{2,}\d/g)) {
const digits = match[0].replace(/[\s-]/g, "");
if (digits.length < min || digits.length > max) continue;
if (!best || digits.length > best.length) best = digits;
}
}
return best;
};
const ALPHANUMERIC = /[A-Z0-9/-]/;
const DIGITS_ONLY = /[0-9]/;
/** Lebanese plates pair digits with a letter group, Arabic or Latin. */
const PLATE_CHARS = /[A-Z0-9ء-ي/-]/;
const MODEL_CHARS = /[A-Z0-9 .-]/;
const extractLicense = (docLines: string[]): ExtractedFields => {
// Field 5 is the licence number on the EU-format card, and it is by far the
// most reliable read — so it is tried before any worded label. The word
// patterns cover older Lebanese licences that predate the numbered layout,
// and the digit-run fallback covers a card whose codes didn't survive OCR.
// A licence number is a run of digits, so requiring some is what keeps a
// stacked-label card from handing back the holder's surname here.
const field5 = numberedField(docLines, "5", /\d{3,}/);
const license_number =
(field5 ? cleanValue(field5, ALPHANUMERIC) : undefined) ??
firstMatch(
docLines,
[
/(?:رقم\s*(?:الرخصة|الاجازة|الرخصه)?|PERMIS\s*(?:DE\s*CONDUIRE\s*)?N|N[°ºO]\s*(?:DE\s*)?PERMIS|LICEN[CS]E\s*(?:NO|NUMBER|N[°ºO]))\s*[:.\-]?\s*([A-Z0-9][A-Z0-9/\- ]{3,19})/,
/\bN[°ºO]\s*[:.\-]?\s*([A-Z0-9][A-Z0-9/\- ]{4,19})/,
],
ALPHANUMERIC,
) ??
longestDigitRun(docLines, 5, 15);
// 3 is the date of birth and 4a the date of issue. Naming them explicitly
// does double duty: 4b gives the expiry outright, and knowing the other two
// keeps them out of the fallback, which would otherwise be free to mistake a
// recent issue date for an expiry.
const birth = numberedDate(docLines, "3");
const issued = numberedDate(docLines, "4A");
const expires = numberedDate(docLines, "4B");
const excluded = new Set([birth, issued].filter(Boolean) as string[]);
// A card that reads 4b but whose expiry has already passed is a real answer,
// not a misread — surface it so the driver sees why the form rejects it,
// rather than silently leaving the field blank.
const license_expiry = expires ?? findExpiry(docLines, excluded);
// A Lebanese licence carries the holder's register number too, but only
// behind an explicit label — a bare digit run on a licence is far more
// likely to be the licence number itself.
const national_id = firstMatch(
docLines,
[
/(?:رقم\s*(?:الهوية|السجل)|REGISTRE|SEJEL|ID\s*(?:NO|NUMBER)|IDENTITY\s*(?:NO|NUMBER))\s*[:.\-]?\s*([0-9][0-9\- ]{4,19})/,
],
DIGITS_ONLY,
);
return { license_number, license_expiry, national_id };
};
const extractId = (docLines: string[]): ExtractedFields => ({
national_id:
firstMatch(
docLines,
[
/(?:رقم\s*(?:الهوية|السجل|البطاقة)|N[°ºO]\s*(?:DE\s*)?(?:CARTE|REGISTRE)|REGISTRE|SEJEL|ID\s*(?:NO|NUMBER)|IDENTITY\s*(?:NO|NUMBER))\s*[:.\-]?\s*([0-9][0-9\- ]{4,19})/,
/\bرقم\s*[:.\-]?\s*([0-9][0-9\- ]{5,19})/,
],
DIGITS_ONLY,
) ?? longestDigitRun(docLines, 6, 14),
});
const extractVehicleRegistration = (docLines: string[]): ExtractedFields => {
const plate_number =
firstMatch(
docLines,
[
/(?:رقم\s*(?:اللوحة|السيارة)|اللوحة|PLAQUE|IMMATRICULATION|PLATE\s*(?:NO|NUMBER)?|REGISTRATION\s*(?:NO|NUMBER)?)\s*[:.\-]?\s*([0-9ء-يA-Z][0-9A-Zء-ي/\- ]{2,14})/,
// Unlabelled but unmistakable: digits, a slash, then the letter group.
/\b(\d{1,7}\s*\/\s*[A-Zء-ي]{1,3})\b/,
],
PLATE_CHARS,
) ?? undefined;
const car_model = firstMatch(
docLines,
[
/(?:نوع\s*(?:السيارة|المركبة)?|الطراز|MARQUE(?:\s*ET\s*TYPE)?|MODELE|MODÈLE|MAKE|MODEL)\s*[:.\-]?\s*([A-Z][A-Z0-9 .-]{2,29})/,
],
MODEL_CHARS,
);
return { plate_number, car_model };
};
/** Drops keys whose value came back empty so callers can spread the result. */
const compact = (fields: ExtractedFields): ExtractedFields =>
Object.fromEntries(
Object.entries(fields).filter(([, value]) => Boolean(value)),
) as ExtractedFields;
/** Pulls the credential fields out of already-recognised document text. */
export const parseDocumentText = (
text: string,
docType: DocumentType,
): ExtractedFields => {
const docLines = lines(normalise(text));
if (docLines.length === 0) return {};
switch (docType) {
case "license":
return compact(extractLicense(docLines));
case "id":
return compact(extractId(docLines));
case "vehicle_reg":
return compact(extractVehicleRegistration(docLines));
}
};
// --- Google Cloud Vision --------------------------------------------------
export class OcrUnavailableError extends Error {}
type VisionResponse = {
responses?: {
fullTextAnnotation?: { text?: string };
error?: { message?: string };
}[];
};
/**
* Runs Vision's document OCR over a scan and returns the recognised text.
*
* DOCUMENT_TEXT_DETECTION (rather than plain TEXT_DETECTION) is the dense-text
* model: it keeps the line structure of a card, which is what every label
* pattern above depends on. The language hints are the three that appear on
* Lebanese documents — without them Vision often transliterates Arabic instead
* of reading it.
*/
export const recogniseDocument = async (image: Buffer): Promise<string> => {
const key = process.env.GOOGLE_VISION_API_KEY;
if (!key) {
throw new OcrUnavailableError("GOOGLE_VISION_API_KEY is not configured.");
}
let response: Response;
try {
response = await fetch(
`${VISION_ENDPOINT}?key=${encodeURIComponent(key)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
requests: [
{
image: { content: image.toString("base64") },
features: [{ type: "DOCUMENT_TEXT_DETECTION", maxResults: 1 }],
imageContext: { languageHints: ["ar", "fr", "en"] },
},
],
}),
// A driver is watching a spinner; failing over to manual entry beats
// holding the screen while Vision is slow.
signal: AbortSignal.timeout(20_000),
},
);
} catch (error) {
throw new OcrUnavailableError(
`Vision request failed: ${(error as Error).message}`,
);
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new OcrUnavailableError(
`Vision responded ${response.status}: ${detail.slice(0, 200)}`,
);
}
const body = (await response.json()) as VisionResponse;
const result = body.responses?.[0];
// Vision reports per-image failures inside a 200 response, so the status
// code alone does not tell you the scan was read.
if (result?.error?.message) {
throw new OcrUnavailableError(`Vision error: ${result.error.message}`);
}
return result?.fullTextAnnotation?.text ?? "";
};