From 1896cbdd112c2d97257e57bf5526dfb6f11624f9 Mon Sep 17 00:00:00 2001 From: Krikorios <99836218+Krikorios@users.noreply.github.com> Date: Wed, 6 May 2026 10:51:55 +0300 Subject: [PATCH] Add cash management schema and immediate variance alerts --- CLAUDE.md | 252 +++ README.md | 87 + bun.lockb | Bin 198351 -> 0 bytes docker-compose.yml | 25 + docs/THREAT_MODEL.md | 78 + package-lock.json | 502 ++--- package.json | 9 +- server/.env | 6 + server/.env.example | 9 + server/db/init/00_auth_shim.sql | 63 + server/db/init/01_run_migrations.sh | 20 + server/db/init/50_employee_payments.sql | 32 + server/db/init/99_seed_admin.sh | 57 + server/package-lock.json | 1752 +++++++++++++++++ server/package.json | 19 + server/src/index.js | 410 ++++ src/App.css | 42 - src/components/AdminDataEntryModal.tsx | 70 +- src/components/CashierTools.tsx | 213 ++ .../DetailedEmployeePaymentReport.tsx | 93 +- src/components/LoginPage.tsx | 10 +- src/components/ManagerConsole.tsx | 768 ++++++++ src/components/OutstandingReportDashboard.tsx | 88 +- src/components/OwnerOverview.tsx | 529 +++++ src/components/ShiftControl.tsx | 590 ++++++ src/components/TransactionEntry.tsx | 1223 ++++++++++++ src/components/UserManagement.tsx | 211 ++ src/components/ui/accordion.tsx | 56 - src/components/ui/alert-dialog.tsx | 139 -- src/components/ui/alert.tsx | 59 - src/components/ui/aspect-ratio.tsx | 5 - src/components/ui/avatar.tsx | 48 - src/components/ui/badge.tsx | 36 - src/components/ui/breadcrumb.tsx | 115 -- src/components/ui/carousel.tsx | 260 --- src/components/ui/chart.tsx | 363 ---- src/components/ui/checkbox.tsx | 28 - src/components/ui/collapsible.tsx | 9 - src/components/ui/command.tsx | 153 -- src/components/ui/context-menu.tsx | 198 -- src/components/ui/drawer.tsx | 116 -- src/components/ui/dropdown-menu.tsx | 198 -- src/components/ui/form.tsx | 176 -- src/components/ui/hover-card.tsx | 27 - src/components/ui/input-otp.tsx | 69 - src/components/ui/menubar.tsx | 234 --- src/components/ui/navigation-menu.tsx | 128 -- src/components/ui/pagination.tsx | 117 -- src/components/ui/progress.tsx | 26 - src/components/ui/radio-group.tsx | 42 - src/components/ui/resizable.tsx | 43 - src/components/ui/scroll-area.tsx | 46 - src/components/ui/separator.tsx | 29 - src/components/ui/sheet.tsx | 131 -- src/components/ui/sidebar.tsx | 761 ------- src/components/ui/skeleton.tsx | 15 - src/components/ui/slider.tsx | 26 - src/components/ui/switch.tsx | 27 - src/components/ui/toggle-group.tsx | 59 - src/components/ui/toggle.tsx | 43 - src/hooks/use-mobile.tsx | 19 - src/hooks/useAuth.tsx | 113 +- src/hooks/useEmployeeData.ts | 172 +- src/hooks/useSupabaseEmployeeData.ts | 271 ++- src/integrations/supabase/client.ts | 64 +- src/integrations/supabase/types.ts | 138 -- src/lib/api.ts | 178 ++ src/lib/currency.ts | 56 + src/lib/services.ts | 69 + src/pages/Index.tsx | 98 +- supabase/migrations/0001_auth_and_org.sql | 348 ++++ supabase/migrations/0002_shifts_and_cash.sql | 391 ++++ .../migrations/0003_transactions_ledger.sql | 525 +++++ supabase/migrations/0004_service_details.sql | 388 ++++ .../migrations/0005_inventory_and_float.sql | 618 ++++++ .../migrations/0006_customers_and_kyc.sql | 376 ++++ .../migrations/0007_receipts_and_evidence.sql | 519 +++++ .../migrations/0008_refunds_and_overrides.sql | 430 ++++ .../0009_external_reconciliation.sql | 573 ++++++ .../migrations/0010_reporting_and_alerts.sql | 511 +++++ supabase/migrations/0011_hardening.sql | 222 +++ .../0013_user_shift_record_rpcs.sql | 667 +++++++ supabase/migrations/0014_product_catalog.sql | 105 + supabase/migrations/0015_midday_drops.sql | 70 + .../migrations/0016_shift_assignments.sql | 78 + .../migrations/0017_get_shop_users_rpc.sql | 20 + .../0018_money_movement_coupling.sql | 506 +++++ .../0019_cash_movement_sign_guard.sql | 121 ++ .../0020_void_reverses_movements.sql | 233 +++ supabase/migrations/0021_fee_schedule.sql | 230 +++ .../migrations/0022_atomic_sale_coupling.sql | 318 +++ .../migrations/0023_fx_rates_and_swap.sql | 296 +++ .../0024_idempotency_and_self_deal.sql | 331 ++++ .../migrations/0025_safe_and_bank_ledger.sql | 311 +++ .../migrations/0026_manager_seed_rpcs.sql | 95 + supabase/migrations/0027_till_management.sql | 113 ++ supabase/migrations/0028_live_drawer.sql | 94 + supabase/migrations/0029_my_active_shift.sql | 48 + .../0030_owner_dashboard_grants.sql | 3 + .../migrations/0031_end_of_day_reports.sql | 309 +++ .../migrations/0032_open_shift_zero_legs.sql | 71 + .../migrations/0033_alert_kind_extensions.sql | 4 + ...mmediate_variance_and_safe_drop_alerts.sql | 267 +++ .../0035_restore_chronic_short_alert_view.sql | 15 + .../0036_allow_internal_verify_chain.sql | 36 + supabase/migrations/README.md | 44 + 106 files changed, 16800 insertions(+), 4604 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 bun.lockb create mode 100644 docker-compose.yml create mode 100644 docs/THREAT_MODEL.md create mode 100644 server/.env create mode 100644 server/.env.example create mode 100644 server/db/init/00_auth_shim.sql create mode 100755 server/db/init/01_run_migrations.sh create mode 100644 server/db/init/50_employee_payments.sql create mode 100755 server/db/init/99_seed_admin.sh create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/index.js delete mode 100644 src/App.css create mode 100644 src/components/CashierTools.tsx create mode 100644 src/components/ManagerConsole.tsx create mode 100644 src/components/OwnerOverview.tsx create mode 100644 src/components/ShiftControl.tsx create mode 100644 src/components/TransactionEntry.tsx create mode 100644 src/components/UserManagement.tsx delete mode 100644 src/components/ui/accordion.tsx delete mode 100644 src/components/ui/alert-dialog.tsx delete mode 100644 src/components/ui/alert.tsx delete mode 100644 src/components/ui/aspect-ratio.tsx delete mode 100644 src/components/ui/avatar.tsx delete mode 100644 src/components/ui/badge.tsx delete mode 100644 src/components/ui/breadcrumb.tsx delete mode 100644 src/components/ui/carousel.tsx delete mode 100644 src/components/ui/chart.tsx delete mode 100644 src/components/ui/checkbox.tsx delete mode 100644 src/components/ui/collapsible.tsx delete mode 100644 src/components/ui/command.tsx delete mode 100644 src/components/ui/context-menu.tsx delete mode 100644 src/components/ui/drawer.tsx delete mode 100644 src/components/ui/dropdown-menu.tsx delete mode 100644 src/components/ui/form.tsx delete mode 100644 src/components/ui/hover-card.tsx delete mode 100644 src/components/ui/input-otp.tsx delete mode 100644 src/components/ui/menubar.tsx delete mode 100644 src/components/ui/navigation-menu.tsx delete mode 100644 src/components/ui/pagination.tsx delete mode 100644 src/components/ui/progress.tsx delete mode 100644 src/components/ui/radio-group.tsx delete mode 100644 src/components/ui/resizable.tsx delete mode 100644 src/components/ui/scroll-area.tsx delete mode 100644 src/components/ui/separator.tsx delete mode 100644 src/components/ui/sheet.tsx delete mode 100644 src/components/ui/sidebar.tsx delete mode 100644 src/components/ui/skeleton.tsx delete mode 100644 src/components/ui/slider.tsx delete mode 100644 src/components/ui/switch.tsx delete mode 100644 src/components/ui/toggle-group.tsx delete mode 100644 src/components/ui/toggle.tsx delete mode 100644 src/hooks/use-mobile.tsx delete mode 100644 src/integrations/supabase/types.ts create mode 100644 src/lib/api.ts create mode 100644 src/lib/currency.ts create mode 100644 src/lib/services.ts create mode 100644 supabase/migrations/0001_auth_and_org.sql create mode 100644 supabase/migrations/0002_shifts_and_cash.sql create mode 100644 supabase/migrations/0003_transactions_ledger.sql create mode 100644 supabase/migrations/0004_service_details.sql create mode 100644 supabase/migrations/0005_inventory_and_float.sql create mode 100644 supabase/migrations/0006_customers_and_kyc.sql create mode 100644 supabase/migrations/0007_receipts_and_evidence.sql create mode 100644 supabase/migrations/0008_refunds_and_overrides.sql create mode 100644 supabase/migrations/0009_external_reconciliation.sql create mode 100644 supabase/migrations/0010_reporting_and_alerts.sql create mode 100644 supabase/migrations/0011_hardening.sql create mode 100644 supabase/migrations/0013_user_shift_record_rpcs.sql create mode 100644 supabase/migrations/0014_product_catalog.sql create mode 100644 supabase/migrations/0015_midday_drops.sql create mode 100644 supabase/migrations/0016_shift_assignments.sql create mode 100644 supabase/migrations/0017_get_shop_users_rpc.sql create mode 100644 supabase/migrations/0018_money_movement_coupling.sql create mode 100644 supabase/migrations/0019_cash_movement_sign_guard.sql create mode 100644 supabase/migrations/0020_void_reverses_movements.sql create mode 100644 supabase/migrations/0021_fee_schedule.sql create mode 100644 supabase/migrations/0022_atomic_sale_coupling.sql create mode 100644 supabase/migrations/0023_fx_rates_and_swap.sql create mode 100644 supabase/migrations/0024_idempotency_and_self_deal.sql create mode 100644 supabase/migrations/0025_safe_and_bank_ledger.sql create mode 100644 supabase/migrations/0026_manager_seed_rpcs.sql create mode 100644 supabase/migrations/0027_till_management.sql create mode 100644 supabase/migrations/0028_live_drawer.sql create mode 100644 supabase/migrations/0029_my_active_shift.sql create mode 100644 supabase/migrations/0030_owner_dashboard_grants.sql create mode 100644 supabase/migrations/0031_end_of_day_reports.sql create mode 100644 supabase/migrations/0032_open_shift_zero_legs.sql create mode 100644 supabase/migrations/0033_alert_kind_extensions.sql create mode 100644 supabase/migrations/0034_immediate_variance_and_safe_drop_alerts.sql create mode 100644 supabase/migrations/0035_restore_chronic_short_alert_view.sql create mode 100644 supabase/migrations/0036_allow_internal_verify_chain.sql create mode 100644 supabase/migrations/README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9b4301 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,252 @@ +# CLAUDE.md + +Persistent context for Claude / Copilot sessions on this repository. +Read this **first** before making changes. + +--- + +## 1. Project Idea + +**CRM OMT — Cash Collection Management System** is a multi-shop POS / cash +control / reconciliation web app aimed at the typical Lebanese cell-phone / +mixed-retail shop. + +### The real-world problem we're solving + +Most cell shops in Lebanon don't just sell phones and do repairs — they also +operate an **OMT counter and/or a Whish counter** (plus Alfa/Touch recharge, +EDL bill payments, FX swap between USD and LBP, etc.) as a side service for +walk-in customers. That side service moves a lot of cash through the till +every day, in two currencies, across multiple employees and shifts. + +What owners actually struggle with: + +- **Cash shortages / "incompatible cash" at end of shift** — the drawer doesn't + match what the system says it should hold. +- **No clear accountability per cashier / per shift** — when money is missing, + it's not obvious *whose* shift it disappeared on. +- **Mixed streams in one drawer** — repair income, item sales, OMT send/receive, + Whish in/out, recharges, bill payments, FX swaps — all flowing through the + same physical cash, in USD *and* LBP, with no separation. +- **Carry-forward shortfalls** — a cashier comes up short one day; the owner + needs that shortfall to roll forward and be cleared by future deposits, not + silently forgotten. +- **Owner has no live visibility** — they want to know, at any moment, how much + cash *should* be in each till and in the safe, who owes what, and where the + variance is. + +This app addresses exactly that: it gives the **shop owner** a tool to track +every money movement (repair, sale, OMT, Whish, recharge, bill, FX, deposit, +withdrawal, refund, void) per **employee** and per **shift**, enforce the +accounting rules in the database (atomic sale coupling, sign guards, void +reverses movements, idempotency), and surface variance + outstanding balances +so losses are caught the same day instead of weeks later. + +### Feature areas + +- **Shift control** — open/close shifts, opening floats, midday drops, end-of-day variance. +- **POS / Transaction entry** — record service sales, fees, FX swaps, customer KYC. +- **Cashier tools** — deposits, withdrawals, refunds, voids, overrides. +- **Manager console** — approvals, fee schedule, fx rates, safe & bank ledger, seed data. +- **User management** — roles (admin / manager / cashier), assignments per shop & shift. +- **Reporting** — outstanding employee balances (carry-forward shortfall logic), + detailed employee payment reports, reconciliation views. + +The accounting model is enforced server-side in PostgreSQL: atomic sale coupling, +cash movement sign guard, void reverses movements, idempotency keys, RLS by +shop/role, etc. (See `supabase/migrations/00xx_*.sql`.) + +--- + +## 2. Origin Note — Supabase ➜ self-hosted Postgres + +> The project was originally scaffolded on **Supabase** (Lovable / `vite_react_shadcn_ts` +> template, `@supabase/supabase-js` client, `auth.users`, RLS using `request.jwt.claim.*`). +> +> It has since been **transformed into a self-hosted PostgreSQL stack**: +> +> - **DB:** local `postgres:16-alpine` via `docker-compose.yml`, data in named volume `dbdata`. +> Schema is the original Supabase migrations under [`supabase/migrations/`](supabase/migrations/), +> replayed by [`server/db/init/01_run_migrations.sh`](server/db/init/01_run_migrations.sh). +> - **Auth shim:** [`server/db/init/00_auth_shim.sql`](server/db/init/00_auth_shim.sql) +> recreates the `auth.users` table + `auth.uid()` / `auth.role()` / `auth.jwt()` SQL +> helpers that the migrations expect, so the original RLS policies keep working. +> - **API:** a small Express server at [`server/src/index.js`](server/src/index.js) +> replaces PostgREST + GoTrue. It issues JWTs via `bcrypt` + `jsonwebtoken`, +> then on every request opens a pooled connection and runs: +> ```sql +> SELECT set_config('request.jwt.claim.sub', $user_id, true); +> SELECT set_config('request.jwt.claim.role', $role, true); +> SELECT set_config('request.jwt.claims', $claims, true); +> SET LOCAL ROLE authenticated; +> ``` +> so RLS continues to evaluate exactly as it did on Supabase. +> - **Frontend shim:** [`src/integrations/supabase/client.ts`](src/integrations/supabase/client.ts) +> is no longer the real Supabase JS client — it is a **drop-in shim** backed by +> [`src/lib/api.ts`](src/lib/api.ts) that exposes the same surface +> (`supabase.auth.*`, `supabase.rpc(...)`, `supabase.from(view).select().eq(...)`). +> This is why existing components keep importing `@/integrations/supabase/client` +> even though there is no Supabase anymore. +- **`@supabase/supabase-js`** has been removed from the project. The shim at + `@/integrations/supabase/client` is now the only "supabase" surface. +> - **Seed admin** is created by [`server/db/init/99_seed_admin.sh`](server/db/init/99_seed_admin.sh) +> from `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` env vars in `docker-compose.yml`. + +**Implication for any future work:** treat `supabase/*` as the **source of truth +for the schema only**. Do not reintroduce calls to a hosted Supabase. New +endpoints must be added to `server/src/index.js` (and, if used as RPCs, exposed +via `app.(...)` SQL functions so they go through the generic `/rpc/:fn` route). + +--- + +## 3. Repository Layout + +``` +cash-collection-management-system/ +├── docker-compose.yml # postgres:16-alpine + volume + init scripts +├── index.html # Vite entry +├── vite.config.ts +├── package.json # frontend (Vite + React 18 + TS + shadcn/ui + tailwind) +├── server/ +│ ├── package.json # express, pg, bcrypt, jsonwebtoken, cors, dotenv +│ ├── .env(.example) # DATABASE_URL, JWT_SECRET, CORS_ORIGIN, PORT +│ ├── src/index.js # the entire Express API (auth, /rpc/:fn, /from/:view, employees…) +│ └── db/init/ # postgres docker-entrypoint-initdb.d +│ ├── 00_auth_shim.sql # recreates auth.users + auth.* helpers +│ ├── 01_run_migrations.sh # replays /sql/migrations/*.sql in order +│ ├── 50_employee_payments.sql # extra app-layer tables for the payment report +│ └── 99_seed_admin.sh # creates first admin from env vars +├── supabase/ +│ ├── config.toml # legacy, unused at runtime +│ └── migrations/ # 0001…0026 — schema + RLS + RPCs (source of truth) +├── src/ +│ ├── main.tsx, App.tsx, index.css +│ ├── pages/ # Index.tsx (tabbed shell), NotFound.tsx +│ ├── components/ # Feature components (see §4) +│ │ └── ui/ # shadcn primitives — only the ones actually used +│ ├── hooks/ +│ │ ├── useAuth.tsx +│ │ ├── useSupabaseEmployeeData.ts # primary data hook (reads/writes via api shim) +│ │ ├── useEmployeeData.ts # legacy adapter, kept for EmployeePaymentReport +│ │ └── use-toast.ts +│ ├── integrations/supabase/ +│ │ └── client.ts # SHIM over src/lib/api.ts — NOT real supabase-js +│ └── lib/ +│ ├── api.ts # fetch wrapper around the Express server +│ ├── services.ts # POS service catalogue (OMT_SEND, WHISH_SEND, …) +│ ├── currency.ts # USD/LBP conversion + formatting +│ └── utils.ts # cn() helper +└── docs/ + └── THREAT_MODEL.md +``` + +--- + +## 4. Feature Components → DB + +| Component | Talks to | +| ------------------------------------- | ---------------------------------------------------------- | +| `LoginPage` | `POST /auth/login` (Express) → JWT in localStorage | +| `OwnerOverview` | `v_owner_dashboard`, `v_z_report`, `v_employee_scorecard_30d`, `alerts` (read), `ack_alert` RPC | +| `ShiftControl` | `supabase.rpc(...)` shift open/declare/finalize + Expected/Counted/Δ panel from `finalize_close` | +| `TransactionEntry` | `supabase.rpc(...)` atomic sale + cash movement RPCs | +| `CashierTools` | `supabase.rpc(...)` deposits / withdrawals / refunds | +| `ManagerConsole` | RPCs for fee schedule, fx rates, safe/bank ledger, seeds | +| `UserManagement` | `useSupabaseEmployeeData` + admin RPCs (`get_shop_users`) | +| `OutstandingReportDashboard` | `useSupabaseEmployeeData` (`/employees`, `/employee_transactions`) | +| `DetailedEmployeePaymentReport` | same | +| `EmployeePaymentReport` | `useEmployeeData` (legacy adapter over the same data) | +| `AdminDataEntryModal` | `useSupabaseEmployeeData.addTransaction(...)` | + +--- + +## 5. How to Run + +```bash +# one-time +cp server/.env.example server/.env # edit JWT_SECRET if exposed +npm install +npm --prefix server install + +# day-to-day (DB + API + Web all together) +npm run dev:all +# DB → docker container crm_omt_db on :5432 +# API → node server on :4000 +# WEB → vite on :5173 (or :8080) + +# build frontend +npm run build + +# wipe & rebuild DB (re-runs all migrations + seeds admin) +npm run db:reset +``` + +Default seed admin (override via env in `docker-compose.yml`): +- email: `admin@local.test` +- password: `ChangeMe123!` + +--- + +## 6. Conventions / Gotchas + +- **Don't import `@supabase/supabase-js` directly.** Use `@/integrations/supabase/client` + (the shim) or `@/lib/api` (raw). The package will be removed. +- **Don't put business logic in the Express server.** All money-touching logic + must live in SQL functions under the `app.*` schema (see migrations 0018–0026) + and be invoked through `/rpc/:fn`. The Express layer only authenticates and + forwards args. +- **RLS depends on JWT claims being set per-connection.** Any new endpoint that + hits a tenant-scoped table must use `withUserClient(req, ...)` in + `server/src/index.js`, not `pool.query` directly. +- **New views exposed via `supabase.from(view)`** must be added to `ALLOWED_VIEWS` + in `server/src/index.js`. +- **shadcn/ui:** only the components actually imported by feature code live in + `src/components/ui/`. If you need another primitive, add it back from + https://ui.shadcn.com — don't restore a kitchen-sink set. +- **Currencies:** USD is the canonical store; LBP is derived via + `getUsdToLbpRate()` in `src/lib/currency.ts`. + +--- + +## 7. Recent Cleanup (2026-05) + +**Cleanup pass:** + +- Root one-off codegen scripts: `rewrite_pos.py`, `update_assign_shift.py`, + `update_shift_control.py`, `update_shiftcontrol_rpc.py`. +- `bun.lockb` (project uses npm). +- `src/App.css` (Vite template leftover, not imported). +- `src/integrations/supabase/types.ts` (Supabase-generated types, unused since the shim). +- `src/hooks/use-mobile.tsx` (only consumed by the now-removed `sidebar` UI). +- Unused shadcn primitives in `src/components/ui/`: + `accordion, alert, alert-dialog, aspect-ratio, avatar, badge, breadcrumb, + carousel, chart, checkbox, collapsible, command, context-menu, drawer, + dropdown-menu, form, hover-card, input-otp, menubar, navigation-menu, + pagination, progress, radio-group, resizable, scroll-area, separator, + sheet, sidebar, skeleton, slider, switch, toggle, toggle-group`. +- `@supabase/supabase-js` uninstalled. + +**Owner-facing additions (against the stated problem):** + +- `ShiftControl` now displays an **Expected / Counted / Δ** panel right after + finalize-close, in USD and LBP, color-coded by short / over / match. +- New **`OwnerOverview`** component, wired as the default tab for `admin` + users. Reads: + - `app.v_owner_dashboard` — per-shop open shifts, open alerts, today's gross. + - `app.v_z_report` — recent closed-shift variances. + - `app.v_employee_scorecard_30d` — 30-day per-cashier variance + voids. + - `app.alerts` — open alerts with one-click acknowledge via `app.ack_alert`. +- Exposed those views/tables in `ALLOWED_VIEWS` in `server/src/index.js`. + RLS continues to scope rows. + +**Known follow-ups still on the list:** + +- Add `WHISH_RECEIVE` (needs new migration: service row + record-receive RPC + + details table or a generic receive path). Today only `WHISH_SEND` is wired. +- Roll real shift variance into the per-cashier outstanding/carry-forward + ledger so the Outstanding Report reflects POS reality, not just manual entries. +- Cashier-side "live drawer" widget while a shift is open + (expected-vs-recorded by stream). Needs a small `app.live_drawer(p_shift_id)` RPC. +- Inventory + repair-ticket UI (DB seeds exist via `0014_product_catalog.sql`). + +`npm run build` is green. diff --git a/README.md b/README.md index b67305d..a83a584 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,90 @@ +# CRM OMT — Cash Collection Management System + +A multi-shop POS / cash control / reconciliation web app for the typical +Lebanese cell-phone or mixed-retail shop that also runs an OMT and/or Whish +counter alongside repair and item sales. + +## The problem + +Most Lebanese cell shops don't just sell phones and do repairs — they also act +as **OMT and/or Whish agents**, sell **Alfa / Touch / Ogero** recharges, take +**EDL** bill payments, and swap between USD and LBP. All of that money flows +through one drawer, in two currencies, across multiple employees and shifts, +mixed with goods and repair income. + +The result for the owner is the recurring "incompatible cash" pain: at the end +of the day the drawer doesn't match what the system *should* hold, and there +is no clear accountability per cashier or per shift. + +## What this app gives the owner + +- **Per-employee, per-shift accountability** with a blind close: the cashier + declares the cash count, the system computes what was expected, and any + variance is recorded against that exact shift. +- **Live variance panel** at end-of-shift (Expected / Counted / Δ in USD and LBP). +- **Owner overview tab** — open shifts, open alerts, today's gross, recent + closed-shift variances, and a 30-day cashier scorecard. +- **All money streams in one ledger** — OMT send/receive, Whish, Alfa/Touch/Ogero + recharge, EDL bill, FX swap, deposits/withdrawals, refunds, voids, mid-day + safe drops, repair, goods sale. +- **DB-enforced accounting**: atomic sale coupling, cash-movement sign guards, + voids reverse movements, idempotency keys, RLS by shop and role. +- **Alerts pipeline** for chronic shorts, void spikes, override spikes, + reconciliation backlog, after-hours activity, voucher write-offs, stock + shrinkage. + +## Architecture + +The schema started life on Supabase but the app **no longer uses Supabase at +runtime**. It runs against a self-hosted Postgres + a thin Express API: + +- **DB:** `postgres:16-alpine` via [docker-compose.yml](docker-compose.yml). + The Supabase migrations under [`supabase/migrations/`](supabase/migrations/) + are replayed on first boot by [`server/db/init/01_run_migrations.sh`](server/db/init/01_run_migrations.sh), + preceded by an `auth.users` shim ([`00_auth_shim.sql`](server/db/init/00_auth_shim.sql)) + so the original `auth.uid()` / RLS policies keep working. +- **API:** [`server/src/index.js`](server/src/index.js) — Express. Issues JWTs + with `bcrypt` + `jsonwebtoken`, opens a pooled connection per request, sets + `request.jwt.claim.*` and `SET LOCAL ROLE authenticated` so RLS evaluates + against the caller. Generic `/rpc/:fn` route forwards to `app.(...)` + SQL functions; `/from/:view` exposes an allow-listed set of read views. +- **Frontend:** Vite + React 18 + TypeScript + shadcn/ui + Tailwind. The + module at [`src/integrations/supabase/client.ts`](src/integrations/supabase/client.ts) + is **a drop-in shim** over [`src/lib/api.ts`](src/lib/api.ts) — same + `.auth`, `.rpc`, `.from(...)` surface, but talks to the Express server. + +See [CLAUDE.md](CLAUDE.md) for the full design notes and conventions. + +## Run it + +```bash +# one-time +cp server/.env.example server/.env # set JWT_SECRET, etc. +npm install +npm --prefix server install + +# day-to-day (DB + API + Web all together) +npm run dev:all +# DB → docker container crm_omt_db on :5432 +# API → node server on :4000 +# WEB → vite on :5173 + +# build the frontend +npm run build + +# wipe & rebuild the DB (reruns migrations + reseeds the admin) +npm run db:reset +``` + +Default seed admin (override via env in [docker-compose.yml](docker-compose.yml)): + +- email: `admin@local.test` +- password: `ChangeMe123!` + +## Tech stack + +React 18, TypeScript, Vite, shadcn/ui, Tailwind, TanStack Query, react-hook-form, +zod · Express 4, pg, bcrypt, jsonwebtoken · PostgreSQL 16 · Docker Compose. 💼 Employee Payment Reconciliation Dashboard A mini web application built with React and Supabase that tracks daily collection vs deposit transactions for employees and enforces a carry-forward balance logic. If an employee's deposit on a given day is less than the collection amount, the shortfall is rolled over and must be cleared by future deposits. The app processes transaction data, maintains a running balance, and generates an intuitive dashboard to visualize employee payment behavior. diff --git a/bun.lockb b/bun.lockb deleted file mode 100644 index 160304d398161f97165233dfe6636caa631bfdfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 198351 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aca31b0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + db: + image: postgres:16-alpine + container_name: crm_omt_db + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: crm_omt + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@local.test} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!} + ADMIN_NAME: ${ADMIN_NAME:-Local Admin} + volumes: + - dbdata:/var/lib/postgresql/data + - ./supabase/migrations:/sql/migrations:ro + - ./server/db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d crm_omt"] + interval: 5s + timeout: 5s + retries: 20 + +volumes: + dbdata: diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..13dbf21 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,78 @@ +# Threat Model — OMT / Recharge Cell Shop + +> **Purpose.** Document, before any code is written, every realistic way an +> employee (or a colluding pair) can steal money or goods from the shop, and +> the specific control the system must enforce to block each path. Every later +> migration, RLS policy, trigger, and UI rule must trace back to a row in this +> table. +> +> This file is **append-only** in spirit: when a new theft vector is +> discovered, add a row; do not delete history. + +## Actors + +| Actor | Description | +| ---------- | -------------------------------------------------------------- | +| `cashier` | Operates a till during a shift. Highest fraud-risk role. | +| `manager` | Approves voids, refunds, overrides. Can collude with cashier. | +| `owner` | Read-everything. Surprise inspections. Sets prices/fees. | +| `auditor` | Read-only third party (accountant). | +| `customer` | May be an accomplice (fake refund, fake cancellation). | + +## Trust boundaries + +1. **Browser/POS ↔ Supabase**: client is hostile. Never trust client-supplied + prices, fees, FX rates, timestamps, user IDs, or shift IDs. +2. **App role ↔ Postgres**: even the service role must not be able to `DELETE` + from the ledger or rewrite hashes. Enforce with table grants + triggers. +3. **Shop ↔ Provider (OMT / Alfa / touch / Bank)**: the provider's statement + is the source of truth for reconciliation. Any local row not present on the + provider statement is suspect. + +## Theft vectors and required controls + +| # | Vector | Control (must exist before go-live) | Enforced in | +| -- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| 1 | Pocket cash, never enter the transaction | Mandatory printed receipt, sequential `reference_no` per shop, customer SMS with reference, gap-detection report | DB + server | +| 2 | Enter transaction, then edit / delete it after customer leaves | Append-only ledger; `UPDATE`/`DELETE` revoked; void requires reason + manager PIN; row hash chain | DB triggers | +| 3 | Recharge own/friend's number flagged as "test" | No "test" flag exists; every recharge debits stock or e-float | Schema | +| 4 | Sell a scratch card, keep cash, claim "lost card" | Voucher serial scanned on intake **and** on sale; loss requires manager approval; variance assigned to cashier | DB + UI | +| 5 | Use shop OMT terminal to send to themselves at zero / reduced fee | Fee schedule server-side and immutable per cashier; OMT statement reconciliation; alert on cashier as sender or beneficiary | DB + recon job | +| 6 | Manipulate FX rate (e.g. "I gave 89,000 LBP/USD instead of 90,000") | `fx_rates` populated by scheduled job; cashier selects, never types; rate stamped on each txn | DB + cron | +| 7 | Skim cash from drawer, blame "shortfall" | Blind closing count (declared before expected revealed); per-cashier variance trend; chronic shortage alert | UI + report | +| 8 | Collect customer cash, "deposit later", never deposit | Forced shift close before leaving; aging-outstanding alert > N hours; bank-deposit reconciliation | DB + recon | +| 9 | Share login with a colleague | Per-user PIN re-prompt, device binding, session timeout, no shared accounts | Auth | +| 10 | Print fake receipts on a second printer | Receipt carries server-signed QR (JWT of `txn_id`); spot-scan by owner returns DB row or "FAKE" | Server | +| 11 | Refund/void to themselves | Void/refund requires manager PIN on same device; cashier cannot self-approve; daily void report per cashier | DB + UI | +| 12 | Sell recharge below price ("friend discount") | Price list server-controlled; cashier UI has no price field; override only by manager and logged | DB + UI | +| 13 | Take goods (phones/accessories) without sale | Per-shift inventory count; CCTV ↔ txn time sync; variance report | UI + ops | +| 14 | Tamper with the closing count | Declared count entered first and locked; expected revealed only after; both stored | DB | +| 15 | Accomplice customer "cancels" after cash handed | Cancellations require manager + reason + photo of voided receipt; CCTV cross-check | UI + ops | +| 16 | Structure large transfers under multiple fake walk-in identities | KYC threshold per (customer, day) and (beneficiary, week); customer record mandatory ≥ threshold | DB + AML report | +| 17 | Off-hours transaction when nobody is watching | Shift hours per shop; after-hours flag + alert | DB + alert | +| 18 | Manager–cashier collusion to mass-void real sales | Void rate per (cashier, manager) pair trended; owner-only weekly review; voids count against shift variance | Report | +| 19 | Replay an old OMT receipt to a new customer | `external_ref` unique per provider; duplicate detection on insert | DB constraint | +| 20 | Cashier opens a second, undeclared till on the same device | One open shift per `till_id`; device fingerprint pinned to till | DB + auth | +| 21 | Cashier marks recharge "failed at provider" and keeps cash | Provider e-recharge response stored as `external_ref` and reconciled; "failed" requires provider failure id | DB + recon | +| 22 | Cashier "exchanges currency" at a worse rate than recorded, pocketing the spread | FX swap is a typed `cash_movement` with both legs at the system rate; deviation requires manager override | DB | +| 23 | Cashier deletes their browser data to "lose" pending offline transactions | Offline queue persisted with server-issued idempotency key; missing key sequences flagged on reconnect | Client + server | +| 24 | Insider (developer/DBA) silently edits the database | Hash chain on ledger; daily hash anchor exported off-site; restricted DB roles; audit of all DDL and privileged SQL | DB + ops | +| 25 | Backdated transaction to fit a doctored shift count | `occurred_at` server-side `now()`; cashier cannot set; backdate only by owner role with reason | DB | + +## Non-negotiables (no go-live without these) + +1. RLS on every table; no table is publicly readable or writable. +2. `INSERT`-only ledger with row hash chain; `DELETE` revoked from every role. +3. All money writes go through `SECURITY DEFINER` Postgres functions, not raw + table writes. +4. Server-controlled prices, fees, and FX rates. No cashier-typed money rules. +5. Blind cash close per shift, with declared-vs-expected variance stored. +6. External reconciliation (OMT, Alfa, touch, bank) before any month-close. +7. Per-user account, device-bound, with PIN re-prompt for sensitive ops. +8. Receipt with signed QR linking back to the ledger row. + +## Review cadence + +- Every new feature PR must reference at least one row above (or add one). +- Quarterly walk-through: pick 5 random rows, demonstrate the control still + works on a staging environment. diff --git a/package-lock.json b/package-lock.json index a830128..2390995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", - "@supabase/supabase-js": "^2.49.8", "@tanstack/react-query": "^5.56.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -67,6 +66,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", + "concurrently": "^9.1.0", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", @@ -83,7 +83,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -815,7 +814,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -833,7 +831,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -848,7 +845,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -858,7 +854,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -868,14 +863,12 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -886,7 +879,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -900,7 +892,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -910,7 +901,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -924,7 +914,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2554,80 +2543,6 @@ "win32" ] }, - "node_modules/@supabase/auth-js": { - "version": "2.69.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", - "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", - "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/node-fetch": { - "version": "2.6.15", - "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", - "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", - "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", - "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14", - "@types/phoenix": "^1.5.4", - "@types/ws": "^8.5.10", - "ws": "^8.18.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", - "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.49.8", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.8.tgz", - "integrity": "sha512-zzBQLgS/jZs7btWcIAc7V5yfB+juG7h0AXxKowMJuySsO5vK+F7Vp+HCa07Z+tu9lZtr3sT9fofkc86bdylmtw==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.69.1", - "@supabase/functions-js": "2.4.4", - "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.19.4", - "@supabase/realtime-js": "2.11.2", - "@supabase/storage-js": "2.7.1" - } - }, "node_modules/@swc/core": { "version": "1.7.39", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", @@ -2989,29 +2904,24 @@ "version": "22.7.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, - "node_modules/@types/phoenix": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", - "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3022,21 +2932,12 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/react": "*" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", @@ -3323,7 +3224,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3336,7 +3236,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3352,14 +3251,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3373,7 +3270,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -3437,14 +3333,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3468,7 +3362,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3524,7 +3417,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3572,7 +3464,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -3597,7 +3488,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3617,6 +3507,84 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4008,7 +3976,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4021,14 +3988,12 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -4041,11 +4006,51 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4059,7 +4064,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -4246,14 +4250,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/dom-helpers": { @@ -4270,7 +4272,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -4312,7 +4313,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/esbuild": { @@ -4591,7 +4591,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -4608,7 +4607,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4635,7 +4633,6 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -4658,7 +4655,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4709,7 +4705,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -4740,7 +4735,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4755,12 +4749,21 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4774,7 +4777,6 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -4795,7 +4797,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -4808,7 +4809,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4818,7 +4818,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -4864,7 +4863,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4942,7 +4940,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -4955,7 +4952,6 @@ "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4971,7 +4967,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4981,7 +4976,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4991,7 +4985,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5004,7 +4997,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5014,14 +5006,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -5037,7 +5027,6 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -5111,7 +5100,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -5124,7 +5112,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -5634,7 +5621,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/lucide-react": { @@ -5659,7 +5645,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -5669,7 +5654,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -5696,7 +5680,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -5713,7 +5696,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -5725,7 +5707,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -5768,7 +5749,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5797,7 +5777,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5857,7 +5836,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -5887,7 +5865,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5897,14 +5874,12 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -5921,14 +5896,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5941,7 +5914,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5951,7 +5923,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5961,7 +5932,6 @@ "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5990,7 +5960,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -6008,7 +5977,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -6028,7 +5996,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6064,7 +6031,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6090,7 +6056,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -6104,7 +6069,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -6148,7 +6112,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -6373,7 +6336,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -6383,7 +6345,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -6430,11 +6391,20 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", @@ -6462,7 +6432,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -6509,7 +6478,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -6529,6 +6497,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -6555,7 +6533,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6568,17 +6545,28 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -6601,7 +6589,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6611,7 +6598,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -6630,7 +6616,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6645,7 +6630,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6655,14 +6639,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6675,7 +6657,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -6692,7 +6673,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6705,7 +6685,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6728,7 +6707,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -6764,7 +6742,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6787,7 +6764,6 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -6841,7 +6817,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -6851,7 +6826,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -6870,7 +6844,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -6879,11 +6852,15 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } }, "node_modules/ts-api-utils": { "version": "1.3.0", @@ -6902,7 +6879,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/tslib": { @@ -6966,6 +6942,7 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -7056,7 +7033,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vaul": { @@ -7154,27 +7130,10 @@ } } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7200,7 +7159,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -7219,7 +7177,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7237,7 +7194,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7247,14 +7203,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7269,7 +7223,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7282,7 +7235,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7291,32 +7243,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "license": "MIT", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=10" } }, "node_modules/yaml": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -7325,6 +7265,80 @@ "node": ">= 14" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 387c1fd..7256962 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,13 @@ "type": "module", "scripts": { "dev": "vite", + "dev:all": "concurrently -k -n DB,API,WEB -c blue,magenta,green \"npm run db:up && docker logs -f crm_omt_db\" \"npm run server:dev\" \"vite\"", + "db:up": "docker compose up -d db", + "db:down": "docker compose down", + "db:reset": "docker compose down -v && docker compose up -d db", + "server:install": "npm --prefix server install", + "server:dev": "npm --prefix server run dev", + "server:start": "npm --prefix server start", "build": "vite build", "build:dev": "vite build --mode development", "lint": "eslint .", @@ -39,7 +46,6 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", - "@supabase/supabase-js": "^2.49.8", "@tanstack/react-query": "^5.56.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -70,6 +76,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", + "concurrently": "^9.1.0", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", diff --git a/server/.env b/server/.env new file mode 100644 index 0000000..e436ef2 --- /dev/null +++ b/server/.env @@ -0,0 +1,6 @@ +# Server config (copy to server/.env or set in your shell) +PORT=4000 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt +JWT_SECRET=change-me-to-a-long-random-string +JWT_EXPIRES_IN=12h +CORS_ORIGIN=http://localhost:5173,http://localhost:8080 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..bb57fd9 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,9 @@ +# Server config (copy to server/.env or set in your shell) +PORT=4000 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt +JWT_SECRET=change-me-to-a-long-random-string +JWT_EXPIRES_IN=12h +CORS_ORIGIN=http://localhost:5173,http://localhost:8080 + +# Frontend clients on the same LAN can use the host machine's IP automatically. +# Add fixed origins here if you want to restrict access more tightly. diff --git a/server/db/init/00_auth_shim.sql b/server/db/init/00_auth_shim.sql new file mode 100644 index 0000000..82450bd --- /dev/null +++ b/server/db/init/00_auth_shim.sql @@ -0,0 +1,63 @@ +-- ===================================================================== +-- 0000 Auth shim +-- Provides `auth.users`, `auth.uid()`, `auth.role()`, `auth.jwt()` so +-- the application migrations (which were written for Supabase) run +-- unmodified. The backend sets `request.jwt.claim.sub` (and friends) +-- per request from the verified JWT, then `set local role authenticated`. +-- ===================================================================== + +create extension if not exists "pgcrypto"; +create extension if not exists "citext"; + +-- Supabase ships these roles; create them if missing (e.g. plain Postgres). +do $$ begin + if not exists (select 1 from pg_roles where rolname = 'anon') then + create role anon nologin noinherit; + end if; + if not exists (select 1 from pg_roles where rolname = 'authenticated') then + create role authenticated nologin noinherit; + end if; + if not exists (select 1 from pg_roles where rolname = 'service_role') then + create role service_role nologin noinherit bypassrls; + end if; +end $$; + +create schema if not exists auth; + +-- Minimal `auth.users` compatible with FKs in app migrations. +create table if not exists auth.users ( + id uuid primary key default gen_random_uuid(), + email citext unique, + password_hash text not null, + full_name text, + is_active boolean not null default true, + created_at timestamptz not null default now(), + last_login_at timestamptz +); + +create or replace function auth.uid() +returns uuid +language sql +stable +as $$ + select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid +$$; + +create or replace function auth.role() +returns text +language sql +stable +as $$ + select coalesce(nullif(current_setting('request.jwt.claim.role', true), ''), 'anon') +$$; + +create or replace function auth.jwt() +returns jsonb +language sql +stable +as $$ + select coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb) +$$; + +grant usage on schema auth to authenticated, anon, service_role; +grant select on auth.users to authenticated, service_role; diff --git a/server/db/init/01_run_migrations.sh b/server/db/init/01_run_migrations.sh new file mode 100755 index 0000000..13c92fa --- /dev/null +++ b/server/db/init/01_run_migrations.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Run all SQL migrations from /sql/migrations/ in lexical order. +# On plain Postgres (no pg_cron extension), wrap any bare +# `create extension if not exists pg_cron;` line so it doesn't abort. +set -euo pipefail + +TMPDIR_M=/tmp/migrations +mkdir -p "$TMPDIR_M" +echo ">> applying app migrations from /sql/migrations" +for f in /sql/migrations/*.sql; do + base="$(basename "$f")" + # Replace the bare pg_cron extension creation with a soft variant. + sed -E "s|^create extension if not exists pg_cron;|do \$\$ begin create extension if not exists pg_cron; exception when others then raise notice 'pg_cron unavailable, skipping schedules'; end \$\$;|i" "$f" > "$TMPDIR_M/$base" + echo ">> $base" + psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + -f "$TMPDIR_M/$base" +done +echo ">> migrations complete" diff --git a/server/db/init/50_employee_payments.sql b/server/db/init/50_employee_payments.sql new file mode 100644 index 0000000..d7ce3a0 --- /dev/null +++ b/server/db/init/50_employee_payments.sql @@ -0,0 +1,32 @@ +-- ===================================================================== +-- Local extension migration: simple employee payment ledger used by the +-- Employee Payment Report UI. Backed by the API; not a Supabase migration. +-- ===================================================================== + +create table if not exists app.employees ( + id uuid primary key default gen_random_uuid(), + emp_id text not null unique, + name text not null, + email text, + department text, + location text, + created_at timestamptz not null default now() +); + +create table if not exists app.employee_transactions ( + id uuid primary key default gen_random_uuid(), + employee_id uuid not null references app.employees(id) on delete cascade, + transaction_date date not null, + collection_amount numeric(18,2) not null default 0, + deposit_amount numeric(18,2) not null default 0, + currency text not null check (currency in ('USD','LBP')), + created_at timestamptz not null default now() +); + +create index if not exists idx_emp_tx_emp on app.employee_transactions(employee_id, transaction_date desc); + +-- These tables are owned by the API; RLS off, gated at the HTTP layer. +alter table app.employees disable row level security; +alter table app.employee_transactions disable row level security; +grant select, insert, update, delete on app.employees to authenticated; +grant select, insert, update, delete on app.employee_transactions to authenticated; diff --git a/server/db/init/99_seed_admin.sh b/server/db/init/99_seed_admin.sh new file mode 100755 index 0000000..b098b47 --- /dev/null +++ b/server/db/init/99_seed_admin.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Seed (or reset) the default admin user, default shop, owner role, Till 1. +set -euo pipefail + +ADMIN_EMAIL="${ADMIN_EMAIL:-admin@local.test}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-ChangeMe123!}" +ADMIN_NAME="${ADMIN_NAME:-Local Admin}" + +echo ">> seeding admin user: ${ADMIN_EMAIL}" + +# Use psql -v to safely substitute values inside the DO block via :'name' -- +# but :'name' only works at top level. So we generate plain SQL with the +# values inlined as quoted literals (escaping single quotes). +escape() { printf "%s" "$1" | sed "s/'/''/g"; } +EM=$(escape "$ADMIN_EMAIL") +PW=$(escape "$ADMIN_PASSWORD") +NM=$(escape "$ADMIN_NAME") + +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" <> admin user ensured: ${ADMIN_EMAIL}" diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..1292bfd --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1752 @@ +{ + "name": "crm-omt-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crm-omt-server", + "version": "0.1.0", + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.1" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5df557e --- /dev/null +++ b/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "crm-omt-server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.1" + } +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..fa442f8 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,410 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import pkg from 'pg'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +const { Pool } = pkg; + +const PORT = Number(process.env.PORT || 4000); +const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt'; +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h'; +const LOCAL_DEV_TOOLS_ENABLED = process.env.ENABLE_LOCAL_TEST_ROUTES === '1' + || (process.env.NODE_ENV !== 'production' && /localhost|127\.0\.0\.1/i.test(DATABASE_URL)); +const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5173,http://localhost:8080') + .split(',').map(s => s.trim()).filter(Boolean); + +function isPrivateIpv4(hostname) { + return /^10\./.test(hostname) + || /^127\./.test(hostname) + || /^192\.168\./.test(hostname) + || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname); +} + +function isAllowedOrigin(origin) { + if (!origin) return true; + if (CORS_ORIGINS.includes(origin)) return true; + + try { + const url = new URL(origin); + return ['localhost', '127.0.0.1'].includes(url.hostname) || isPrivateIpv4(url.hostname); + } catch { + return false; + } +} + +const pool = new Pool({ connectionString: DATABASE_URL, max: 10 }); + +const app = express(); +app.use(cors({ + origin(origin, callback) { + callback(isAllowedOrigin(origin) ? null : new Error('Not allowed by CORS'), isAllowedOrigin(origin)); + }, + credentials: true, +})); +app.use(express.json({ limit: '1mb' })); + +// ---- helpers --------------------------------------------------------- + +function signToken(user) { + return jwt.sign( + { sub: user.id, email: user.email, role: 'authenticated' }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN, audience: 'authenticated' }, + ); +} + +function authRequired(req, res, next) { + const hdr = req.get('authorization') || ''; + const m = hdr.match(/^Bearer\s+(.+)$/i); + if (!m) return res.status(401).json({ error: 'missing token' }); + try { + const claims = jwt.verify(m[1], JWT_SECRET); + req.user = { id: claims.sub, email: claims.email, role: claims.role || 'authenticated', claims }; + next(); + } catch (e) { + return res.status(401).json({ error: 'invalid token' }); + } +} + +/** + * Run `fn(client)` on a checked-out connection that has its session + * configured to impersonate the authenticated user, so RLS works: + * SET LOCAL request.jwt.claim.sub = + * SET LOCAL request.jwt.claims = + * SET LOCAL ROLE authenticated + */ +async function withUserClient(req, fn) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query("SELECT set_config('request.jwt.claim.sub', $1, true)", [req.user.id]); + await client.query("SELECT set_config('request.jwt.claim.role', $1, true)", [req.user.role]); + await client.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify(req.user.claims)]); + await client.query("SET LOCAL ROLE authenticated"); + const out = await fn(client); + await client.query('COMMIT'); + return out; + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } +} + +function dbError(res, e) { + console.error('[db]', e.code || '', e.message); + res.status(400).json({ error: e.message, code: e.code, detail: e.detail }); +} + +// ---- auth ------------------------------------------------------------ + +app.post('/auth/login', async (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) return res.status(400).json({ error: 'email and password required' }); + try { + const { rows } = await pool.query( + 'SELECT id, email, password_hash, full_name, is_active FROM auth.users WHERE email = $1 LIMIT 1', + [String(email).trim().toLowerCase()], + ); + const u = rows[0]; + if (!u || !u.is_active) return res.status(401).json({ error: 'invalid credentials' }); + const ok = await bcrypt.compare(password, u.password_hash); + if (!ok) return res.status(401).json({ error: 'invalid credentials' }); + await pool.query('UPDATE auth.users SET last_login_at = now() WHERE id = $1', [u.id]); + const token = signToken(u); + res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name } }); + } catch (e) { dbError(res, e); } +}); + +app.post('/auth/logout', authRequired, (_req, res) => { + // Stateless JWT — client just drops the token. (Add a denylist if needed.) + res.json({ ok: true }); +}); + +// ---- generic RPC ----------------------------------------------------- + +// POST /rpc/:fn body = { ...named args matching app.(...) signature } +app.post('/rpc/:fn', authRequired, async (req, res) => { + const fn = req.params.fn; + if (!/^[a-z_][a-z0-9_]{0,62}$/i.test(fn)) { + return res.status(400).json({ error: 'invalid function name' }); + } + const args = req.body && typeof req.body === 'object' ? req.body : {}; + const names = Object.keys(args); + // Always call as `SELECT * FROM app.(...)` so SETOF/TABLE/composite + // functions expand to rows/columns. Scalar functions yield one row with a + // single column named after the function. + const argList = names.map((n, i) => `${n} => $${i + 1}`).join(', '); + const sql = `SELECT * FROM app.${fn}(${argList})`; + const params = names.map((n) => args[n]); + try { + const data = await withUserClient(req, async (client) => { + const r = await client.query(sql, params); + // Scalar: 1 row, 1 column => unwrap. + if (r.rows.length === 1 && r.fields.length === 1) { + return r.rows[0][r.fields[0].name]; + } + // Single-row composite (e.g. RETURNS record / OUT params): return as object. + if (r.rows.length === 1) return r.rows[0]; + return r.rows; + }); + res.json({ data }); + } catch (e) { dbError(res, e); } +}); + +// ---- table/view reads ------------------------------------------------ + +// GET /from/:view?col=val&col2=val2 -> SELECT * FROM app. WHERE ... +const ALLOWED_VIEWS = new Set([ + 'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions', + 'v_manage_tills', + // Owner / manager dashboards (RLS still restricts rows to allowed shops): + 'v_owner_dashboard', 'v_z_report', 'v_employee_scorecard_30d', 'alerts', 'v_end_of_day_reports', +]); +app.get('/from/:view', authRequired, async (req, res) => { + const view = req.params.view; + if (!ALLOWED_VIEWS.has(view)) return res.status(404).json({ error: 'unknown view' }); + const filters = Object.entries(req.query).filter(([k]) => /^[a-z_][a-z0-9_]*$/i.test(k)); + const where = filters.length + ? 'WHERE ' + filters.map(([k], i) => `${k} = $${i + 1}`).join(' AND ') + : ''; + const params = filters.map(([, v]) => v); + try { + const data = await withUserClient(req, async (client) => { + const r = await client.query(`SELECT * FROM app.${view} ${where}`, params); + return r.rows; + }); + res.json({ data }); + } catch (e) { dbError(res, e); } +}); + +// ---- employees + employee transactions (Employee Payment Report) ----- + +app.get('/employees', authRequired, async (_req, res) => { + try { + const { rows } = await pool.query( + 'SELECT id, emp_id, name, email, department, location FROM app.employees ORDER BY name', + ); + res.json({ data: rows }); + } catch (e) { dbError(res, e); } +}); + +app.post('/employees', authRequired, async (req, res) => { + const { emp_id, name, email, department, location } = req.body || {}; + if (!emp_id || !name) return res.status(400).json({ error: 'emp_id and name required' }); + try { + const { rows } = await pool.query( + `INSERT INTO app.employees(emp_id, name, email, department, location) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (emp_id) DO UPDATE + SET name = EXCLUDED.name, email = EXCLUDED.email, + department = EXCLUDED.department, location = EXCLUDED.location + RETURNING id, emp_id, name, email, department, location`, + [emp_id, name, email || null, department || null, location || null], + ); + res.json({ data: rows[0] }); + } catch (e) { dbError(res, e); } +}); + +app.get('/employee_transactions', authRequired, async (req, res) => { + const { employee_id } = req.query; + try { + const { rows } = await pool.query( + employee_id + ? `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency + FROM app.employee_transactions WHERE employee_id = $1 ORDER BY transaction_date` + : `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency + FROM app.employee_transactions ORDER BY transaction_date`, + employee_id ? [employee_id] : [], + ); + res.json({ data: rows }); + } catch (e) { dbError(res, e); } +}); + +app.post('/employee_transactions', authRequired, async (req, res) => { + const { employee_id, transaction_date, collection_amount, deposit_amount, currency } = req.body || {}; + if (!employee_id || !transaction_date) { + return res.status(400).json({ error: 'employee_id and transaction_date required' }); + } + try { + const { rows } = await pool.query( + `INSERT INTO app.employee_transactions + (employee_id, transaction_date, collection_amount, deposit_amount, currency) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, employee_id, transaction_date, collection_amount, deposit_amount, currency`, + [ + employee_id, + transaction_date, + Number(collection_amount) || 0, + Number(deposit_amount) || 0, + currency || 'USD', + ], + ); + res.json({ data: rows[0] }); + } catch (e) { dbError(res, e); } +}); + +// ---- admin: user management ----------------------------------------- + +async function ensureAdmin(req, res) { + // Owner-anywhere == admin in the UI. Compute via app.is_owner_anywhere(). + try { + const ok = await withUserClient(req, async (client) => { + const r = await client.query('SELECT app.is_owner_anywhere() AS ok'); + return !!r.rows[0]?.ok; + }); + if (!ok) { res.status(403).json({ error: 'admin only' }); return false; } + return true; + } catch (e) { dbError(res, e); return false; } +} + +app.get('/admin/users', authRequired, async (req, res) => { + if (!(await ensureAdmin(req, res))) return; + try { + const { rows } = await pool.query(` + SELECT u.id, u.email, u.is_active, u.full_name, + p.full_name AS profile_name, + coalesce( + (SELECT a.role::text FROM app.user_shop_assignments a + WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1), + 'cashier' + ) AS shop_role, + EXISTS (SELECT 1 FROM app.user_shop_assignments a + WHERE a.user_id = u.id AND a.role = 'owner') AS is_admin, + (SELECT e.emp_id FROM app.employees e WHERE e.email = u.email LIMIT 1) AS emp_id, + (SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department + FROM auth.users u + LEFT JOIN app.user_profiles p ON p.user_id = u.id + ORDER BY u.created_at + `); + res.json({ data: rows }); + } catch (e) { dbError(res, e); } +}); + +app.post('/admin/users', authRequired, async (req, res) => { + if (!(await ensureAdmin(req, res))) return; + const { email, password, name, role, department, empId } = req.body || {}; + if (!email || !password || !name) { + return res.status(400).json({ error: 'email, password, name required' }); + } + if (String(password).length < 6) { + return res.status(400).json({ error: 'password must be at least 6 chars' }); + } + const isAdmin = role === 'admin'; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const hash = await bcrypt.hash(password, 10); + const u = await client.query( + `INSERT INTO auth.users(email, password_hash, full_name, is_active) + VALUES ($1, $2, $3, true) RETURNING id, email, full_name`, + [String(email).trim().toLowerCase(), hash, name], + ); + const userId = u.rows[0].id; + await client.query( + `INSERT INTO app.user_profiles(user_id, full_name, is_active) + VALUES ($1, $2, true) + ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`, + [userId, name], + ); + // Assign to the first available shop (Default Shop typically) so they have a role. + const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1'); + if (shop.rows[0]) { + await client.query( + `INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`, + [userId, shop.rows[0].id, isAdmin ? 'owner' : 'cashier', req.user.id], + ); + } + if (!isAdmin && empId) { + await client.query( + `INSERT INTO app.employees(emp_id, name, email, department) + VALUES ($1, $2, $3, $4) + ON CONFLICT (emp_id) DO UPDATE + SET name = EXCLUDED.name, email = EXCLUDED.email, + department = EXCLUDED.department`, + [empId, name, email, department || null], + ); + } + await client.query('COMMIT'); + res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } }); + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + dbError(res, e); + } finally { + client.release(); + } +}); + +app.delete('/admin/users/:id', authRequired, async (req, res) => { + if (!(await ensureAdmin(req, res))) return; + if (req.params.id === req.user.id) { + return res.status(400).json({ error: 'cannot delete yourself' }); + } + try { + await pool.query('DELETE FROM auth.users WHERE id = $1', [req.params.id]); + res.json({ ok: true }); + } catch (e) { dbError(res, e); } +}); + +app.post('/admin/dev/end_of_day/reopen_latest', authRequired, async (req, res) => { + if (!LOCAL_DEV_TOOLS_ENABLED) { + return res.status(404).json({ error: 'not found' }); + } + if (!(await ensureAdmin(req, res))) return; + + const { shop_id: shopId } = req.body || {}; + if (!shopId) { + return res.status(400).json({ error: 'shop_id required' }); + } + + try { + const r = await pool.query( + `with shop_bounds as ( + select + min(business_date) as oldest_business_date, + min(submitted_at) as oldest_submitted_at + from app.end_of_day_reports + where shop_id = $1 + ), + latest as ( + select id + from app.end_of_day_reports + where shop_id = $1 + order by submitted_at desc + limit 1 + ) + update app.end_of_day_reports e + set business_date = shop_bounds.oldest_business_date - interval '1 day', + submitted_at = shop_bounds.oldest_submitted_at - interval '1 day' + from latest, shop_bounds + where e.id = latest.id + returning e.id, e.shop_id, e.business_date, e.submitted_at`, + [shopId], + ); + const data = r.rows[0] ?? null; + + if (!data) { + return res.status(404).json({ error: 'no end-of-day report found for this shop' }); + } + + res.json({ data }); + } catch (e) { dbError(res, e); } +}); + +// ---- health ---------------------------------------------------------- + +app.get('/health', async (_req, res) => { + try { await pool.query('SELECT 1'); res.json({ ok: true }); } + catch (e) { res.status(500).json({ ok: false, error: e.message }); } +}); + +app.listen(PORT, () => { + console.log(`[server] listening on http://localhost:${PORT}`); +}); diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/components/AdminDataEntryModal.tsx b/src/components/AdminDataEntryModal.tsx index 516e69d..bb31c2d 100644 --- a/src/components/AdminDataEntryModal.tsx +++ b/src/components/AdminDataEntryModal.tsx @@ -1,5 +1,4 @@ - -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -12,6 +11,8 @@ import { format } from "date-fns"; import { cn } from "@/lib/utils"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; import { useToast } from "@/hooks/use-toast"; +import { Currency } from "@/lib/currency"; +import { useAuth } from "@/hooks/useAuth"; interface AdminDataEntryModalProps { isOpen: boolean; @@ -21,14 +22,27 @@ interface AdminDataEntryModalProps { export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminDataEntryModalProps) => { const { employees, addTransaction } = useSupabaseEmployeeData(); + const { user } = useAuth(); const { toast } = useToast(); - + + const isEmployee = user?.role === 'employee'; + const lockedEmployeeId = isEmployee + ? employees.find(e => e.emp_id === user?.empId || e.email === user?.email)?.id + : undefined; + const [selectedEmployeeId, setSelectedEmployeeId] = useState(''); const [collectionAmount, setCollectionAmount] = useState(''); const [depositAmount, setDepositAmount] = useState(''); + const [currency, setCurrency] = useState('USD'); const [selectedDate, setSelectedDate] = useState(new Date()); const [loading, setLoading] = useState(false); + useEffect(() => { + if (isEmployee && lockedEmployeeId) { + setSelectedEmployeeId(lockedEmployeeId); + } + }, [isEmployee, lockedEmployeeId, isOpen]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -59,7 +73,8 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData await addTransaction(selectedEmployeeId, { transaction_date: format(selectedDate, 'yyyy-MM-dd'), collection_amount: collection, - deposit_amount: deposit + deposit_amount: deposit, + currency, }); toast({ @@ -71,6 +86,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData setSelectedEmployeeId(''); setCollectionAmount(''); setDepositAmount(''); + setCurrency('USD'); setSelectedDate(new Date()); onDataUpdate(); @@ -89,6 +105,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData setSelectedEmployeeId(''); setCollectionAmount(''); setDepositAmount(''); + setCurrency('USD'); setSelectedDate(new Date()); onClose(); }; @@ -97,26 +114,40 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData

- Insert Employee Data + + {isEmployee ? "Submit Transaction" : "Insert Employee Data"} +
- - + - {employees.map(employee => ( + {(isEmployee && lockedEmployeeId + ? employees.filter(e => e.id === lockedEmployeeId) + : employees + ).map(employee => ( {employee.name} (ID: {employee.emp_id}) ))} + {isEmployee && !lockedEmployeeId && ( +

+ Your account is not linked to an employee record. Ask an admin to set your Employee ID. +

+ )}
@@ -148,10 +179,23 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
+
+ + +
+
setCollectionAmount(e.target.value)} className="text-right" - step="0.01" + step={currency === 'USD' ? '0.01' : '1'} min="0" disabled={loading} /> @@ -168,7 +212,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
setDepositAmount(e.target.value)} className="text-right" - step="0.01" + step={currency === 'USD' ? '0.01' : '1'} min="0" disabled={loading} /> diff --git a/src/components/CashierTools.tsx b/src/components/CashierTools.tsx new file mode 100644 index 0000000..928397f --- /dev/null +++ b/src/components/CashierTools.tsx @@ -0,0 +1,213 @@ +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { supabase } from "@/integrations/supabase/client"; + +/** + * FxSwapDialog — cashier swaps cash between USD and LBP at the + * currently posted rate. Server-side validates the rate against the + * active fx_rates band, requires the two legs to balance within 1 LBP, + * and posts both cash_movements rows + a FX_SWAP transaction atomically. + */ +export const FxSwapDialog: React.FC<{ + open: boolean; + onClose: () => void; + shopId: string; + tillId: string; + defaultRate?: string; + onDone?: () => void; +}> = ({ open, onClose, shopId, tillId, defaultRate, onDone }) => { + const { toast } = useToast(); + const [direction, setDirection] = useState<"sell_usd" | "buy_usd">("sell_usd"); + const [usdAmount, setUsdAmount] = useState(""); + const [lbpAmount, setLbpAmount] = useState(""); + const [rate, setRate] = useState(defaultRate ?? ""); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + + // Fetch current FX rate when opened. + useEffect(() => { + if (!open || !shopId) return; + (async () => { + const { data } = await supabase + .from("fx_rates") + .select("usd_to_lbp_rate, effective_to") + .eq("shop_id", shopId); + const active = (data ?? []).find((r: { effective_to: string | null }) => !r.effective_to); + if (active) setRate(String((active as { usd_to_lbp_rate: number }).usd_to_lbp_rate)); + })(); + }, [open, shopId]); + + // Auto-compute the opposite leg whenever rate or one amount changes. + const computeFromUsd = (usd: string) => { + setUsdAmount(usd); + const r = Number(rate); + const u = Number(usd); + if (r > 0 && u > 0) setLbpAmount(String(Math.round(u * r))); + }; + const computeFromLbp = (lbp: string) => { + setLbpAmount(lbp); + const r = Number(rate); + const l = Number(lbp); + if (r > 0 && l > 0) setUsdAmount((l / r).toFixed(2)); + }; + + const submit = async () => { + const u = Number(usdAmount); + const l = Number(lbpAmount); + const r = Number(rate); + if (!u || !l || !r || !shopId || !tillId) { + toast({ title: "Fill all fields", variant: "destructive" }); + return; + } + setBusy(true); + try { + // direction === sell_usd: USD leaves till (positive usd_out), LBP enters (positive lbp_in) + // direction === buy_usd: USD enters till (negative usd_out), LBP leaves (negative lbp_in) + const usdOut = direction === "sell_usd" ? u : -u; + const lbpIn = direction === "sell_usd" ? l : -l; + const { error } = await supabase.rpc("record_fx_swap", { + p_shop: shopId, + p_till: tillId, + p_usd_out: usdOut, + p_lbp_in: lbpIn, + p_fx_rate: r, + p_notes: notes.trim() || null, + }); + if (error) throw error; + toast({ title: "FX swap recorded" }); + setUsdAmount(""); setLbpAmount(""); setNotes(""); + onDone?.(); + onClose(); + } catch (e) { + toast({ title: "Swap failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + !o && onClose()}> + + + Cash FX Swap + + Convert till cash between USD and LBP at the posted rate. + + +
+
+ + +
+
+
+ + computeFromUsd(e.target.value)} /> +
+
+ + computeFromLbp(e.target.value)} /> +
+
+
+ + setRate(e.target.value)} /> +

+ Must match the currently posted rate within tolerance. +

+
+
+ + setNotes(e.target.value)} /> +
+
+ + + + +
+
+ ); +}; + +/** + * SelfDealOverrideDialog — manager enters their PIN to authorize the + * NEXT money-transfer transaction even if the cashier matches the + * sender/beneficiary KYC. The override is stored in a session GUC and + * automatically cleared by the trigger after one use. + */ +export const SelfDealOverrideDialog: React.FC<{ + open: boolean; + onClose: () => void; + shopId: string; + onGranted?: () => void; +}> = ({ open, onClose, shopId, onGranted }) => { + const { toast } = useToast(); + const [pin, setPin] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (!pin || !shopId) return; + setBusy(true); + try { + const { error } = await supabase.rpc("manager_allow_next_self_deal", { + p_manager_pin: pin, + p_shop: shopId, + }); + if (error) throw error; + toast({ + title: "Override granted", + description: "The next self-deal transfer in this session will be allowed.", + }); + setPin(""); + onGranted?.(); + onClose(); + } catch (e) { + toast({ title: "Override denied", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + !o && onClose()}> + + + Manager Self-Deal Override + + Enter the manager / owner PIN to allow exactly one transfer + where the cashier is sender or beneficiary. The override is + single-use and audit-logged. + + +
+
+ + setPin(e.target.value)} autoFocus /> +
+
+ + + + +
+
+ ); +}; diff --git a/src/components/DetailedEmployeePaymentReport.tsx b/src/components/DetailedEmployeePaymentReport.tsx index 262af43..ba959cc 100644 --- a/src/components/DetailedEmployeePaymentReport.tsx +++ b/src/components/DetailedEmployeePaymentReport.tsx @@ -1,9 +1,9 @@ - import React, { useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card, CardContent } from "@/components/ui/card"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; +import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency"; interface DetailedTransaction { location: string; @@ -17,18 +17,12 @@ interface DetailedTransaction { } export const DetailedEmployeePaymentReport = () => { - const { employees, transactions } = useSupabaseEmployeeData(); + const [displayCurrency, setDisplayCurrency] = useState("USD"); + const { employees, transactions } = useSupabaseEmployeeData(displayCurrency); const [selectedEmployeeId, setSelectedEmployeeId] = useState('all'); - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('en-IN', { - style: 'currency', - currency: 'INR' - }).format(amount); - }; - const formatDate = (date: string) => { - return new Date(date).toLocaleDateString('en-IN'); + return new Date(date).toLocaleDateString('en-US'); }; // Process transactions with the specific business logic @@ -38,6 +32,11 @@ export const DetailedEmployeePaymentReport = () => { const employeeTransactions = transactions .filter(t => t.employee_id === employeeId) + .map(t => ({ + ...t, + collection_amount: convert(t.collection_amount, t.currency, displayCurrency), + deposit_amount: convert(t.deposit_amount, t.currency, displayCurrency), + })) .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); const detailedTransactions: DetailedTransaction[] = []; @@ -62,7 +61,7 @@ export const DetailedEmployeePaymentReport = () => { if (collections > 0) { pendingCollections.push({ amount: collections, date }); detailedTransactions.push({ - location: 'BGRoad, Karnataka', + location: employee.location || 'BGRoad, Karnataka', empId: employee.emp_id.replace('EMP', ''), empName: employee.name, collectionAmount: collections, @@ -106,7 +105,7 @@ export const DetailedEmployeePaymentReport = () => { // If there's remaining deposit after clearing collections, add separate deposit entries while (remainingDeposit > 0) { detailedTransactions.push({ - location: 'BGRoad, Karnataka', + location: employee.location || 'BGRoad, Karnataka', empId: employee.emp_id.replace('EMP', ''), empName: employee.name, collectionAmount: 0, @@ -133,30 +132,54 @@ export const DetailedEmployeePaymentReport = () => { const detailedTransactions = getTransactionsToShow(); - // Calculate totals + // Calculate totals (combined, in display currency) const totalCollection = detailedTransactions.reduce((sum, t) => sum + t.collectionAmount, 0); const totalDeposit = detailedTransactions.reduce((sum, t) => sum + t.depositAmount, 0); const totalDifference = totalDeposit - totalCollection; + // Per-currency totals from raw transactions, filtered to current selection + const filteredRawTx = selectedEmployeeId === 'all' + ? transactions + : transactions.filter(t => t.employee_id === selectedEmployeeId); + const totalCollectionUSD = filteredRawTx.filter(t => t.currency === 'USD').reduce((s, t) => s + t.collection_amount, 0); + const totalCollectionLBP = filteredRawTx.filter(t => t.currency === 'LBP').reduce((s, t) => s + t.collection_amount, 0); + const totalDepositUSD = filteredRawTx.filter(t => t.currency === 'USD').reduce((s, t) => s + t.deposit_amount, 0); + const totalDepositLBP = filteredRawTx.filter(t => t.currency === 'LBP').reduce((s, t) => s + t.deposit_amount, 0); + const totalDifferenceUSD = totalDepositUSD - totalCollectionUSD; + const totalDifferenceLBP = totalDepositLBP - totalCollectionLBP; + return (
{/* Header */}

Employee Payment Report (Detailed)

+
+ Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP + +
{/* Summary Cards */}
-
-
+
+
-
-

Total Collection

-

(MM) Amount

-

{formatCurrency(totalCollection)}

+
+

Total Collection (MM)

+

USD: {formatCurrency(totalCollectionUSD, "USD")}

+

LBP: {formatCurrency(totalCollectionLBP, "LBP")}

+

≈ {formatCurrency(totalCollection, displayCurrency)}

@@ -164,16 +187,17 @@ export const DetailedEmployeePaymentReport = () => { -
-
+
+
-
-

Total Deposit

-

Amount

-

{formatCurrency(totalDeposit)}

+
+

Total Deposit Amount

+

USD: {formatCurrency(totalDepositUSD, "USD")}

+

LBP: {formatCurrency(totalDepositLBP, "LBP")}

+

≈ {formatCurrency(totalDeposit, displayCurrency)}

@@ -181,17 +205,18 @@ export const DetailedEmployeePaymentReport = () => { -
-
+
+
=
-
+

Net Difference

-

Amount

-

= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(totalDifference)} +

USD: = 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}

+

LBP: = 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}

+

= 0 ? 'text-green-600' : 'text-red-600'}`}> + ≈ {formatCurrency(totalDifference, displayCurrency)}

@@ -239,13 +264,13 @@ export const DetailedEmployeePaymentReport = () => { {transaction.empId} {transaction.empName} - {transaction.collectionAmount > 0 ? transaction.collectionAmount.toLocaleString() : '-'} + {transaction.collectionAmount > 0 ? formatCurrency(transaction.collectionAmount, displayCurrency) : '-'} {transaction.collectionDate ? formatDate(transaction.collectionDate) : '-'} - {transaction.depositAmount > 0 ? transaction.depositAmount.toLocaleString() : '-'} + {transaction.depositAmount > 0 ? formatCurrency(transaction.depositAmount, displayCurrency) : '-'} {transaction.depositDate ? formatDate(transaction.depositDate) : '-'} @@ -255,7 +280,7 @@ export const DetailedEmployeePaymentReport = () => { transaction.difference === 0 ? 'text-gray-600' : transaction.difference > 0 ? 'text-green-600' : 'text-red-600' }`}> - {transaction.difference === 0 ? '-' : transaction.difference.toLocaleString()} + {transaction.difference === 0 ? '-' : formatCurrency(transaction.difference, displayCurrency)} diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 3b0562f..243d985 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -10,8 +10,8 @@ interface LoginPageProps { } export const LoginPage = ({ onLogin }: LoginPageProps) => { - const [email, setEmail] = useState('admin@astra.in'); - const [password, setPassword] = useState('123456'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const { signIn } = useAuth(); @@ -21,13 +21,10 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => { e.preventDefault(); setLoading(true); - console.log('Attempting login with:', { email, password }); - try { const { error } = await signIn(email, password); if (error) { - console.error('Login error:', error); toast({ title: "Login Error", description: error.message, @@ -43,7 +40,6 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => { onLogin(); } catch (err) { - console.error('Unexpected error:', err); toast({ title: "Error", description: "An unexpected error occurred", @@ -83,7 +79,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
setEmail(e.target.value)} className="w-full h-12 px-4 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-purple-500" diff --git a/src/components/ManagerConsole.tsx b/src/components/ManagerConsole.tsx new file mode 100644 index 0000000..119b4f1 --- /dev/null +++ b/src/components/ManagerConsole.tsx @@ -0,0 +1,768 @@ +import React, { useEffect, useMemo, useState, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { supabase } from "@/integrations/supabase/client"; + +type Currency = "USD" | "LBP"; +type IdDocType = "lebanese_id" | "passport" | "residence_permit" | "driver_license" | "other"; + +const SERVICE_CODES = [ + "OMT_SEND", "OMT_RECEIVE", "WU_SEND", "WU_RECEIVE", + "WHISH_SEND", "OMT_BILL", "EDL_BILL", + "ALFA_RECHARGE", "TOUCH_RECHARGE", "GOODS_SALE", "REPAIR", +]; + +interface ShopUser { + user_id: string; + full_name: string; + role: string; +} + +interface FeeRow { + id: string; + service_code: string; + currency: Currency; + min_amount: number; + max_amount: number; + fee_fixed: number; + fee_pct: number; + commission_fixed: number; + commission_pct: number; + effective_from: string; + effective_to: string | null; +} + +interface FxRow { + id: string; + usd_to_lbp_rate: number; + tolerance_pct: number; + effective_from: string; + effective_to: string | null; +} + +interface SafeBalanceRow { + shop_id: string; + shop_name: string; + safe_id: string; + safe_name: string; + currency: Currency | null; + balance: number; + updated_at: string | null; +} + +interface BankDepositRow { + id: string; + amount: number; + currency: Currency; + bank_ref: string | null; + notes: string | null; + created_at: string; +} + +/** + * ManagerConsole — single screen for managers/owners to: + * - Seed / view fee_schedule rows (per service per currency). + * - Seed / view fx_rates rows. + * - Set cashier KYC (id_type, id_number, phone) used by self-deal blocking. + * - Record a bank deposit out of the safe and view safe balances. + * + * All writes go through SECURITY DEFINER RPCs from migrations 0021/0023/0024/0025; + * direct table writes are blocked by RLS. + */ +export const ManagerConsole: React.FC = () => { + const { user } = useAuth(); + const { toast } = useToast(); + + const managerShops = useMemo( + () => (user?.shops ?? []).filter(s => s.role === "manager" || s.role === "owner"), + [user] + ); + const [shopId, setShopId] = useState(""); + useEffect(() => { + if (managerShops.length && !shopId) setShopId(managerShops[0].shop_id); + }, [managerShops, shopId]); + + if (!managerShops.length) { + return ( + + + You don't have manager or owner role in any shop. + + + ); + } + + return ( +
+ {managerShops.length > 1 && ( +
+ + +
+ )} + + + + Fee Schedule + FX Rates + Tills + Cashier KYC + Safe / Bank + + + + + + + + + + + + + + + + + + +
+ ); +}; + +// --------------------------------------------------------------------------- +// Fee schedule tab +// --------------------------------------------------------------------------- +const FeeScheduleTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [busy, setBusy] = useState(false); + + const [serviceCode, setServiceCode] = useState("OMT_SEND"); + const [currency, setCurrency] = useState("USD"); + const [minAmount, setMinAmount] = useState("0"); + const [maxAmount, setMaxAmount] = useState("999999999"); + const [feeFixed, setFeeFixed] = useState("0"); + const [feePct, setFeePct] = useState("0"); + const [commFixed, setCommFixed] = useState("0"); + const [commPct, setCommPct] = useState("0"); + + const refresh = useCallback(async () => { + if (!shopId) return; + const { data, error } = await supabase + .from("fee_schedule") + .select("id, service_code, currency, min_amount, max_amount, fee_fixed, fee_pct, commission_fixed, commission_pct, effective_from, effective_to") + .eq("shop_id", shopId) + .order("service_code") + .order("effective_from", { ascending: false }); + if (error) { + toast({ title: "Could not load fee schedule", description: error.message, variant: "destructive" }); + return; + } + setRows((data ?? []) as FeeRow[]); + }, [shopId, toast]); + + useEffect(() => { refresh(); }, [refresh]); + + const submit = async () => { + if (!shopId) return; + setBusy(true); + try { + const { error } = await supabase.rpc("set_fee_bracket", { + p_shop: shopId, + p_service_code: serviceCode, + p_currency: currency, + p_min_amount: Number(minAmount) || 0, + p_max_amount: Number(maxAmount) || 0, + p_fee_fixed: Number(feeFixed) || 0, + p_fee_pct: Number(feePct) || 0, + p_commission_fixed: Number(commFixed) || 0, + p_commission_pct: Number(commPct) || 0, + }); + if (error) throw error; + toast({ title: "Fee bracket saved" }); + await refresh(); + } catch (e) { + toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + Fee Schedule + +
+
+ + +
+
+ + +
+
setMinAmount(e.target.value)} />
+
setMaxAmount(e.target.value)} />
+
setFeeFixed(e.target.value)} />
+
setFeePct(e.target.value)} />
+
setCommFixed(e.target.value)} />
+
setCommPct(e.target.value)} />
+
+ + + + + + ServiceCurr + MinMax + Fee fixFee % + Comm fixComm % + FromTo + + + + {rows.map(r => ( + + {r.service_code} + {r.currency} + {r.min_amount} + {r.max_amount} + {r.fee_fixed} + {r.fee_pct} + {r.commission_fixed} + {r.commission_pct} + {new Date(r.effective_from).toLocaleDateString()} + {r.effective_to ? new Date(r.effective_to).toLocaleDateString() : "—"} + + ))} + {!rows.length && No brackets yet.} + +
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// FX rates tab +// --------------------------------------------------------------------------- +const FxRatesTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [rate, setRate] = useState("89500"); + const [tol, setTol] = useState("1.0"); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + if (!shopId) return; + const { data, error } = await supabase + .from("fx_rates") + .select("id, usd_to_lbp_rate, tolerance_pct, effective_from, effective_to") + .eq("shop_id", shopId) + .order("effective_from", { ascending: false }); + if (error) { + toast({ title: "Could not load FX rates", description: error.message, variant: "destructive" }); + return; + } + setRows((data ?? []) as FxRow[]); + }, [shopId, toast]); + + useEffect(() => { refresh(); }, [refresh]); + + const submit = async () => { + if (!shopId) return; + setBusy(true); + try { + const { error } = await supabase.rpc("set_fx_rate", { + p_shop: shopId, + p_usd_to_lbp: Number(rate), + p_tolerance_pct: Number(tol), + }); + if (error) throw error; + toast({ title: "FX rate saved" }); + await refresh(); + } catch (e) { + toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + FX Rates (USD → LBP) + +
+
setRate(e.target.value)} />
+
setTol(e.target.value)} />
+
+ +
+
+ + + + RateTolerance + FromTo + + + + {rows.map(r => ( + + {r.usd_to_lbp_rate.toLocaleString()} + {r.tolerance_pct}% + {new Date(r.effective_from).toLocaleString()} + {r.effective_to ? new Date(r.effective_to).toLocaleString() : "active"} + + ))} + {!rows.length && No rates yet.} + +
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Cashier KYC tab +// --------------------------------------------------------------------------- +const KycTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [users, setUsers] = useState([]); + const [userId, setUserId] = useState(""); + const [idType, setIdType] = useState("lebanese_id"); + const [idNumber, setIdNumber] = useState(""); + const [phone, setPhone] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!shopId) { setUsers([]); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase.rpc("get_shop_users", { p_shop_id: shopId }); + if (cancelled) return; + if (error) { + toast({ title: "Could not load users", description: error.message, variant: "destructive" }); + return; + } + const formatted = ((data ?? []) as Array<{user_id: string; full_name: string | null; role: string}>).map(r => ({ + user_id: r.user_id, + full_name: r.full_name ?? "Unknown", + role: r.role, + })); + setUsers(formatted); + })(); + return () => { cancelled = true; }; + }, [shopId, toast]); + + const submit = async () => { + if (!shopId || !userId || !idNumber.trim()) { + toast({ title: "User and ID number required", variant: "destructive" }); + return; + } + setBusy(true); + try { + const { error } = await supabase.rpc("set_user_kyc", { + p_user_id: userId, + p_shop: shopId, + p_id_type: idType, + p_id_number: idNumber.trim(), + p_phone_kyc: phone.trim() || null, + }); + if (error) throw error; + toast({ title: "KYC saved" }); + setIdNumber(""); setPhone(""); + } catch (e) { + toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + Cashier KYC (used for self-deal blocking) + +

+ Recording a cashier's ID and phone here lets the database refuse any + OMT/WU/Whish transfer where the cashier is the sender or beneficiary. +

+
+
+ + +
+
+ + +
+
setIdNumber(e.target.value)} />
+
setPhone(e.target.value)} placeholder="+961…" />
+
+ +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Safe + bank deposit tab +// --------------------------------------------------------------------------- +const SafeAndBankTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [balances, setBalances] = useState([]); + const [deposits, setDeposits] = useState([]); + + const [currency, setCurrency] = useState("USD"); + const [amount, setAmount] = useState(""); + const [bankRef, setBankRef] = useState(""); + const [slipUrl, setSlipUrl] = useState(""); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + if (!shopId) return; + const [bal, dep] = await Promise.all([ + supabase.from("v_safe_balance").select("*").eq("shop_id", shopId), + supabase + .from("bank_deposits") + .select("id, amount, currency, bank_ref, notes, created_at") + .eq("shop_id", shopId) + .order("created_at", { ascending: false }) + .limit(20), + ]); + if (bal.error) toast({ title: "Could not load safe balance", description: bal.error.message, variant: "destructive" }); + else setBalances((bal.data ?? []) as SafeBalanceRow[]); + if (dep.error) toast({ title: "Could not load deposits", description: dep.error.message, variant: "destructive" }); + else setDeposits((dep.data ?? []) as BankDepositRow[]); + }, [shopId, toast]); + + useEffect(() => { refresh(); }, [refresh]); + + const submit = async () => { + if (!shopId || !amount) return; + setBusy(true); + try { + const { error } = await supabase.rpc("record_bank_deposit", { + p_shop: shopId, + p_currency: currency, + p_amount: Number(amount), + p_bank_ref: bankRef.trim() || null, + p_deposit_slip_url: slipUrl.trim() || null, + p_notes: notes.trim() || null, + }); + if (error) throw error; + toast({ title: "Bank deposit recorded" }); + setAmount(""); setBankRef(""); setSlipUrl(""); setNotes(""); + await refresh(); + } catch (e) { + toast({ title: "Deposit failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( +
+ + Safe balance + + + + SafeCurrencyBalanceUpdated + + + {balances.map((b, i) => ( + + {b.safe_name} + {b.currency ?? "—"} + {Number(b.balance).toLocaleString()} + {b.updated_at ? new Date(b.updated_at).toLocaleString() : "—"} + + ))} + {!balances.length && No safe activity yet.} + +
+
+
+ + + Record bank deposit + +
+
+ + +
+
setAmount(e.target.value)} />
+
setBankRef(e.target.value)} />
+
setSlipUrl(e.target.value)} />
+
setNotes(e.target.value)} />
+
+ +
+
+
+
+ + + Recent deposits + + + + WhenCurrAmountBank refNotes + + + {deposits.map(d => ( + + {new Date(d.created_at).toLocaleString()} + {d.currency} + {Number(d.amount).toLocaleString()} + {d.bank_ref ?? "—"} + {d.notes ?? "—"} + + ))} + {!deposits.length && No deposits yet.} + +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Tills tab — owner-only create / rename / activate-deactivate +// --------------------------------------------------------------------------- +interface TillRow { + till_id: string; + shop_id: string; + name: string; + is_active: boolean; + created_at: string; +} + +const TillsTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { user } = useAuth(); + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [busy, setBusy] = useState(false); + const [newName, setNewName] = useState(""); + const [editingId, setEditingId] = useState(""); + const [editingName, setEditingName] = useState(""); + + const isOwner = useMemo( + () => (user?.shops ?? []).some(s => s.shop_id === shopId && s.role === "owner"), + [user, shopId] + ); + + const load = useCallback(async () => { + if (!shopId) return; + const { data, error } = await supabase + .from("v_manage_tills") + .select("till_id, shop_id, name, is_active, created_at") + .eq("shop_id", shopId); + if (error) { + toast({ title: "Could not load tills", description: error.message, variant: "destructive" }); + return; + } + setRows(((data ?? []) as TillRow[]).slice().sort((a, b) => a.name.localeCompare(b.name))); + }, [shopId, toast]); + + useEffect(() => { load(); }, [load]); + + const create = async () => { + const name = newName.trim(); + if (!name) return; + setBusy(true); + const { error } = await supabase.rpc("create_till", { p_shop: shopId, p_name: name }); + setBusy(false); + if (error) { + toast({ title: "Could not create till", description: error.message, variant: "destructive" }); + return; + } + toast({ title: "Till created" }); + setNewName(""); + load(); + }; + + const rename = async (till_id: string) => { + const name = editingName.trim(); + if (!name) return; + setBusy(true); + const { error } = await supabase.rpc("rename_till", { p_till: till_id, p_name: name }); + setBusy(false); + if (error) { + toast({ title: "Could not rename till", description: error.message, variant: "destructive" }); + return; + } + toast({ title: "Till renamed" }); + setEditingId(""); setEditingName(""); + load(); + }; + + const setActive = async (till_id: string, active: boolean) => { + setBusy(true); + const { error } = await supabase.rpc("set_till_active", { p_till: till_id, p_active: active }); + setBusy(false); + if (error) { + toast({ title: active ? "Could not activate" : "Could not deactivate", + description: error.message, variant: "destructive" }); + return; + } + toast({ title: active ? "Till activated" : "Till deactivated" }); + load(); + }; + + return ( + + + Tills + + + {!isOwner && ( +
+ Only the shop owner can create, rename, or deactivate tills. You can view the list below. +
+ )} + + {isOwner && ( +
+
+ + setNewName(e.target.value)} /> +
+ +
+ )} + + + + + Name + Status + Created + Actions + + + + {rows.map((t) => ( + + + {editingId === t.till_id ? ( + setEditingName(e.target.value)} + className="h-9" /> + ) : t.name} + + + + {t.is_active ? "Active" : "Inactive"} + + + + {new Date(t.created_at).toLocaleDateString()} + + + {isOwner && (editingId === t.till_id ? ( + <> + + + + ) : ( + <> + + {t.is_active ? ( + + ) : ( + + )} + + ))} + + + ))} + {!rows.length && ( + + No tills yet — add one above. + + )} + +
+
+
+ ); +}; diff --git a/src/components/OutstandingReportDashboard.tsx b/src/components/OutstandingReportDashboard.tsx index 2700b41..a612e67 100644 --- a/src/components/OutstandingReportDashboard.tsx +++ b/src/components/OutstandingReportDashboard.tsx @@ -1,11 +1,13 @@ - -import React from 'react'; +import React, { useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card, CardContent } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; +import { Currency, formatCurrency, getUsdToLbpRate } from "@/lib/currency"; export const OutstandingReportDashboard = () => { - const { employees, getEmployeeSummary } = useSupabaseEmployeeData(); + const [displayCurrency, setDisplayCurrency] = useState("USD"); + const { employees, getEmployeeSummary } = useSupabaseEmployeeData(displayCurrency); const employeeSummaries = employees.map(employee => { const summary = getEmployeeSummary(employee.id); @@ -15,40 +17,56 @@ export const OutstandingReportDashboard = () => { }; }); - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('en-IN', { - style: 'currency', - currency: 'INR' - }).format(amount); - }; - const formatDate = (date: string) => { - return new Date(date).toLocaleDateString('en-IN'); + return new Date(date).toLocaleDateString('en-US'); }; - // Calculate totals + // Calculate totals (combined, in display currency) const totalCollection = employeeSummaries.reduce((sum, emp) => sum + emp.totalCollection, 0); const totalDeposit = employeeSummaries.reduce((sum, emp) => sum + emp.totalDeposit, 0); const totalDifference = totalCollection - totalDeposit; + // Per-currency totals (raw, untouched amounts for clarity) + const totalCollectionUSD = employeeSummaries.reduce((s, e) => s + e.totalCollectionUSD, 0); + const totalCollectionLBP = employeeSummaries.reduce((s, e) => s + e.totalCollectionLBP, 0); + const totalDepositUSD = employeeSummaries.reduce((s, e) => s + e.totalDepositUSD, 0); + const totalDepositLBP = employeeSummaries.reduce((s, e) => s + e.totalDepositLBP, 0); + const totalDifferenceUSD = totalCollectionUSD - totalDepositUSD; + const totalDifferenceLBP = totalCollectionLBP - totalDepositLBP; + return (
-
- Outstanding Report (All Locations) +
+
+ Outstanding Report (All Locations) +
+
+ Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP + +
{/* Summary Cards */}
-
-
+
+
-
+

Total Collection (MM)

-

(All Locations)

-

{formatCurrency(totalCollection)}

+

USD: {formatCurrency(totalCollectionUSD, "USD")}

+

LBP: {formatCurrency(totalCollectionLBP, "LBP")}

+

≈ {formatCurrency(totalCollection, displayCurrency)}

@@ -56,16 +74,17 @@ export const OutstandingReportDashboard = () => { -
-
+
+
-
+

Total Deposit Amount

-

(All Locations)

-

{formatCurrency(totalDeposit)}

+

USD: {formatCurrency(totalDepositUSD, "USD")}

+

LBP: {formatCurrency(totalDepositLBP, "LBP")}

+

≈ {formatCurrency(totalDeposit, displayCurrency)}

@@ -73,16 +92,17 @@ export const OutstandingReportDashboard = () => { -
-
+
+
=
-
+

Difference Amount

-

(All Locations)

-

{formatCurrency(totalDifference)}

+

USD: 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}

+

LBP: 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}

+

0 ? 'text-red-600' : 'text-green-600'}`}>≈ {formatCurrency(totalDifference, displayCurrency)}

@@ -105,18 +125,20 @@ export const OutstandingReportDashboard = () => { {employeeSummaries.map((employee) => ( - BGRoad, Karnataka + {employee.location || 'BGRoad, Karnataka'} {employee.emp_id.replace('EMP', '')} {employee.name} - {employee.totalCollection.toLocaleString()} + {formatCurrency(employee.totalCollection, displayCurrency)} - {employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '26 Mar 2025'} + {employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'} 0 ? 'text-red-600' : 'text-green-600'}`}> - {employee.outstandingAmount > 0 ? employee.outstandingAmount.toLocaleString() : `(${Math.abs(employee.outstandingAmount).toLocaleString()})`} + {employee.outstandingAmount > 0 + ? formatCurrency(employee.outstandingAmount, displayCurrency) + : `(${formatCurrency(Math.abs(employee.outstandingAmount), displayCurrency)})`} diff --git a/src/components/OwnerOverview.tsx b/src/components/OwnerOverview.tsx new file mode 100644 index 0000000..e68a075 --- /dev/null +++ b/src/components/OwnerOverview.tsx @@ -0,0 +1,529 @@ +import React, { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { api } from "@/lib/api"; + +/** + * Owner / Manager overview. + * + * Surfaces what the shop owner actually wants to see when they walk in: + * - per-shop snapshot: open shifts, open alerts, today's gross + * - last closed shifts with their variance USD/LBP + * - 30-day cashier scorecard (variance + voids) + * - open alerts they can acknowledge + * + * All data comes from the existing app.* views/tables wired through the + * Express `/from/:view` route (see ALLOWED_VIEWS in server/src/index.js). + * RLS continues to scope rows to the user's shops. + */ + +interface ShopRow { + shop_id: string; + shop_name: string; + open_shifts: number; + open_alerts: number; + critical_alerts: number; + open_recon_exceptions: number; + period_started_at: string; + last_end_of_day_at: string | null; + today_gross_usd: string | number; + today_gross_lbp: string | number; +} + +interface EndOfDayReportRow { + id: string; + shop_id: string; + business_date: string; + submitted_at: string; + submitted_by_name: string; + note: string | null; + period_started_at: string; + period_ended_at: string; + completed_txn_count: number; + voided_txn_count: number; + gross_usd: string | number; + gross_lbp: string | number; + safe_drop_usd: string | number; + safe_drop_lbp: string | number; + closed_shift_count: number; + total_variance_usd: string | number; + total_variance_lbp: string | number; + activity_count: number; + activity_log: Array<{ + occurred_at: string; + event_type: string; + metadata: Record; + }>; +} + +interface ZRow { + shift_id: string; + shop_id: string; + cashier_id: string; + opened_at: string; + closed_at: string | null; + status: string; + expected_close_usd: string | number; + expected_close_lbp: string | number; + declared_close_usd: string | number | null; + declared_close_lbp: string | number | null; + variance_usd: string | number | null; + variance_lbp: string | number | null; +} + +interface ScoreRow { + cashier_id: string; + shop_id: string; + shifts_30d: number; + total_var_usd: string | number; + total_var_lbp: string | number; + voids_30d: number; +} + +interface AlertRow { + id: string; + shop_id: string; + kind: string; + severity: "info" | "warn" | "critical"; + subject_id: string | null; + payload: Record; + created_at: string; + acknowledged_at: string | null; +} + +const fmtNum = (v: string | number | null | undefined, decimals = 2) => { + const n = Number(v ?? 0); + if (!Number.isFinite(n)) return "0"; + return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); +}; + +const varianceTone = (v: string | number | null | undefined) => { + const n = Number(v ?? 0); + if (n === 0) return "text-emerald-700"; + if (n < 0) return "text-rose-700 font-semibold"; + return "text-amber-700 font-semibold"; +}; + +const fmtBusinessDate = (value: string) => { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleDateString(); +}; + +const isLocalDev = import.meta.env.DEV; + +export const OwnerOverview: React.FC = () => { + const { user } = useAuth(); + const { toast } = useToast(); + + const [shops, setShops] = useState([]); + const [zRows, setZRows] = useState([]); + const [scores, setScores] = useState([]); + const [alerts, setAlerts] = useState([]); + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + const [submittingShopId, setSubmittingShopId] = useState(null); + const [selectedReportId, setSelectedReportId] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + const [shopRes, zRes, scoreRes, alertRes, reportRes] = await Promise.all([ + api.fromView("v_owner_dashboard"), + api.fromView("v_z_report"), + api.fromView("v_employee_scorecard_30d"), + api.fromView("alerts"), + api.fromView("v_end_of_day_reports"), + ]); + if (cancelled) return; + setShops(shopRes.data ?? []); + // Most recent first; cap to 25 closed shifts for the table. + const closed = (zRes.data ?? []) + .filter(r => r.status === "closed" && r.closed_at) + .sort((a, b) => (b.closed_at ?? "").localeCompare(a.closed_at ?? "")) + .slice(0, 25); + setZRows(closed); + setScores(scoreRes.data ?? []); + const open = (alertRes.data ?? []) + .filter(a => !a.acknowledged_at) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + setAlerts(open); + const recentReports = (reportRes.data ?? []) + .slice() + .sort((a, b) => b.submitted_at.localeCompare(a.submitted_at)); + setReports(recentReports); + setSelectedReportId(current => ( + current && recentReports.some(r => r.id === current) + ? current + : recentReports[0]?.id ?? null + )); + setLoading(false); + })(); + return () => { cancelled = true; }; + }, [refreshKey]); + + const ackAlert = async (id: string) => { + const { error } = await api.rpc("ack_alert", { + p_alert: id, + p_note: "Acknowledged from owner overview", + }); + if (error) { + toast({ title: "Could not acknowledge", description: error.message, variant: "destructive" }); + return; + } + setRefreshKey(k => k + 1); + }; + + const shopName = (id: string) => + shops.find(s => s.shop_id === id)?.shop_name + ?? user?.shops.find(s => s.shop_id === id)?.shop_name + ?? id.slice(0, 8); + + const latestReportForShop = (shopId: string) => + reports.find(r => r.shop_id === shopId) ?? null; + + const selectedReport = reports.find(r => r.id === selectedReportId) ?? null; + + const submitEndOfDay = async (shopId: string) => { + setSubmittingShopId(shopId); + const { error } = await api.rpc("submit_end_of_day", { + p_shop: shopId, + p_note: "Submitted from owner overview", + }); + if (error) { + toast({ title: "Could not submit end of day", description: error.message, variant: "destructive" }); + setSubmittingShopId(null); + return; + } + toast({ title: "End of day submitted", description: `${shopName(shopId)} counters reset for the next day.` }); + setSubmittingShopId(null); + setRefreshKey(k => k + 1); + }; + + const reopenLatestEndOfDayForTesting = async (shopId: string) => { + setSubmittingShopId(shopId); + const { error } = await api.post("/admin/dev/end_of_day/reopen_latest", { + shop_id: shopId, + }); + if (error) { + toast({ title: "Could not reset local EOD", description: error.message, variant: "destructive" }); + setSubmittingShopId(null); + return; + } + toast({ + title: "Local EOD reset", + description: `${shopName(shopId)} latest end-of-day report was moved back one day for another test pass.`, + }); + setSubmittingShopId(null); + setRefreshKey(k => k + 1); + }; + + return ( +
+ + + End of day + + +
+ Owner-only submit. This writes an append-only end-of-day report, captures the activity log for the current period, and resets dashboard counters from the submission time forward. +
+
+ {shops.map(s => { + const latest = latestReportForShop(s.shop_id); + return ( +
+
+
{s.shop_name}
+
+ {latest + ? `Last end of day: ${new Date(latest.submitted_at).toLocaleString()}` + : "No end-of-day report submitted yet."} +
+
+
+
Open shifts: {s.open_shifts}
+
Current gross USD: {fmtNum(s.today_gross_usd, 2)}
+
Current gross LBP: {fmtNum(s.today_gross_lbp, 0)}
+
+
+ + {isLocalDev && ( + + )} +
+
+ ); + })} +
+
+
+ + {/* Per-shop KPIs */} + + + Current day across your shops + + + +
+ Gross counters reset from the most recent end-of-day submission for each shop. If no report exists yet, they fall back to the current Beirut business day. +
+ {shops.length === 0 ? ( +
No shops visible.
+ ) : ( +
+ {shops.map(s => ( +
+
{s.shop_name}
+
+
Open shifts{s.open_shifts}
+
+ Open alerts + 0 ? "text-rose-700" : Number(s.open_alerts) > 0 ? "text-amber-700" : ""}`}> + {s.open_alerts}{Number(s.critical_alerts) > 0 ? ` (${s.critical_alerts}!)` : ""} + +
+
Recon exceptions{s.open_recon_exceptions}
+
Today gross USD{fmtNum(s.today_gross_usd, 2)}
+
Today gross LBP{fmtNum(s.today_gross_lbp, 0)}
+
Current period started{new Date(s.period_started_at).toLocaleString()}
+
+
+ ))} +
+ )} +
+
+ + {/* Open alerts */} + + + Open alerts ({alerts.length}) + + + {alerts.length === 0 ? ( +
No open alerts. Drawers are clean.
+ ) : ( + + + + When + Shop + Kind + Severity + Detail + Action + + + + {alerts.map(a => ( + + {new Date(a.created_at).toLocaleString()} + {shopName(a.shop_id)} + {a.kind} + + {a.severity} + + + {JSON.stringify(a.payload)} + + + + + + ))} + +
+ )} +
+
+ + {/* Recently closed shifts with variance */} + + + Recently closed shifts — variance + + + {zRows.length === 0 ? ( +
No closed shifts yet.
+ ) : ( + + + + Closed + Shop + Expected USD + Counted USD + Δ USD + Expected LBP + Counted LBP + Δ LBP + + + + {zRows.map(r => ( + + {r.closed_at ? new Date(r.closed_at).toLocaleString() : "—"} + {shopName(r.shop_id)} + {fmtNum(r.expected_close_usd, 2)} + {fmtNum(r.declared_close_usd, 2)} + {fmtNum(r.variance_usd, 2)} + {fmtNum(r.expected_close_lbp, 0)} + {fmtNum(r.declared_close_lbp, 0)} + {fmtNum(r.variance_lbp, 0)} + + ))} + +
+ )} +
+
+ + {/* 30-day cashier scorecard */} + + + Cashier scorecard (last 30 days) + + + {scores.length === 0 ? ( +
No closed shifts in the last 30 days.
+ ) : ( + + + + Cashier + Shop + Shifts + Σ Δ USD + Σ Δ LBP + Voids + + + + {scores + .slice() + .sort((a, b) => Math.abs(Number(b.total_var_usd ?? 0)) - Math.abs(Number(a.total_var_usd ?? 0))) + .map(s => ( + + {s.cashier_id.slice(0, 8)}… + {shopName(s.shop_id)} + {s.shifts_30d} + {fmtNum(s.total_var_usd, 2)} + {fmtNum(s.total_var_lbp, 0)} + {s.voids_30d} + + ))} + +
+ )} +
+
+ + + + Recent end-of-day reports + + + {reports.length === 0 ? ( +
No end-of-day reports yet.
+ ) : ( + + + + Business date + Shop + Submitted + By + Txns + Gross USD + Gross LBP + Activities + + + + {reports.map(r => ( + setSelectedReportId(r.id)} + > + {fmtBusinessDate(r.business_date)} + {shopName(r.shop_id)} + {new Date(r.submitted_at).toLocaleString()} + {r.submitted_by_name} + {r.completed_txn_count} + {fmtNum(r.gross_usd, 2)} + {fmtNum(r.gross_lbp, 0)} + {r.activity_count} + + ))} + +
+ )} +
+
+ + + + Activity log for selected report + + + {!selectedReport ? ( +
Select an end-of-day report to inspect the activity log.
+ ) : selectedReport.activity_log.length === 0 ? ( +
No activity events were captured in that reporting period.
+ ) : ( + + + + When + Event + Metadata + + + + {selectedReport.activity_log.map((entry, index) => ( + + {new Date(entry.occurred_at).toLocaleString()} + {entry.event_type} + + {JSON.stringify(entry.metadata)} + + + ))} + +
+ )} +
+
+
+ ); +}; + +export default OwnerOverview; diff --git a/src/components/ShiftControl.tsx b/src/components/ShiftControl.tsx new file mode 100644 index 0000000..f563863 --- /dev/null +++ b/src/components/ShiftControl.tsx @@ -0,0 +1,590 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { supabase } from "@/integrations/supabase/client"; +import { FxSwapDialog, SelfDealOverrideDialog } from "@/components/CashierTools"; + +interface MyTill { + till_id: string; + shop_id: string; + name: string; + is_active: boolean; +} + +interface OpenShift { + shift_id: string; + till_id: string; + opened_at: string; + status: string; + opening_usd: number; + opening_lbp: number; + declared_at?: string | null; + declared_close_usd?: number | null; + declared_close_lbp?: number | null; +} + +interface LiveDrawerSummary { + shift_id: string; + expected_usd: number; + expected_lbp: number; + customer_in_usd: number; + customer_in_lbp: number; + payout_out_usd: number; + payout_out_lbp: number; + dropped_to_safe_usd: number; + dropped_to_safe_lbp: number; + fx_net_usd: number; + fx_net_lbp: number; + txn_count: number; + last_txn_at: string | null; +} + +function formatAmount(value: number, decimals: number) { + return value.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +/** + * ShiftControl + * + * Open / declare-close / finalize-close. The cashier MUST have an open + * shift before TransactionEntry can record anything (the RPC checks). + * For the owner, this also serves as a one-stop view of every till. + */ +export const ShiftControl: React.FC = () => { + const { user } = useAuth(); + const { toast } = useToast(); + + const [shopId, setShopId] = useState(""); + const [tills, setTills] = useState([]); + const [tillId, setTillId] = useState(""); + const [shopUsers, setShopUsers] = useState<{user_id: string, full_name: string, role: string}[]>([]); + const [assignedUserId, setAssignedUserId] = useState(""); + const [openShift, setOpenShift] = useState(null); + + const [openingUsd, setOpeningUsd] = useState(""); + const [openingLbp, setOpeningLbp] = useState(""); + + const [declaredUsd, setDeclaredUsd] = useState(""); + const [declaredLbp, setDeclaredLbp] = useState(""); + + const [dropUsd, setDropUsd] = useState(""); + const [dropLbp, setDropLbp] = useState(""); + + const [fxOpen, setFxOpen] = useState(false); + const [overrideOpen, setOverrideOpen] = useState(false); + + const [busy, setBusy] = useState(false); + const [drawerSummary, setDrawerSummary] = useState(null); + + // Result of the most recent finalize_close, used to display + // Expected / Declared / Variance to the cashier and manager. + const [closeResult, setCloseResult] = useState<{ + expected_usd: number; + expected_lbp: number; + declared_usd: number; + declared_lbp: number; + variance_usd: number; + variance_lbp: number; + } | null>(null); + + const shops = user?.shops ?? []; + const selectedShopRole = shops.find((shop) => shop.shop_id === shopId)?.role; + const canAssignShift = selectedShopRole === "owner" || selectedShopRole === "manager"; + useEffect(() => { + if (shops.length === 1) setShopId(shops[0].shop_id); + }, [shops]); + + // Load tills for the chosen shop + useEffect(() => { + if (!shopId) { setShopUsers([]); setAssignedUserId(""); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .rpc('get_shop_users', { p_shop_id: shopId }); + + if (cancelled) return; + if (error) { + console.error("Could not load shop users:", error); + return; + } + + const formattedUsers = (data || []).map((row: any) => ({ + user_id: row.user_id, + full_name: row.full_name || 'Unknown', + role: row.role + })); + setShopUsers(formattedUsers); + + // Auto-assign to current user if they are in the list + if (user && formattedUsers.some(u => u.user_id === user.id)) { + setAssignedUserId(user.id); + } else if (formattedUsers.length > 0) { + setAssignedUserId(formattedUsers[0].user_id); + } + })(); + return () => { cancelled = true; }; + }, [shopId, user]); + + useEffect(() => { + if (!shopId) { setTills([]); setTillId(""); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .from("v_my_tills") + .select("till_id, shop_id, name, is_active") + .eq("shop_id", shopId); + if (cancelled) return; + if (error) { + toast({ title: "Could not load tills", description: error.message, + variant: "destructive" }); + return; + } + setTills((data ?? []) as MyTill[]); + if (data && data.length === 1) setTillId(data[0].till_id); + })(); + return () => { cancelled = true; }; + }, [shopId, toast]); + + // Refresh open-shift status for caller + const refreshShift = useCallback(async () => { + if (!shopId) { setOpenShift(null); return; } + const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId }); + const row = Array.isArray(data) + ? (data[0] as OpenShift | undefined) ?? null + : ((data as OpenShift | null) ?? null); + setOpenShift(row); + if (row) setTillId(row.till_id); + }, [shopId]); + + const refreshDrawer = useCallback(async (shiftId?: string) => { + if (!shiftId) { + setDrawerSummary(null); + return; + } + const { data, error } = await supabase.rpc("live_drawer", { p_shift_id: shiftId }); + if (error) { + toast({ + title: "Could not load live drawer", + description: error.message, + variant: "destructive", + }); + return; + } + const row = Array.isArray(data) + ? (data[0] as LiveDrawerSummary | undefined) ?? null + : ((data as LiveDrawerSummary | null) ?? null); + setDrawerSummary(row); + }, [toast]); + + useEffect(() => { refreshShift(); }, [refreshShift]); + + useEffect(() => { + if (!openShift) { + setDrawerSummary(null); + return; + } + refreshDrawer(openShift.shift_id); + if (openShift.status !== "open") return; + const timer = window.setInterval(() => { + refreshDrawer(openShift.shift_id); + }, 15000); + return () => window.clearInterval(timer); + }, [openShift, refreshDrawer]); + + const handleOpen = async () => { + if (!tillId) { + toast({ title: "Pick a till", variant: "destructive" }); + return; + } + setBusy(true); + try { + const assignedUserParam = canAssignShift && assignedUserId && assignedUserId !== user?.id + ? assignedUserId + : undefined; + const { error } = await supabase.rpc("open_shift", { + p_till_id: tillId, + p_opening_usd: Number.parseFloat(openingUsd) || 0, + p_opening_lbp: Number.parseFloat(openingLbp) || 0, + p_assigned_user_id: assignedUserParam, + }); + if (error) throw error; + toast({ title: "Shift opened" }); + setOpeningUsd(""); setOpeningLbp(""); + await refreshShift(); + } catch (e) { + toast({ title: "Could not open shift", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + const handleDeclare = async () => { + if (!openShift) return; + if (!declaredUsd.trim() && !declaredLbp.trim()) { + toast({ + title: "Count required", + description: "Enter the counted USD and/or LBP before declaring close.", + variant: "destructive", + }); + return; + } + setBusy(true); + try { + const { error } = await supabase.rpc("declare_close", { + p_shift_id: openShift.shift_id, + p_declared_close_usd: Number.parseFloat(declaredUsd) || 0, + p_declared_close_lbp: Number.parseFloat(declaredLbp) || 0, + }); + if (error) throw error; + toast({ title: "Declared. Finalize when ready." }); + await refreshDrawer(openShift.shift_id); + await refreshShift(); + } catch (e) { + toast({ title: "Could not declare close", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + + const handleDrop = async () => { + if (!openShift) return; + if (!dropUsd && !dropLbp) return; + setBusy(true); + try { + const { error } = await supabase.rpc("record_cash_drop", { + p_shift_id: openShift.shift_id, + p_drop_usd: Number.parseFloat(dropUsd) || 0, + p_drop_lbp: Number.parseFloat(dropLbp) || 0, + p_notes: "Mid-day safe drop", + }); + if (error) throw error; + toast({ title: "Safe drop recorded successfully" }); + setDropUsd(""); setDropLbp(""); + await refreshDrawer(openShift.shift_id); + await refreshShift(); + } catch (e) { + toast({ title: "Could not record drop", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + const handleFinalize = async () => { + if (!openShift) return; + setBusy(true); + const declared_usd = Number(declaredUsd) || 0; + const declared_lbp = Number(declaredLbp) || 0; + try { + const { data, error } = await supabase.rpc("finalize_close", { + p_shift_id: openShift.shift_id, + }); + if (error) throw error; + // finalize_close returns a single row { expected_usd, expected_lbp, variance_usd, variance_lbp } + const row = (Array.isArray(data) ? data[0] : data) as + | { expected_usd?: number; expected_lbp?: number; variance_usd?: number; variance_lbp?: number } + | null; + if (row) { + setCloseResult({ + expected_usd: Number(row.expected_usd ?? 0), + expected_lbp: Number(row.expected_lbp ?? 0), + declared_usd, + declared_lbp, + variance_usd: Number(row.variance_usd ?? 0), + variance_lbp: Number(row.variance_lbp ?? 0), + }); + } + toast({ title: "Shift closed" }); + setDeclaredUsd(""); setDeclaredLbp(""); + setDrawerSummary(null); + await refreshShift(); + } catch (e) { + toast({ title: "Could not finalize", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + + Shift control + + +
+
+ + +
+
+ + +
+ {!openShift && canAssignShift && ( +
+ + +
+ )} +
+ + {openShift ? ( +
+
+ Open shift: till{" "} + {openShift.till_id.slice(0, 8)}… opened{" "} + {new Date(openShift.opened_at).toLocaleString()} · status{" "} + {openShift.status} +
+ Opening float: USD {openShift.opening_usd} / LBP{" "} + {openShift.opening_lbp} +
+ + {drawerSummary && ( +
+
+
+

Live drawer now

+ + {drawerSummary.txn_count} txns + +
+
+
+
Expected USD
+
+ {formatAmount(drawerSummary.expected_usd, 2)} +
+
+
+
Expected LBP
+
+ {formatAmount(drawerSummary.expected_lbp, 0)} +
+
+
+
+ Last transaction: {drawerSummary.last_txn_at ? new Date(drawerSummary.last_txn_at).toLocaleString() : "No completed transactions yet"} +
+
+ +
+

What moved this drawer

+
+
+ Customer in USD + {formatAmount(drawerSummary.customer_in_usd, 2)} +
+
+ Customer in LBP + {formatAmount(drawerSummary.customer_in_lbp, 0)} +
+
+ Payout out USD + {formatAmount(drawerSummary.payout_out_usd, 2)} +
+
+ Payout out LBP + {formatAmount(drawerSummary.payout_out_lbp, 0)} +
+
+ Dropped to safe USD + {formatAmount(drawerSummary.dropped_to_safe_usd, 2)} +
+
+ Dropped to safe LBP + {formatAmount(drawerSummary.dropped_to_safe_lbp, 0)} +
+
+ FX net USD + {formatAmount(drawerSummary.fx_net_usd, 2)} +
+
+ FX net LBP + {formatAmount(drawerSummary.fx_net_lbp, 0)} +
+
+
+
+ )} + + {openShift.status === "open" ? ( + <> +
+

Mid-Day Safe Drop

+

+ Transfer large sums of money out of the drawer and into the shop safe to secure it. This removes it from your end-of-shift expected count. +

+
+
+ + setDropUsd(e.target.value)} /> +
+
+ + setDropLbp(e.target.value)} /> +
+ +
+
+ +
+ + +
+ +
+
+ + setDeclaredUsd(e.target.value)} /> +
+
+ + setDeclaredLbp(e.target.value)} /> +
+ +
+ + ) : ( +
+
+
Close declared
+
+
Counted USD: {formatAmount(Number(openShift.declared_close_usd ?? 0), 2)}
+
Counted LBP: {formatAmount(Number(openShift.declared_close_lbp ?? 0), 0)}
+
+

+ The drawer count is locked. Finalize to reveal the expected cash and variance. +

+
+ +
+ )} +
+ ) : closeResult ? ( +
+
+

Last shift close — variance summary

+ +
+
+ {([ + { label: "USD", expected: closeResult.expected_usd, declared: closeResult.declared_usd, variance: closeResult.variance_usd, decimals: 2 }, + { label: "LBP", expected: closeResult.expected_lbp, declared: closeResult.declared_lbp, variance: closeResult.variance_lbp, decimals: 0 }, + ] as const).map((r) => { + const fmt = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: r.decimals, maximumFractionDigits: r.decimals }); + const tone = + r.variance === 0 ? "text-emerald-700 bg-emerald-50 border-emerald-200" + : r.variance < 0 ? "text-rose-700 bg-rose-50 border-rose-200" + : "text-amber-700 bg-amber-50 border-amber-200"; + const label = + r.variance === 0 ? "Match" + : r.variance < 0 ? "SHORT" + : "OVER"; + return ( +
+
{r.label}
+
Expected{fmt(r.expected)}
+
Counted{fmt(r.declared)}
+
+ Δ {label}{fmt(r.variance)} +
+
+ ); + })} +
+

+ Negative Δ = drawer is short of what the system expected. + Positive Δ = drawer has more than expected. Anything non-zero + should be investigated before the cashier leaves. +

+
+ ) : ( +
+
+
+ + setOpeningUsd(e.target.value)} /> +
+
+ + setOpeningLbp(e.target.value)} /> +
+ +
+

+ Count the drawer cash before opening. The amounts + you declare here become the audit baseline for this shift. +

+
+ )} +
+ setFxOpen(false)} + shopId={shopId} + tillId={openShift?.till_id ?? tillId} + onDone={refreshShift} + /> + setOverrideOpen(false)} + shopId={shopId} + /> +
+ ); +}; diff --git a/src/components/TransactionEntry.tsx b/src/components/TransactionEntry.tsx new file mode 100644 index 0000000..8f53253 --- /dev/null +++ b/src/components/TransactionEntry.tsx @@ -0,0 +1,1223 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectGroup, SelectItem, SelectLabel, + SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { supabase } from "@/integrations/supabase/client"; +import { + SERVICES_BY_CATEGORY, CATEGORY_LABEL, findService, + type ServiceCategory, type ServiceDef, +} from "@/lib/services"; + +interface TransactionEntryProps { + isOpen: boolean; + onClose: () => void; + onCreated?: (txnId: string) => void; +} + +interface MyTill { + till_id: string; + shop_id: string; + name: string; + is_active: boolean; +} + +interface ProductCatalogItem { + id: string; + service_code: string; + product_code: string; + name: string; + unit_cost_usd: number; + unit_face_usd: number; + unit_cost_lbp?: number; + unit_face_lbp?: number; +} + +interface OpenShift { + shift_id: string; + till_id: string; + opened_at: string; +} + +interface RecentTransactionRow { + id: string; + reference_no: number; + service_code: string; + service_name: string; + payment_method: PaymentMethod; + gross_usd: number; + gross_lbp: number; + external_ref: string | null; + occurred_at: string; +} + +interface ReceiptPrintRow { + receipt_id: string; + qr_token: string; + pdf_url: string | null; +} + +interface TransactionConfirmation { + txnId: string; + referenceNo: number | null; + serviceName: string; + paymentMethod: PaymentMethod; + grossUsd: number; + grossLbp: number; + externalRef: string | null; + occurredAt: string | null; + receiptId: string | null; + receiptToken: string | null; + receiptPdfUrl: string | null; +} + +// Real `app.payment_method` enum values from migration 0003. +const PAYMENT_METHODS = [ + "cash_usd", "cash_lbp", "whish", "omt_wallet", "card", "bank_transfer", +] as const; +type PaymentMethod = typeof PAYMENT_METHODS[number]; + +const PAYMENT_METHOD_LABELS: Record = { + cash_usd: "Cash USD", + cash_lbp: "Cash LBP", + whish: "Whish", + omt_wallet: "OMT Wallet", + card: "Card", + bank_transfer: "Bank transfer", +}; + +const ID_DOC_TYPES = [ + "lebanese_id", "passport", "residence_permit", "driver_license", "other", +] as const; + +const QUICK_SERVICES: Array<{ code: string; icon: string; accent: string; label: string; note: string }> = [ + { code: "OMT_SEND", icon: "💸", accent: "bg-blue-50 hover:bg-blue-100 border-blue-200 text-blue-700", label: "OMT Send", note: "Take cash, then capture sender and beneficiary." }, + { code: "OMT_RECEIVE", icon: "🏦", accent: "bg-emerald-50 hover:bg-emerald-100 border-emerald-200 text-emerald-700", label: "OMT Receive", note: "Confirm payout code and beneficiary ID first." }, + { code: "WHISH_SEND", icon: "📱", accent: "bg-purple-50 hover:bg-purple-100 border-purple-200 text-purple-700", label: "Whish Transfer", note: "Fast wallet send with sender checks." }, + { code: "ALFA_RECHARGE", icon: "📡", accent: "bg-indigo-50 hover:bg-indigo-100 border-indigo-200 text-indigo-700", label: "Alfa Recharge", note: "Use the catalog product for faster entry." }, + { code: "TOUCH_RECHARGE", icon: "📞", accent: "bg-orange-50 hover:bg-orange-100 border-orange-200 text-orange-700", label: "Touch Recharge", note: "Voucher or e-recharge reference required." }, + { code: "EDL_BILL", icon: "⚡", accent: "bg-slate-50 hover:bg-slate-100 border-slate-200 text-slate-700", label: "EDL Bill", note: "Reference, biller, and account number required." }, +]; + +const SERVICE_GUIDANCE: Record = { + OMT_SEND: { + description: "Collect the cash first, then capture sender identity and beneficiary details before saving.", + required: ["Amount", "Sender ID", "Sender phone", "Beneficiary", "Reference"], + }, + OMT_RECEIVE: { + description: "No cash should leave the drawer until the payout code and beneficiary ID are confirmed.", + required: ["Payout code", "Beneficiary", "Beneficiary ID", "Amount"], + }, + WHISH_SEND: { + description: "Treat this like a money transfer: sender checks, beneficiary details, and provider reference are all mandatory.", + required: ["Amount", "Sender ID", "Sender phone", "Beneficiary", "Reference"], + }, + ALFA_RECHARGE: { + description: "Choose the product from the catalog when possible so the face value drops straight into the amount field.", + required: ["Operator", "Subscriber number", "Product", "Voucher or e-ref"], + }, + TOUCH_RECHARGE: { + description: "Choose the product from the catalog when possible so the face value drops straight into the amount field.", + required: ["Operator", "Subscriber number", "Product", "Voucher or e-ref"], + }, + EDL_BILL: { + description: "Check the account number, biller code, and customer reference before collecting the payment.", + required: ["Amount", "Account number", "Reference", "Biller"], + }, +}; + +const ACTION_LABELS: Record = { + OMT_SEND: "Record OMT send", + OMT_RECEIVE: "Record OMT receive", + WHISH_SEND: "Record Whish send", + ALFA_RECHARGE: "Record Alfa recharge", + TOUCH_RECHARGE: "Record Touch recharge", + EDL_BILL: "Record EDL bill", + OMT_BILL: "Record bill payment", + GOODS_SALE: "Record goods sale", + REPAIR: "Record repair", +}; + +function num(s: string): number { + const v = Number.parseFloat(s); + return Number.isFinite(v) ? v : 0; +} + +/** + * Single entry point used by both shop owner and cashier. Picks the + * service category, then renders only the fields that category requires + * and dispatches to the matching record_* RPC. + */ +export const TransactionEntry: React.FC = ({ + isOpen, onClose, onCreated, +}) => { + const { user } = useAuth(); + const { toast } = useToast(); + + const shops = user?.shops ?? []; + const [shopId, setShopId] = useState(""); + const [tills, setTills] = useState([]); + const [tillId, setTillId] = useState(""); + const [shift, setShift] = useState(null); + const [catalogItems, setCatalogItems] = useState([]); + + const [serviceCode, setServiceCode] = useState(""); + const service: ServiceDef | undefined = useMemo( + () => findService(serviceCode), [serviceCode]); + + // Money + const [paymentMethod, setPaymentMethod] = useState("cash_usd"); + const [grossUsd, setGrossUsd] = useState(""); + const [grossLbp, setGrossLbp] = useState(""); + const [feeUsd, setFeeUsd] = useState(""); + const [feeLbp, setFeeLbp] = useState(""); + const [commissionUsd, setCommissionUsd] = useState(""); + const [commissionLbp, setCommissionLbp] = useState(""); + const [fxRate, setFxRate] = useState("89500"); + + // OMT / WU send + const [direction, setDirection] = useState<"domestic"|"international">("domestic"); + const [senderName, setSenderName] = useState(""); + const [senderIdType, setSenderIdType] = useState("lebanese_id"); + const [senderIdNumber, setSenderIdNumber] = useState(""); + const [senderPhone, setSenderPhone] = useState(""); + const [destinationCountry, setDestinationCountry] = useState(""); + const [purposeCode, setPurposeCode] = useState("family_support"); + const [beneficiaryName, setBeneficiaryName] = useState(""); + const [beneficiaryPhone, setBeneficiaryPhone] = useState(""); + const [externalRef, setExternalRef] = useState(""); + + // OMT receive + const [payoutCode, setPayoutCode] = useState(""); + const [recvIdType, setRecvIdType] = useState("lebanese_id"); + const [recvIdNumber, setRecvIdNumber] = useState(""); + const [originCountry, setOriginCountry] = useState(""); + + // Bills + const [billerCode, setBillerCode] = useState(""); + const [accountNumber, setAccountNumber] = useState(""); + const [billPeriod, setBillPeriod] = useState(""); + + // Recharge + const [operator, setOperator] = useState(""); + const [msisdn, setMsisdn] = useState(""); + const [productCode, setProductCode] = useState(""); + const [voucherSerial, setVoucherSerial] = useState(""); + const [erechargeRef, setErechargeRef] = useState(""); + const [unitFaceUsd, setUnitFaceUsd] = useState(""); + const [unitCostUsd, setUnitCostUsd] = useState(""); + + // Goods sale + const [sku, setSku] = useState(""); + const [qty, setQty] = useState("1"); + const [unitPriceUsd, setUnitPriceUsd] = useState(""); + const [goodsUnitCostUsd, setGoodsUnitCostUsd] = useState(""); + const [serialNumber, setSerialNumber] = useState(""); + + // Repair + const [deviceType, setDeviceType] = useState(""); + const [deviceImei, setDeviceImei] = useState(""); + const [issueSummary, setIssueSummary] = useState(""); + const [warrantyDays, setWarrantyDays] = useState("0"); + + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [lastRecorded, setLastRecorded] = useState(null); + const [isClosing, setIsClosing] = useState(false); + + // Auto-select if the user has a single shop. + useEffect(() => { if (shops.length === 1) setShopId(shops[0].shop_id); }, + [shops]); + + + // Fetch active products for the shop + useEffect(() => { + if (!shopId) { + setCatalogItems([]); + return; + } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .from("products") + .select("*") + .eq("shop_id", shopId) + .eq("is_active", true); + if (cancelled) return; + if (!error && data) { + setCatalogItems(data as ProductCatalogItem[]); + } + })(); + return () => { cancelled = true; }; + }, [shopId]); + + + // Default operator from selected service + useEffect(() => { + if (!service) return; + if (service.code === "ALFA_RECHARGE") setOperator("ALFA"); + else if (service.code === "TOUCH_RECHARGE") setOperator("TOUCH"); + else if (service.code === "OGERO_RECHARGE") setOperator("OGERO"); + else if (service.code === "INTERNET_RECHARGE") setOperator(""); + if (service.code === "EDL_BILL") setBillerCode("EDL"); + if (service.code === "OMT_BILL") setBillerCode(""); + }, [service]); + + // Load tills for the chosen shop + useEffect(() => { + if (!isOpen || !shopId) { setTills([]); setTillId(""); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .from("v_my_tills") + .select("till_id, shop_id, name, is_active") + .eq("shop_id", shopId); + if (cancelled) return; + if (error) { toast({ title: "Could not load tills", + description: error.message, variant: "destructive" }); + return; } + setTills((data ?? []) as MyTill[]); + if (data && data.length === 1) setTillId(data[0].till_id); + })(); + return () => { cancelled = true; }; + }, [isOpen, shopId, toast]); + + // Open-shift status + useEffect(() => { + if (!isOpen || !shopId) { setShift(null); return; } + let cancelled = false; + (async () => { + const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId }); + if (cancelled) return; + const row = Array.isArray(data) + ? (data[0] as OpenShift | undefined) ?? null + : ((data as OpenShift | null) ?? null); + setShift(row); + if (row) setTillId(row.till_id); + })(); + return () => { cancelled = true; }; + }, [isOpen, shopId]); + + const reset = () => { + setLastRecorded(null); + setServiceCode(""); + setPaymentMethod("cash_usd"); + setGrossUsd(""); setGrossLbp(""); + setFeeUsd(""); setFeeLbp(""); + setCommissionUsd(""); setCommissionLbp(""); + setFxRate(""); + setOperator(""); + setSenderName(""); setSenderIdNumber(""); setSenderPhone(""); + setDestinationCountry(""); setBeneficiaryName(""); setBeneficiaryPhone(""); + setExternalRef(""); + setPayoutCode(""); setRecvIdNumber(""); setOriginCountry(""); + setBillerCode(""); setAccountNumber(""); setBillPeriod(""); + setMsisdn(""); setProductCode(""); setVoucherSerial(""); setErechargeRef(""); + setUnitFaceUsd(""); setUnitCostUsd(""); + setSku(""); setQty("1"); setUnitPriceUsd(""); setGoodsUnitCostUsd(""); + setSerialNumber(""); + setDeviceType(""); setDeviceImei(""); setIssueSummary(""); setWarrantyDays("0"); + setNotes(""); + }; + + useEffect(() => { + if (isOpen) { + setIsClosing(false); + return; + } + + reset(); + }, [isOpen]); + + const handleClose = () => { + setIsClosing(true); + onClose(); + }; + + const recordAnother = () => { + reset(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!shopId || !service || !shift || !tillId) { + toast({ + title: "Missing context", + description: !shift + ? "You have no open shift in this shop. Open a shift first." + : "Pick a shop, till and service.", + variant: "destructive", + }); + return; + } + if (num(grossUsd) === 0 && num(grossLbp) === 0) { + toast({ title: "Amount required", + description: "Enter a gross USD or LBP amount.", + variant: "destructive" }); + return; + } + + setSubmitting(true); + try { + let rpc: { fn: string; args: Record }; + + switch (service.code) { + case "ALFA_RECHARGE": + case "TOUCH_RECHARGE": + case "OGERO_RECHARGE": + case "INTERNET_RECHARGE": + if (!voucherSerial.trim() && !erechargeRef.trim()) { + throw new Error("Either voucher serial or e-recharge reference is required."); + } + if (!msisdn.trim() || !productCode.trim() || !operator.trim()) { + throw new Error("Operator, MSISDN and product code are required."); + } + rpc = { + fn: "record_recharge", + args: { + p_shop: shopId, p_till: tillId, p_service_code: service.code, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_fx_rate: num(fxRate) || null, + p_operator: operator, p_msisdn: msisdn, + p_product_code: productCode, + p_voucher_serial: voucherSerial || null, + p_e_recharge_ref: erechargeRef || null, + p_unit_face_usd: num(unitFaceUsd) || null, + p_unit_cost_usd: num(unitCostUsd) || null, + p_notes: notes || null, + }, + }; + break; + + case "OMT_SEND": + case "WU_SEND": + if (!senderName.trim() || !senderIdNumber.trim() + || !senderPhone.trim() || !beneficiaryName.trim() + || !externalRef.trim() || !purposeCode.trim()) { + throw new Error("Sender ID, phone, beneficiary, ref and purpose are required."); + } + rpc = { + fn: "record_omt_send", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_commission_usd: num(commissionUsd), + p_commission_lbp: num(commissionLbp), + p_fx_rate: num(fxRate) || null, + p_external_ref: externalRef, + p_direction: direction, + p_sender_full_name: senderName, + p_sender_id_type: senderIdType, + p_sender_id_number: senderIdNumber, + p_sender_phone: senderPhone, + p_sender_dob: null, + p_sender_nationality: null, + p_beneficiary_full_name: beneficiaryName, + p_beneficiary_phone: beneficiaryPhone || null, + p_destination_country: + direction === "international" ? destinationCountry : null, + p_purpose_code: purposeCode, + p_purpose_note: null, + p_kyc_doc_url: null, + p_customer_id: null, + p_notes: notes || null, + p_service_code: service.code, + }, + }; + break; + + case "WHISH_SEND": + if (!senderName.trim() || !senderIdNumber.trim() + || !senderPhone.trim() || !beneficiaryName.trim() + || !externalRef.trim() || !purposeCode.trim()) { + throw new Error("Sender ID, phone, beneficiary, ref and purpose are required."); + } + rpc = { + fn: "record_whish_send", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_commission_usd: num(commissionUsd), + p_commission_lbp: num(commissionLbp), + p_fx_rate: num(fxRate) || null, + p_external_ref: externalRef, + p_direction: direction, + p_sender_full_name: senderName, + p_sender_id_type: senderIdType, + p_sender_id_number: senderIdNumber, + p_sender_phone: senderPhone, + p_beneficiary_full_name: beneficiaryName, + p_beneficiary_phone: beneficiaryPhone || null, + p_purpose_code: purposeCode, + p_purpose_note: null, + p_kyc_doc_url: null, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "OMT_RECEIVE": + case "WU_RECEIVE": + if (!payoutCode.trim() || !beneficiaryName.trim() + || !recvIdNumber.trim()) { + throw new Error("Payout code, beneficiary name and ID number are required."); + } + rpc = { + fn: "record_omt_receive", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_commission_usd: num(commissionUsd), + p_commission_lbp: num(commissionLbp), + p_fx_rate: num(fxRate) || null, + p_payout_code: payoutCode, + p_beneficiary_full_name: beneficiaryName, + p_beneficiary_id_type: recvIdType, + p_beneficiary_id_number: recvIdNumber, + p_beneficiary_phone: beneficiaryPhone || null, + p_origin_country: originCountry || null, + p_kyc_doc_url: null, + p_customer_id: null, + p_notes: notes || null, + p_service_code: service.code, + }, + }; + break; + + case "OMT_BILL": + case "EDL_BILL": + if (!billerCode.trim() || !accountNumber.trim() + || !externalRef.trim()) { + throw new Error("Biller, account number and reference are required."); + } + rpc = { + fn: "record_bill", + args: { + p_shop: shopId, p_till: tillId, p_service_code: service.code, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_fx_rate: num(fxRate) || null, + p_external_ref: externalRef, + p_biller_code: billerCode, + p_account_number: accountNumber, + p_period: billPeriod || null, + p_customer_name: beneficiaryName || null, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "GOODS_SALE": + if (!sku.trim() || num(unitPriceUsd) <= 0) { + throw new Error("SKU and unit price are required."); + } + rpc = { + fn: "record_goods_sale", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fx_rate: num(fxRate) || null, + p_sku: sku, p_qty: Math.max(1, parseInt(qty, 10) || 1), + p_unit_cost_usd: num(goodsUnitCostUsd), + p_unit_price_usd: num(unitPriceUsd), + p_serial_number: serialNumber || null, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "REPAIR": + if (!deviceType.trim() || !issueSummary.trim()) { + throw new Error("Device type and issue summary are required."); + } + rpc = { + fn: "record_repair", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fx_rate: num(fxRate) || null, + p_device_type: deviceType, + p_device_imei: deviceImei || null, + p_issue_summary: issueSummary, + p_warranty_days: parseInt(warrantyDays, 10) || 0, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "REFUND": + throw new Error( + "Refunds must be issued from the original transaction, not entered manually."); + + default: + throw new Error(`Unsupported service: ${service.code}`); + } + + const { data, error } = await supabase.rpc(rpc.fn, rpc.args); + if (error) throw error; + + const txnId = data as string; + const recentRes = await supabase + .from("v_my_recent_transactions") + .select("*") + .eq("id", txnId); + const recentRow = recentRes.data?.[0]; + const receiptRes = await supabase.rpc( + "record_receipt_print", + { p_txn_id: txnId, p_kind: "original", p_device: "cashier-ui" }, + ); + const receiptRow = Array.isArray(receiptRes.data) + ? receiptRes.data[0] + : receiptRes.data; + + setLastRecorded({ + txnId, + referenceNo: recentRow?.reference_no ?? null, + serviceName: recentRow?.service_name ?? service?.label ?? service.code, + paymentMethod: recentRow?.payment_method ?? paymentMethod, + grossUsd: Number(recentRow?.gross_usd ?? num(grossUsd)), + grossLbp: Number(recentRow?.gross_lbp ?? num(grossLbp)), + externalRef: (recentRow?.external_ref ?? externalRef) || null, + occurredAt: recentRow?.occurred_at ?? null, + receiptId: receiptRow?.receipt_id ?? null, + receiptToken: receiptRow?.qr_token ?? null, + receiptPdfUrl: receiptRow?.pdf_url ?? null, + }); + + toast({ + title: "Transaction recorded", + description: receiptRes.error + ? "Transaction saved, but the signed receipt log still needs attention." + : recentRow?.reference_no + ? `Receipt #${recentRow.reference_no} is ready.` + : "Transaction saved successfully.", + }); + onCreated?.(txnId); + } catch (err) { + toast({ + title: "Could not record transaction", + description: (err as Error).message, + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + }; + + const cat: ServiceCategory | undefined = service?.category; + const canStartTransaction = Boolean(shopId && tillId && shift); + const serviceGuidance = service ? SERVICE_GUIDANCE[service.code] : null; + const submitLabel = service ? ACTION_LABELS[service.code] ?? "Record transaction" : "Record transaction"; + + if (!isOpen && isClosing) return null; + + return ( + { if (!open) handleClose(); }}> + + + + New transaction + + + Pick the service category, then fill the fields it requires. + The server assigns the receipt number and chains the row into + the per-shop ledger. + + + + {lastRecorded ? ( +
+
+
+
+

Transaction saved

+

+ The server accepted the transaction and chained it into the ledger. +

+
+
+
Receipt
+
+ {lastRecorded.referenceNo ?? "Pending"} +
+
+
+ +
+
+
Service
+
{lastRecorded.serviceName}
+
+
+
Payment method
+
{PAYMENT_METHOD_LABELS[lastRecorded.paymentMethod] ?? lastRecorded.paymentMethod}
+
+
+
Amount
+
+ USD {lastRecorded.grossUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {lastRecorded.grossLbp > 0 ? ` · LBP ${lastRecorded.grossLbp.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : ""} +
+
+
+
External reference
+
{lastRecorded.externalRef || "Not captured"}
+
+
+
Signed receipt log
+
+ {lastRecorded.receiptId ? "Logged and ready for verification" : "Not logged yet"} +
+
+
Receipt log ID: {lastRecorded.receiptId ?? "Unavailable"}
+
QR token: {lastRecorded.receiptToken ?? "Unavailable"}
+
PDF: {lastRecorded.receiptPdfUrl ?? "Not generated yet"}
+
+
+
+ +
+ Transaction ID: {lastRecorded.txnId} + {lastRecorded.occurredAt ? ` · ${new Date(lastRecorded.occurredAt).toLocaleString()}` : ""} +
+
+ +
+ + +
+
+ ) : ( + + + {/* Shop / Till / Open shift */} +
+
+ + +
+
+ + +
+
+ +
+ {shift + ? `opened ${new Date(shift.opened_at).toLocaleTimeString()}` + : shopId ? "open one in Shift Control" : "—"} +
+
+
+ +
+ {canStartTransaction + ? "Ready for cashier entry. Pick a service and record the customer-facing transaction." + : "Pick the shop and till, then open your shift in Shift Control before starting customer transactions."} +
+ + {/* Quick Action POS UI */} + {!serviceCode ? ( +
+
+ {QUICK_SERVICES.map((quickService) => ( + + ))} +
+
+ +
+ +
+
+
+ ) : ( +
+
+
+ {cat} + {service?.label} +
+ +
+ {serviceGuidance && ( +
+
+

Before you save

+

{serviceGuidance.description}

+
+
+ {serviceGuidance.required.map((item) => ( + + {item} + + ))} +
+
+ )} + {/* Money */} +
+
+ + setGrossUsd(e.target.value)} /> +
+
+ + setGrossLbp(e.target.value)} /> +
+ {(cat === "money_transfer" || cat === "telecom_recharge" + || cat === "bills") && ( + <> +
+ + setFeeUsd(e.target.value)} /> +
+
+ + setFeeLbp(e.target.value)} /> +
+ + )} + {cat === "money_transfer" && ( + <> +
+ + setCommissionUsd(e.target.value)} /> +
+
+ + setCommissionLbp(e.target.value)} /> +
+ + )} +
+ +
+
+ + +
+ {/* FX rate removed to prevent manipulation */} +
+ + {/* Money-transfer SEND fields */} + {(service?.code === "OMT_SEND" || service?.code === "WU_SEND" + || service?.code === "WHISH_SEND") && ( +
+
Sender / Beneficiary
+
+
+ + +
+
+ + setExternalRef(e.target.value)} /> +
+
+ + setSenderName(e.target.value)} /> +
+
+ + setSenderPhone(e.target.value)} /> +
+
+ + +
+
+ + setSenderIdNumber(e.target.value)} /> +
+
+ + setBeneficiaryName(e.target.value)} /> +
+
+ + setBeneficiaryPhone(e.target.value)} /> +
+ {direction === "international" && ( +
+ + setDestinationCountry(e.target.value.toUpperCase())} /> +
+ )} +
+ + +
+
+
+ )} + + {/* Money-transfer RECEIVE fields */} + {(service?.code === "OMT_RECEIVE" || service?.code === "WU_RECEIVE") && ( +
+
Beneficiary payout
+
+
+ + setPayoutCode(e.target.value)} /> +
+
+ + setOriginCountry(e.target.value.toUpperCase())} /> +
+
+ + setBeneficiaryName(e.target.value)} /> +
+
+ + setBeneficiaryPhone(e.target.value)} /> +
+
+ + +
+
+ + setRecvIdNumber(e.target.value)} /> +
+
+
+ )} + + {/* Bill fields */} + {(service?.code === "OMT_BILL" || service?.code === "EDL_BILL") && ( +
+
Bill
+
+
+ + setBillerCode(e.target.value.toUpperCase())} /> +
+
+ + setAccountNumber(e.target.value)} /> +
+
+ + setBillPeriod(e.target.value)} /> +
+
+ + setExternalRef(e.target.value)} /> +
+
+ + setBeneficiaryName(e.target.value)} /> +
+
+
+ )} + + {/* Recharge fields */} + {cat === "telecom_recharge" && ( +
+
Recharge
+
+
+ + setOperator(e.target.value.toUpperCase())} /> +
+
+ + setMsisdn(e.target.value)} /> +
+
+ + +
+
+ + setVoucherSerial(e.target.value)} /> +
+
+ + setErechargeRef(e.target.value)} /> +
+ {/* Face value USD and Cost USD inputs removed for security against skimming. These should be fetched by Product Code automatically down the line. */} +
+

+ Either a voucher serial or an e-recharge reference is required. +

+
+ )} + + {/* Goods sale */} + {service?.code === "GOODS_SALE" && ( +
+
Goods sale
+
+
+ + setSku(e.target.value)} /> +
+
+ + setQty(e.target.value)} /> +
+
+ + setUnitPriceUsd(e.target.value)} /> +
+
+ + setGoodsUnitCostUsd(e.target.value)} /> +
+
+ + setSerialNumber(e.target.value)} /> +
+
+
+ )} + + {/* Repair */} + {service?.code === "REPAIR" && ( +
+
Repair
+
+
+ + setDeviceType(e.target.value)} /> +
+
+ + setDeviceImei(e.target.value)} /> +
+
+ + setIssueSummary(e.target.value)} /> +
+
+ + setWarrantyDays(e.target.value)} /> +
+
+
+ )} + +
+ +