From a1ba5ee0932f0f96dc9a55ae34a101d200ddc300 Mon Sep 17 00:00:00 2001 From: pguerrerox Date: Fri, 27 Mar 2026 13:56:54 +0000 Subject: [PATCH] feat: migrate app to local Fastify and Postgres stack Replace Supabase auth and search runtime with a local Fastify API, PostgreSQL/PostGIS schema, and local session handling. Scaffold the worker and deep-research foundations while keeping the existing research, dashboard, and map flows running on the new backend. --- .env.example | 18 +- .gitignore | 1 + README.md | 54 +- db/migrations/0001_local_core.sql | 188 +++ db/scripts/migrate.ts | 54 + db/scripts/seed-postal-placeholder.ts | 2 + index.html | 2 +- package-lock.json | 1676 +++++++++++++++++-- package.json | 25 +- server/src/app.ts | 30 + server/src/auth/constants.ts | 2 + server/src/auth/middleware.ts | 33 + server/src/auth/passwords.ts | 9 + server/src/auth/sessions.ts | 111 ++ server/src/auth/users.ts | 78 + server/src/config/env.ts | 26 + server/src/db/boss.ts | 28 + server/src/db/pool.ts | 17 + server/src/index.ts | 16 + server/src/jobs/names.ts | 2 + server/src/jobs/register-jobs.ts | 12 + server/src/routes/auth.ts | 112 ++ server/src/routes/health.ts | 9 + server/src/routes/search-jobs.ts | 75 + server/src/search/google-places.ts | 192 +++ server/src/search/repository.ts | 231 +++ server/src/search/run-search.ts | 65 + server/src/search/types.ts | 158 ++ server/src/worker.ts | 19 + shared/types.ts | 14 + src/App.tsx | 182 +- src/components/Dashboard.tsx | 6 +- src/components/Layout.tsx | 10 +- src/components/MapView.tsx | 42 +- src/components/SearchSetup.tsx | 537 ++++-- src/lib/api.ts | 28 + src/lib/auth.ts | 39 + src/lib/database.ts | 194 +-- src/lib/supabase.ts | 34 - src/vite-env.d.ts | 3 +- supabase/functions/run-search/index.ts | 367 ---- supabase/migrations/20260322120000_init.sql | 165 -- tsconfig.json | 2 +- tsconfig.server.json | 16 + 44 files changed, 3756 insertions(+), 1128 deletions(-) create mode 100644 db/migrations/0001_local_core.sql create mode 100644 db/scripts/migrate.ts create mode 100644 db/scripts/seed-postal-placeholder.ts create mode 100644 server/src/app.ts create mode 100644 server/src/auth/constants.ts create mode 100644 server/src/auth/middleware.ts create mode 100644 server/src/auth/passwords.ts create mode 100644 server/src/auth/sessions.ts create mode 100644 server/src/auth/users.ts create mode 100644 server/src/config/env.ts create mode 100644 server/src/db/boss.ts create mode 100644 server/src/db/pool.ts create mode 100644 server/src/index.ts create mode 100644 server/src/jobs/names.ts create mode 100644 server/src/jobs/register-jobs.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/routes/search-jobs.ts create mode 100644 server/src/search/google-places.ts create mode 100644 server/src/search/repository.ts create mode 100644 server/src/search/run-search.ts create mode 100644 server/src/search/types.ts create mode 100644 server/src/worker.ts create mode 100644 shared/types.ts create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth.ts delete mode 100644 src/lib/supabase.ts delete mode 100644 supabase/functions/run-search/index.ts delete mode 100644 supabase/migrations/20260322120000_init.sql create mode 100644 tsconfig.server.json 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) {

Lead Dashboard

-

Browse Supabase-backed search results and export targeted lead lists.

+

Browse saved search results from your local workspace and export targeted lead lists.