zerito commited on
Commit
1baca0b
·
1 Parent(s): 4a51c37

Deploy backend update Tue Jan 20 17:02:56 CET 2026

Browse files
src/controllers/trackController.ts CHANGED
@@ -6,6 +6,35 @@ import { TrackFeature, TrackFeatureCollection } from '../types/types';
6
  * TrackController - Handles HTTP requests for public ski tracks
7
  */
8
  export class TrackController {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  /**
10
  * GET /api/tracks
11
  * Fetch all public tracks with optional location filtering and pagination
@@ -19,16 +48,19 @@ export class TrackController {
19
  // Prepare filters for DB query
20
  const filters: { activities?: string[], searchQuery?: string, difficulty?: string } = {};
21
 
22
- if (activities) {
23
- filters.activities = (activities as string).split(',').map(a => a.trim().toLowerCase());
 
24
  }
25
-
26
- if (q) {
27
- filters.searchQuery = q as string;
 
28
  }
29
 
30
- if (difficulty) {
31
- filters.difficulty = difficulty as string;
 
32
  }
33
 
34
  // Pagination (must be applied at DB level to avoid huge Supabase egress)
 
6
  * TrackController - Handles HTTP requests for public ski tracks
7
  */
8
  export class TrackController {
9
+ private static parseOptionalString(value: unknown): string | undefined {
10
+ if (typeof value !== 'string') return undefined;
11
+ const trimmed = value.trim();
12
+ if (!trimmed || trimmed === 'undefined' || trimmed === 'null') return undefined;
13
+ return trimmed;
14
+ }
15
+
16
+ private static normalizeStringList(values: unknown[]): string[] | undefined {
17
+ const normalized = values
18
+ .filter((v): v is string => typeof v === 'string')
19
+ .map(v => v.trim().toLowerCase())
20
+ .filter(v => v.length > 0 && v !== 'undefined' && v !== 'null');
21
+
22
+ if (normalized.length === 0) return undefined;
23
+ return Array.from(new Set(normalized));
24
+ }
25
+
26
+ private static parseActivitiesParam(value: unknown): string[] | undefined {
27
+ if (Array.isArray(value)) {
28
+ return TrackController.normalizeStringList(value);
29
+ }
30
+
31
+ if (typeof value === 'string') {
32
+ return TrackController.normalizeStringList(value.split(','));
33
+ }
34
+
35
+ return undefined;
36
+ }
37
+
38
  /**
39
  * GET /api/tracks
40
  * Fetch all public tracks with optional location filtering and pagination
 
48
  // Prepare filters for DB query
49
  const filters: { activities?: string[], searchQuery?: string, difficulty?: string } = {};
50
 
51
+ const parsedActivities = TrackController.parseActivitiesParam(activities);
52
+ if (parsedActivities) {
53
+ filters.activities = parsedActivities;
54
  }
55
+
56
+ const parsedQuery = TrackController.parseOptionalString(q);
57
+ if (parsedQuery) {
58
+ filters.searchQuery = parsedQuery;
59
  }
60
 
61
+ const parsedDifficulty = TrackController.parseOptionalString(difficulty);
62
+ if (parsedDifficulty) {
63
+ filters.difficulty = parsedDifficulty;
64
  }
65
 
66
  // Pagination (must be applied at DB level to avoid huge Supabase egress)
src/models/TrackModel.ts CHANGED
@@ -6,6 +6,87 @@ import { Track } from '../types/types';
6
  * These are read-only tracks imported from OpenSkiData
7
  */
8
  export class TrackModel {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  /**
10
  * Find all tracks in the database
11
  */
@@ -49,27 +130,8 @@ export class TrackModel {
49
  searchQuery?: string;
50
  }
51
  ): Promise<Track[]> {
52
- const { activities, searchQuery } = filters;
53
-
54
- const conditions = [];
55
-
56
- if (activities && activities.length > 0) {
57
- // uses is TEXT[], so we can use the overlap operator &&
58
- conditions.push(sql`uses && ${activities}`);
59
- }
60
-
61
- if (searchQuery) {
62
- const pattern = `%${searchQuery}%`;
63
- conditions.push(sql`(name ILIKE ${pattern} OR description ILIKE ${pattern})`);
64
- }
65
-
66
- let where = sql``;
67
- if (conditions.length > 0) {
68
- where = sql`WHERE ${conditions[0]}`;
69
- for (let i = 1; i < conditions.length; i++) {
70
- where = sql`${where} AND ${conditions[i]}`;
71
- }
72
- }
73
 
74
  const tracks = await sql<Track[]>`
75
  SELECT
@@ -123,66 +185,13 @@ export class TrackModel {
123
  radiusKm: number;
124
  }
125
  ): Promise<{ tracks: Track[]; total: number }> {
126
- const { activities, searchQuery, difficulty } = filters;
127
  const { limit, offset } = pagination;
 
 
 
 
128
 
129
- const conditions = [];
130
-
131
- if (activities && activities.length > 0) {
132
- // uses is TEXT[], so we can use the overlap operator &&
133
- conditions.push(sql`uses && ${activities}`);
134
- }
135
-
136
- if (searchQuery) {
137
- const pattern = `%${searchQuery}%`;
138
- conditions.push(sql`(name ILIKE ${pattern} OR description ILIKE ${pattern})`);
139
- }
140
-
141
- if (difficulty) {
142
- conditions.push(sql`difficulty = ${difficulty}`);
143
- }
144
-
145
- if (location && Number.isFinite(location.lat) && Number.isFinite(location.lng) && Number.isFinite(location.radiusKm)) {
146
- const radiusKm = Math.max(0, location.radiusKm);
147
- const lat = location.lat;
148
- const lng = location.lng;
149
-
150
- // Cheap pre-filter: bounding box (helps avoid doing trig on every row)
151
- const deltaLat = radiusKm / 111.0;
152
- const cosLat = Math.cos((lat * Math.PI) / 180);
153
- const deltaLng = cosLat === 0 ? 180 : radiusKm / (111.0 * Math.max(0.000001, cosLat));
154
-
155
- const minLat = lat - deltaLat;
156
- const maxLat = lat + deltaLat;
157
- const minLng = lng - deltaLng;
158
- const maxLng = lng + deltaLng;
159
-
160
- // JSONB GeoJSON: first coordinate is [lng, lat]
161
- conditions.push(sql`((path->'coordinates'->0->>1)::float8 BETWEEN ${minLat} AND ${maxLat})`);
162
- conditions.push(sql`((path->'coordinates'->0->>0)::float8 BETWEEN ${minLng} AND ${maxLng})`);
163
-
164
- // Exact circle filter using the first coordinate (same logic as the controller previously used)
165
- conditions.push(sql`
166
- (
167
- 6371 * 2 * asin(
168
- sqrt(
169
- power(sin(radians((${lat} - ((path->'coordinates'->0->>1)::float8))) / 2)), 2)
170
- + cos(radians(${lat}))
171
- * cos(radians(((path->'coordinates'->0->>1)::float8)))
172
- * power(sin(radians((${lng} - ((path->'coordinates'->0->>0)::float8))) / 2)), 2)
173
- )
174
- )
175
- ) <= ${radiusKm}
176
- `);
177
- }
178
-
179
- let where = sql``;
180
- if (conditions.length > 0) {
181
- where = sql`WHERE ${conditions[0]}`;
182
- for (let i = 1; i < conditions.length; i++) {
183
- where = sql`${where} AND ${conditions[i]}`;
184
- }
185
- }
186
 
187
  type TrackRow = Track & { total_count: number };
188
 
 
6
  * These are read-only tracks imported from OpenSkiData
7
  */
8
  export class TrackModel {
9
+ private static buildWhere(conditions: any[]): any {
10
+ if (!conditions.length) return sql``;
11
+
12
+ let where = sql`WHERE ${conditions[0]}`;
13
+ for (let i = 1; i < conditions.length; i++) {
14
+ where = sql`${where} AND ${conditions[i]}`;
15
+ }
16
+ return where;
17
+ }
18
+
19
+ private static buildFilterConditions(filters: {
20
+ activities?: string[];
21
+ searchQuery?: string;
22
+ difficulty?: string;
23
+ }): any[] {
24
+ const { activities, searchQuery, difficulty } = filters;
25
+
26
+ const conditions: any[] = [];
27
+
28
+ if (activities && activities.length > 0) {
29
+ // uses is TEXT[], so we can use the overlap operator &&
30
+ // IMPORTANT: Use sql.array so postgres.js treats this as a single text[] parameter.
31
+ conditions.push(sql`uses && ${sql.array(activities, 25)}`);
32
+ }
33
+
34
+ if (searchQuery) {
35
+ const pattern = `%${searchQuery}%`;
36
+ conditions.push(sql`(name ILIKE ${pattern} OR description ILIKE ${pattern})`);
37
+ }
38
+
39
+ if (difficulty) {
40
+ conditions.push(sql`difficulty = ${difficulty}`);
41
+ }
42
+
43
+ return conditions;
44
+ }
45
+
46
+ private static buildLocationConditions(location?: {
47
+ lat: number;
48
+ lng: number;
49
+ radiusKm: number;
50
+ }): any[] {
51
+ if (!location) return [];
52
+ if (!Number.isFinite(location.lat) || !Number.isFinite(location.lng) || !Number.isFinite(location.radiusKm)) return [];
53
+
54
+ const conditions: any[] = [];
55
+ const radiusKm = Math.max(0, location.radiusKm);
56
+ const lat = location.lat;
57
+ const lng = location.lng;
58
+
59
+ // Cheap pre-filter: bounding box (helps avoid doing trig on every row)
60
+ const deltaLat = radiusKm / 111.0;
61
+ const cosLat = Math.cos((lat * Math.PI) / 180);
62
+ const deltaLng = cosLat === 0 ? 180 : radiusKm / (111.0 * Math.max(0.000001, cosLat));
63
+
64
+ const minLat = lat - deltaLat;
65
+ const maxLat = lat + deltaLat;
66
+ const minLng = lng - deltaLng;
67
+ const maxLng = lng + deltaLng;
68
+
69
+ // JSONB GeoJSON: first coordinate is [lng, lat]
70
+ conditions.push(sql`((path->'coordinates'->0->>1)::float8 BETWEEN ${minLat} AND ${maxLat})`);
71
+ conditions.push(sql`((path->'coordinates'->0->>0)::float8 BETWEEN ${minLng} AND ${maxLng})`);
72
+
73
+ // Exact circle filter using the first coordinate
74
+ conditions.push(sql`
75
+ (
76
+ 6371 * 2 * asin(
77
+ sqrt(
78
+ power(sin(radians((${lat} - ((path->'coordinates'->0->>1)::float8))) / 2)), 2)
79
+ + cos(radians(${lat}))
80
+ * cos(radians(((path->'coordinates'->0->>1)::float8)))
81
+ * power(sin(radians((${lng} - ((path->'coordinates'->0->>0)::float8))) / 2)), 2)
82
+ )
83
+ )
84
+ ) <= ${radiusKm}
85
+ `);
86
+
87
+ return conditions;
88
+ }
89
+
90
  /**
91
  * Find all tracks in the database
92
  */
 
130
  searchQuery?: string;
131
  }
132
  ): Promise<Track[]> {
133
+ const conditions = TrackModel.buildFilterConditions(filters);
134
+ const where = TrackModel.buildWhere(conditions);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
  const tracks = await sql<Track[]>`
137
  SELECT
 
185
  radiusKm: number;
186
  }
187
  ): Promise<{ tracks: Track[]; total: number }> {
 
188
  const { limit, offset } = pagination;
189
+ const conditions = [
190
+ ...TrackModel.buildFilterConditions(filters),
191
+ ...TrackModel.buildLocationConditions(location),
192
+ ];
193
 
194
+ const where = TrackModel.buildWhere(conditions);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
  type TrackRow = Track & { total_count: number };
197