File size: 12,582 Bytes
350debe
90a13c0
350debe
90a13c0
 
 
 
350debe
1baca0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fefce67
1baca0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90a13c0
 
 
350debe
 
90a13c0
 
967688d
90a13c0
 
 
 
 
967688d
90a13c0
967688d
 
 
 
 
 
 
 
 
 
 
90a13c0
350debe
90a13c0
350debe
 
 
 
aee2095
 
 
 
 
 
 
 
 
 
1baca0b
 
aee2095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a51c37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1baca0b
 
 
 
4a51c37
1baca0b
4a51c37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90a13c0
 
 
 
 
 
 
967688d
90a13c0
 
 
 
 
967688d
90a13c0
967688d
 
 
 
 
 
 
 
 
 
 
90a13c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350debe
90a13c0
 
 
 
 
350debe
90a13c0
 
967688d
90a13c0
 
 
 
 
967688d
90a13c0
967688d
 
 
 
 
 
 
 
 
 
 
90a13c0
 
350debe
 
4a51c37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90a13c0
350debe
90a13c0
 
967688d
 
 
 
 
 
 
350debe
90a13c0
350debe
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
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
        }
    }
}