WORKFLOW-APP/lib/format.ts
Ysterbaard 1fecf02214 Add pre-send validation gate + idempotent sign-back to appointment flow
PB: 3 new fields on ysterbaard_director_appointments
  - proceed_anyway (bool) — true if user proceeded with missing emails
  - missing_emails (text) — comma-separated keys
  - missing_emails_detail (text) — JSON per-recipient validation

App:
  - POST /api/appointments: builds recipient report (initial +
    distro); returns 422 + report if proceed_anyway is missing
    and any recipient is missing/invalid. preview=1 returns the
    report without sending.
  - PATCH /api/appointments/[id]: tightened idempotency guard to
    also short-circuit on signature_received (not just
    mail_sent_to_all). Prevents double-send on double-click.
  - DirectorAppointmentTab: two-step modal — "Validate recipients"
    surfaces the report (status dots + per-recipient email),
    then Send / Proceed anyway / Fix records first. New
    "company_email" gap surfaced before any email leaves.
  - lib/format.ts: isValidEmail() shared helper.

End-to-end tested: 422 enforces server-side, preview returns
the report, double-PATCH signs only once.
2026-08-18 21:29:08 +02:00

79 lines
3.2 KiB
TypeScript

// lib/format.ts
// Defensive formatters. All PocketBase text fields may come back as
// null, undefined, or — for some columns historically stored as
// numerics — actual numbers. String() coerce first.
const PLACEHOLDER = "—";
export function text(value: unknown, fallback: string = PLACEHOLDER): string {
if (value === null || value === undefined) return fallback;
const s = String(value).trim();
return s.length === 0 ? fallback : s;
}
export function number(value: unknown, fallback: number = 0): number {
if (value === null || value === undefined || value === "") return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return PLACEHOLDER;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return PLACEHOLDER;
// SA-style dd MMM yyyy (e.g. "08 Jul 2026"). Uses user locale-stable
// English month names to avoid I18n drift in screenshots.
const day = String(d.getDate()).padStart(2, "0");
const month = d.toLocaleString("en-US", { month: "short", timeZone: "UTC" });
const year = d.getUTCFullYear();
return `${day} ${month} ${year}`;
}
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return PLACEHOLDER;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return PLACEHOLDER;
const day = String(d.getDate()).padStart(2, "0");
const month = d.toLocaleString("en-US", { month: "short", timeZone: "UTC" });
const year = d.getUTCFullYear();
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day} ${month} ${year} ${hh}:${mm}`;
}
/** Map raw `enterprise_status` text into the three buckets we render. */
export function deriveCompanyStatus(
enterpriseStatus: string | null,
isActive: boolean,
): "active" | "inactive" | "in_process" | "deregistered" {
const s = (enterpriseStatus ?? "").toLowerCase();
if (s.includes("deregist")) return "deregistered";
if (s.includes("in process") || s.includes("process")) return "in_process";
if (s.includes("in business") || s.includes("active")) return "active";
if (s.includes("inactive")) return "inactive";
// Fall back to the boolean if text status is ambiguous.
return isActive ? "active" : "inactive";
}
/** Build a PocketBase filter string safely. Escapes single quotes
* by doubling them (PB's own escape convention). */
export function quoteFilter(input: string): string {
return input.replace(/'/g, "''");
}
/** Loose email format check. Returns true if the string looks like
* a real address — not a typo, not empty, not missing the domain. */
export function isValidEmail(value: unknown): boolean {
if (typeof value !== "string") return false;
const v = value.trim();
if (!v) return false;
// Standard-ish: local@domain.tld, no spaces, must contain @ and a dot in the domain
// Allow + (plus addressing), dots in local part, etc.
return /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/.test(v);
}
/** True if the recipient is missing OR has an invalid format. */
export function isMissingOrInvalidEmail(value: unknown): boolean {
return !isValidEmail(value);
}