diff --git a/.env.example b/.env.example
index 8fb4552..42415f3 100644
--- a/.env.example
+++ b/.env.example
@@ -1,11 +1,13 @@
# Frontend env vars for the Vite app
-VITE_SUPABASE_URL="http://YOUR_SUPABASE_API_HOST"
-VITE_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
+VITE_API_BASE_URL="http://localhost:4000/api"
VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY"
-# Backend / Edge Function secrets
-# Do not expose these in the browser.
-SUPABASE_URL="http://YOUR_SUPABASE_API_HOST"
-SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
-SUPABASE_SERVICE_ROLE_KEY="YOUR_SUPABASE_SERVICE_ROLE_KEY"
-GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
\ No newline at end of file
+# Local backend env vars
+DATABASE_URL="postgres://postgres:postgres@localhost:5432/leads4less"
+COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV"
+APP_HOST="0.0.0.0"
+APP_PORT="4000"
+APP_ORIGIN="http://localhost:3000"
+PG_BOSS_SCHEMA="pgboss"
+SESSION_TTL_DAYS="30"
+GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
diff --git a/.gitignore b/.gitignore
index 5a86d2a..0f09ab7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
node_modules/
build/
dist/
+dist-server/
coverage/
.DS_Store
*.log
diff --git a/README.md b/README.md
index cc97929..5095afc 100644
--- a/README.md
+++ b/README.md
@@ -1,35 +1,41 @@
# Leads4Less
-Leads4Less is a React + Vite app for finding local business leads, saving them in Supabase, and browsing them in dashboard and map views.
+Leads4Less is a React + Vite app for finding local business leads, saving them in Postgres, and browsing them in dashboard and map views.
## Stack
- React 19 + Vite
-- Supabase Auth + Postgres + Edge Functions
-- Google Maps Platform for maps, geocoding, and Places search
+- Local Fastify API + pg-boss worker
+- PostgreSQL + PostGIS
+- Google Maps Platform for browser maps and Places search
## Local App Setup
1. Install dependencies:
`npm install`
-2. Copy `.env.example` to `.env.local` and fill in:
- - `VITE_SUPABASE_URL`
- - `VITE_SUPABASE_ANON_KEY`
+2. Copy `.env.example` to `.env.local` and fill in at least:
- `VITE_GOOGLE_MAPS_PLATFORM_KEY`
-3. Run the app:
- `npm run dev`
-
-## Supabase Setup
-
-1. Create a Supabase project.
-2. Enable email/password auth in Supabase Auth.
-3. Apply the SQL migration in `supabase/migrations/20260322120000_init.sql`.
-4. Deploy the Edge Function in `supabase/functions/run-search/index.ts`.
-5. Set these Edge Function secrets in Supabase:
- - `SUPABASE_URL`
- - `SUPABASE_ANON_KEY`
- - `SUPABASE_SERVICE_ROLE_KEY`
+ - `DATABASE_URL`
+ - `COOKIE_SECRET`
- `GOOGLE_MAPS_SERVER_KEY`
+3. Run the frontend:
+ `npm run dev:web`
+
+## Local API Setup
+
+1. Ensure PostgreSQL is running locally with PostGIS available.
+2. Apply the local database migrations:
+ `npm run migrate`
+3. Start the API:
+ `npm run dev:api`
+4. Start the worker:
+ `npm run dev:worker`
+
+## Database Layout
+
+- `db/migrations/0001_local_core.sql` creates the local-first schema.
+- `db/scripts/migrate.ts` applies migrations in order and records them in `schema_migrations`.
+- `db/scripts/seed-postal-placeholder.ts` is a placeholder for ZIP/ZCTA and Canada FSA imports.
## Google Maps Requirements
@@ -41,9 +47,9 @@ Enable these Google Cloud APIs for the keys you use:
Use a browser-restricted key for `VITE_GOOGLE_MAPS_PLATFORM_KEY` and a server-side key for `GOOGLE_MAPS_SERVER_KEY`.
-## Current Flow
+## Current Runtime Flow
-1. User signs in with Supabase email/password auth.
-2. The app submits a search request to the `run-search` Edge Function.
-3. The function geocodes the location, calls Google Places, upserts businesses, and stores job results in Supabase.
-4. The dashboard and map load saved leads from Postgres.
+1. The React app authenticates through the local Fastify API using cookie-backed sessions.
+2. Search requests run through the local API and persist results in PostgreSQL.
+3. The worker foundation is available for asynchronous job execution and deep research expansion.
+4. The target architecture is fully local auth + Fastify routes + pg-boss workers + PostgreSQL/PostGIS.
diff --git a/db/migrations/0001_local_core.sql b/db/migrations/0001_local_core.sql
new file mode 100644
index 0000000..0f91a25
--- /dev/null
+++ b/db/migrations/0001_local_core.sql
@@ -0,0 +1,188 @@
+create extension if not exists pgcrypto;
+create extension if not exists postgis;
+
+create or replace function public.set_updated_at()
+returns trigger
+language plpgsql
+as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$;
+
+create table if not exists public.users (
+ id uuid primary key default gen_random_uuid(),
+ email text not null unique,
+ password_hash text not null,
+ display_name text,
+ avatar_url text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create table if not exists public.sessions (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.users (id) on delete cascade,
+ token_hash text not null unique,
+ expires_at timestamptz not null,
+ last_seen_at timestamptz,
+ user_agent text,
+ ip_address inet,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create table if not exists public.postal_areas (
+ id uuid primary key default gen_random_uuid(),
+ country_code text not null,
+ postal_code text not null,
+ display_name text,
+ normalized_postal_code text not null,
+ geom geometry(multipolygon, 4326) not null,
+ centroid geography(point, 4326),
+ search_radius_m integer,
+ metadata_json jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint postal_areas_country_code_postal_code_key unique (country_code, normalized_postal_code)
+);
+
+create table if not exists public.deep_research_batches (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.users (id) on delete cascade,
+ pin_lat double precision not null,
+ pin_lng double precision not null,
+ pin_geom geography(point, 4326),
+ base_postal_code text,
+ country_code text,
+ propagation integer not null default 0,
+ business_type text not null,
+ keywords text,
+ status text not null check (status in ('pending', 'running', 'completed', 'failed', 'stopped')),
+ total_postal_areas integer not null default 0,
+ total_results integer not null default 0,
+ started_at timestamptz,
+ completed_at timestamptz,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create table if not exists public.search_jobs (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.users (id) on delete cascade,
+ deep_research_batch_id uuid references public.deep_research_batches (id) on delete set null,
+ postal_area_id uuid references public.postal_areas (id) on delete set null,
+ name text not null,
+ city text,
+ address text,
+ postal_code text,
+ country_code text,
+ radius_km numeric not null,
+ business_type text not null,
+ keywords text,
+ status text not null check (status in ('pending', 'running', 'completed', 'failed', 'stopped')),
+ total_results integer not null default 0,
+ cancel_requested boolean not null default false,
+ requested_lead_limit integer not null default 60,
+ search_center_geom geography(point, 4326),
+ started_at timestamptz,
+ completed_at timestamptz,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create table if not exists public.businesses (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.users (id) on delete cascade,
+ external_source_id text,
+ source text not null,
+ name text not null,
+ address text,
+ city text,
+ state_province text,
+ postal_code text,
+ country text,
+ phone text,
+ website text,
+ rating numeric,
+ review_count integer,
+ category text,
+ hours_json jsonb,
+ latitude double precision,
+ longitude double precision,
+ geom geography(point, 4326),
+ general_info text,
+ metadata_json jsonb,
+ first_seen_at timestamptz,
+ last_seen_at timestamptz,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint businesses_user_source_external_source_key unique (user_id, source, external_source_id)
+);
+
+create table if not exists public.search_job_results (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references public.users (id) on delete cascade,
+ search_job_id uuid not null references public.search_jobs (id) on delete cascade,
+ business_id uuid not null references public.businesses (id) on delete cascade,
+ matched_keywords text[],
+ rank integer,
+ captured_at timestamptz not null default now(),
+ constraint search_job_results_job_business_key unique (search_job_id, business_id)
+);
+
+create table if not exists public.postal_area_neighbors (
+ postal_area_id uuid not null references public.postal_areas (id) on delete cascade,
+ neighbor_postal_area_id uuid not null references public.postal_areas (id) on delete cascade,
+ created_at timestamptz not null default now(),
+ primary key (postal_area_id, neighbor_postal_area_id),
+ check (postal_area_id <> neighbor_postal_area_id)
+);
+
+create index if not exists sessions_user_id_idx on public.sessions (user_id);
+create index if not exists sessions_expires_at_idx on public.sessions (expires_at);
+create index if not exists search_jobs_user_created_at_idx on public.search_jobs (user_id, created_at desc);
+create index if not exists search_jobs_batch_idx on public.search_jobs (deep_research_batch_id);
+create index if not exists businesses_user_created_at_idx on public.businesses (user_id, created_at desc);
+create index if not exists search_job_results_user_job_idx on public.search_job_results (user_id, search_job_id);
+create index if not exists deep_research_batches_user_created_at_idx on public.deep_research_batches (user_id, created_at desc);
+create index if not exists postal_areas_geom_idx on public.postal_areas using gist (geom);
+create index if not exists postal_areas_centroid_idx on public.postal_areas using gist (centroid);
+create index if not exists businesses_geom_idx on public.businesses using gist (geom);
+
+drop trigger if exists set_users_updated_at on public.users;
+create trigger set_users_updated_at
+before update on public.users
+for each row
+execute function public.set_updated_at();
+
+drop trigger if exists set_sessions_updated_at on public.sessions;
+create trigger set_sessions_updated_at
+before update on public.sessions
+for each row
+execute function public.set_updated_at();
+
+drop trigger if exists set_postal_areas_updated_at on public.postal_areas;
+create trigger set_postal_areas_updated_at
+before update on public.postal_areas
+for each row
+execute function public.set_updated_at();
+
+drop trigger if exists set_deep_research_batches_updated_at on public.deep_research_batches;
+create trigger set_deep_research_batches_updated_at
+before update on public.deep_research_batches
+for each row
+execute function public.set_updated_at();
+
+drop trigger if exists set_search_jobs_updated_at on public.search_jobs;
+create trigger set_search_jobs_updated_at
+before update on public.search_jobs
+for each row
+execute function public.set_updated_at();
+
+drop trigger if exists set_businesses_updated_at on public.businesses;
+create trigger set_businesses_updated_at
+before update on public.businesses
+for each row
+execute function public.set_updated_at();
diff --git a/db/scripts/migrate.ts b/db/scripts/migrate.ts
new file mode 100644
index 0000000..7753c87
--- /dev/null
+++ b/db/scripts/migrate.ts
@@ -0,0 +1,54 @@
+import 'dotenv/config';
+import { readdir, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { getDbPool } from '../../server/src/db/pool.js';
+
+const currentDir = path.dirname(fileURLToPath(import.meta.url));
+const migrationsDir = path.resolve(currentDir, '../migrations');
+
+async function run() {
+ const pool = getDbPool();
+ const client = await pool.connect();
+
+ try {
+ await client.query(`
+ create table if not exists public.schema_migrations (
+ id text primary key,
+ applied_at timestamptz not null default now()
+ )
+ `);
+
+ const appliedRows = await client.query<{ id: string }>('select id from public.schema_migrations');
+ const appliedIds = new Set(appliedRows.rows.map((row) => row.id));
+
+ const migrationFiles = (await readdir(migrationsDir))
+ .filter((entry) => entry.endsWith('.sql'))
+ .sort((left, right) => left.localeCompare(right));
+
+ for (const migrationFile of migrationFiles) {
+ if (appliedIds.has(migrationFile)) {
+ continue;
+ }
+
+ const migrationPath = path.join(migrationsDir, migrationFile);
+ const sql = await readFile(migrationPath, 'utf8');
+
+ console.log(`Applying migration ${migrationFile}`);
+ await client.query('begin');
+ await client.query(sql);
+ await client.query('insert into public.schema_migrations (id) values ($1)', [migrationFile]);
+ await client.query('commit');
+ }
+
+ console.log('Migrations complete');
+ } catch (error) {
+ await client.query('rollback');
+ throw error;
+ } finally {
+ client.release();
+ await pool.end();
+ }
+}
+
+await run();
diff --git a/db/scripts/seed-postal-placeholder.ts b/db/scripts/seed-postal-placeholder.ts
new file mode 100644
index 0000000..88639d4
--- /dev/null
+++ b/db/scripts/seed-postal-placeholder.ts
@@ -0,0 +1,2 @@
+console.log('Postal dataset import is not implemented yet.');
+console.log('Next step: add ZIP/ZCTA and Canada FSA import scripts into db/datasets and db/scripts.');
diff --git a/index.html b/index.html
index 5dd782c..7f5485f 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@
- Leads4Less
+ Leads4less
diff --git a/package-lock.json b/package-lock.json
index 6439658..b88082a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,22 +8,31 @@
"name": "leads4less",
"version": "0.0.0",
"dependencies": {
- "@supabase/supabase-js": "^2.57.4",
+ "@fastify/cookie": "^11.0.2",
+ "@fastify/cors": "^11.2.0",
"@tailwindcss/vite": "^4.1.14",
"@vis.gl/react-google-maps": "^1.7.1",
"@vitejs/plugin-react": "^5.0.4",
+ "argon2": "^0.44.0",
"clsx": "^2.1.1",
+ "dotenv": "^17.3.1",
+ "fastify": "^5.8.4",
"lucide-react": "^0.546.0",
"papaparse": "^5.5.3",
+ "pg": "^8.20.0",
+ "pg-boss": "^12.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
- "vite": "^6.2.0"
+ "vite": "^6.2.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.14.0",
+ "@types/pg": "^8.20.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
+ "tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
@@ -291,6 +300,579 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@epic-web/invariant": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
+ "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
+ "license": "MIT"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
+ "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
+ "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
+ "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
+ "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
+ "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
+ "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
+ "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
+ "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
+ "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
+ "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
+ "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
+ "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
+ "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
+ "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
+ "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
+ "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
+ "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
+ "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
+ "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
+ "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
+ "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@fastify/ajv-compiler": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
+ "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.12.0",
+ "ajv-formats": "^3.0.1",
+ "fast-uri": "^3.0.0"
+ }
+ },
+ "node_modules/@fastify/cookie": {
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
+ "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.0",
+ "fastify-plugin": "^5.0.0"
+ }
+ },
+ "node_modules/@fastify/cors": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
+ "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fastify-plugin": "^5.0.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
+ "node_modules/@fastify/error": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
+ "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/fast-json-stringify-compiler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
+ "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fast-json-stringify": "^6.0.0"
+ }
+ },
+ "node_modules/@fastify/forwarded": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
+ "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/merge-json-schemas": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
+ "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@fastify/proxy-addr": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
+ "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/forwarded": "^3.0.0",
+ "ipaddr.js": "^2.1.0"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -336,6 +918,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@phc/format": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
+ "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -667,86 +1264,6 @@
"win32"
]
},
- "node_modules/@supabase/auth-js": {
- "version": "2.99.3",
- "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.3.tgz",
- "integrity": "sha512-vMEVLA1kGGYd/kdsJSwtjiFUZM1nGfrz2DWmgMBZtocV48qL+L2+4QpIkueXyBEumMQZFEyhz57i/5zGHjvdBw==",
- "license": "MIT",
- "dependencies": {
- "tslib": "2.8.1"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@supabase/functions-js": {
- "version": "2.99.3",
- "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.3.tgz",
- "integrity": "sha512-6tk2zrcBkzKaaBXPOG5nshn30uJNFGOH9LxOnE8i850eQmsX+jVm7vql9kTPyvUzEHwU4zdjSOkXS9M+9ukMVA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "2.8.1"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@supabase/postgrest-js": {
- "version": "2.99.3",
- "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.3.tgz",
- "integrity": "sha512-8HxEf+zNycj7Z8+ONhhlu+7J7Ha+L6weyCtdEeK2mN5OWJbh6n4LPU4iuJ5UlCvvNnbSXMoutY7piITEEAgl2g==",
- "license": "MIT",
- "dependencies": {
- "tslib": "2.8.1"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@supabase/realtime-js": {
- "version": "2.99.3",
- "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.3.tgz",
- "integrity": "sha512-c1azgZ2nZPczbY5k5u5iFrk1InpxN81IvNE+UBAkjrBz3yc5ALLJNkeTQwbJZT4PZBuYXEzqYGLMuh9fdTtTMg==",
- "license": "MIT",
- "dependencies": {
- "@types/phoenix": "^1.6.6",
- "@types/ws": "^8.18.1",
- "tslib": "2.8.1",
- "ws": "^8.18.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@supabase/storage-js": {
- "version": "2.99.3",
- "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.3.tgz",
- "integrity": "sha512-lOfIm4hInNcd8x0i1LWphnLKxec42wwbjs+vhaVAvR801Vda0UAMbTooUY6gfqgQb8v29GofqKuQMMTAsl6w/w==",
- "license": "MIT",
- "dependencies": {
- "iceberg-js": "^0.8.1",
- "tslib": "2.8.1"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@supabase/supabase-js": {
- "version": "2.99.3",
- "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.3.tgz",
- "integrity": "sha512-GuPbzoEaI51AkLw9VGhLNvnzw4PHbS3p8j2/JlvLeZNQMKwZw4aEYQIDBRtFwL5Nv7/275n9m4DHtakY8nCvgg==",
- "license": "MIT",
- "dependencies": {
- "@supabase/auth-js": "2.99.3",
- "@supabase/functions-js": "2.99.3",
- "@supabase/postgrest-js": "2.99.3",
- "@supabase/realtime-js": "2.99.3",
- "@supabase/storage-js": "2.99.3"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1061,24 +1578,22 @@
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
- "node_modules/@types/phoenix": {
- "version": "1.6.7",
- "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
- "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
- "license": "MIT"
- },
- "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==",
+ "node_modules/@types/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "@types/node": "*"
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
}
},
"node_modules/@vis.gl/react-google-maps": {
@@ -1115,6 +1630,70 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/abstract-logging": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
+ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
+ "license": "MIT"
+ },
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/argon2": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
+ "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@phc/format": "^1.0.0",
+ "cross-env": "^10.0.0",
+ "node-addon-api": "^8.5.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -1152,6 +1731,26 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/avvio": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz",
+ "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/error": "^4.0.0",
+ "fastq": "^1.17.1"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz",
@@ -1232,6 +1831,62 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cron-parser": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
+ "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
+ "license": "MIT",
+ "dependencies": {
+ "luxon": "^3.7.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cross-env": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
+ "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "@epic-web/invariant": "^1.0.0",
+ "cross-spawn": "^7.0.6"
+ },
+ "bin": {
+ "cross-env": "dist/bin/cross-env.js",
+ "cross-env-shell": "dist/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1249,6 +1904,15 @@
}
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1258,6 +1922,18 @@
"node": ">=8"
}
},
+ "node_modules/dotenv": {
+ "version": "17.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
+ "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
@@ -1277,6 +1953,48 @@
"node": ">=10.13.0"
}
},
+ "node_modules/esbuild": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
+ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.4",
+ "@esbuild/android-arm": "0.27.4",
+ "@esbuild/android-arm64": "0.27.4",
+ "@esbuild/android-x64": "0.27.4",
+ "@esbuild/darwin-arm64": "0.27.4",
+ "@esbuild/darwin-x64": "0.27.4",
+ "@esbuild/freebsd-arm64": "0.27.4",
+ "@esbuild/freebsd-x64": "0.27.4",
+ "@esbuild/linux-arm": "0.27.4",
+ "@esbuild/linux-arm64": "0.27.4",
+ "@esbuild/linux-ia32": "0.27.4",
+ "@esbuild/linux-loong64": "0.27.4",
+ "@esbuild/linux-mips64el": "0.27.4",
+ "@esbuild/linux-ppc64": "0.27.4",
+ "@esbuild/linux-riscv64": "0.27.4",
+ "@esbuild/linux-s390x": "0.27.4",
+ "@esbuild/linux-x64": "0.27.4",
+ "@esbuild/netbsd-arm64": "0.27.4",
+ "@esbuild/netbsd-x64": "0.27.4",
+ "@esbuild/openbsd-arm64": "0.27.4",
+ "@esbuild/openbsd-x64": "0.27.4",
+ "@esbuild/openharmony-arm64": "0.27.4",
+ "@esbuild/sunos-x64": "0.27.4",
+ "@esbuild/win32-arm64": "0.27.4",
+ "@esbuild/win32-ia32": "0.27.4",
+ "@esbuild/win32-x64": "0.27.4"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1286,12 +2004,137 @@
"node": ">=6"
}
},
+ "node_modules/fast-decode-uri-component": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
+ "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
+ "node_modules/fast-json-stringify": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz",
+ "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/merge-json-schemas": "^0.2.0",
+ "ajv": "^8.12.0",
+ "ajv-formats": "^3.0.1",
+ "fast-uri": "^3.0.0",
+ "json-schema-ref-resolver": "^3.0.0",
+ "rfdc": "^1.2.0"
+ }
+ },
+ "node_modules/fast-querystring": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
+ "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-decode-uri-component": "^1.0.1"
+ }
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fastify": {
+ "version": "5.8.4",
+ "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
+ "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/ajv-compiler": "^4.0.5",
+ "@fastify/error": "^4.0.0",
+ "@fastify/fast-json-stringify-compiler": "^5.0.0",
+ "@fastify/proxy-addr": "^5.0.0",
+ "abstract-logging": "^2.0.1",
+ "avvio": "^9.0.0",
+ "fast-json-stringify": "^6.0.0",
+ "find-my-way": "^9.0.0",
+ "light-my-request": "^6.0.0",
+ "pino": "^9.14.0 || ^10.1.0",
+ "process-warning": "^5.0.0",
+ "rfdc": "^1.3.1",
+ "secure-json-parse": "^4.0.0",
+ "semver": "^7.6.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
+ "node_modules/fastify-plugin": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
+ "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/fastify/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/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1309,6 +2152,20 @@
}
}
},
+ "node_modules/find-my-way": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
+ "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-querystring": "^1.0.0",
+ "safe-regex2": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -1346,21 +2203,40 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-tsconfig": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
- "node_modules/iceberg-js": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
- "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "node_modules/ipaddr.js": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
+ "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
- "node": ">=20.0.0"
+ "node": ">= 10"
}
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1388,6 +2264,31 @@
"node": ">=6"
}
},
+ "node_modules/json-schema-ref-resolver": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
+ "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -1400,6 +2301,43 @@
"node": ">=6"
}
},
+ "node_modules/light-my-request": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
+ "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "process-warning": "^4.0.0",
+ "set-cookie-parser": "^2.6.0"
+ }
+ },
+ "node_modules/light-my-request/node_modules/process-warning": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
+ "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1667,6 +2605,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1700,18 +2647,174 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/node-addon-api": {
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
+ "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"license": "MIT"
},
+ "node_modules/non-error": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/non-error/-/non-error-0.1.0.tgz",
+ "integrity": "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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-boss": {
+ "version": "12.14.0",
+ "resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-12.14.0.tgz",
+ "integrity": "sha512-sxF+Y5w6uRRkFCen3LM6BOcSG5gdMM3/hs+WiycAHHX8EuP5Zod5eTo79IlmegXFzIgtYWs7Bcjjhid4TRLlvg==",
+ "license": "MIT",
+ "dependencies": {
+ "cron-parser": "^5.5.0",
+ "pg": "^8.19.0",
+ "serialize-error": "^13.0.1"
+ },
+ "bin": {
+ "pg-boss": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "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/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1730,6 +2833,43 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pino": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
+ "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^3.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^4.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+ "license": "MIT"
+ },
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -1765,6 +2905,67 @@
"dev": true,
"license": "MIT"
},
+ "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/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -1795,6 +2996,59 @@
"node": ">=0.10.0"
}
},
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "devOptional": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/ret": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
+ "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "license": "MIT"
+ },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -1839,12 +3093,59 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/safe-regex2": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
+ "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "ret": "~0.5.0"
+ },
+ "bin": {
+ "safe-regex2": "bin/safe-regex2.js"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/secure-json-parse": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
+ "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -1854,6 +3155,58 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/serialize-error": {
+ "version": "13.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-13.0.1.tgz",
+ "integrity": "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==",
+ "license": "MIT",
+ "dependencies": {
+ "non-error": "^0.1.0",
+ "type-fest": "^5.4.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1863,6 +3216,27 @@
"node": ">=0.10.0"
}
},
+ "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/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
@@ -1892,6 +3266,18 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/thread-stream": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
+ "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1908,11 +3294,56 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/toad-cache": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
+ "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
+ "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==",
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
},
"node_modules/typescript": {
"version": "5.8.3",
@@ -1932,6 +3363,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "devOptional": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
@@ -2495,25 +3927,28 @@
"@esbuild/win32-x64": "0.25.12"
}
},
- "node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "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": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
+ "node": ">=0.4"
}
},
"node_modules/yallist": {
@@ -2521,6 +3956,15 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index 69cf988..81a0680 100644
--- a/package.json
+++ b/package.json
@@ -5,28 +5,45 @@
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
+ "dev:web": "vite --port=3000 --host=0.0.0.0",
+ "dev:api": "tsx --tsconfig tsconfig.server.json server/src/index.ts",
+ "dev:worker": "tsx --tsconfig tsconfig.server.json server/src/worker.ts",
"build": "vite build",
+ "build:api": "tsc -p tsconfig.server.json",
"preview": "vite preview",
- "clean": "rm -rf dist",
- "lint": "tsc --noEmit"
+ "clean": "rm -rf dist dist-server",
+ "lint": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
+ "migrate": "tsx --tsconfig tsconfig.server.json db/scripts/migrate.ts",
+ "seed:postal": "tsx --tsconfig tsconfig.server.json db/scripts/seed-postal-placeholder.ts",
+ "start:api": "node dist-server/server/src/index.js",
+ "start:worker": "node dist-server/server/src/worker.js"
},
"dependencies": {
- "@supabase/supabase-js": "^2.57.4",
+ "@fastify/cookie": "^11.0.2",
+ "@fastify/cors": "^11.2.0",
"@tailwindcss/vite": "^4.1.14",
"@vis.gl/react-google-maps": "^1.7.1",
"@vitejs/plugin-react": "^5.0.4",
+ "argon2": "^0.44.0",
"clsx": "^2.1.1",
+ "dotenv": "^17.3.1",
+ "fastify": "^5.8.4",
"lucide-react": "^0.546.0",
"papaparse": "^5.5.3",
+ "pg": "^8.20.0",
+ "pg-boss": "^12.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
- "vite": "^6.2.0"
+ "vite": "^6.2.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.14.0",
+ "@types/pg": "^8.20.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
+ "tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
diff --git a/server/src/app.ts b/server/src/app.ts
new file mode 100644
index 0000000..7724dcd
--- /dev/null
+++ b/server/src/app.ts
@@ -0,0 +1,30 @@
+import Fastify from 'fastify';
+import cookie from '@fastify/cookie';
+import cors from '@fastify/cors';
+import { getEnv } from './config/env.js';
+import { authRoutes } from './routes/auth.js';
+import { healthRoutes } from './routes/health.js';
+import { searchJobRoutes } from './routes/search-jobs.js';
+
+export async function buildApp() {
+ const env = getEnv();
+ const app = Fastify({
+ logger: true,
+ });
+
+ await app.register(cors, {
+ origin: env.APP_ORIGIN,
+ credentials: true,
+ });
+
+ await app.register(cookie, {
+ secret: env.COOKIE_SECRET,
+ hook: 'onRequest',
+ });
+
+ await app.register(healthRoutes, { prefix: '/api' });
+ await app.register(authRoutes, { prefix: '/api' });
+ await app.register(searchJobRoutes, { prefix: '/api' });
+
+ return app;
+}
diff --git a/server/src/auth/constants.ts b/server/src/auth/constants.ts
new file mode 100644
index 0000000..74f0c72
--- /dev/null
+++ b/server/src/auth/constants.ts
@@ -0,0 +1,2 @@
+export const SESSION_COOKIE_NAME = 'leads4less_session';
+export const SESSION_TOKEN_BYTES = 32;
diff --git a/server/src/auth/middleware.ts b/server/src/auth/middleware.ts
new file mode 100644
index 0000000..0681e86
--- /dev/null
+++ b/server/src/auth/middleware.ts
@@ -0,0 +1,33 @@
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import type { SessionUser } from '../../../shared/types.js';
+import { getDbPool } from '../db/pool.js';
+import { getSessionTokenFromRequest, getSessionUserByToken } from './sessions.js';
+
+declare module 'fastify' {
+ interface FastifyRequest {
+ authUser: SessionUser | null;
+ }
+}
+
+export async function hydrateAuthUser(request: FastifyRequest) {
+ const token = getSessionTokenFromRequest(request);
+
+ if (!token) {
+ request.authUser = null;
+ return null;
+ }
+
+ const user = await getSessionUserByToken(getDbPool(), token);
+ request.authUser = user;
+ return user;
+}
+
+export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
+ const user = await hydrateAuthUser(request);
+
+ if (!user) {
+ return reply.code(401).send({ error: 'Unauthorized' });
+ }
+
+ return undefined;
+}
diff --git a/server/src/auth/passwords.ts b/server/src/auth/passwords.ts
new file mode 100644
index 0000000..24427a4
--- /dev/null
+++ b/server/src/auth/passwords.ts
@@ -0,0 +1,9 @@
+import argon2 from 'argon2';
+
+export async function hashPassword(password: string) {
+ return argon2.hash(password);
+}
+
+export async function verifyPassword(passwordHash: string, password: string) {
+ return argon2.verify(passwordHash, password);
+}
diff --git a/server/src/auth/sessions.ts b/server/src/auth/sessions.ts
new file mode 100644
index 0000000..bde18fd
--- /dev/null
+++ b/server/src/auth/sessions.ts
@@ -0,0 +1,111 @@
+import { createHash, randomBytes } from 'node:crypto';
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import type { Pool, PoolClient } from 'pg';
+import type { SessionUser } from '../../../shared/types.js';
+import { getEnv } from '../config/env.js';
+import { SESSION_COOKIE_NAME, SESSION_TOKEN_BYTES } from './constants.js';
+
+type DbClient = Pool | PoolClient;
+
+type SessionRow = {
+ session_id: string;
+ id: string;
+ email: string;
+ display_name: string | null;
+ avatar_url: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+function hashSessionToken(token: string) {
+ return createHash('sha256').update(token).digest('hex');
+}
+
+function sessionExpiryDate() {
+ const env = getEnv();
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + env.SESSION_TTL_DAYS);
+ return expiresAt;
+}
+
+function mapSessionRow(row: SessionRow): SessionUser {
+ return {
+ sessionId: row.session_id,
+ id: row.id,
+ email: row.email,
+ displayName: row.display_name || row.email.split('@')[0] || 'User',
+ avatarUrl: row.avatar_url,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export function setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date) {
+ const env = getEnv();
+
+ reply.setCookie(SESSION_COOKIE_NAME, token, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: env.NODE_ENV === 'production',
+ expires: expiresAt,
+ });
+}
+
+export function clearSessionCookie(reply: FastifyReply) {
+ reply.clearCookie(SESSION_COOKIE_NAME, {
+ path: '/',
+ sameSite: 'lax',
+ });
+}
+
+export function getSessionTokenFromRequest(request: FastifyRequest) {
+ const cookies = request.cookies as Record;
+ return cookies[SESSION_COOKIE_NAME] ?? null;
+}
+
+export async function createSession(db: DbClient, userId: string, metadata: { userAgent?: string; ipAddress?: string | null }) {
+ const token = randomBytes(SESSION_TOKEN_BYTES).toString('hex');
+ const tokenHash = hashSessionToken(token);
+ const expiresAt = sessionExpiryDate();
+
+ const result = await db.query<{ id: string }>(
+ `
+ insert into public.sessions (user_id, token_hash, expires_at, last_seen_at, user_agent, ip_address)
+ values ($1, $2, $3, now(), $4, $5)
+ returning id
+ `,
+ [userId, tokenHash, expiresAt.toISOString(), metadata.userAgent ?? null, metadata.ipAddress ?? null],
+ );
+
+ return {
+ sessionId: result.rows[0].id,
+ token,
+ expiresAt,
+ };
+}
+
+export async function deleteSessionByToken(db: DbClient, token: string) {
+ await db.query('delete from public.sessions where token_hash = $1', [hashSessionToken(token)]);
+}
+
+export async function getSessionUserByToken(db: DbClient, token: string) {
+ const tokenHash = hashSessionToken(token);
+ const result = await db.query(
+ `
+ select s.id as session_id, u.id, u.email, u.display_name, u.avatar_url, u.created_at, u.updated_at
+ from public.sessions s
+ join public.users u on u.id = s.user_id
+ where s.token_hash = $1 and s.expires_at > now()
+ limit 1
+ `,
+ [tokenHash],
+ );
+
+ if (result.rowCount === 0) {
+ return null;
+ }
+
+ await db.query('update public.sessions set last_seen_at = now() where token_hash = $1', [tokenHash]);
+ return mapSessionRow(result.rows[0]);
+}
diff --git a/server/src/auth/users.ts b/server/src/auth/users.ts
new file mode 100644
index 0000000..ea3af30
--- /dev/null
+++ b/server/src/auth/users.ts
@@ -0,0 +1,78 @@
+import type { Pool, PoolClient } from 'pg';
+import type { AppUser } from '../../../shared/types.js';
+
+type DbClient = Pool | PoolClient;
+
+type UserRow = {
+ id: string;
+ email: string;
+ password_hash: string;
+ display_name: string | null;
+ avatar_url: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type UserWithPassword = AppUser & {
+ passwordHash: string;
+};
+
+export function normalizeEmail(email: string) {
+ return email.trim().toLowerCase();
+}
+
+export function mapUserRow(row: UserRow): UserWithPassword {
+ return {
+ id: row.id,
+ email: row.email,
+ displayName: row.display_name || row.email.split('@')[0] || 'User',
+ avatarUrl: row.avatar_url,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ passwordHash: row.password_hash,
+ };
+}
+
+export function toAppUser(user: UserWithPassword): AppUser {
+ return {
+ id: user.id,
+ email: user.email,
+ displayName: user.displayName,
+ avatarUrl: user.avatarUrl,
+ createdAt: user.createdAt,
+ updatedAt: user.updatedAt,
+ };
+}
+
+export async function getUserByEmail(db: DbClient, email: string) {
+ const normalizedEmail = normalizeEmail(email);
+ const result = await db.query(
+ `
+ select id, email, password_hash, display_name, avatar_url, created_at, updated_at
+ from public.users
+ where lower(email) = $1
+ limit 1
+ `,
+ [normalizedEmail],
+ );
+
+ if (result.rowCount === 0) {
+ return null;
+ }
+
+ return mapUserRow(result.rows[0]);
+}
+
+export async function createUser(db: DbClient, input: { email: string; passwordHash: string; displayName?: string | null }) {
+ const normalizedEmail = normalizeEmail(input.email);
+ const result = await db.query(
+ `
+ insert into public.users (email, password_hash, display_name)
+ values ($1, $2, $3)
+ returning id, email, password_hash, display_name, avatar_url, created_at, updated_at
+ `,
+ [normalizedEmail, input.passwordHash, input.displayName?.trim() || null],
+ );
+
+ return mapUserRow(result.rows[0]);
+}
diff --git a/server/src/config/env.ts b/server/src/config/env.ts
new file mode 100644
index 0000000..c091cc2
--- /dev/null
+++ b/server/src/config/env.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod';
+
+const envSchema = z.object({
+ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
+ APP_HOST: z.string().default('0.0.0.0'),
+ APP_PORT: z.coerce.number().int().positive().default(4000),
+ APP_ORIGIN: z.string().default('http://localhost:3000'),
+ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
+ COOKIE_SECRET: z.string().min(1, 'COOKIE_SECRET is required'),
+ GOOGLE_MAPS_SERVER_KEY: z.string().optional(),
+ PG_BOSS_SCHEMA: z.string().default('pgboss'),
+ SESSION_TTL_DAYS: z.coerce.number().int().positive().default(30),
+});
+
+export type AppEnv = z.infer;
+
+let cachedEnv: AppEnv | null = null;
+
+export function getEnv(): AppEnv {
+ if (cachedEnv) {
+ return cachedEnv;
+ }
+
+ cachedEnv = envSchema.parse(process.env);
+ return cachedEnv;
+}
diff --git a/server/src/db/boss.ts b/server/src/db/boss.ts
new file mode 100644
index 0000000..20310f7
--- /dev/null
+++ b/server/src/db/boss.ts
@@ -0,0 +1,28 @@
+import { PgBoss } from 'pg-boss';
+import { getEnv } from '../config/env.js';
+
+let boss: PgBoss | null = null;
+
+export async function getBoss() {
+ if (boss) {
+ return boss;
+ }
+
+ const env = getEnv();
+ boss = new PgBoss({
+ connectionString: env.DATABASE_URL,
+ schema: env.PG_BOSS_SCHEMA,
+ });
+
+ await boss.start();
+ return boss;
+}
+
+export async function stopBoss() {
+ if (!boss) {
+ return;
+ }
+
+ await boss.stop();
+ boss = null;
+}
diff --git a/server/src/db/pool.ts b/server/src/db/pool.ts
new file mode 100644
index 0000000..ce27d5e
--- /dev/null
+++ b/server/src/db/pool.ts
@@ -0,0 +1,17 @@
+import { Pool } from 'pg';
+import { getEnv } from '../config/env.js';
+
+let pool: Pool | null = null;
+
+export function getDbPool() {
+ if (pool) {
+ return pool;
+ }
+
+ const env = getEnv();
+ pool = new Pool({
+ connectionString: env.DATABASE_URL,
+ });
+
+ return pool;
+}
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 0000000..69b4e3b
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,16 @@
+import 'dotenv/config';
+import { buildApp } from './app.js';
+import { getEnv } from './config/env.js';
+
+const env = getEnv();
+const app = await buildApp();
+
+try {
+ await app.listen({
+ host: env.APP_HOST,
+ port: env.APP_PORT,
+ });
+} catch (error) {
+ app.log.error(error);
+ process.exit(1);
+}
diff --git a/server/src/jobs/names.ts b/server/src/jobs/names.ts
new file mode 100644
index 0000000..3edcd8c
--- /dev/null
+++ b/server/src/jobs/names.ts
@@ -0,0 +1,2 @@
+export const RUN_SEARCH_JOB = 'search.run';
+export const RUN_DEEP_RESEARCH_BATCH_JOB = 'deep-research.run';
diff --git a/server/src/jobs/register-jobs.ts b/server/src/jobs/register-jobs.ts
new file mode 100644
index 0000000..721dcf2
--- /dev/null
+++ b/server/src/jobs/register-jobs.ts
@@ -0,0 +1,12 @@
+import type { PgBoss } from 'pg-boss';
+import { RUN_DEEP_RESEARCH_BATCH_JOB, RUN_SEARCH_JOB } from './names.js';
+
+export async function registerJobs(boss: PgBoss) {
+ await boss.work(RUN_SEARCH_JOB, async ([job]) => {
+ console.warn(`Job ${RUN_SEARCH_JOB} is queued but not implemented yet`, job?.id);
+ });
+
+ await boss.work(RUN_DEEP_RESEARCH_BATCH_JOB, async ([job]) => {
+ console.warn(`Job ${RUN_DEEP_RESEARCH_BATCH_JOB} is queued but not implemented yet`, job?.id);
+ });
+}
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
new file mode 100644
index 0000000..8658cfc
--- /dev/null
+++ b/server/src/routes/auth.ts
@@ -0,0 +1,112 @@
+import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
+import { ZodError, z } from 'zod';
+import { hashPassword, verifyPassword } from '../auth/passwords.js';
+import { clearSessionCookie, createSession, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
+import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
+import { getDbPool } from '../db/pool.js';
+
+const signUpSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(6),
+ displayName: z.string().trim().min(1).max(120).optional(),
+});
+
+const loginSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(1),
+});
+
+function getRequestMetadata(request: FastifyRequest) {
+ return {
+ userAgent: request.headers['user-agent'],
+ ipAddress: request.ip || null,
+ };
+}
+
+export const authRoutes: FastifyPluginAsync = async (app) => {
+ app.get('/auth/me', async (request) => {
+ const token = getSessionTokenFromRequest(request);
+
+ if (!token) {
+ return { user: null };
+ }
+
+ const user = await getSessionUserByToken(getDbPool(), token);
+ return { user };
+ });
+
+ app.post('/auth/signup', async (request, reply) => {
+ try {
+ const payload = signUpSchema.parse(request.body);
+ const db = getDbPool();
+
+ const existingUser = await getUserByEmail(db, payload.email);
+ if (existingUser) {
+ return reply.code(409).send({ error: 'An account with that email already exists.' });
+ }
+
+ const passwordHash = await hashPassword(payload.password);
+ const user = await createUser(db, {
+ email: payload.email,
+ passwordHash,
+ displayName: payload.displayName,
+ });
+
+ const session = await createSession(db, user.id, getRequestMetadata(request));
+ setSessionCookie(reply, session.token, session.expiresAt);
+
+ return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
+ } catch (error) {
+ if (error instanceof ZodError) {
+ return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid signup payload.' });
+ }
+
+ if ((error as { code?: string }).code === '23505') {
+ return reply.code(409).send({ error: 'An account with that email already exists.' });
+ }
+
+ request.log.error(error);
+ return reply.code(500).send({ error: 'Failed to create account.' });
+ }
+ });
+
+ app.post('/auth/login', async (request, reply) => {
+ try {
+ const payload = loginSchema.parse(request.body);
+ const db = getDbPool();
+ const user = await getUserByEmail(db, payload.email);
+
+ if (!user) {
+ return reply.code(401).send({ error: 'Invalid email or password.' });
+ }
+
+ const isValidPassword = await verifyPassword(user.passwordHash, payload.password);
+ if (!isValidPassword) {
+ return reply.code(401).send({ error: 'Invalid email or password.' });
+ }
+
+ const session = await createSession(db, user.id, getRequestMetadata(request));
+ setSessionCookie(reply, session.token, session.expiresAt);
+
+ return { user: { ...toAppUser(user), sessionId: session.sessionId } };
+ } catch (error) {
+ if (error instanceof ZodError) {
+ return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid login payload.' });
+ }
+
+ request.log.error(error);
+ return reply.code(500).send({ error: 'Failed to sign in.' });
+ }
+ });
+
+ app.post('/auth/logout', async (request, reply) => {
+ const token = getSessionTokenFromRequest(request);
+
+ if (token) {
+ await deleteSessionByToken(getDbPool(), token);
+ }
+
+ clearSessionCookie(reply);
+ return { success: true };
+ });
+};
diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts
new file mode 100644
index 0000000..ea6a942
--- /dev/null
+++ b/server/src/routes/health.ts
@@ -0,0 +1,9 @@
+import type { FastifyPluginAsync } from 'fastify';
+
+export const healthRoutes: FastifyPluginAsync = async (app) => {
+ app.get('/health', async () => ({
+ ok: true,
+ service: 'leads4less-api',
+ timestamp: new Date().toISOString(),
+ }));
+};
diff --git a/server/src/routes/search-jobs.ts b/server/src/routes/search-jobs.ts
new file mode 100644
index 0000000..e39c465
--- /dev/null
+++ b/server/src/routes/search-jobs.ts
@@ -0,0 +1,75 @@
+import type { FastifyPluginAsync } from 'fastify';
+import { z } from 'zod';
+import { requireAuth } from '../auth/middleware.js';
+import { getDbPool } from '../db/pool.js';
+import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js';
+import { runSearchForUser } from '../search/run-search.js';
+
+const runSearchSchema = z.object({
+ name: z.string().trim().min(1).max(160).optional(),
+ location: z.string().trim().min(1),
+ radiusKm: z.coerce.number().positive().max(50),
+ businessType: z.string().trim().min(1),
+ keywords: z.string().trim().optional(),
+});
+
+const jobParamsSchema = z.object({
+ jobId: z.string().uuid(),
+});
+
+export const searchJobRoutes: FastifyPluginAsync = async (app) => {
+ app.get('/search-jobs', { preHandler: requireAuth }, async (request) => {
+ const limitValue = typeof request.query === 'object' && request.query && 'limit' in request.query ? Number((request.query as { limit?: string }).limit) : 100;
+ const jobs = await listSearchJobsForUser(getDbPool(), request.authUser!.id, Number.isFinite(limitValue) ? Math.min(Math.max(limitValue, 1), 200) : 100);
+ return { jobs };
+ });
+
+ app.post('/search-jobs', { preHandler: requireAuth }, async (request, reply) => {
+ try {
+ const payload = runSearchSchema.parse(request.body);
+ const result = await runSearchForUser(getDbPool(), request.authUser!.id, payload);
+ return reply.code(201).send(result);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid research payload.' });
+ }
+
+ request.log.error(error);
+ return reply.code(500).send({ error: error instanceof Error ? error.message : 'Failed to run research.' });
+ }
+ });
+
+ app.get('/search-jobs/:jobId', { preHandler: requireAuth }, async (request, reply) => {
+ const { jobId } = jobParamsSchema.parse(request.params);
+ const job = await getSearchJobForUser(getDbPool(), request.authUser!.id, jobId);
+
+ if (!job) {
+ return reply.code(404).send({ error: 'Research job not found.' });
+ }
+
+ return { job };
+ });
+
+ app.get('/search-jobs/:jobId/businesses', { preHandler: requireAuth }, async (request, reply) => {
+ const { jobId } = jobParamsSchema.parse(request.params);
+ const businesses = await listBusinessesForJobIds(getDbPool(), request.authUser!.id, [jobId]);
+ return { businesses };
+ });
+
+ app.get('/search-job-results/links', { preHandler: requireAuth }, async (request) => {
+ const links = await listSearchJobResultLinksForUser(getDbPool(), request.authUser!.id);
+ return { links };
+ });
+
+ app.get('/businesses', { preHandler: requireAuth }, async (request) => {
+ const query = (request.query ?? {}) as { jobIds?: string | string[]; jobId?: string };
+ const rawJobIds = Array.isArray(query.jobIds) ? query.jobIds : typeof query.jobIds === 'string' ? query.jobIds.split(',') : [];
+ const jobIds = [...rawJobIds, ...(query.jobId ? [query.jobId] : [])].filter(Boolean);
+
+ const businesses = jobIds.length > 0
+ ? await listBusinessesForJobIds(getDbPool(), request.authUser!.id, jobIds)
+ : await listBusinessesForUser(getDbPool(), request.authUser!.id);
+
+ return { businesses };
+ });
+};
diff --git a/server/src/search/google-places.ts b/server/src/search/google-places.ts
new file mode 100644
index 0000000..b2e5ebb
--- /dev/null
+++ b/server/src/search/google-places.ts
@@ -0,0 +1,192 @@
+type AddressComponent = {
+ longText?: string;
+ shortText?: string;
+ types?: string[];
+};
+
+type Place = {
+ id?: string;
+ displayName?: { text?: string };
+ formattedAddress?: string;
+ location?: { latitude?: number; longitude?: number };
+ rating?: number;
+ userRatingCount?: number;
+ websiteUri?: string;
+ nationalPhoneNumber?: string;
+ types?: string[];
+ addressComponents?: AddressComponent[];
+};
+
+type SearchPlacesResponse = {
+ places: Place[];
+ nextPageToken?: string;
+};
+
+const PLACES_PAGE_SIZE = 20;
+const MAX_PLACES_PER_RUN = 60;
+const MAX_PLACE_PAGES = Math.ceil(MAX_PLACES_PER_RUN / PLACES_PAGE_SIZE);
+
+function getAddressComponent(components: AddressComponent[] | undefined, type: string, useShort = false) {
+ if (!components) {
+ return null;
+ }
+
+ const match = components.find((component) => component.types?.includes(type));
+ if (!match) {
+ return null;
+ }
+
+ return useShort ? match.shortText || match.longText || null : match.longText || match.shortText || null;
+}
+
+export type BusinessUpsertRecord = {
+ externalSourceId: string | null;
+ source: string;
+ name: string;
+ address: string | null;
+ city: string | null;
+ stateProvince: string | null;
+ postalCode: string | null;
+ country: string | null;
+ phone: string | null;
+ website: string | null;
+ rating: number | null;
+ reviewCount: number | null;
+ category: string;
+ latitude: number | null;
+ longitude: number | null;
+ metadataJson: Record;
+ firstSeenAt: string;
+ lastSeenAt: string;
+ updatedAt: string;
+};
+
+export async function geocodeLocation(location: string, apiKey: string) {
+ const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');
+ url.searchParams.set('address', location);
+ url.searchParams.set('key', apiKey);
+
+ const response = await fetch(url);
+ const payload = (await response.json()) as {
+ status?: string;
+ results?: Array<{ geometry?: { location?: { lat: number; lng: number } } }>;
+ };
+
+ if (!response.ok || payload.status !== 'OK' || !payload.results?.[0]?.geometry?.location) {
+ throw new Error('Unable to geocode the requested location');
+ }
+
+ return payload.results[0].geometry.location as { lat: number; lng: number };
+}
+
+async function searchPlaces(params: {
+ apiKey: string;
+ textQuery: string;
+ lat: number;
+ lng: number;
+ radiusKm: number;
+ pageToken?: string;
+}) {
+ const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Goog-Api-Key': params.apiKey,
+ 'X-Goog-FieldMask': [
+ 'places.id',
+ 'places.displayName',
+ 'places.formattedAddress',
+ 'places.location',
+ 'places.rating',
+ 'places.userRatingCount',
+ 'places.websiteUri',
+ 'places.nationalPhoneNumber',
+ 'places.types',
+ 'places.addressComponents',
+ 'nextPageToken',
+ ].join(','),
+ },
+ body: JSON.stringify({
+ textQuery: params.textQuery,
+ pageSize: PLACES_PAGE_SIZE,
+ ...(params.pageToken ? { pageToken: params.pageToken } : {}),
+ locationBias: {
+ circle: {
+ center: {
+ latitude: params.lat,
+ longitude: params.lng,
+ },
+ radius: Math.min(params.radiusKm * 1000, 50000),
+ },
+ },
+ }),
+ });
+
+ const payload = (await response.json()) as {
+ error?: { message?: string };
+ places?: Place[];
+ nextPageToken?: string;
+ };
+
+ if (!response.ok) {
+ throw new Error(payload.error?.message || 'Places search failed');
+ }
+
+ return {
+ places: (payload.places || []) as Place[],
+ nextPageToken: typeof payload.nextPageToken === 'string' ? payload.nextPageToken : undefined,
+ } as SearchPlacesResponse;
+}
+
+export async function collectPlaces(params: { apiKey: string; textQuery: string; lat: number; lng: number; radiusKm: number }) {
+ const uniquePlaces = new Map();
+ let nextPageToken: string | undefined;
+
+ for (let page = 0; page < MAX_PLACE_PAGES && uniquePlaces.size < MAX_PLACES_PER_RUN; page += 1) {
+ const response = await searchPlaces({ ...params, pageToken: nextPageToken });
+
+ response.places.forEach((place) => {
+ if (!place.id || uniquePlaces.has(place.id) || uniquePlaces.size >= MAX_PLACES_PER_RUN) {
+ return;
+ }
+
+ uniquePlaces.set(place.id, place);
+ });
+
+ if (!response.nextPageToken || response.places.length === 0) {
+ break;
+ }
+
+ nextPageToken = response.nextPageToken;
+ }
+
+ return Array.from(uniquePlaces.values());
+}
+
+export function buildBusinessPayload(place: Place, businessType: string): BusinessUpsertRecord {
+ const now = new Date().toISOString();
+
+ return {
+ externalSourceId: place.id ?? null,
+ source: 'google_places',
+ name: place.displayName?.text || 'Unknown business',
+ address: place.formattedAddress ?? null,
+ city: getAddressComponent(place.addressComponents, 'locality'),
+ stateProvince: getAddressComponent(place.addressComponents, 'administrative_area_level_1', true),
+ postalCode: getAddressComponent(place.addressComponents, 'postal_code'),
+ country: getAddressComponent(place.addressComponents, 'country', true),
+ phone: place.nationalPhoneNumber ?? null,
+ website: place.websiteUri ?? null,
+ rating: place.rating ?? null,
+ reviewCount: place.userRatingCount ?? null,
+ category: businessType,
+ latitude: place.location?.latitude ?? null,
+ longitude: place.location?.longitude ?? null,
+ metadataJson: {
+ google_types: place.types ?? [],
+ },
+ firstSeenAt: now,
+ lastSeenAt: now,
+ updatedAt: now,
+ };
+}
diff --git a/server/src/search/repository.ts b/server/src/search/repository.ts
new file mode 100644
index 0000000..4d28f56
--- /dev/null
+++ b/server/src/search/repository.ts
@@ -0,0 +1,231 @@
+import type { Pool, PoolClient } from 'pg';
+import type { BusinessUpsertRecord } from './google-places.js';
+import { mapBusinessRow, mapSearchJobRow, type BusinessDto, type BusinessRow, type RunSearchInput, type SearchJobDto, type SearchJobResultLinkDto, type SearchJobRow, } from './types.js';
+
+type DbClient = Pool | PoolClient;
+
+export async function createSearchJob(db: DbClient, userId: string, payload: RunSearchInput) {
+ const now = new Date().toISOString();
+ const jobName = payload.name || `${payload.businessType} in ${payload.location}`;
+
+ const result = await db.query(
+ `
+ insert into public.search_jobs (
+ user_id, name, city, radius_km, business_type, keywords, status, total_results, started_at, created_at, updated_at
+ )
+ values ($1, $2, $3, $4, $5, $6, 'running', 0, $7, $7, $7)
+ returning id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
+ status, total_results, started_at, completed_at, created_at, updated_at
+ `,
+ [userId, jobName, payload.location, payload.radiusKm, payload.businessType, payload.keywords ?? null, now],
+ );
+
+ return mapSearchJobRow(result.rows[0]);
+}
+
+export async function updateSearchJobCenter(db: DbClient, jobId: string, lat: number, lng: number) {
+ await db.query(
+ `
+ update public.search_jobs
+ set search_center_geom = ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
+ updated_at = now()
+ where id = $3
+ `,
+ [lat, lng, jobId],
+ );
+}
+
+export async function completeSearchJob(db: DbClient, jobId: string, totalResults: number) {
+ const completedAt = new Date().toISOString();
+ const result = await db.query(
+ `
+ update public.search_jobs
+ set total_results = $2,
+ status = 'completed',
+ completed_at = $3,
+ updated_at = $3
+ where id = $1
+ returning id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
+ status, total_results, started_at, completed_at, created_at, updated_at
+ `,
+ [jobId, totalResults, completedAt],
+ );
+
+ return mapSearchJobRow(result.rows[0]);
+}
+
+export async function failSearchJob(db: DbClient, jobId: string) {
+ await db.query(
+ `
+ update public.search_jobs
+ set status = 'failed', updated_at = now()
+ where id = $1
+ `,
+ [jobId],
+ );
+}
+
+export async function upsertBusiness(db: DbClient, userId: string, business: BusinessUpsertRecord) {
+ const result = await db.query<{ id: string }>(
+ `
+ insert into public.businesses (
+ user_id, external_source_id, source, name, address, city, state_province, postal_code, country,
+ phone, website, rating, review_count, category, latitude, longitude, geom, metadata_json,
+ first_seen_at, last_seen_at, updated_at
+ )
+ values (
+ $1, $2, $3, $4, $5, $6, $7, $8, $9,
+ $10, $11, $12, $13, $14, $15, $16,
+ case when $15 is not null and $16 is not null then ST_SetSRID(ST_MakePoint($16, $15), 4326)::geography else null end,
+ $17::jsonb, $18, $19, $20
+ )
+ on conflict (user_id, source, external_source_id)
+ do update set
+ name = excluded.name,
+ address = excluded.address,
+ city = excluded.city,
+ state_province = excluded.state_province,
+ postal_code = excluded.postal_code,
+ country = excluded.country,
+ phone = excluded.phone,
+ website = excluded.website,
+ rating = excluded.rating,
+ review_count = excluded.review_count,
+ category = excluded.category,
+ latitude = excluded.latitude,
+ longitude = excluded.longitude,
+ geom = excluded.geom,
+ metadata_json = excluded.metadata_json,
+ last_seen_at = excluded.last_seen_at,
+ updated_at = excluded.updated_at
+ returning id
+ `,
+ [
+ userId,
+ business.externalSourceId,
+ business.source,
+ business.name,
+ business.address,
+ business.city,
+ business.stateProvince,
+ business.postalCode,
+ business.country,
+ business.phone,
+ business.website,
+ business.rating,
+ business.reviewCount,
+ business.category,
+ business.latitude,
+ business.longitude,
+ JSON.stringify(business.metadataJson),
+ business.firstSeenAt,
+ business.lastSeenAt,
+ business.updatedAt,
+ ],
+ );
+
+ return result.rows[0].id;
+}
+
+export async function upsertSearchJobResult(
+ db: DbClient,
+ input: { userId: string; searchJobId: string; businessId: string; matchedKeywords: string[] | null; rank: number; capturedAt: string },
+) {
+ await db.query(
+ `
+ insert into public.search_job_results (user_id, search_job_id, business_id, matched_keywords, rank, captured_at)
+ values ($1, $2, $3, $4, $5, $6)
+ on conflict (search_job_id, business_id)
+ do update set
+ matched_keywords = excluded.matched_keywords,
+ rank = excluded.rank,
+ captured_at = excluded.captured_at
+ `,
+ [input.userId, input.searchJobId, input.businessId, input.matchedKeywords, input.rank, input.capturedAt],
+ );
+}
+
+export async function listSearchJobsForUser(db: DbClient, userId: string, limit = 100) {
+ const result = await db.query(
+ `
+ select id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
+ status, total_results, started_at, completed_at, created_at, updated_at
+ from public.search_jobs
+ where user_id = $1
+ order by created_at desc
+ limit $2
+ `,
+ [userId, limit],
+ );
+
+ return result.rows.map(mapSearchJobRow);
+}
+
+export async function getSearchJobForUser(db: DbClient, userId: string, jobId: string) {
+ const result = await db.query(
+ `
+ select id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
+ status, total_results, started_at, completed_at, created_at, updated_at
+ from public.search_jobs
+ where user_id = $1 and id = $2
+ limit 1
+ `,
+ [userId, jobId],
+ );
+
+ if (result.rowCount === 0) {
+ return null;
+ }
+
+ return mapSearchJobRow(result.rows[0]);
+}
+
+export async function listBusinessesForUser(db: DbClient, userId: string) {
+ const result = await db.query(
+ `
+ select id, user_id, external_source_id, source, name, address, city, state_province, postal_code,
+ country, phone, website, rating, review_count, category, hours_json, latitude, longitude,
+ general_info, metadata_json, first_seen_at, last_seen_at, created_at, updated_at
+ from public.businesses
+ where user_id = $1
+ order by created_at desc
+ `,
+ [userId],
+ );
+
+ return result.rows.map(mapBusinessRow);
+}
+
+export async function listSearchJobResultLinksForUser(db: DbClient, userId: string): Promise {
+ const result = await db.query<{ business_id: string; search_job_id: string }>(
+ `
+ select business_id, search_job_id
+ from public.search_job_results
+ where user_id = $1
+ `,
+ [userId],
+ );
+
+ return result.rows.map((row) => ({ businessId: row.business_id, searchJobId: row.search_job_id }));
+}
+
+export async function listBusinessesForJobIds(db: DbClient, userId: string, jobIds: string[]): Promise {
+ if (jobIds.length === 0) {
+ return [];
+ }
+
+ const result = await db.query(
+ `
+ select distinct b.id, b.user_id, b.external_source_id, b.source, b.name, b.address, b.city, b.state_province, b.postal_code,
+ b.country, b.phone, b.website, b.rating, b.review_count, b.category, b.hours_json, b.latitude, b.longitude,
+ b.general_info, b.metadata_json, b.first_seen_at, b.last_seen_at, b.created_at, b.updated_at
+ from public.businesses b
+ join public.search_job_results r on r.business_id = b.id
+ where b.user_id = $1 and r.search_job_id = any($2::uuid[])
+ order by b.created_at desc
+ `,
+ [userId, jobIds],
+ );
+
+ return result.rows.map(mapBusinessRow);
+}
diff --git a/server/src/search/run-search.ts b/server/src/search/run-search.ts
new file mode 100644
index 0000000..7c15640
--- /dev/null
+++ b/server/src/search/run-search.ts
@@ -0,0 +1,65 @@
+import type { Pool } from 'pg';
+import { getEnv } from '../config/env.js';
+import { buildBusinessPayload, collectPlaces, geocodeLocation } from './google-places.js';
+import { completeSearchJob, createSearchJob, failSearchJob, updateSearchJobCenter, upsertBusiness, upsertSearchJobResult } from './repository.js';
+import type { RunSearchInput, RunSearchResult } from './types.js';
+
+export async function runSearchForUser(db: Pool, userId: string, payload: RunSearchInput): Promise {
+ const env = getEnv();
+ const job = await createSearchJob(db, userId, payload);
+ const jobId = job.id;
+
+ try {
+ if (!env.GOOGLE_MAPS_SERVER_KEY) {
+ throw new Error('GOOGLE_MAPS_SERVER_KEY is required for running research.');
+ }
+
+ const geocoded = await geocodeLocation(payload.location, env.GOOGLE_MAPS_SERVER_KEY);
+ await updateSearchJobCenter(db, jobId, geocoded.lat, geocoded.lng);
+
+ const places = await collectPlaces({
+ apiKey: env.GOOGLE_MAPS_SERVER_KEY,
+ textQuery: [payload.businessType, payload.keywords].filter(Boolean).join(' '),
+ lat: geocoded.lat,
+ lng: geocoded.lng,
+ radiusKm: payload.radiusKm,
+ });
+
+ const matchedKeywords = payload.keywords
+ ? payload.keywords
+ .split(',')
+ .map((keyword) => keyword.trim())
+ .filter(Boolean)
+ : [];
+
+ const capturedAt = new Date().toISOString();
+ let totalResults = 0;
+
+ for (const [index, place] of places.entries()) {
+ if (!place.id || !place.displayName?.text) {
+ continue;
+ }
+
+ const businessId = await upsertBusiness(db, userId, buildBusinessPayload(place, payload.businessType));
+ await upsertSearchJobResult(db, {
+ userId,
+ searchJobId: jobId,
+ businessId,
+ matchedKeywords: matchedKeywords.length > 0 ? matchedKeywords : null,
+ rank: index + 1,
+ capturedAt,
+ });
+
+ totalResults += 1;
+ }
+
+ const completedJob = await completeSearchJob(db, jobId, totalResults);
+ return {
+ job: completedJob,
+ totalResults,
+ };
+ } catch (error) {
+ await failSearchJob(db, jobId);
+ throw error;
+ }
+}
diff --git a/server/src/search/types.ts b/server/src/search/types.ts
new file mode 100644
index 0000000..2698230
--- /dev/null
+++ b/server/src/search/types.ts
@@ -0,0 +1,158 @@
+export type SearchJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
+
+export type SearchJobDto = {
+ id: string;
+ userId: string;
+ name: string;
+ city?: string;
+ address?: string;
+ postalCode?: string;
+ radiusKm: number;
+ businessType: string;
+ keywords?: string;
+ status: SearchJobStatus;
+ totalResults: number;
+ startedAt?: string;
+ completedAt?: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type BusinessDto = {
+ id: string;
+ userId: string;
+ externalSourceId?: string;
+ source: string;
+ name: string;
+ address?: string;
+ city?: string;
+ stateProvince?: string;
+ postalCode?: string;
+ country?: string;
+ phone?: string;
+ website?: string;
+ rating?: number;
+ reviewCount?: number;
+ category?: string;
+ hoursJson?: string;
+ latitude?: number;
+ longitude?: number;
+ generalInfo?: string;
+ metadataJson?: string;
+ firstSeenAt?: string;
+ lastSeenAt?: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type SearchJobResultLinkDto = {
+ businessId: string;
+ searchJobId: string;
+};
+
+export type RunSearchInput = {
+ name?: string;
+ location: string;
+ radiusKm: number;
+ businessType: string;
+ keywords?: string;
+};
+
+export type RunSearchResult = {
+ job: SearchJobDto;
+ totalResults: number;
+};
+
+export type SearchJobRow = {
+ id: string;
+ user_id: string;
+ name: string;
+ city: string | null;
+ address: string | null;
+ postal_code: string | null;
+ radius_km: number;
+ business_type: string;
+ keywords: string | null;
+ status: SearchJobStatus;
+ total_results: number;
+ started_at: string | null;
+ completed_at: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type BusinessRow = {
+ id: string;
+ user_id: string;
+ external_source_id: string | null;
+ source: string;
+ name: string;
+ address: string | null;
+ city: string | null;
+ state_province: string | null;
+ postal_code: string | null;
+ country: string | null;
+ phone: string | null;
+ website: string | null;
+ rating: number | null;
+ review_count: number | null;
+ category: string | null;
+ hours_json: Record | null;
+ latitude: number | null;
+ longitude: number | null;
+ general_info: string | null;
+ metadata_json: Record | null;
+ first_seen_at: string | null;
+ last_seen_at: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export function mapSearchJobRow(row: SearchJobRow): SearchJobDto {
+ return {
+ id: row.id,
+ userId: row.user_id,
+ name: row.name,
+ city: row.city ?? undefined,
+ address: row.address ?? undefined,
+ postalCode: row.postal_code ?? undefined,
+ radiusKm: Number(row.radius_km),
+ businessType: row.business_type,
+ keywords: row.keywords ?? undefined,
+ status: row.status,
+ totalResults: row.total_results,
+ startedAt: row.started_at ?? undefined,
+ completedAt: row.completed_at ?? undefined,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export function mapBusinessRow(row: BusinessRow): BusinessDto {
+ return {
+ id: row.id,
+ userId: row.user_id,
+ externalSourceId: row.external_source_id ?? undefined,
+ source: row.source,
+ name: row.name,
+ address: row.address ?? undefined,
+ city: row.city ?? undefined,
+ stateProvince: row.state_province ?? undefined,
+ postalCode: row.postal_code ?? undefined,
+ country: row.country ?? undefined,
+ phone: row.phone ?? undefined,
+ website: row.website ?? undefined,
+ rating: row.rating ?? undefined,
+ reviewCount: row.review_count ?? undefined,
+ category: row.category ?? undefined,
+ hoursJson: row.hours_json ? JSON.stringify(row.hours_json) : undefined,
+ latitude: row.latitude ?? undefined,
+ longitude: row.longitude ?? undefined,
+ generalInfo: row.general_info ?? undefined,
+ metadataJson: row.metadata_json ? JSON.stringify(row.metadata_json) : undefined,
+ firstSeenAt: row.first_seen_at ?? undefined,
+ lastSeenAt: row.last_seen_at ?? undefined,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
diff --git a/server/src/worker.ts b/server/src/worker.ts
new file mode 100644
index 0000000..21582d9
--- /dev/null
+++ b/server/src/worker.ts
@@ -0,0 +1,19 @@
+import 'dotenv/config';
+import { getBoss, stopBoss } from './db/boss.js';
+import { getEnv } from './config/env.js';
+import { registerJobs } from './jobs/register-jobs.js';
+
+const env = getEnv();
+const boss = await getBoss();
+
+await registerJobs(boss);
+
+console.log(`Leads4less worker started with pg-boss schema '${env.PG_BOSS_SCHEMA}'`);
+
+const shutdown = async () => {
+ await stopBoss();
+ process.exit(0);
+};
+
+process.on('SIGINT', () => void shutdown());
+process.on('SIGTERM', () => void shutdown());
diff --git a/shared/types.ts b/shared/types.ts
new file mode 100644
index 0000000..90c8b43
--- /dev/null
+++ b/shared/types.ts
@@ -0,0 +1,14 @@
+export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
+
+export interface AppUser {
+ id: string;
+ email: string;
+ displayName: string;
+ avatarUrl?: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface SessionUser extends AppUser {
+ sessionId: string;
+}
diff --git a/src/App.tsx b/src/App.tsx
index eee968c..4e4b710 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,21 +1,22 @@
import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
-import type { User } from '@supabase/supabase-js';
import { APIProvider } from '@vis.gl/react-google-maps';
import { AlertCircle, Briefcase, LogIn, ShieldAlert, UserPlus } from 'lucide-react';
import { Layout } from './components/Layout';
import { SearchSetup } from './components/SearchSetup';
import { Dashboard } from './components/Dashboard';
import { MapView } from './components/MapView';
-import { hasSupabaseConfig, supabase } from './lib/supabase';
+import type { AppUser } from '../shared/types';
+import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
+import { hasApiConfig } from './lib/api';
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? '';
const hasValidMapsKey = Boolean(GOOGLE_MAPS_API_KEY) && GOOGLE_MAPS_API_KEY !== 'YOUR_API_KEY';
export default function App() {
- const [user, setUser] = useState(null);
+ const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
- const [selectedJobId, setSelectedJobId] = useState(null);
+ const [selectedJobIds, setSelectedJobIds] = useState([]);
const [authError, setAuthError] = useState(null);
const [authNotice, setAuthNotice] = useState(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
@@ -28,99 +29,96 @@ export default function App() {
let isMounted = true;
const loadSession = async () => {
- const { data, error } = await supabase.auth.getSession();
+ try {
+ const sessionUser = await getLocalSessionUser();
- if (!isMounted) {
- return;
+ if (!isMounted) {
+ return;
+ }
+
+ setUser(sessionUser);
+ } catch (error) {
+ if (!isMounted) {
+ return;
+ }
+
+ setAuthError(error instanceof Error ? error.message : 'Failed to load session.');
+ } finally {
+ if (isMounted) {
+ setLoading(false);
+ }
}
-
- if (error) {
- setAuthError(error.message);
- }
-
- setUser(data.session?.user ?? null);
- setLoading(false);
};
void loadSession();
- const {
- data: { subscription },
- } = supabase.auth.onAuthStateChange((_event, session) => {
- if (!isMounted) {
- return;
- }
-
- setUser(session?.user ?? null);
- setLoading(false);
- });
-
return () => {
isMounted = false;
- subscription.unsubscribe();
};
}, []);
- const handleSelectJob = (jobId: string) => {
- setSelectedJobId(jobId);
+ const handleToggleJobSelection = (jobId: string) => {
+ setSelectedJobIds((currentJobIds) =>
+ currentJobIds.includes(jobId) ? currentJobIds.filter((currentJobId) => currentJobId !== jobId) : [...currentJobIds, jobId],
+ );
+ };
+
+ const handleSelectCreatedJob = (jobId: string) => {
+ setSelectedJobIds((currentJobIds) => (currentJobIds.includes(jobId) ? currentJobIds : [...currentJobIds, jobId]));
+ };
+
+ const handleShowSelectedOnMap = () => {
+ if (selectedJobIds.length === 0) {
+ return;
+ }
+
setActiveTab('map');
};
+ const handleClearSelectedJobs = () => {
+ setSelectedJobIds([]);
+ };
+
const handleLogin = async () => {
setAuthError(null);
setAuthNotice(null);
setIsAuthenticating(true);
- if (authMode === 'sign_up') {
- const { data, error } = await supabase.auth.signUp({
- email,
- password,
- options: {
- data: {
- full_name: displayName.trim() || undefined,
- },
- },
- });
+ try {
+ if (authMode === 'sign_up') {
+ const nextUser = await signUpWithLocalAuth({
+ email,
+ password,
+ displayName: displayName.trim() || undefined,
+ });
- if (error) {
- setAuthError(error.message);
- setIsAuthenticating(false);
+ setUser(nextUser);
+ setAuthNotice('Account created and signed in.');
return;
}
- setAuthNotice(
- data.session
- ? 'Account created and signed in.'
- : 'Account created. If email confirmation is enabled, check your inbox before signing in.',
- );
+ const nextUser = await signInWithLocalAuth({
+ email,
+ password,
+ });
+
+ setUser(nextUser);
+ } catch (error) {
+ setAuthError(error instanceof Error ? error.message : 'Authentication failed.');
+ } finally {
setIsAuthenticating(false);
- return;
}
-
- const { error } = await supabase.auth.signInWithPassword({
- email,
- password,
- });
-
- if (error) {
- setAuthError(error.message);
- setIsAuthenticating(false);
- return;
- }
-
- setIsAuthenticating(false);
};
const handleLogout = async () => {
- const { error } = await supabase.auth.signOut();
-
- if (error) {
- setAuthError(error.message);
- return;
+ try {
+ await signOutWithLocalAuth();
+ setUser(null);
+ setSelectedJobIds([]);
+ setAuthNotice(null);
+ } catch (error) {
+ setAuthError(error instanceof Error ? error.message : 'Failed to sign out.');
}
-
- setSelectedJobId(null);
- setAuthNotice(null);
};
if (loading) {
@@ -131,33 +129,33 @@ export default function App() {
);
}
- if (!hasSupabaseConfig) {
+ if (!hasApiConfig) {
return (
}
- title="Supabase Config Required"
- description="Add your Supabase project URL and anon key before running the app."
+ title="Local API Config Required"
+ description="Add your local API base URL before running the app."
steps={[
- 'Create a Supabase project.',
- 'Add VITE_SUPABASE_URL to your local environment.',
- 'Add VITE_SUPABASE_ANON_KEY to your local environment.',
+ 'Start the local Fastify API server.',
+ 'Add VITE_API_BASE_URL to your local environment.',
+ 'Ensure the API can reach your local PostgreSQL database.',
'Restart the Vite dev server after updating env vars.',
]}
- footer="The app uses Supabase Auth, Postgres, and Edge Functions now."
+ footer="The app now uses a local API, PostgreSQL, and local session auth."
/>
);
}
if (!hasValidMapsKey) {
return (
- }
- title="Google Maps API Key Required"
- description="Add a browser key for map rendering and a server key for the Supabase search function."
+ }
+ title="Google Maps API Key Required"
+ description="Add a browser key for map rendering and a server key for the local search API."
steps={[
'Create a Google Maps Platform API key for the browser app.',
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
- 'Set GOOGLE_MAPS_SERVER_KEY in Supabase Edge Function secrets.',
+ 'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
'Enable Geocoding API and Places API in Google Cloud.',
]}
footer="The app will start once the browser key is available."
@@ -173,8 +171,8 @@ export default function App() {
- Lead Finder
- Use Supabase email auth to access your lead workspace.
+ Leads4less
+ Create a local account to access your lead workspace.
@@ -298,17 +296,21 @@ export default function App() {
{
- setActiveTab(tab);
- if (tab !== 'map') {
- setSelectedJobId(null);
- }
- }}
+ setActiveTab={setActiveTab}
onLogout={() => void handleLogout()}
>
- {activeTab === 'setup' && }
+ {activeTab === 'setup' && (
+
+ )}
{activeTab === 'dashboard' && }
- {activeTab === 'map' && }
+ {activeTab === 'map' && }
);
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
index 826662f..8764161 100644
--- a/src/components/Dashboard.tsx
+++ b/src/components/Dashboard.tsx
@@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
-import type { User } from '@supabase/supabase-js';
import Papa from 'papaparse';
import {
ArrowUpDown,
@@ -16,13 +15,14 @@ import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { listBusinesses, listJobResultLinks, listSearchJobs, type SearchJobResultLink } from '../lib/database';
import type { Business, SearchJob } from '../types';
+import type { AppUser } from '../../shared/types';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface DashboardProps {
- user: User;
+ user: AppUser;
}
export function Dashboard({ user }: DashboardProps) {
@@ -184,7 +184,7 @@ export function Dashboard({ user }: DashboardProps) {
diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx
index 2ab904e..9b143b1 100644
--- a/src/components/MapView.tsx
+++ b/src/components/MapView.tsx
@@ -1,21 +1,22 @@
import React, { useEffect, useMemo, useState } from 'react';
-import type { User } from '@supabase/supabase-js';
import { Map, AdvancedMarker, InfoWindow, Pin, useMap } from '@vis.gl/react-google-maps';
import { Globe, Loader2, MapPin, Navigation, Phone, Star } from 'lucide-react';
-import { listBusinesses, listBusinessesForJob } from '../lib/database';
+import { listBusinesses, listBusinessesForJobs } from '../lib/database';
import type { Business } from '../types';
+import type { AppUser } from '../../shared/types';
interface MapViewProps {
- user: User;
- jobId?: string | null;
+ user: AppUser;
+ jobIds?: string[] | null;
}
-export function MapView({ user, jobId }: MapViewProps) {
+export function MapView({ user, jobIds }: MapViewProps) {
const map = useMap();
const [businesses, setBusinesses] = useState([]);
const [selected, setSelected] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const selectedJobCount = jobIds?.length ?? 0;
useEffect(() => {
const loadBusinesses = async () => {
@@ -23,7 +24,7 @@ export function MapView({ user, jobId }: MapViewProps) {
setError(null);
try {
- const nextBusinesses = jobId ? await listBusinessesForJob(user.id, jobId) : await listBusinesses(user.id);
+ const nextBusinesses = selectedJobCount > 0 ? await listBusinessesForJobs(user.id, jobIds ?? []) : await listBusinesses(user.id);
setBusinesses(nextBusinesses);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load map leads.');
@@ -33,7 +34,7 @@ export function MapView({ user, jobId }: MapViewProps) {
};
void loadBusinesses();
- }, [jobId, user.id]);
+ }, [jobIds, selectedJobCount, user.id]);
useEffect(() => {
if (!selected) {
@@ -96,6 +97,25 @@ export function MapView({ user, jobId }: MapViewProps) {
);
}
+ if (businesses.length === 0) {
+ return (
+
+
+
+
+
+
No leads to show on the map
+
+ {selectedJobCount > 0
+ ? 'The selected research jobs do not have saved map results yet. Try completed jobs or run the research again.'
+ : 'No saved leads are available yet. Run a research job to populate the map.'}
+
+ {error &&
{error}
}
+
+
+ );
+ }
+
return (
{error && (
@@ -198,10 +218,12 @@ export function MapView({ user, jobId }: MapViewProps) {
Selected Lead
{selected ? selected.name : 'None'}
- {jobId && (
+ {selectedJobCount > 0 && (
-
Filtering by Job
-
Active filter applied
+
Filtering by Selection
+
+ {selectedJobCount === 1 ? '1 selected research job' : `${selectedJobCount} selected research jobs`}
+
)}
diff --git a/src/components/SearchSetup.tsx b/src/components/SearchSetup.tsx
index 304818e..cf7bce7 100644
--- a/src/components/SearchSetup.tsx
+++ b/src/components/SearchSetup.tsx
@@ -1,33 +1,78 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import type { User } from '@supabase/supabase-js';
-import { AlertCircle, CheckCircle2, History, Loader2, MapPin, Play } from 'lucide-react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ AlertCircle,
+ CalendarDays,
+ Check,
+ CheckCircle2,
+ CircleOff,
+ Clock3,
+ Loader2,
+ MapPin,
+ Play,
+ Search as SearchIcon,
+ SlidersHorizontal,
+} from 'lucide-react';
import { listSearchJobs, runSearch } from '../lib/database';
-import type { SearchJob } from '../types';
+import type { SearchJob, SearchJobStatus } from '../types';
+import type { AppUser } from '../../shared/types';
interface SearchSetupProps {
- user: User;
- onSelectJob: (jobId: string) => void;
+ user: AppUser;
+ selectedJobIds: string[];
+ onToggleJobSelection: (jobId: string) => void;
+ onSelectCreatedJob: (jobId: string) => void;
+ onShowSelectedOnMap: () => void;
+ onClearSelection: () => void;
}
-export function SearchSetup({ user, onSelectJob }: SearchSetupProps) {
+type JobStatusFilter = 'all' | SearchJobStatus;
+type SortOption = 'newest' | 'oldest' | 'results';
+
+const statusOptions: Array<{ value: JobStatusFilter; label: string }> = [
+ { value: 'all', label: 'All statuses' },
+ { value: 'pending', label: 'Pending' },
+ { value: 'running', label: 'Running' },
+ { value: 'completed', label: 'Completed' },
+ { value: 'failed', label: 'Failed' },
+ { value: 'stopped', label: 'Stopped' },
+];
+
+const sortOptions: Array<{ value: SortOption; label: string }> = [
+ { value: 'newest', label: 'Newest first' },
+ { value: 'oldest', label: 'Oldest first' },
+ { value: 'results', label: 'Most results' },
+];
+
+export function SearchSetup({
+ user,
+ selectedJobIds,
+ onToggleJobSelection,
+ onSelectCreatedJob,
+ onShowSelectedOnMap,
+ onClearSelection,
+}: SearchSetupProps) {
const [name, setName] = useState('');
const [location, setLocation] = useState('');
const [radius, setRadius] = useState(5);
const [businessType, setBusinessType] = useState('');
const [keywords, setKeywords] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortOrder, setSortOrder] = useState('newest');
const [isSearching, setIsSearching] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [jobs, setJobs] = useState([]);
const [error, setError] = useState(null);
+ const selectedJobCount = selectedJobIds.length;
const refreshJobs = useCallback(async () => {
setIsLoadingHistory(true);
try {
- const nextJobs = await listSearchJobs(user.id, 10);
+ const nextJobs = await listSearchJobs(user.id, 100);
setJobs(nextJobs);
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load search history.');
+ setError(err instanceof Error ? err.message : 'Failed to load research jobs.');
} finally {
setIsLoadingHistory(false);
}
@@ -37,6 +82,42 @@ export function SearchSetup({ user, onSelectJob }: SearchSetupProps) {
void refreshJobs();
}, [refreshJobs]);
+ const filteredJobs = useMemo(() => {
+ const normalizedSearch = searchTerm.trim().toLowerCase();
+
+ const nextJobs = jobs.filter((job) => {
+ const matchesStatus = statusFilter === 'all' || job.status === statusFilter;
+
+ if (!matchesStatus) {
+ return false;
+ }
+
+ if (!normalizedSearch) {
+ return true;
+ }
+
+ const haystack = [job.name, job.businessType, job.city, job.address, job.keywords]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase();
+
+ return haystack.includes(normalizedSearch);
+ });
+
+ nextJobs.sort((left, right) => {
+ if (sortOrder === 'results') {
+ return right.totalResults - left.totalResults;
+ }
+
+ const leftTime = new Date(left.createdAt).getTime();
+ const rightTime = new Date(right.createdAt).getTime();
+
+ return sortOrder === 'oldest' ? leftTime - rightTime : rightTime - leftTime;
+ });
+
+ return nextJobs;
+ }, [jobs, searchTerm, sortOrder, statusFilter]);
+
const handleRunSearch = async (e: React.FormEvent) => {
e.preventDefault();
setIsSearching(true);
@@ -52,165 +133,355 @@ export function SearchSetup({ user, onSelectJob }: SearchSetupProps) {
});
await refreshJobs();
- onSelectJob(response.job.id);
+ onSelectCreatedJob(response.job.id);
} catch (err) {
- setError(err instanceof Error ? err.message : 'Search failed.');
+ setError(err instanceof Error ? err.message : 'Research failed.');
} finally {
setIsSearching(false);
}
};
return (
-