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.
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user