snowva-backend / src /models /TrackModel.ts
zerito's picture
Deploy backend update Tue Jan 20 20:36:55 CET 2026
fefce67
Raw
History Blame Contribute Delete
12.6 kB
import sql from '../db';
import { Track } from '../types/types';
/**
* TrackModel - Handles database operations for public ski tracks
* These are read-only tracks imported from OpenSkiData
*/
export class TrackModel {
private static buildWhere(conditions: any[]): any {
if (!conditions.length) return sql``;
let where = sql`WHERE ${conditions[0]}`;
for (let i = 1; i < conditions.length; i++) {
where = sql`${where} AND ${conditions[i]}`;
}
return where;
}
private static buildFilterConditions(filters: {
activities?: string[];
searchQuery?: string;
difficulty?: string;
}): any[] {
const { activities, searchQuery, difficulty } = filters;
const conditions: any[] = [];
if (activities && activities.length > 0) {
console.log(`Filtering by activities: ${activities.join(', ')}`);
conditions.push(sql`uses && ${sql.array(activities, 25)}`);
}
if (searchQuery) {
const pattern = `%${searchQuery}%`;
conditions.push(sql`(name ILIKE ${pattern} OR description ILIKE ${pattern})`);
}
if (difficulty) {
conditions.push(sql`difficulty = ${difficulty}`);
}
return conditions;
}
private static buildLocationConditions(location?: {
lat: number;
lng: number;
radiusKm: number;
}): any[] {
if (!location) return [];
if (!Number.isFinite(location.lat) || !Number.isFinite(location.lng) || !Number.isFinite(location.radiusKm)) return [];
const conditions: any[] = [];
const radiusKm = Math.max(0, location.radiusKm);
const lat = location.lat;
const lng = location.lng;
// Cheap pre-filter: bounding box (helps avoid doing trig on every row)
const deltaLat = radiusKm / 111.0;
const cosLat = Math.cos((lat * Math.PI) / 180);
const deltaLng = cosLat === 0 ? 180 : radiusKm / (111.0 * Math.max(0.000001, cosLat));
const minLat = lat - deltaLat;
const maxLat = lat + deltaLat;
const minLng = lng - deltaLng;
const maxLng = lng + deltaLng;
// JSONB GeoJSON: first coordinate is [lng, lat]
conditions.push(sql`((path->'coordinates'->0->>1)::float8 BETWEEN ${minLat} AND ${maxLat})`);
conditions.push(sql`((path->'coordinates'->0->>0)::float8 BETWEEN ${minLng} AND ${maxLng})`);
// Exact circle filter using the first coordinate
conditions.push(sql`
(
6371 * 2 * asin(
sqrt(
power(sin(radians((${lat} - ((path->'coordinates'->0->>1)::float8))) / 2)), 2)
+ cos(radians(${lat}))
* cos(radians(((path->'coordinates'->0->>1)::float8)))
* power(sin(radians((${lng} - ((path->'coordinates'->0->>0)::float8))) / 2)), 2)
)
)
) <= ${radiusKm}
`);
return conditions;
}
/**
* Find all tracks in the database
*/
static async findAll(): Promise<Track[]> {
const tracks = await sql<Track[]>`
SELECT
id,
external_id,
name,
description,
path as geojson,
properties,
difficulty,
difficulty_convention,
type,
uses,
ref,
oneway,
gladed,
patrolled,
lit,
grooming,
status,
websites,
wikidata_id,
elevation_profile,
created_at
FROM routes
ORDER BY created_at DESC
`;
return tracks;
}
/**
* Find tracks with filters
* Optimized to filter by activities and search query in the database
*/
static async findWithFilters(
filters: {
activities?: string[];
searchQuery?: string;
}
): Promise<Track[]> {
const conditions = TrackModel.buildFilterConditions(filters);
const where = TrackModel.buildWhere(conditions);
const tracks = await sql<Track[]>`
SELECT
id,
external_id,
name,
description,
path as geojson,
properties,
difficulty,
difficulty_convention,
type,
uses,
ref,
oneway,
gladed,
patrolled,
lit,
grooming,
status,
websites,
wikidata_id,
elevation_profile,
created_at
FROM routes
${where}
ORDER BY created_at DESC
`;
return tracks;
}
/**
* Find tracks with filters and DB-level pagination.
*
* IMPORTANT: Pagination must happen in SQL to avoid downloading the full
* table from Supabase (which counts toward Supabase egress).
*/
static async findWithFiltersPaginated(
filters: {
activities?: string[];
searchQuery?: string;
difficulty?: string;
},
pagination: {
limit: number;
offset: number;
},
location?: {
lat: number;
lng: number;
radiusKm: number;
}
): Promise<{ tracks: Track[]; total: number }> {
const { limit, offset } = pagination;
const conditions = [
...TrackModel.buildFilterConditions(filters),
...TrackModel.buildLocationConditions(location),
];
const where = TrackModel.buildWhere(conditions);
type TrackRow = Track & { total_count: number };
const rows = await sql<TrackRow[]>`
SELECT
id,
external_id,
name,
description,
path as geojson,
properties,
difficulty,
difficulty_convention,
type,
uses,
ref,
oneway,
gladed,
patrolled,
lit,
grooming,
status,
websites,
wikidata_id,
elevation_profile,
created_at,
COUNT(*) OVER()::int as total_count
FROM routes
${where}
ORDER BY created_at DESC
LIMIT ${limit}
OFFSET ${offset}
`;
let total = rows.length > 0 ? rows[0].total_count : 0;
if (rows.length === 0 && offset > 0) {
const countRows = await sql<{ total: number }[]>`
SELECT COUNT(*)::int as total
FROM routes
${where}
`;
total = countRows[0]?.total ?? 0;
}
const tracks: Track[] = rows.map(({ total_count, ...track }) => track);
return { tracks, total };
}
/**
* Find a single track by ID
*/
static async findById(id: number): Promise<Track | null> {
const tracks = await sql<Track[]>`
SELECT
id,
external_id,
name,
description,
path as geojson,
properties,
difficulty,
difficulty_convention,
type,
uses,
ref,
oneway,
gladed,
patrolled,
lit,
grooming,
status,
websites,
wikidata_id,
elevation_profile,
created_at
FROM routes
WHERE id = ${id}
`;
return tracks.length > 0 ? tracks[0] : null;
}
/**
* Find tracks within a geographic radius
* Note: This is a simple implementation. For production, consider using PostGIS
*/
static async findByLocation(
lat: number,
lng: number,
radius: number
): Promise<Track[]> {
// For now, we'll fetch all tracks and filter in the controller
// In production, you'd want to use PostGIS for efficient spatial queries
return this.findAll();
}
/**
* Ensure the routes table exists with the correct schema
* This is called during initialization
*/
static async ensureTable(): Promise<void> {
await sql`
CREATE TABLE IF NOT EXISTS routes (
id SERIAL PRIMARY KEY,
external_id VARCHAR(255),
name VARCHAR(255),
description TEXT,
path JSONB,
properties JSONB,
difficulty VARCHAR(50),
difficulty_convention VARCHAR(50),
type VARCHAR(50),
uses TEXT[],
ref VARCHAR(50),
oneway VARCHAR(50),
gladed VARCHAR(50),
patrolled VARCHAR(50),
lit VARCHAR(50),
grooming VARCHAR(50),
status VARCHAR(50),
websites TEXT[],
wikidata_id VARCHAR(255),
elevation_profile JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
`;
// If the table already existed with a reduced schema (e.g., local seed),
// make sure required columns exist.
// This prevents runtime failures like "column external_id does not exist".
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS external_id VARCHAR(255);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS name VARCHAR(255);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS description TEXT;`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS path JSONB;`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS properties JSONB;`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS difficulty VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS difficulty_convention VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS type VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS uses TEXT[];`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS ref VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS oneway VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS gladed VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS patrolled VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS lit VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS grooming VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS status VARCHAR(50);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS websites TEXT[];`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS wikidata_id VARCHAR(255);`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS elevation_profile JSONB;`;
await sql`ALTER TABLE routes ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();`;
// Ensure indexes exist
try {
await sql`CREATE INDEX IF NOT EXISTS idx_routes_difficulty ON routes(difficulty);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_type ON routes(type);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_status ON routes(status);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_grooming ON routes(grooming);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_gladed ON routes(gladed);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_patrolled ON routes(patrolled);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_lit ON routes(lit);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_external_id ON routes(external_id);`;
await sql`CREATE INDEX IF NOT EXISTS idx_routes_uses ON routes USING GIN (uses);`;
} catch (e) {
// Ignore errors if indexes already exist
}
}
}