@trafficgroup/knex-rel 0.1.3 → 0.1.5

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.
Files changed (30) hide show
  1. package/dist/dao/VideoMinuteResultDAO.d.ts +0 -4
  2. package/dist/dao/VideoMinuteResultDAO.js +7 -48
  3. package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
  4. package/dist/dao/camera/camera.dao.d.ts +8 -1
  5. package/dist/dao/camera/camera.dao.js +27 -8
  6. package/dist/dao/camera/camera.dao.js.map +1 -1
  7. package/dist/dao/report-configuration/report-configuration.dao.d.ts +94 -0
  8. package/dist/dao/report-configuration/report-configuration.dao.js +352 -0
  9. package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -0
  10. package/dist/dao/video/video.dao.d.ts +10 -0
  11. package/dist/dao/video/video.dao.js +40 -16
  12. package/dist/dao/video/video.dao.js.map +1 -1
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +3 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/interfaces/report-configuration/report-configuration.interfaces.d.ts +26 -0
  17. package/dist/interfaces/report-configuration/report-configuration.interfaces.js +3 -0
  18. package/dist/interfaces/report-configuration/report-configuration.interfaces.js.map +1 -0
  19. package/migrations/20250930200521_migration.ts +52 -0
  20. package/package.json +1 -1
  21. package/plan.md +755 -212
  22. package/src/dao/VideoMinuteResultDAO.ts +7 -64
  23. package/src/dao/camera/camera.dao.ts +30 -10
  24. package/src/dao/report-configuration/report-configuration.dao.ts +402 -0
  25. package/src/dao/video/video.dao.ts +46 -18
  26. package/src/index.ts +8 -0
  27. package/src/interfaces/report-configuration/report-configuration.interfaces.ts +30 -0
  28. package/cameras_analysis.md +0 -199
  29. package/folder_cameraid_analysis.md +0 -167
  30. package/migrations/20250924000000_camera_name_search_index.ts +0 -22
@@ -615,11 +615,11 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
615
615
 
616
616
  // Aggregate vehicle counts by class and lane
617
617
  if (results.vehicles) {
618
- for (const [rawVehicleClass, lanes] of Object.entries(
619
- results.vehicles,
620
- )) {
621
- // Normalize vehicle class names to standard format for frontend compatibility
622
- const vehicleClass = this.normalizeATRVehicleClass(rawVehicleClass);
618
+ for (const [vehicleClass, lanes] of Object.entries(results.vehicles)) {
619
+ // Skip 'total' pseudo-class if present
620
+ if (vehicleClass === "total") {
621
+ continue;
622
+ }
623
623
 
624
624
  if (!aggregated.vehicles[vehicleClass]) {
625
625
  aggregated.vehicles[vehicleClass] = {};
@@ -645,12 +645,9 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
645
645
  // Aggregate total count
646
646
  aggregated.total_count += results.total_count || 0;
647
647
 
648
- // Aggregate detected classes with normalized names
648
+ // Aggregate detected classes (use raw detection labels)
649
649
  if (results.detected_classes) {
650
- for (const [rawCls, count] of Object.entries(
651
- results.detected_classes,
652
- )) {
653
- const cls = this.normalizeATRVehicleClass(rawCls);
650
+ for (const [cls, count] of Object.entries(results.detected_classes)) {
654
651
  aggregated.detected_classes[cls] =
655
652
  (aggregated.detected_classes[cls] || 0) + ((count as number) || 0);
656
653
  }
@@ -715,60 +712,6 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
715
712
  return cloned;
716
713
  }
717
714
 
718
- /**
719
- * Normalize ATR vehicle class names to standard format for frontend compatibility
720
- */
721
- private normalizeATRVehicleClass(rawVehicleClass: string): string {
722
- const normalized = rawVehicleClass.toLowerCase().replace(/[_\s-]/g, "");
723
-
724
- // Map raw vehicle classes to standard classes
725
- if (
726
- normalized.includes("car") ||
727
- normalized === "vehicle" ||
728
- normalized === "automobiles"
729
- ) {
730
- return "cars";
731
- }
732
- if (
733
- normalized.includes("medium") ||
734
- normalized.includes("pickup") ||
735
- (normalized.includes("truck") && !normalized.includes("heavy"))
736
- ) {
737
- return "mediums";
738
- }
739
- if (
740
- normalized.includes("heavy") ||
741
- normalized.includes("largetruck") ||
742
- normalized.includes("bigtruck")
743
- ) {
744
- return "heavy_trucks";
745
- }
746
- if (
747
- normalized.includes("pedestrian") ||
748
- normalized.includes("person") ||
749
- normalized.includes("people")
750
- ) {
751
- return "pedestrians";
752
- }
753
- if (
754
- normalized.includes("bicycle") ||
755
- normalized.includes("bike") ||
756
- normalized.includes("cyclist")
757
- ) {
758
- return "bicycles";
759
- }
760
- if (normalized.includes("total") || normalized.includes("all")) {
761
- return "total";
762
- }
763
-
764
- // Handle specific known ATR classes
765
- if (rawVehicleClass === "mediums") return "mediums";
766
- if (rawVehicleClass === "heavy_trucks") return "heavy_trucks";
767
-
768
- // Default fallback for unknown classes
769
- return "cars";
770
- }
771
-
772
715
  /**
773
716
  * Format time label for display (HH:MM:SS format)
774
717
  */
@@ -1,6 +1,7 @@
1
1
  import { Knex } from "knex";
2
2
  import { IBaseDAO, IDataPaginator } from "../../d.types";
3
3
  import { ICamera } from "../../interfaces/camera/camera.interfaces";
4
+ import { IVideo } from "../../interfaces/video/video.interfaces";
4
5
  import KnexManager from "../../KnexConnection";
5
6
 
6
7
  export class CameraDAO implements IBaseDAO<ICamera> {
@@ -77,6 +78,9 @@ export class CameraDAO implements IBaseDAO<ICamera> {
77
78
  return cameras;
78
79
  }
79
80
 
81
+ /**
82
+ * Get all cameras with optional search filter by name (case-insensitive partial match)
83
+ */
80
84
  async getAllWithSearch(
81
85
  page: number,
82
86
  limit: number,
@@ -84,15 +88,21 @@ export class CameraDAO implements IBaseDAO<ICamera> {
84
88
  ): Promise<IDataPaginator<ICamera>> {
85
89
  const offset = (page - 1) * limit;
86
90
 
87
- const query = this._knex("cameras");
91
+ let query = this._knex("cameras");
88
92
 
89
- if (name && name.trim() !== "") {
90
- query.where("name", "ilike", `%${name.trim()}%`);
93
+ // Apply search filter if name provided (escape special chars to prevent pattern injection)
94
+ if (name && name.trim().length > 0) {
95
+ const escapedName = name.trim().replace(/[%_\\]/g, "\\$&");
96
+ query = query.where("name", "ilike", `%${escapedName}%`);
91
97
  }
92
98
 
93
99
  const [countResult] = await query.clone().count("* as count");
94
100
  const totalCount = +countResult.count;
95
- const cameras = await query.clone().limit(limit).offset(offset);
101
+ const cameras = await query
102
+ .clone()
103
+ .limit(limit)
104
+ .offset(offset)
105
+ .orderBy("name", "asc");
96
106
 
97
107
  return {
98
108
  success: true,
@@ -105,21 +115,31 @@ export class CameraDAO implements IBaseDAO<ICamera> {
105
115
  };
106
116
  }
107
117
 
118
+ /**
119
+ * Get paginated videos for a specific camera with folder data included
120
+ */
108
121
  async getVideosByCamera(
109
122
  cameraId: number,
110
123
  page: number,
111
124
  limit: number,
112
- ): Promise<IDataPaginator<any>> {
125
+ ): Promise<IDataPaginator<IVideo>> {
113
126
  const offset = (page - 1) * limit;
114
127
 
115
- const query = this._knex("videos as v")
128
+ const query = this._knex("video as v")
116
129
  .innerJoin("folders as f", "v.folderId", "f.id")
117
- .where("v.cameraId", cameraId)
118
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"));
130
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
131
+ .where("v.cameraId", cameraId);
119
132
 
120
- const [countResult] = await query.clone().clearSelect().count("* as count");
133
+ // Optimized count query without JOIN
134
+ const [countResult] = await this._knex("video as v")
135
+ .where("v.cameraId", cameraId)
136
+ .count("* as count");
121
137
  const totalCount = +countResult.count;
122
- const videos = await query.clone().limit(limit).offset(offset);
138
+ const videos = await query
139
+ .clone()
140
+ .limit(limit)
141
+ .offset(offset)
142
+ .orderBy("v.created_at", "desc");
123
143
 
124
144
  return {
125
145
  success: true,
@@ -0,0 +1,402 @@
1
+ import { Knex } from "knex";
2
+ import { IBaseDAO, IDataPaginator } from "../../d.types";
3
+ import {
4
+ IReportConfiguration,
5
+ IReportConfigurationData,
6
+ IReportConfigurationInput,
7
+ IValidationResult,
8
+ } from "../../interfaces/report-configuration/report-configuration.interfaces";
9
+ import KnexManager from "../../KnexConnection";
10
+
11
+ /**
12
+ * Mapping from detection labels to FHWA vehicle classes (1-13)
13
+ *
14
+ * FHWA Classes:
15
+ * - Class 1: Motorcycles
16
+ * - Class 2: Cars (passenger cars)
17
+ * - Class 3: Pickup trucks, vans, motorized vehicles
18
+ * - Class 4: Buses
19
+ * - Class 5: Work vans (2-axle, 6-tire single units)
20
+ * - Classes 6-8: Single unit trucks
21
+ * - Classes 9-13: Articulated trucks (semi-trailers, multi-trailers)
22
+ *
23
+ * Non-motorized vehicles (pedestrian, bicycle, non_motorized_vehicle) are EXCLUDED
24
+ */
25
+ const DETECTION_LABEL_TO_FHWA: Record<string, number[]> = {
26
+ motorcycle: [1],
27
+ car: [2],
28
+ pickup_truck: [3],
29
+ motorized_vehicle: [3], // Maps to Class 3 (same as pickup_truck)
30
+ bus: [4],
31
+ work_van: [5],
32
+ single_unit_truck: [6, 7, 8], // Classes 6-8
33
+ articulated_truck: [9, 10, 11, 12, 13], // Classes 9-13
34
+ // pedestrian, bicycle, non_motorized_vehicle are EXCLUDED
35
+ };
36
+
37
+ export class ReportConfigurationDAO implements IBaseDAO<IReportConfiguration> {
38
+ private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
39
+ private tableName = "report_configurations";
40
+
41
+ /**
42
+ * Create a new report configuration
43
+ */
44
+ async create(item: IReportConfigurationInput): Promise<IReportConfiguration> {
45
+ // Validate configuration before creating
46
+ const validation = this.validateConfiguration(item.configuration);
47
+ if (!validation.valid) {
48
+ throw new Error(`Invalid configuration: ${validation.errors.join(", ")}`);
49
+ }
50
+
51
+ const [createdConfig] = await this._knex(this.tableName)
52
+ .insert({
53
+ name: item.name,
54
+ description: item.description,
55
+ configuration: JSON.stringify(item.configuration),
56
+ })
57
+ .returning("*");
58
+
59
+ return this._deserialize(createdConfig);
60
+ }
61
+
62
+ /**
63
+ * Get configuration by numeric ID
64
+ */
65
+ async getById(id: number): Promise<IReportConfiguration | null> {
66
+ const config = await this._knex(this.tableName).where({ id }).first();
67
+ return config ? this._deserialize(config) : null;
68
+ }
69
+
70
+ /**
71
+ * Get configuration by UUID
72
+ */
73
+ async getByUuid(uuid: string): Promise<IReportConfiguration | null> {
74
+ const config = await this._knex(this.tableName).where({ uuid }).first();
75
+ return config ? this._deserialize(config) : null;
76
+ }
77
+
78
+ /**
79
+ * Get configuration by name
80
+ */
81
+ async getByName(name: string): Promise<IReportConfiguration | null> {
82
+ const config = await this._knex(this.tableName)
83
+ .whereRaw("LOWER(name) = LOWER(?)", [name])
84
+ .first();
85
+ return config ? this._deserialize(config) : null;
86
+ }
87
+
88
+ /**
89
+ * Update a configuration
90
+ */
91
+ async update(
92
+ id: number,
93
+ item: Partial<IReportConfigurationInput>,
94
+ ): Promise<IReportConfiguration | null> {
95
+ // If configuration is being updated, validate it
96
+ if (item.configuration) {
97
+ const validation = this.validateConfiguration(item.configuration);
98
+ if (!validation.valid) {
99
+ throw new Error(
100
+ `Invalid configuration: ${validation.errors.join(", ")}`,
101
+ );
102
+ }
103
+ }
104
+
105
+ const updateData: any = {};
106
+ if (item.name !== undefined) updateData.name = item.name;
107
+ if (item.description !== undefined)
108
+ updateData.description = item.description;
109
+ if (item.configuration !== undefined)
110
+ updateData.configuration = JSON.stringify(item.configuration);
111
+
112
+ const [updatedConfig] = await this._knex(this.tableName)
113
+ .where({ id })
114
+ .update(updateData)
115
+ .returning("*");
116
+
117
+ return updatedConfig ? this._deserialize(updatedConfig) : null;
118
+ }
119
+
120
+ /**
121
+ * Delete a configuration
122
+ * Prevents deletion of the last configuration (business logic protection)
123
+ */
124
+ async delete(id: number): Promise<boolean> {
125
+ // Count total configurations
126
+ const [{ count }] = await this._knex(this.tableName).count("* as count");
127
+
128
+ if (parseInt(count as string) <= 1) {
129
+ throw new Error(
130
+ "Cannot delete the last configuration. At least one configuration must exist.",
131
+ );
132
+ }
133
+
134
+ const result = await this._knex(this.tableName).where({ id }).del();
135
+ return result > 0;
136
+ }
137
+
138
+ /**
139
+ * Get all configurations with pagination
140
+ */
141
+ async getAll(
142
+ page: number,
143
+ limit: number,
144
+ ): Promise<IDataPaginator<IReportConfiguration>> {
145
+ const offset = (page - 1) * limit;
146
+
147
+ const [countResult] = await this._knex(this.tableName).count("* as count");
148
+ const totalCount = +countResult.count;
149
+ const configs = await this._knex(this.tableName)
150
+ .limit(limit)
151
+ .offset(offset)
152
+ .orderBy("created_at", "desc");
153
+
154
+ return {
155
+ success: true,
156
+ data: configs.map((c) => this._deserialize(c)),
157
+ page,
158
+ limit,
159
+ count: configs.length,
160
+ totalCount,
161
+ totalPages: Math.ceil(totalCount / limit),
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Validate a report configuration
167
+ *
168
+ * Rules:
169
+ * - Minimum 2, maximum 7 custom classes
170
+ * - Custom class names must be 1-30 characters
171
+ * - FHWA classes must be in range 1-13
172
+ * - Each FHWA class can only be mapped to one custom class (no duplicates)
173
+ * - Each custom class must have at least one FHWA class
174
+ */
175
+ validateConfiguration(config: IReportConfigurationData): IValidationResult {
176
+ const errors: string[] = [];
177
+
178
+ // Validate version exists
179
+ if (!config.version) {
180
+ errors.push("Configuration version is required");
181
+ }
182
+
183
+ // Validate custom classes array
184
+ if (!config.customClasses || !Array.isArray(config.customClasses)) {
185
+ errors.push("customClasses must be an array");
186
+ return { valid: false, errors };
187
+ }
188
+
189
+ // Min 2, max 7 custom classes
190
+ if (config.customClasses.length < 2) {
191
+ errors.push("Minimum 2 custom classes required");
192
+ }
193
+ if (config.customClasses.length > 7) {
194
+ errors.push("Maximum 7 custom classes allowed");
195
+ }
196
+
197
+ // Check name length (max 30 chars) and FHWA classes validity
198
+ const allFhwaClasses: number[] = [];
199
+ config.customClasses.forEach((cls, idx) => {
200
+ if (!cls.name || cls.name.length === 0) {
201
+ errors.push(`Custom class ${idx + 1}: name cannot be empty`);
202
+ }
203
+ if (cls.name && cls.name.length > 30) {
204
+ errors.push(`Custom class ${idx + 1}: name exceeds 30 characters`);
205
+ }
206
+
207
+ if (!Array.isArray(cls.fhwaClasses) || cls.fhwaClasses.length === 0) {
208
+ errors.push(
209
+ `Custom class ${idx + 1}: must have at least one FHWA class`,
210
+ );
211
+ } else {
212
+ cls.fhwaClasses.forEach((fhwa) => {
213
+ if (!Number.isInteger(fhwa) || fhwa < 1 || fhwa > 13) {
214
+ errors.push(
215
+ `Custom class ${idx + 1}: FHWA class ${fhwa} is invalid (must be 1-13)`,
216
+ );
217
+ }
218
+ allFhwaClasses.push(fhwa);
219
+ });
220
+ }
221
+ });
222
+
223
+ // Check for duplicate FHWA classes (many-to-one only)
224
+ const uniqueFhwaClasses = new Set(allFhwaClasses);
225
+ if (uniqueFhwaClasses.size !== allFhwaClasses.length) {
226
+ const duplicates = allFhwaClasses.filter(
227
+ (item, index) => allFhwaClasses.indexOf(item) !== index,
228
+ );
229
+ errors.push(
230
+ `Duplicate FHWA classes detected: ${[...new Set(duplicates)].join(", ")}. Each FHWA class can only be mapped to one custom class.`,
231
+ );
232
+ }
233
+
234
+ return { valid: errors.length === 0, errors };
235
+ }
236
+
237
+ /**
238
+ * Apply configuration transformation to detection results
239
+ *
240
+ * Two-step transformation:
241
+ * 1. Detection labels → FHWA classes (using DETECTION_LABEL_TO_FHWA mapping)
242
+ * 2. FHWA classes → Custom classes (using configuration)
243
+ *
244
+ * @param detectionResults - Raw detection results with labels as keys and counts as values
245
+ * Example: { 'car': 150, 'articulated_truck': 23, 'motorcycle': 5 }
246
+ * @param config - The report configuration to apply
247
+ * @returns Transformed results with custom class names as keys and counts as values
248
+ * Example: { 'Cars': 155, 'Heavy Trucks': 23 }
249
+ */
250
+ applyConfiguration(
251
+ detectionResults: Record<string, number>,
252
+ config: IReportConfiguration,
253
+ ): Record<string, number> {
254
+ // Step 1: Detection labels → FHWA classes
255
+ const fhwaClassCounts: Record<number, number> = {};
256
+
257
+ for (const [label, count] of Object.entries(detectionResults)) {
258
+ const fhwaClasses = DETECTION_LABEL_TO_FHWA[label];
259
+ if (fhwaClasses && fhwaClasses.length > 0) {
260
+ fhwaClasses.forEach((fhwaClass) => {
261
+ fhwaClassCounts[fhwaClass] =
262
+ (fhwaClassCounts[fhwaClass] || 0) + count;
263
+ });
264
+ }
265
+ // Labels not in DETECTION_LABEL_TO_FHWA are silently ignored (e.g., pedestrian, bicycle)
266
+ }
267
+
268
+ // Step 2: FHWA classes → Custom classes
269
+ const customClassCounts: Record<string, number> = {};
270
+
271
+ config.configuration.customClasses.forEach((customClass) => {
272
+ let total = 0;
273
+ customClass.fhwaClasses.forEach((fhwaClass) => {
274
+ total += fhwaClassCounts[fhwaClass] || 0;
275
+ });
276
+ customClassCounts[customClass.name] = total;
277
+ });
278
+
279
+ return customClassCounts;
280
+ }
281
+
282
+ /**
283
+ * Transform nested vehicle structure with custom class mapping
284
+ *
285
+ * Handles both ATR (lane-based) and TMC (direction/turn-based) formats
286
+ * Preserves all nesting levels while transforming detection labels to custom classes
287
+ *
288
+ * @param vehiclesStructure - Nested vehicles object with detection labels as keys
289
+ * ATR: { "car": { "0": 45, "1": 50 }, ... }
290
+ * TMC: { "car": { "NORTH": { "straight": 10 }, ... }, ... }
291
+ * @param config - Report configuration with custom class mappings
292
+ * @returns Transformed structure with custom class names as keys
293
+ */
294
+ applyConfigurationToNestedStructure(
295
+ vehiclesStructure: Record<string, any>,
296
+ config: IReportConfiguration,
297
+ ): Record<string, any> {
298
+ // Build reverse mapping: detection label → custom class name
299
+ const detectionToCustomClass: Record<string, string> = {};
300
+
301
+ for (const customClass of config.configuration.customClasses) {
302
+ // For each FHWA class in this custom class
303
+ for (const fhwaClass of customClass.fhwaClasses) {
304
+ // Find all detection labels that map to this FHWA class
305
+ for (const [label, fhwaClasses] of Object.entries(
306
+ DETECTION_LABEL_TO_FHWA,
307
+ )) {
308
+ if (fhwaClasses.includes(fhwaClass)) {
309
+ detectionToCustomClass[label] = customClass.name;
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ // Initialize empty structure for each custom class
316
+ const result: Record<string, any> = {};
317
+ for (const customClass of config.configuration.customClasses) {
318
+ result[customClass.name] = {};
319
+ }
320
+
321
+ // Iterate through detection labels in input structure
322
+ for (const [detectionLabel, nestedData] of Object.entries(
323
+ vehiclesStructure,
324
+ )) {
325
+ const customClassName = detectionToCustomClass[detectionLabel];
326
+
327
+ // Skip labels not mapped to any custom class (e.g., pedestrian, bicycle)
328
+ if (!customClassName) {
329
+ continue;
330
+ }
331
+
332
+ // Deep merge nested data into custom class accumulator
333
+ result[customClassName] = this._deepMergeNumericData(
334
+ result[customClassName],
335
+ nestedData,
336
+ );
337
+ }
338
+
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * Deep merge numeric data at arbitrary nesting levels
344
+ *
345
+ * Recursively merges two nested structures, summing numeric leaf values
346
+ * Handles ATR format (2 levels: vehicle → lane → count)
347
+ * Handles TMC format (3 levels: vehicle → direction → turn → count)
348
+ *
349
+ * @param target - Target accumulator object
350
+ * @param source - Source data to merge into target
351
+ * @returns Merged object with summed numeric values
352
+ */
353
+ private _deepMergeNumericData(target: any, source: any): any {
354
+ // Base case: if source is a number, add it to target
355
+ if (typeof source === "number") {
356
+ return (typeof target === "number" ? target : 0) + source;
357
+ }
358
+
359
+ // If source is not an object, return target unchanged
360
+ if (typeof source !== "object" || source === null) {
361
+ return target;
362
+ }
363
+
364
+ // Ensure target is an object
365
+ if (typeof target !== "object" || target === null) {
366
+ target = {};
367
+ }
368
+
369
+ // Recursively merge each key in source
370
+ for (const [key, value] of Object.entries(source)) {
371
+ target[key] = this._deepMergeNumericData(target[key], value);
372
+ }
373
+
374
+ return target;
375
+ }
376
+
377
+ /**
378
+ * Get the FHWA mapping constant (for reference/debugging)
379
+ */
380
+ getDetectionLabelToFhwaMapping(): Record<string, number[]> {
381
+ return { ...DETECTION_LABEL_TO_FHWA };
382
+ }
383
+
384
+ /**
385
+ * Deserialize database row to IReportConfiguration interface
386
+ * Converts snake_case to camelCase and parses JSONB
387
+ */
388
+ private _deserialize(row: any): IReportConfiguration {
389
+ return {
390
+ id: row.id,
391
+ uuid: row.uuid,
392
+ name: row.name,
393
+ description: row.description,
394
+ configuration:
395
+ typeof row.configuration === "string"
396
+ ? JSON.parse(row.configuration)
397
+ : row.configuration,
398
+ created_at: row.created_at,
399
+ updated_at: row.updated_at,
400
+ };
401
+ }
402
+ }
@@ -222,13 +222,13 @@ export class VideoDAO implements IBaseDAO<IVideo> {
222
222
  // Check if metadata has at least one key with pt1 and pt2 properties
223
223
  query = query.whereRaw(`
224
224
  EXISTS (
225
- SELECT 1
225
+ SELECT 1
226
226
  FROM jsonb_each(metadata) as entry(key, value)
227
- WHERE key != 'lanes'
227
+ WHERE key != 'lanes'
228
228
  AND key != 'finish_line'
229
229
  AND jsonb_typeof(value) = 'object'
230
- AND value ? 'pt1'
231
- AND value ? 'pt2'
230
+ AND jsonb_exists(value, 'pt1')
231
+ AND jsonb_exists(value, 'pt2')
232
232
  AND jsonb_typeof(value->'pt1') = 'array'
233
233
  AND jsonb_typeof(value->'pt2') = 'array'
234
234
  AND jsonb_array_length(value->'pt1') = 2
@@ -246,26 +246,47 @@ export class VideoDAO implements IBaseDAO<IVideo> {
246
246
  }
247
247
  }
248
248
 
249
+ /**
250
+ * Get all video IDs for a specific folder (used for cascade operations)
251
+ */
249
252
  async getVideoIdsByFolderId(folderId: number): Promise<number[]> {
250
- const videos = await this._knex("video")
251
- .where("folderId", folderId)
252
- .select("id");
253
- return videos.map((video) => video.id);
253
+ const rows = await this._knex("video")
254
+ .where({ folderId })
255
+ .select("id")
256
+ .orderBy("id", "asc");
257
+
258
+ return rows.map((row) => row.id);
254
259
  }
255
260
 
261
+ /**
262
+ * Bulk update camera assignment for multiple videos
263
+ * Supports optional transaction for service-level transaction management
264
+ */
256
265
  async bulkUpdateCamera(
257
266
  videoIds: number[],
258
267
  cameraId: number | null,
259
268
  trx?: Knex.Transaction,
260
269
  ): Promise<number> {
261
- const knexInstance = trx || this._knex;
262
- const result = await knexInstance("video").whereIn("id", videoIds).update({
263
- cameraId: cameraId,
264
- updated_at: knexInstance.fn.now(),
265
- });
266
- return result;
270
+ if (!videoIds || videoIds.length === 0) {
271
+ return 0;
272
+ }
273
+
274
+ const query = trx || this._knex;
275
+
276
+ const result = await query("video")
277
+ .whereIn("id", videoIds)
278
+ .update({
279
+ cameraId: cameraId,
280
+ updated_at: query.fn.now(),
281
+ })
282
+ .returning("id");
283
+
284
+ return result.length;
267
285
  }
268
286
 
287
+ /**
288
+ * Get videos by camera ID with folder information (paginated)
289
+ */
269
290
  async getVideosByCameraIdWithFolder(
270
291
  cameraId: number,
271
292
  page: number,
@@ -275,12 +296,19 @@ export class VideoDAO implements IBaseDAO<IVideo> {
275
296
 
276
297
  const query = this._knex("video as v")
277
298
  .innerJoin("folders as f", "v.folderId", "f.id")
278
- .where("v.cameraId", cameraId)
279
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"));
299
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
300
+ .where("v.cameraId", cameraId);
280
301
 
281
- const [countResult] = await query.clone().clearSelect().count("* as count");
302
+ // Optimized count query without JOIN
303
+ const [countResult] = await this._knex("video as v")
304
+ .where("v.cameraId", cameraId)
305
+ .count("* as count");
282
306
  const totalCount = +countResult.count;
283
- const videos = await query.clone().limit(limit).offset(offset);
307
+ const videos = await query
308
+ .clone()
309
+ .limit(limit)
310
+ .offset(offset)
311
+ .orderBy("v.created_at", "desc");
284
312
 
285
313
  return {
286
314
  success: true,