@trafficgroup/knex-rel 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ # Folder Table CameraId Analysis
2
+
3
+ ## Issue Summary
4
+
5
+ The `cameraId` column in the `folders` table is being saved as `null` despite being defined as optional in the schema and interface.
6
+
7
+ ## Database Schema Analysis
8
+
9
+ ### Current Schema (from migrations)
10
+
11
+ #### Initial Folder Table Creation (20250717161310_migration.ts)
12
+
13
+ ```sql
14
+ CREATE TABLE folders (
15
+ id SERIAL PRIMARY KEY,
16
+ name VARCHAR NOT NULL,
17
+ createdBy INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE,
18
+ status ENUM('UPLOADING', 'COMPLETE') NOT NULL DEFAULT 'UPLOADING',
19
+ studyId INTEGER NOT NULL REFERENCES study(id) ON DELETE CASCADE,
20
+ created_at TIMESTAMP,
21
+ updated_at TIMESTAMP
22
+ );
23
+ ```
24
+
25
+ #### Camera Integration (20250911000000_migration.ts)
26
+
27
+ ```sql
28
+ -- Added cameraId column to folders table
29
+ ALTER TABLE folders ADD COLUMN cameraId INTEGER NULLABLE
30
+ REFERENCES cameras(id) ON DELETE SET NULL;
31
+ CREATE INDEX ON folders (cameraId);
32
+ ```
33
+
34
+ ### Current Interface Definition
35
+
36
+ ```typescript
37
+ export interface IFolder {
38
+ id: number;
39
+ uuid: string;
40
+ name: string;
41
+ createdBy: number; // user.id
42
+ status: "UPLOADING" | "COMPLETE";
43
+ studyId: number; // study.id
44
+ cameraId?: number; // camera.id - OPTIONAL field
45
+ created_at: string;
46
+ updated_at: string;
47
+ study?: IStudy;
48
+ camera?: ICamera;
49
+ }
50
+ ```
51
+
52
+ ## DAO Implementation Analysis
53
+
54
+ ### Current FolderDAO Issues
55
+
56
+ 1. **No Camera Relationship Joins**: The DAO queries join with `study` table but don't include `camera` joins
57
+ 2. **Missing Camera Data Population**: Queries don't populate the optional `camera` field
58
+ 3. **Basic CRUD Operations**: The `create()` method simply inserts the provided data without validation
59
+
60
+ ### Key Methods Analysis
61
+
62
+ #### Create Method
63
+
64
+ ```typescript
65
+ async create(item: IFolder): Promise<IFolder> {
66
+ const [createdFolder] = await this._knex("folders")
67
+ .insert(item)
68
+ .returning("*");
69
+ return createdFolder;
70
+ }
71
+ ```
72
+
73
+ - **Issue**: This will insert whatever `cameraId` is provided in the `item` parameter
74
+ - **Behavior**: If `cameraId` is undefined or not provided, it will be stored as `NULL` (which is valid)
75
+
76
+ #### Query Methods (getById, getByUuid, getAll)
77
+
78
+ ```typescript
79
+ // Current pattern - only joins with study
80
+ const folder = await this._knex("folders as f")
81
+ .innerJoin("study as s", "f.studyId", "s.id")
82
+ .select("f.*", this._knex.raw("to_jsonb(s.*) as study"))
83
+ .where("f.id", id)
84
+ .first();
85
+ ```
86
+
87
+ - **Missing**: No LEFT JOIN with cameras table to populate camera data
88
+ - **Impact**: Even if `cameraId` has a valid value, the `camera` object won't be populated
89
+
90
+ ## Root Cause Analysis
91
+
92
+ ### Why CameraId is Null
93
+
94
+ 1. **Optional Field Behavior**: Since `cameraId` is optional (`cameraId?: number`), if not explicitly provided in create requests, it defaults to `null`
95
+
96
+ 2. **No Default Value**: Unlike other fields, there's no database-level default value for `cameraId`
97
+
98
+ 3. **Application Logic Gap**: No validation or business logic to ensure `cameraId` is populated when appropriate
99
+
100
+ ### Database Schema is Correct
101
+
102
+ The database schema properly supports `cameraId`:
103
+
104
+ - ✅ Column exists and is nullable
105
+ - ✅ Foreign key constraint to `cameras(id)`
106
+ - ✅ Proper ON DELETE SET NULL behavior
107
+ - ✅ Index created for performance
108
+
109
+ ## Recommended Solutions
110
+
111
+ ### 1. Immediate Fix: Verify Input Data
112
+
113
+ Check what data is being passed to the `create()` method:
114
+
115
+ - Log the `item` parameter in FolderDAO.create()
116
+ - Verify if `cameraId` is being provided in the request
117
+
118
+ ### 2. Enhanced DAO Queries
119
+
120
+ Update FolderDAO to include camera relationships:
121
+
122
+ ```typescript
123
+ // Enhanced getById with camera join
124
+ async getById(id: number): Promise<IFolder | null> {
125
+ const folder = await this._knex("folders as f")
126
+ .innerJoin("study as s", "f.studyId", "s.id")
127
+ .leftJoin("cameras as c", "f.cameraId", "c.id")
128
+ .select(
129
+ "f.*",
130
+ this._knex.raw("to_jsonb(s.*) as study"),
131
+ this._knex.raw("to_jsonb(c.*) as camera")
132
+ )
133
+ .where("f.id", id)
134
+ .first();
135
+ return folder || null;
136
+ }
137
+ ```
138
+
139
+ ### 3. API Layer Investigation
140
+
141
+ Check the API endpoints that create folders:
142
+
143
+ - Verify if `cameraId` is being extracted from request body
144
+ - Ensure DTO/validation layer passes `cameraId` through
145
+ - Check if frontend is sending `cameraId` in requests
146
+
147
+ ### 4. Business Logic Validation
148
+
149
+ Consider if folders should always have a camera:
150
+
151
+ - If required: Add validation in create method
152
+ - If optional: Current behavior is correct (null is valid)
153
+
154
+ ## Implementation Priority
155
+
156
+ 1. **High**: Investigate API request data flow
157
+ 2. **High**: Add camera relationship to DAO queries
158
+ 3. **Medium**: Add logging to track cameraId values
159
+ 4. **Low**: Consider business logic changes
160
+
161
+ ## Files to Examine Next
162
+
163
+ 1. `api-rel/src/controllers/folder/folder.controller.ts` - Controller logic
164
+ 2. `api-rel/src/routes/folder/` - Route definitions and DTOs
165
+ 3. Frontend folder creation forms - Check if cameraId is being sent
166
+
167
+ The database schema and DAO patterns are correctly implemented. The issue likely lies in the data flow from the API/frontend layers not providing `cameraId` values during folder creation.
@@ -0,0 +1,61 @@
1
+ import type { Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ // Create cameras table
5
+ await knex.schema.createTable("cameras", (table) => {
6
+ table.increments("id").primary();
7
+ table
8
+ .uuid("uuid")
9
+ .defaultTo(knex.raw("uuid_generate_v4()"))
10
+ .notNullable()
11
+ .unique();
12
+ table.string("name", 100).notNullable();
13
+ table.decimal("longitude", 10, 7).notNullable();
14
+ table.decimal("latitude", 10, 7).notNullable();
15
+ table.timestamps(true, true);
16
+
17
+ table.index(["uuid"]);
18
+ table.index(["longitude", "latitude"]);
19
+ });
20
+
21
+ // Add cameraId to videos table
22
+ await knex.schema.alterTable("video", (table) => {
23
+ table
24
+ .integer("cameraId")
25
+ .nullable()
26
+ .references("id")
27
+ .inTable("cameras")
28
+ .onDelete("SET NULL");
29
+ table.index(["cameraId"]);
30
+ });
31
+
32
+ // Add cameraId to folders table
33
+ await knex.schema.alterTable("folders", (table) => {
34
+ table
35
+ .integer("cameraId")
36
+ .nullable()
37
+ .references("id")
38
+ .inTable("cameras")
39
+ .onDelete("SET NULL");
40
+ table.index(["cameraId"]);
41
+ });
42
+ }
43
+
44
+ export async function down(knex: Knex): Promise<void> {
45
+ // Remove cameraId from folders table
46
+ await knex.schema.alterTable("folders", (table) => {
47
+ table.dropIndex(["cameraId"]);
48
+ table.dropForeign(["cameraId"]);
49
+ table.dropColumn("cameraId");
50
+ });
51
+
52
+ // Remove cameraId from videos table
53
+ await knex.schema.alterTable("video", (table) => {
54
+ table.dropIndex(["cameraId"]);
55
+ table.dropForeign(["cameraId"]);
56
+ table.dropColumn("cameraId");
57
+ });
58
+
59
+ // Drop cameras table
60
+ await knex.schema.dropTable("cameras");
61
+ }
@@ -0,0 +1,37 @@
1
+ import type { Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("video", (table) => {
5
+ // Add annotationSourceId column with foreign key to video table
6
+ table
7
+ .integer("annotationSourceId")
8
+ .nullable()
9
+ .references("id")
10
+ .inTable("video")
11
+ .onDelete("SET NULL");
12
+
13
+ // Add index for performance when querying by annotation source
14
+ table.index(["annotationSourceId"], "idx_video_annotation_source_id");
15
+
16
+ // Add composite index for efficient template lookups
17
+ table.index(
18
+ ["folderId", "videoType", "status"],
19
+ "idx_video_template_lookup",
20
+ );
21
+ });
22
+ }
23
+
24
+ export async function down(knex: Knex): Promise<void> {
25
+ await knex.schema.alterTable("video", (table) => {
26
+ // Drop indexes first
27
+ table.dropIndex(
28
+ ["folderId", "videoType", "status"],
29
+ "idx_video_template_lookup",
30
+ );
31
+ table.dropIndex(["annotationSourceId"], "idx_video_annotation_source_id");
32
+
33
+ // Drop foreign key and column
34
+ table.dropForeign(["annotationSourceId"]);
35
+ table.dropColumn("annotationSourceId");
36
+ });
37
+ }
@@ -0,0 +1,22 @@
1
+ import type { Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("cameras", (table) => {
5
+ // Add index for case-insensitive name searches
6
+ table.index(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
7
+ });
8
+
9
+ // Add GIN index for full-text search if needed for fuzzy matching
10
+ await knex.raw(`
11
+ CREATE INDEX idx_cameras_name_gin
12
+ ON cameras
13
+ USING GIN (to_tsvector('english', name))
14
+ `);
15
+ }
16
+
17
+ export async function down(knex: Knex): Promise<void> {
18
+ await knex.raw("DROP INDEX IF EXISTS idx_cameras_name_gin");
19
+ await knex.schema.alterTable("cameras", (table) => {
20
+ table.dropIndex(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
21
+ });
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trafficgroup/knex-rel",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Knex Module",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
package/plan.md ADDED
@@ -0,0 +1,288 @@
1
+ # Database Optimization Plan for Camera Bulk Operations
2
+
3
+ ## Current Schema Analysis
4
+
5
+ ### Existing Tables & Indexes
6
+
7
+ **cameras table:**
8
+
9
+ - Primary key: `id` (auto-increment)
10
+ - Unique index: `uuid`
11
+ - Composite index: `[longitude, latitude]` for geospatial queries
12
+
13
+ **video table:**
14
+
15
+ - Primary key: `id`
16
+ - Foreign key: `cameraId` → `cameras.id` (nullable, with index)
17
+ - Foreign key: `folderId` → `folders.id` (with implicit index)
18
+ - Unique index: `uuid`
19
+ - Composite indexes:
20
+ - `[annotationSourceId]`
21
+ - `[folderId, videoType, status]` (template lookup)
22
+
23
+ **folders table:**
24
+
25
+ - Primary key: `id`
26
+ - Foreign key: `cameraId` → `cameras.id` (nullable, with index)
27
+ - Foreign key: `studyId` → `study.id` (with implicit index)
28
+ - Unique index: `uuid`
29
+
30
+ ### Performance Analysis for Required Operations
31
+
32
+ #### 1. ✅ Bulk update videos when folder cameraId changes
33
+
34
+ **Query:** `UPDATE video SET cameraId = ? WHERE folderId = ?`
35
+
36
+ - **Current Performance:** GOOD
37
+ - **Existing Index:** `[folderId]` (implicit from FK constraint)
38
+ - **Status:** No optimization needed
39
+
40
+ #### 2. ❌ Search cameras by name with pagination
41
+
42
+ **Query:** `SELECT * FROM cameras WHERE name ILIKE '%search%' ORDER BY name LIMIT ? OFFSET ?`
43
+
44
+ - **Current Performance:** POOR - Full table scan
45
+ - **Missing Index:** Text search index on `name` column
46
+ - **Impact:** Critical performance issue for camera search
47
+
48
+ #### 3. ✅ Find videos by specific camera
49
+
50
+ **Query:** `SELECT * FROM video WHERE cameraId = ?`
51
+
52
+ - **Current Performance:** GOOD
53
+ - **Existing Index:** `[cameraId]` from migration `20250911000000_migration.ts`
54
+ - **Status:** No optimization needed
55
+
56
+ #### 4. ⚠️ Bulk assign camera to multiple videos
57
+
58
+ **Query:** `UPDATE video SET cameraId = ? WHERE id IN (...)`
59
+
60
+ - **Current Performance:** ACCEPTABLE but can be optimized
61
+ - **Existing Index:** Primary key on `id`
62
+ - **Optimization Opportunity:** Batch processing with transaction optimization
63
+
64
+ ## Required Database Optimizations
65
+
66
+ ### New Migrations Required
67
+
68
+ #### Migration: Add camera name search index
69
+
70
+ **File:** `20250924000000_camera_name_search_index.ts`
71
+ **Purpose:** Optimize camera search by name functionality
72
+
73
+ ```typescript
74
+ export async function up(knex: Knex): Promise<void> {
75
+ await knex.schema.alterTable("cameras", (table) => {
76
+ // Add index for case-insensitive name searches
77
+ table.index(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
78
+ });
79
+
80
+ // Add GIN index for full-text search if needed for fuzzy matching
81
+ await knex.raw(`
82
+ CREATE INDEX idx_cameras_name_gin
83
+ ON cameras
84
+ USING GIN (to_tsvector('english', name))
85
+ `);
86
+ }
87
+
88
+ export async function down(knex: Knex): Promise<void> {
89
+ await knex.raw("DROP INDEX IF EXISTS idx_cameras_name_gin");
90
+ await knex.schema.alterTable("cameras", (table) => {
91
+ table.dropIndex(knex.raw("LOWER(name)"), "idx_cameras_name_lower");
92
+ });
93
+ }
94
+ ```
95
+
96
+ ### DAO Method Optimizations
97
+
98
+ #### CameraDAO Enhancements Required
99
+
100
+ **New Methods Needed:**
101
+
102
+ ```typescript
103
+ // Optimized paginated search by name
104
+ async searchByName(
105
+ searchTerm: string,
106
+ page: number,
107
+ limit: number
108
+ ): Promise<IDataPaginator<ICamera>> {
109
+ const offset = (page - 1) * limit;
110
+ const searchPattern = `%${searchTerm.toLowerCase()}%`;
111
+
112
+ const query = this._knex("cameras")
113
+ .whereRaw("LOWER(name) LIKE ?", [searchPattern])
114
+ .orderBy("name");
115
+
116
+ const [countResult] = await query.clone().clearSelect().count("* as count");
117
+ const totalCount = +countResult.count;
118
+ const cameras = await query.clone().limit(limit).offset(offset);
119
+
120
+ return {
121
+ success: true,
122
+ data: cameras,
123
+ page,
124
+ limit,
125
+ count: cameras.length,
126
+ totalCount,
127
+ totalPages: Math.ceil(totalCount / limit),
128
+ };
129
+ }
130
+
131
+ // Get all videos associated with a camera (with pagination)
132
+ async getVideosByCamera(
133
+ cameraId: number,
134
+ page: number,
135
+ limit: number
136
+ ): Promise<IDataPaginator<IVideo>> {
137
+ const offset = (page - 1) * limit;
138
+
139
+ const query = this._knex("video as v")
140
+ .innerJoin("folders as f", "v.folderId", "f.id")
141
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
142
+ .where("v.cameraId", cameraId)
143
+ .orderBy("v.created_at", "desc");
144
+
145
+ const [countResult] = await query.clone().clearSelect().count("* as count");
146
+ const totalCount = +countResult.count;
147
+ const videos = await query.clone().limit(limit).offset(offset);
148
+
149
+ return {
150
+ success: true,
151
+ data: videos,
152
+ page,
153
+ limit,
154
+ count: videos.length,
155
+ totalCount,
156
+ totalPages: Math.ceil(totalCount / limit),
157
+ };
158
+ }
159
+ ```
160
+
161
+ #### VideoDAO Enhancements Required
162
+
163
+ **New Methods Needed:**
164
+
165
+ ```typescript
166
+ // Bulk update videos by folder (folder camera cascade)
167
+ async bulkUpdateCameraByFolder(
168
+ folderId: number,
169
+ cameraId: number | null
170
+ ): Promise<number> {
171
+ const result = await this._knex("video")
172
+ .where("folderId", folderId)
173
+ .update({
174
+ cameraId: cameraId,
175
+ updated_at: this._knex.fn.now()
176
+ });
177
+ return result;
178
+ }
179
+
180
+ // Bulk assign camera to multiple videos
181
+ async bulkUpdateCamera(
182
+ videoIds: number[],
183
+ cameraId: number | null
184
+ ): Promise<number> {
185
+ if (videoIds.length === 0) return 0;
186
+
187
+ const result = await this._knex("video")
188
+ .whereIn("id", videoIds)
189
+ .update({
190
+ cameraId: cameraId,
191
+ updated_at: this._knex.fn.now()
192
+ });
193
+ return result;
194
+ }
195
+
196
+ // Get videos by multiple folder IDs (for folder camera propagation)
197
+ async getVideosByFolderIds(folderIds: number[]): Promise<IVideo[]> {
198
+ if (folderIds.length === 0) return [];
199
+
200
+ return await this._knex("video")
201
+ .whereIn("folderId", folderIds)
202
+ .select("id", "uuid", "name", "folderId", "cameraId");
203
+ }
204
+ ```
205
+
206
+ ## Query Performance Strategies
207
+
208
+ ### 1. Camera Search Optimization
209
+
210
+ - **Index Strategy:** Case-insensitive BTREE index on `LOWER(name)`
211
+ - **Query Pattern:** `WHERE LOWER(name) LIKE LOWER(?)`
212
+ - **Full-text Option:** GIN index with `to_tsvector()` for fuzzy search
213
+ - **Expected Performance:** O(log n) lookup instead of O(n) scan
214
+
215
+ ### 2. Bulk Operation Optimization
216
+
217
+ - **Transaction Wrapping:** All bulk operations in single transactions
218
+ - **Batch Size Limits:** Process in chunks of 1000 records max
219
+ - **Index Usage:** Leverage existing primary key and foreign key indexes
220
+
221
+ ### 3. Folder Camera Cascade Optimization
222
+
223
+ - **Single Query Strategy:** Use `WHERE folderId = ?` leveraging existing index
224
+ - **Atomic Updates:** Single UPDATE statement rather than individual updates
225
+ - **Expected Performance:** O(log n) folder lookup + O(k) video updates
226
+
227
+ ## Implementation Order
228
+
229
+ 1. **Migration First:** Deploy camera name search index
230
+ 2. **DAO Methods:** Add optimized search and bulk operation methods
231
+ 3. **Transaction Optimization:** Implement proper transaction handling
232
+ 4. **Testing:** Validate performance improvements
233
+
234
+ ## Risk Assessment
235
+
236
+ ### Migration Safety: LOW RISK
237
+
238
+ - Adding indexes is non-blocking operation
239
+ - No data changes, only performance improvements
240
+ - Easy rollback with down migration
241
+
242
+ ### Performance Impact: HIGH BENEFIT
243
+
244
+ - Camera search: 100x-1000x improvement (O(n) → O(log n))
245
+ - Bulk operations: Proper indexing already exists
246
+ - Folder cascade: Leverages existing folderId index
247
+
248
+ ### Pattern Compliance: 100% COMPLIANT
249
+
250
+ - Follows existing DAO patterns exactly
251
+ - Maintains IBaseDAO interface requirements
252
+ - Uses established naming conventions
253
+ - Preserves UUID external API pattern
254
+
255
+ ## Validation Strategy
256
+
257
+ ### Performance Testing Queries
258
+
259
+ ```sql
260
+ -- Test camera search performance
261
+ EXPLAIN ANALYZE SELECT * FROM cameras
262
+ WHERE LOWER(name) LIKE '%traffic%'
263
+ ORDER BY name LIMIT 20;
264
+
265
+ -- Test video-by-camera lookup
266
+ EXPLAIN ANALYZE SELECT * FROM video
267
+ WHERE cameraId = 1
268
+ ORDER BY created_at DESC LIMIT 20;
269
+
270
+ -- Test folder cascade update
271
+ EXPLAIN ANALYZE UPDATE video
272
+ SET cameraId = 1 WHERE folderId = 5;
273
+ ```
274
+
275
+ ### Success Metrics
276
+
277
+ - Camera search < 50ms for 10k+ cameras
278
+ - Video-by-camera < 100ms for 1k+ videos
279
+ - Folder cascade < 200ms for 100+ videos per folder
280
+ - Bulk video update < 500ms for 1000+ videos
281
+
282
+ ## Conclusion
283
+
284
+ **Current Status:** Schema is well-designed with most needed indexes already present. Only critical gap is camera name search optimization.
285
+
286
+ **Priority:** HIGH for camera name search index, MEDIUM for DAO method enhancements.
287
+
288
+ **Complexity:** LOW - Single migration required, standard DAO patterns.
@@ -0,0 +1,79 @@
1
+ import { Knex } from "knex";
2
+ import { IBaseDAO, IDataPaginator } from "../../d.types";
3
+ import { ICamera } from "../../interfaces/camera/camera.interfaces";
4
+ import KnexManager from "../../KnexConnection";
5
+
6
+ export class CameraDAO implements IBaseDAO<ICamera> {
7
+ private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
8
+
9
+ async create(item: ICamera): Promise<ICamera> {
10
+ const [createdCamera] = await this._knex("cameras")
11
+ .insert(item)
12
+ .returning("*");
13
+ return createdCamera;
14
+ }
15
+
16
+ async getById(id: number): Promise<ICamera | null> {
17
+ const camera = await this._knex("cameras").where({ id }).first();
18
+ return camera || null;
19
+ }
20
+
21
+ async getByUuid(uuid: string): Promise<ICamera | null> {
22
+ const camera = await this._knex("cameras").where({ uuid }).first();
23
+ return camera || null;
24
+ }
25
+
26
+ async update(id: number, item: Partial<ICamera>): Promise<ICamera | null> {
27
+ const [updatedCamera] = await this._knex("cameras")
28
+ .where({ id })
29
+ .update(item)
30
+ .returning("*");
31
+ return updatedCamera || null;
32
+ }
33
+
34
+ async delete(id: number): Promise<boolean> {
35
+ const result = await this._knex("cameras").where({ id }).del();
36
+ return result > 0;
37
+ }
38
+
39
+ async getAll(page: number, limit: number): Promise<IDataPaginator<ICamera>> {
40
+ const offset = (page - 1) * limit;
41
+
42
+ const [countResult] = await this._knex("cameras").count("* as count");
43
+ const totalCount = +countResult.count;
44
+ const cameras = await this._knex("cameras").limit(limit).offset(offset);
45
+
46
+ return {
47
+ success: true,
48
+ data: cameras,
49
+ page,
50
+ limit,
51
+ count: cameras.length,
52
+ totalCount,
53
+ totalPages: Math.ceil(totalCount / limit),
54
+ };
55
+ }
56
+
57
+ async getByName(name: string): Promise<ICamera | null> {
58
+ const camera = await this._knex("cameras").where({ name }).first();
59
+ return camera || null;
60
+ }
61
+
62
+ async getCamerasNearCoordinates(
63
+ longitude: number,
64
+ latitude: number,
65
+ radiusKm: number = 1,
66
+ ): Promise<ICamera[]> {
67
+ // Using ST_DWithin for geographic distance calculation
68
+ // This is a PostgreSQL-specific query for geospatial operations
69
+ const cameras = await this._knex("cameras").whereRaw(
70
+ `ST_DWithin(
71
+ ST_MakePoint(longitude, latitude)::geography,
72
+ ST_MakePoint(?, ?)::geography,
73
+ ?
74
+ )`,
75
+ [longitude, latitude, radiusKm * 1000], // Convert km to meters
76
+ );
77
+ return cameras;
78
+ }
79
+ }