@trafficgroup/knex-rel 0.1.4 → 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.
package/plan.md CHANGED
@@ -1,1034 +1,831 @@
1
- # Report Configurations Database Implementation Plan
1
+ # Missing DAO Methods Implementation Plan
2
2
 
3
3
  ## Executive Summary
4
4
 
5
- This plan implements a **system-wide** report configuration system that allows users to map FHWA 13-class vehicle classifications to custom groupings for traffic reports. The configuration applies a **3-tier mapping** system: Detection Labels FHWA Classes → Custom Classes.
5
+ This plan details the implementation of 5 missing DAO methods across CameraDAO and VideoDAO that are currently being called from api-rel services. All methods have been analyzed for call patterns, data flow, and database requirements.
6
6
 
7
- **Key Decisions from User Clarifications:**
7
+ ---
8
8
 
9
- - System-wide configurations (NOT user-scoped)
10
- - Configurations selected when viewing video results or study summaries
11
- - Always maintain at least 1 configuration (cannot delete last one)
12
- - Many-to-One ONLY mapping (no duplicates allowed)
13
- - Unmapped FHWA classes excluded from reports
14
- - API-level transformation (frontend receives only transformed data)
15
- - Applies to both TMC and ATR study types
16
- - Minute-level granularity transformation
9
+ ## Schema Analysis
17
10
 
18
- ---
11
+ ### Tables Involved
19
12
 
20
- ## Critical Design Challenge: Detection Label → FHWA Class Mapping
13
+ **cameras**
21
14
 
22
- ### The Problem
15
+ ```sql
16
+ id: SERIAL PRIMARY KEY
17
+ uuid: UUID NOT NULL UNIQUE (indexed)
18
+ name: VARCHAR(100) NOT NULL
19
+ longitude: DECIMAL(10,7) NOT NULL
20
+ latitude: DECIMAL(10,7) NOT NULL
21
+ created_at: TIMESTAMP
22
+ updated_at: TIMESTAMP
23
+ INDEX: (uuid)
24
+ INDEX: (longitude, latitude)
25
+ ```
23
26
 
24
- The user described a 3-tier mapping system:
27
+ **video**
25
28
 
29
+ ```sql
30
+ id: SERIAL PRIMARY KEY
31
+ uuid: UUID NOT NULL UNIQUE
32
+ folderId: INTEGER NOT NULL REFERENCES folders(id)
33
+ cameraId: INTEGER NULL REFERENCES cameras(id) ON DELETE SET NULL
34
+ name: VARCHAR
35
+ videoLocation: VARCHAR
36
+ status: VARCHAR (QUEUED|PROCESSING|COMPLETED|FAILED|PENDING)
37
+ ... (additional fields)
38
+ INDEX: (cameraId)
26
39
  ```
27
- Detection Labels → FHWA Classes → Custom Classes
40
+
41
+ **folders**
42
+
43
+ ```sql
44
+ id: SERIAL PRIMARY KEY
45
+ uuid: UUID NOT NULL UNIQUE
46
+ name: VARCHAR
47
+ cameraId: INTEGER NULL REFERENCES cameras(id) ON DELETE SET NULL
48
+ INDEX: (cameraId)
28
49
  ```
29
50
 
30
- However, **I cannot find a hardcoded mapping** from detection labels to FHWA classes in the codebase.
51
+ ---
31
52
 
32
- **Detection Labels Found in System:**
53
+ ## Method 1: CameraDAO.getAllWithSearch()
33
54
 
34
- - From VideoMinuteResultDAO.normalizeATRVehicleClass() (lines 702-731):
35
- - `car`, `vehicle`, `automobiles` → normalized to `cars`
36
- - `medium`, `pickup`, `truck` → normalized to `mediums`
37
- - `heavy`, `largetruck`, `bigtruck` → normalized to `heavy_trucks`
38
- - `pedestrian`, `person`, `people` → normalized to `pedestrians`
39
- - `bicycle`, `bike`, `cyclist` → normalized to `bicycles`
55
+ ### Call Analysis
40
56
 
41
- **FHWA 13-Class System (from user clarifications):**
57
+ **Location**: `api-rel/src/service/camera/camera.service.ts:274`
42
58
 
43
- ```
44
- Class 1: motorcycle
45
- Class 2: car
46
- Class 3: pickup_truck
47
- Class 4: bus
48
- Class 5: work_van
49
- Class 6-8: single_unit_truck
50
- Class 9-13: articulated_truck
59
+ **Method Signature**:
60
+
61
+ ```typescript
62
+ async getAllWithSearch(page: number, limit: number, name?: string): Promise<IDataPaginator<ICamera>>
51
63
  ```
52
64
 
53
- ### The Solution: Hardcoded FHWA Mapping in ReportConfigurationDAO
65
+ **Called From**: CameraService.getAllWithSearch()
54
66
 
55
- Since no existing mapping exists, we'll create a **static mapping table** in the ReportConfigurationDAO:
67
+ - **Parameters**: page, limit, name (optional string for search)
68
+ - **Return Value**: IDataPaginator<ICamera> - used to map to CameraDTO[]
69
+ - **Purpose**: Filter cameras by name with pagination support
70
+
71
+ **Data Flow**:
56
72
 
57
- ```typescript
58
- const DETECTION_LABEL_TO_FHWA: Record<string, number[]> = {
59
- motorcycle: [1],
60
- car: [2],
61
- pickup_truck: [3],
62
- bus: [4],
63
- work_van: [5],
64
- single_unit_truck: [6, 7, 8],
65
- articulated_truck: [9, 10, 11, 12, 13],
66
- // Non-motorized (excluded from FHWA system)
67
- pedestrian: [],
68
- bicycle: [],
69
- motorized_vehicle: [2], // Map to car
70
- non_motorized_vehicle: [],
71
- };
73
+ ```
74
+ API Request → CameraService.getAllWithSearch(page, limit, name)
75
+ CameraDAO.getAllWithSearch(page, limit, name)
76
+ Returns IDataPaginator<ICamera>
77
+ Service maps to CameraDTO[] (exposes UUID, hides ID)
78
+ API Response
72
79
  ```
73
80
 
74
- **Transformation Flow:**
81
+ ### Implementation Requirements
75
82
 
76
- 1. Video processing returns detection labels (e.g., `car`, `pickup_truck`)
77
- 2. DAO converts detection labels → FHWA classes using DETECTION_LABEL_TO_FHWA
78
- 3. DAO applies user configuration: FHWA classes → Custom classes
79
- 4. API returns only custom class counts to frontend
83
+ **SQL Query**:
80
84
 
81
- ### Replacement of Existing Normalization
85
+ ```sql
86
+ -- Count query
87
+ SELECT COUNT(*) as count FROM cameras WHERE name ILIKE '%search%'
82
88
 
83
- The `normalizeATRVehicleClass()` function (VideoMinuteResultDAO lines 702-731) will be **completely replaced**. Instead of normalizing to `cars`, `mediums`, `heavy_trucks`, the new system will:
89
+ -- Data query
90
+ SELECT * FROM cameras
91
+ WHERE name ILIKE '%search%'
92
+ LIMIT ? OFFSET ?
93
+ ```
84
94
 
85
- 1. Keep raw detection labels in database
86
- 2. Apply FHWA mapping + user configuration at aggregation time
87
- 3. Return only custom class names to API
95
+ **Key Details**:
88
96
 
89
- ---
97
+ - Use ILIKE for case-insensitive search (PostgreSQL)
98
+ - Search is optional (if name undefined/null, return all)
99
+ - Use '%search%' pattern for partial matching
100
+ - Follow existing getAll() pattern from camera.dao.ts:34-50
90
101
 
91
- ## Database Schema
102
+ **Performance Considerations**:
92
103
 
93
- ### Table: `report_configurations`
104
+ - Index on name column NOT currently exists - consider adding
105
+ - ILIKE with leading wildcard prevents index usage
106
+ - For now, acceptable for camera table (likely small dataset)
94
107
 
95
- ```sql
96
- CREATE TABLE report_configurations (
97
- id SERIAL PRIMARY KEY,
98
- uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
99
- name VARCHAR(30) NOT NULL UNIQUE,
100
- description TEXT,
101
- configuration JSONB NOT NULL,
102
- is_default BOOLEAN NOT NULL DEFAULT false,
103
- created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
104
- updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
105
- );
106
-
107
- CREATE INDEX idx_report_configurations_uuid ON report_configurations(uuid);
108
- CREATE INDEX idx_report_configurations_is_default ON report_configurations(is_default);
109
- CREATE INDEX idx_report_configurations_name_lower ON report_configurations(LOWER(name));
110
- ```
108
+ ---
111
109
 
112
- **Constraints:**
110
+ ## Method 2: CameraDAO.getVideosByCamera()
113
111
 
114
- - `name`: VARCHAR(30), UNIQUE, NOT NULL (max 30 characters)
115
- - `uuid`: UUID, UNIQUE, NOT NULL, auto-generated
116
- - `configuration`: JSONB, NOT NULL
117
- - At least 1 configuration must exist (enforced at application level)
112
+ ### Call Analysis
118
113
 
119
- ### Configuration JSONB Structure
114
+ **Location**: `api-rel/src/service/camera/camera.service.ts:308`
115
+
116
+ **Method Signature**:
120
117
 
121
118
  ```typescript
122
- {
123
- "version": "1.0",
124
- "customClasses": [
125
- {
126
- "name": "Light Vehicles",
127
- "fhwaClasses": [1, 2, 3]
128
- },
129
- {
130
- "name": "Medium Trucks",
131
- "fhwaClasses": [5]
132
- },
133
- {
134
- "name": "Heavy Trucks",
135
- "fhwaClasses": [4, 6, 7, 8, 9, 10, 11, 12, 13]
136
- }
137
- ]
138
- }
119
+ async getVideosByCamera(cameraId: number, page: number, limit: number): Promise<IDataPaginator<IVideo>>
139
120
  ```
140
121
 
141
- **Validation Rules:**
122
+ **Called From**: CameraService.getVideosByCamera()
142
123
 
143
- - `customClasses` array: min 2, max 7 items
144
- - Each `name`: max 30 characters
145
- - Each `fhwaClasses` array: unique integers from 1-13
146
- - No FHWA class can appear in multiple custom classes (Many-to-One)
147
- - Unmapped FHWA classes are allowed (will be excluded from reports)
124
+ - **Parameters**: cameraId (internal numeric ID), page, limit
125
+ - **Return Value**: IDataPaginator with video data PLUS folder information
126
+ - **Purpose**: Get all videos associated with a specific camera (paginated)
148
127
 
149
- ---
128
+ **Data Flow**:
150
129
 
151
- ## Migration
130
+ ```
131
+ API Request (cameraUuid) → CameraService.getVideosByCamera(cameraUuid, page, limit)
132
+ → Get camera by UUID to obtain ID (line 302)
133
+ → CameraDAO.getVideosByCamera(camera.id, page, limit)
134
+ → Returns IDataPaginator<IVideo> with folder data
135
+ → Service maps to VideoWithFolderDTO[] (line 311-312)
136
+ → API Response
137
+ ```
152
138
 
153
- ### File: `YYYYMMDDHHMMSS_create_report_configurations.ts`
139
+ **Critical Detail**: Service expects folder data in response (line 312: mapToVideoWithFolderDTO)
154
140
 
155
- ```typescript
156
- import type { Knex } from "knex";
157
-
158
- export async function up(knex: Knex): Promise<void> {
159
- await knex.schema.createTable("report_configurations", (table) => {
160
- table.increments("id").primary();
161
- table
162
- .uuid("uuid")
163
- .notNullable()
164
- .unique()
165
- .defaultTo(knex.raw("gen_random_uuid()"));
166
- table.string("name", 30).notNullable().unique();
167
- table.text("description").nullable();
168
- table.jsonb("configuration").notNullable();
169
- table.boolean("is_default").notNullable().defaultTo(false);
170
- table.timestamps(true, true);
171
-
172
- // Indexes
173
- table.index("uuid", "idx_report_configurations_uuid");
174
- table.index("is_default", "idx_report_configurations_is_default");
175
- });
176
-
177
- // Add case-insensitive name index
178
- await knex.raw(`
179
- CREATE INDEX idx_report_configurations_name_lower
180
- ON report_configurations(LOWER(name))
181
- `);
182
-
183
- // Seed default configuration (matches current system behavior)
184
- await knex("report_configurations").insert({
185
- name: "Default",
186
- description: "Standard configuration matching current system behavior",
187
- configuration: JSON.stringify({
188
- version: "1.0",
189
- customClasses: [
190
- {
191
- name: "Cars",
192
- fhwaClasses: [1, 2], // motorcycle, car
193
- },
194
- {
195
- name: "Medium Trucks",
196
- fhwaClasses: [3, 5], // pickup_truck, work_van
197
- },
198
- {
199
- name: "Heavy Trucks",
200
- fhwaClasses: [4, 6, 7, 8, 9, 10, 11, 12, 13], // bus, single_unit_truck, articulated_truck
201
- },
202
- ],
203
- }),
204
- is_default: true,
205
- });
206
- }
141
+ ### Implementation Requirements
207
142
 
208
- export async function down(knex: Knex): Promise<void> {
209
- await knex.raw("DROP INDEX IF EXISTS idx_report_configurations_name_lower");
210
- await knex.schema.dropTableIfExists("report_configurations");
211
- }
143
+ **SQL Query** (must JOIN with folders):
144
+
145
+ ```sql
146
+ -- Count query
147
+ SELECT COUNT(*) as count
148
+ FROM video v
149
+ WHERE v.cameraId = ?
150
+
151
+ -- Data query with folder JOIN
152
+ SELECT v.*, to_jsonb(f.*) as folder
153
+ FROM video v
154
+ INNER JOIN folders f ON v.folderId = f.id
155
+ WHERE v.cameraId = ?
156
+ LIMIT ? OFFSET ?
212
157
  ```
213
158
 
214
- **Migration Safety:** LOW RISK
159
+ **Key Details**:
215
160
 
216
- - Creates new table (no data loss)
217
- - Seeds single default configuration
218
- - Rollback is clean (drop table)
161
+ - MUST include folder data (use to_jsonb like VideoDAO.getById:21)
162
+ - Filter by cameraId (indexed)
163
+ - Follow VideoDAO.getAll() pattern for folder JOIN (lines 50-59)
164
+ - Return IDataPaginator<IVideo> with folder field populated
219
165
 
220
- ---
166
+ **Performance Considerations**:
221
167
 
222
- ## Interface Definition
168
+ - cameraId already indexed (migration 20250911000000_migration.ts:20)
169
+ - JOIN with folders is necessary for DTO mapping
170
+ - Consider ORDER BY created_at DESC for chronological listing
223
171
 
224
- ### File: `src/interfaces/report-configuration/report-configuration.interfaces.ts`
172
+ ---
225
173
 
226
- ```typescript
227
- export interface IReportConfiguration {
228
- id: number;
229
- uuid: string;
230
- name: string;
231
- description?: string;
232
- configuration: IReportConfigurationData;
233
- isDefault: boolean;
234
- createdAt: string;
235
- updatedAt: string;
236
- }
174
+ ## Method 3: VideoDAO.bulkUpdateCamera()
237
175
 
238
- export interface IReportConfigurationData {
239
- version: string;
240
- customClasses: ICustomClass[];
241
- }
176
+ ### Call Analysis
242
177
 
243
- export interface ICustomClass {
244
- name: string;
245
- fhwaClasses: number[];
246
- }
178
+ **Location 1**: `api-rel/src/service/camera/camera.service.ts:364`
179
+ **Location 2**: `api-rel/src/service/video/video.service.ts:34`
180
+ **Location 3**: `api-rel/src/service/folder/folder.service.ts:96`
247
181
 
248
- export interface IReportConfigurationInput {
249
- name: string;
250
- description?: string;
251
- configuration: IReportConfigurationData;
252
- isDefault?: boolean;
253
- }
182
+ **Method Signature**:
254
183
 
255
- export interface IConfigurationValidationResult {
256
- valid: boolean;
257
- errors: string[];
258
- }
184
+ ```typescript
185
+ async bulkUpdateCamera(videoIds: number[], cameraId: number | null, trx?: Knex.Transaction): Promise<number>
259
186
  ```
260
187
 
261
- ---
188
+ **Call Patterns**:
262
189
 
263
- ## DAO Implementation
190
+ **Pattern 1 - CameraService.bulkAssignToVideos() (line 364)**:
264
191
 
265
- ### File: `src/dao/report-configuration/report-configuration.dao.ts`
192
+ - Context: Assigning camera to multiple videos
193
+ - Parameters: videoIds (array of numeric IDs), camera.id (number)
194
+ - Transaction: NO transaction passed (relies on DAO atomicity)
195
+ - Return: updatedCount used for logging (line 365)
266
196
 
267
- ```typescript
268
- import { Knex } from "knex";
269
- import { IBaseDAO, IDataPaginator } from "../../d.types";
270
- import {
271
- IReportConfiguration,
272
- IReportConfigurationInput,
273
- IReportConfigurationData,
274
- IConfigurationValidationResult,
275
- } from "../../interfaces/report-configuration/report-configuration.interfaces";
276
- import KnexManager from "../../KnexConnection";
277
-
278
- // Static mapping: Detection Label → FHWA Classes
279
- const DETECTION_LABEL_TO_FHWA: Record<string, number[]> = {
280
- motorcycle: [1],
281
- car: [2],
282
- pickup_truck: [3],
283
- bus: [4],
284
- work_van: [5],
285
- single_unit_truck: [6, 7, 8],
286
- articulated_truck: [9, 10, 11, 12, 13],
287
- motorized_vehicle: [2], // Map to car
288
- // Non-motorized excluded from FHWA
289
- pedestrian: [],
290
- bicycle: [],
291
- non_motorized_vehicle: [],
292
- };
293
-
294
- export class ReportConfigurationDAO implements IBaseDAO<IReportConfiguration> {
295
- private knex: Knex = KnexManager.getConnection();
296
- private tableName = "report_configurations";
297
-
298
- // ==================== STANDARD CRUD ====================
299
-
300
- async create(item: IReportConfigurationInput): Promise<IReportConfiguration> {
301
- // Validate configuration before creation
302
- const validation = this.validateConfiguration(item.configuration);
303
- if (!validation.valid) {
304
- throw new Error(`Invalid configuration: ${validation.errors.join(", ")}`);
305
- }
197
+ **Pattern 2 - VideoService.bulkUpdateCamera() (line 34)**:
306
198
 
307
- const dbData = this.mapInputToDbRow(item);
308
- const [result] = await this.knex(this.tableName)
309
- .insert(dbData)
310
- .returning("*");
199
+ - Context: Bulk update within service transaction
200
+ - Parameters: videoIds, cameraId (can be null), trx (REQUIRED)
201
+ - Transaction: YES - passed from service-level transaction (line 15)
202
+ - Return: updatedCount used for result tracking (line 54)
203
+ - Error Handling: Transaction rollback on failure (line 58)
311
204
 
312
- return this.mapDbRowToEntity(result);
313
- }
205
+ **Pattern 3 - FolderService.cascadeUpdateVideosCamera() (line 96)**:
314
206
 
315
- async getById(id: number): Promise<IReportConfiguration | null> {
316
- const result = await this.knex(this.tableName).where("id", id).first();
317
- return result ? this.mapDbRowToEntity(result) : null;
318
- }
207
+ - Context: Cascading camera assignment from folder to videos
208
+ - Parameters: videoIds, cameraId (can be null), trx (OPTIONAL)
209
+ - Transaction: OPTIONAL - may be passed from folder update (line 86)
210
+ - Return: updatedCount for cascade logging (line 59)
319
211
 
320
- async getByUuid(uuid: string): Promise<IReportConfiguration | null> {
321
- const result = await this.knex(this.tableName).where("uuid", uuid).first();
322
- return result ? this.mapDbRowToEntity(result) : null;
323
- }
212
+ ### Implementation Requirements
324
213
 
325
- async getAll(
326
- page: number = 1,
327
- limit: number = 10,
328
- ): Promise<IDataPaginator<IReportConfiguration>> {
329
- const offset = (page - 1) * limit;
214
+ **SQL Query**:
330
215
 
331
- const [countResult] = await this.knex(this.tableName).count("* as count");
332
- const totalCount = +countResult.count;
216
+ ```sql
217
+ UPDATE video
218
+ SET cameraId = ?, updated_at = NOW()
219
+ WHERE id = ANY(?)
220
+ RETURNING id
221
+ ```
333
222
 
334
- const data = await this.knex(this.tableName)
335
- .select("*")
336
- .orderBy("is_default", "desc") // Default first
337
- .orderBy("name", "asc")
338
- .limit(limit)
339
- .offset(offset);
223
+ **Key Details**:
340
224
 
341
- return {
342
- success: true,
343
- data: this.mapDbRowsToEntities(data),
344
- page,
345
- limit,
346
- count: data.length,
347
- totalCount,
348
- totalPages: Math.ceil(totalCount / limit),
349
- };
350
- }
351
-
352
- async update(
353
- id: number,
354
- item: Partial<IReportConfigurationInput>,
355
- ): Promise<IReportConfiguration | null> {
356
- // Validate configuration if provided
357
- if (item.configuration) {
358
- const validation = this.validateConfiguration(item.configuration);
359
- if (!validation.valid) {
360
- throw new Error(
361
- `Invalid configuration: ${validation.errors.join(", ")}`,
362
- );
363
- }
364
- }
225
+ - Accept optional transaction parameter (trx?: Knex.Transaction)
226
+ - Use transaction if provided, otherwise use default connection
227
+ - Update cameraId (can be null for unassignment)
228
+ - Update updated_at timestamp
229
+ - Return count of updated rows (number)
230
+ - Use whereIn() for array parameter
365
231
 
366
- const dbData = this.mapInputToDbRow(item);
367
- const [result] = await this.knex(this.tableName)
368
- .where("id", id)
369
- .update({ ...dbData, updated_at: this.knex.fn.now() })
370
- .returning("*");
232
+ **Transaction Handling**:
371
233
 
372
- return result ? this.mapDbRowToEntity(result) : null;
373
- }
234
+ ```typescript
235
+ const query = trx || this._knex;
236
+ const result = await query("video")
237
+ .whereIn("id", videoIds)
238
+ .update({ cameraId, updated_at: query.fn.now() })
239
+ .returning("id");
240
+ return result.length;
241
+ ```
374
242
 
375
- async delete(id: number): Promise<boolean> {
376
- // Use deleteWithValidation to prevent deletion of last config
377
- return this.deleteWithValidation(id);
378
- }
243
+ **Performance Considerations**:
379
244
 
380
- // ==================== CUSTOM METHODS ====================
245
+ - Bulk update is efficient (single query)
246
+ - videoIds array should be validated (non-empty)
247
+ - Consider max batch size limit (100-1000 videos)
248
+ - No index needed on id (primary key)
381
249
 
382
- /**
383
- * Delete configuration with validation (prevents deletion of last config)
384
- */
385
- async deleteWithValidation(id: number): Promise<boolean> {
386
- const [countResult] = await this.knex(this.tableName).count("* as count");
387
- const totalCount = +countResult.count;
250
+ **Error Handling**:
388
251
 
389
- if (totalCount <= 1) {
390
- throw new Error(
391
- "Cannot delete the last configuration. At least one configuration must exist.",
392
- );
393
- }
252
+ - If transaction fails in caller, rollback handles cleanup
253
+ - Return 0 if videoIds is empty array
254
+ - Let Knex errors propagate to service layer
394
255
 
395
- const result = await this.knex(this.tableName).where("id", id).del();
396
- return result > 0;
397
- }
398
-
399
- /**
400
- * Get default configuration (for UI convenience)
401
- */
402
- async getDefault(): Promise<IReportConfiguration | null> {
403
- const result = await this.knex(this.tableName)
404
- .where("is_default", true)
405
- .first();
406
-
407
- // If no default, return first configuration
408
- if (!result) {
409
- const fallback = await this.knex(this.tableName)
410
- .orderBy("created_at", "asc")
411
- .first();
412
- return fallback ? this.mapDbRowToEntity(fallback) : null;
413
- }
256
+ ---
414
257
 
415
- return this.mapDbRowToEntity(result);
416
- }
417
-
418
- /**
419
- * Set a configuration as default (removes default flag from others)
420
- */
421
- async setDefault(id: number): Promise<boolean> {
422
- await this.knex.transaction(async (trx) => {
423
- // Remove default flag from all
424
- await trx(this.tableName).update({ is_default: false });
425
-
426
- // Set new default
427
- await trx(this.tableName).where("id", id).update({ is_default: true });
428
- });
429
-
430
- return true;
431
- }
432
-
433
- /**
434
- * Validate configuration structure and business rules
435
- */
436
- validateConfiguration(
437
- config: IReportConfigurationData,
438
- ): IConfigurationValidationResult {
439
- const errors: string[] = [];
440
-
441
- // Check structure
442
- if (!config || typeof config !== "object") {
443
- errors.push("Configuration must be an object");
444
- return { valid: false, errors };
445
- }
258
+ ## Method 4: VideoDAO.getVideoIdsByFolderId()
446
259
 
447
- if (!config.customClasses || !Array.isArray(config.customClasses)) {
448
- errors.push("customClasses must be an array");
449
- return { valid: false, errors };
450
- }
260
+ ### Call Analysis
451
261
 
452
- // Rule: Min 2, max 7 custom classes
453
- if (config.customClasses.length < 2) {
454
- errors.push("At least 2 custom classes required");
455
- }
456
- if (config.customClasses.length > 7) {
457
- errors.push("Maximum 7 custom classes allowed");
458
- }
262
+ **Location 1**: `api-rel/src/service/folder/folder.service.ts:49`
263
+ **Location 2**: `api-rel/src/service/folder/folder.service.ts:89`
264
+ **Location 3**: `api-rel/src/service/folder/folder.service.ts:163`
265
+ **Location 4**: `api-rel/src/service/video/video.service.ts:119`
459
266
 
460
- // Track FHWA classes to detect duplicates
461
- const usedFhwaClasses = new Set<number>();
462
- const classNames = new Set<string>();
463
-
464
- for (const customClass of config.customClasses) {
465
- // Check name length
466
- if (!customClass.name || customClass.name.length > 30) {
467
- errors.push(
468
- `Custom class name must be 1-30 characters: "${customClass.name}"`,
469
- );
470
- }
471
-
472
- // Check for duplicate names
473
- if (classNames.has(customClass.name)) {
474
- errors.push(`Duplicate custom class name: "${customClass.name}"`);
475
- }
476
- classNames.add(customClass.name);
477
-
478
- // Check fhwaClasses array
479
- if (
480
- !Array.isArray(customClass.fhwaClasses) ||
481
- customClass.fhwaClasses.length === 0
482
- ) {
483
- errors.push(
484
- `Custom class "${customClass.name}" must have at least one FHWA class`,
485
- );
486
- continue;
487
- }
488
-
489
- // Check for duplicates (Many-to-One enforcement)
490
- for (const fhwaClass of customClass.fhwaClasses) {
491
- if (usedFhwaClasses.has(fhwaClass)) {
492
- errors.push(
493
- `FHWA class ${fhwaClass} appears in multiple custom classes (not allowed)`,
494
- );
495
- }
496
- usedFhwaClasses.add(fhwaClass);
497
-
498
- // Validate FHWA class range
499
- if (fhwaClass < 1 || fhwaClass > 13) {
500
- errors.push(`Invalid FHWA class ${fhwaClass} (must be 1-13)`);
501
- }
502
- }
503
- }
267
+ **Method Signature**:
504
268
 
505
- return {
506
- valid: errors.length === 0,
507
- errors,
508
- };
509
- }
510
-
511
- /**
512
- * Apply configuration to raw detection counts
513
- * @param detectionCounts - Raw counts from video processing (e.g., {car: 50, pickup_truck: 20})
514
- * @param configuration - Report configuration to apply
515
- * @returns Transformed counts with custom class names
516
- */
517
- applyConfiguration(
518
- detectionCounts: Record<string, number>,
519
- configuration: IReportConfigurationData,
520
- ): Record<string, number> {
521
- // Step 1: Convert detection labels to FHWA classes
522
- const fhwaCounts: Record<number, number> = {};
523
-
524
- for (const [detectionLabel, count] of Object.entries(detectionCounts)) {
525
- const fhwaClasses = DETECTION_LABEL_TO_FHWA[detectionLabel] || [];
526
-
527
- // Distribute count equally among FHWA classes (for multi-mapped labels like single_unit_truck)
528
- const countPerClass = count / fhwaClasses.length;
529
-
530
- for (const fhwaClass of fhwaClasses) {
531
- fhwaCounts[fhwaClass] = (fhwaCounts[fhwaClass] || 0) + countPerClass;
532
- }
533
- }
269
+ ```typescript
270
+ async getVideoIdsByFolderId(folderId: number): Promise<number[]>
271
+ ```
534
272
 
535
- // Step 2: Apply user configuration (FHWA classes → Custom classes)
536
- const customCounts: Record<string, number> = {};
273
+ **Call Patterns**:
537
274
 
538
- for (const customClass of configuration.customClasses) {
539
- let total = 0;
275
+ **Pattern 1 - FolderService.updateWithCameraCascade() (line 49)**:
540
276
 
541
- for (const fhwaClass of customClass.fhwaClasses) {
542
- total += fhwaCounts[fhwaClass] || 0;
543
- }
277
+ - Context: Get all videos in folder to cascade camera update
278
+ - Parameters: existingFolder.id
279
+ - Return: videoIds array used for cascading (line 52-57)
280
+ - Transaction Context: Within folder update transaction
544
281
 
545
- customCounts[customClass.name] = Math.round(total);
546
- }
282
+ **Pattern 2 - FolderService.cascadeUpdateVideosCamera() (line 89)**:
547
283
 
548
- return customCounts;
549
- }
284
+ - Context: Get video IDs for bulk camera update
285
+ - Parameters: folderId
286
+ - Return: videoIds passed to bulkUpdateCamera (line 96)
287
+ - Early return if empty array (line 91-93)
550
288
 
551
- // ==================== MAPPING HELPERS ====================
289
+ **Pattern 3 - FolderService.getVideoCascadeCount() (line 163)**:
552
290
 
553
- private mapDbRowToEntity(row: any): IReportConfiguration {
554
- return {
555
- id: row.id,
556
- uuid: row.uuid,
557
- name: row.name,
558
- description: row.description,
559
- configuration: row.configuration,
560
- isDefault: row.is_default,
561
- createdAt: row.created_at,
562
- updatedAt: row.updated_at,
563
- };
564
- }
291
+ - Context: Count videos that would be affected by cascade
292
+ - Parameters: folder.id
293
+ - Return: videoIds.length for count (line 164)
294
+ - Read-only operation (no updates)
565
295
 
566
- private mapDbRowsToEntities(rows: any[]): IReportConfiguration[] {
567
- return rows.map((row) => this.mapDbRowToEntity(row));
568
- }
296
+ **Pattern 4 - VideoService.getVideoIdsByFolderId() (line 119)**:
569
297
 
570
- private mapInputToDbRow(input: Partial<IReportConfigurationInput>): any {
571
- const dbRow: any = {};
298
+ - Context: Get all video IDs in folder for cascade operations
299
+ - Parameters: folderId
300
+ - Return: number[] passed back to caller
301
+ - Used for service-level cascade logic
572
302
 
573
- if (input.name !== undefined) dbRow.name = input.name;
574
- if (input.description !== undefined) dbRow.description = input.description;
575
- if (input.configuration !== undefined)
576
- dbRow.configuration = input.configuration;
577
- if (input.isDefault !== undefined) dbRow.is_default = input.isDefault;
303
+ ### Implementation Requirements
578
304
 
579
- return dbRow;
580
- }
581
- }
305
+ **SQL Query**:
582
306
 
583
- export default ReportConfigurationDAO;
307
+ ```sql
308
+ SELECT id FROM video WHERE folderId = ?
584
309
  ```
585
310
 
586
- ---
587
-
588
- ## Integration with VideoMinuteResultDAO
311
+ **Key Details**:
589
312
 
590
- ### Modifications Required
313
+ - Simple SELECT of id column only
314
+ - Filter by folderId
315
+ - Return array of numeric IDs: number[]
316
+ - No pagination needed (cascade operations need ALL videos)
317
+ - No JOIN needed (only IDs required)
591
318
 
592
- The `VideoMinuteResultDAO` must be modified to accept and apply report configurations during aggregation.
319
+ **Performance Considerations**:
593
320
 
594
- **File:** `src/dao/VideoMinuteResultDAO.ts`
321
+ - folderId should be indexed (check if exists)
322
+ - Query is read-only, no locking needed
323
+ - Could return large arrays (100s-1000s of videos per folder)
324
+ - Consider ORDER BY id for consistency
595
325
 
596
- ### Changes to Method Signatures
326
+ **Return Value**:
597
327
 
598
328
  ```typescript
599
- // NEW: Add configurationId parameter to aggregation methods
600
- async getGroupedMinuteResultsByVideoUuid(
601
- videoUuid: string,
602
- groupingMinutes: number = 1,
603
- configurationId?: number, // NEW PARAMETER
604
- startMinute?: number,
605
- endMinute?: number,
606
- ): Promise<IGroupedResponse>
329
+ const ids = await this._knex("video")
330
+ .where({ folderId })
331
+ .select("id")
332
+ .orderBy("id", "asc");
333
+ return ids.map((row) => row.id);
607
334
  ```
608
335
 
609
- ### Integration Strategy
610
-
611
- 1. **Import ReportConfigurationDAO:**
336
+ ---
612
337
 
613
- ```typescript
614
- import { ReportConfigurationDAO } from "./report-configuration/report-configuration.dao";
615
- ```
338
+ ## Method 5: VideoDAO.getVideosByCameraIdWithFolder()
616
339
 
617
- 2. **Modify aggregateTMCResults() (lines 508-585):**
618
- - Accept `configuration` parameter
619
- - Apply configuration transformation AFTER aggregating detection labels
620
- - Replace vehicle class keys with custom class names
340
+ ### Call Analysis
621
341
 
622
- 3. **Modify aggregateATRResults() (lines 590-642):**
623
- - Accept `configuration` parameter
624
- - Remove `normalizeATRVehicleClass()` calls (lines 606, 634)
625
- - Apply configuration transformation instead
342
+ **Location**: `api-rel/src/service/video/video.service.ts:77`
626
343
 
627
- 4. **Add new helper method:**
344
+ **Method Signature**:
628
345
 
629
- ```typescript
630
- private async fetchConfiguration(configurationId?: number): Promise<IReportConfigurationData> {
631
- const configDAO = new ReportConfigurationDAO();
346
+ ```typescript
347
+ async getVideosByCameraIdWithFolder(cameraId: number, page: number, limit: number): Promise<IDataPaginator<IVideo>>
348
+ ```
632
349
 
633
- let config: IReportConfiguration | null;
350
+ **Called From**: VideoService.getVideosByCameraId()
634
351
 
635
- if (configurationId) {
636
- config = await configDAO.getById(configurationId);
637
- } else {
638
- config = await configDAO.getDefault();
639
- }
352
+ - **Parameters**: cameraId (numeric ID), page, limit
353
+ - **Return Value**: IDataPaginator<IVideo> - directly returned to caller
354
+ - **Purpose**: Get paginated videos for a camera with folder info
640
355
 
641
- if (!config) {
642
- throw new Error("No report configuration found");
643
- }
356
+ **Data Flow**:
644
357
 
645
- return config.configuration;
646
- }
647
- ```
358
+ ```
359
+ API/Service Request → VideoService.getVideosByCameraId(cameraId, page, limit)
360
+ → VideoDAO.getVideosByCameraIdWithFolder(cameraId, page, limit)
361
+ → Returns IDataPaginator<IVideo>
362
+ → Service returns directly (line 77)
363
+ ```
648
364
 
649
- 5. **Update aggregation flow:**
365
+ **Critical Detail**: Method name explicitly includes "WithFolder" - MUST JOIN folders
650
366
 
651
- ```typescript
652
- // In getGroupedMinuteResultsByVideoUuid()
653
- const configuration = await this.fetchConfiguration(configurationId);
367
+ ### Implementation Requirements
654
368
 
655
- const aggregatedGroups: IGroupedResult[] = rows.map((row: any) => {
656
- // ... existing aggregation logic ...
369
+ **SQL Query**:
657
370
 
658
- let aggregatedResult;
659
- if (studyType === "TMC") {
660
- aggregatedResult = this.aggregateTMCResults(allResults, configuration);
661
- } else {
662
- aggregatedResult = this.aggregateATRResults(allResults, configuration);
663
- }
371
+ ```sql
372
+ -- Count query
373
+ SELECT COUNT(*) as count
374
+ FROM video v
375
+ WHERE v.cameraId = ?
376
+
377
+ -- Data query with folder JOIN
378
+ SELECT v.*, to_jsonb(f.*) as folder
379
+ FROM video v
380
+ INNER JOIN folders f ON v.folderId = f.id
381
+ WHERE v.cameraId = ?
382
+ ORDER BY v.created_at DESC
383
+ LIMIT ? OFFSET ?
384
+ ```
664
385
 
665
- // ... return result ...
666
- });
667
- ```
386
+ **Key Details**:
668
387
 
669
- ### Detailed Transformation Example (ATR)
388
+ - IDENTICAL to CameraDAO.getVideosByCamera() implementation
389
+ - Filter by cameraId
390
+ - MUST include folder data via JOIN
391
+ - Follow VideoDAO.getAll() JOIN pattern (lines 50-59)
392
+ - Return IDataPaginator<IVideo>
670
393
 
671
- **Before transformation (raw detection labels):**
394
+ **Performance Considerations**:
672
395
 
673
- ```typescript
674
- {
675
- vehicles: {
676
- car: { lane_1: 50, lane_2: 30 },
677
- pickup_truck: { lane_1: 10, lane_2: 15 },
678
- bus: { lane_1: 5, lane_2: 3 }
679
- },
680
- detected_classes: {
681
- car: 80,
682
- pickup_truck: 25,
683
- bus: 8
684
- }
685
- }
686
- ```
396
+ - Same as Method 2 (getVideosByCamera)
397
+ - cameraId indexed
398
+ - Consider composite index on (cameraId, created_at) for sorting
687
399
 
688
- **After applying configuration:**
400
+ **Pattern Consistency**:
689
401
 
690
- ```typescript
691
- // Configuration: Cars [1,2], Medium Trucks [3,5], Heavy Trucks [4,6-13]
692
- {
693
- vehicles: {
694
- "Cars": { lane_1: 50, lane_2: 30 }, // car → FHWA 2 → Cars
695
- "Medium Trucks": { lane_1: 10, lane_2: 15 }, // pickup_truck → FHWA 3 → Medium Trucks
696
- "Heavy Trucks": { lane_1: 5, lane_2: 3 } // bus → FHWA 4 → Heavy Trucks
697
- },
698
- detected_classes: {
699
- "Cars": 80,
700
- "Medium Trucks": 25,
701
- "Heavy Trucks": 8
702
- }
703
- }
704
- ```
402
+ - This method duplicates CameraDAO.getVideosByCamera() logic
403
+ - Both should use IDENTICAL query structure
404
+ - Consider refactoring to shared private method in future
705
405
 
706
406
  ---
707
407
 
708
- ## Export Updates
408
+ ## Implementation Order
709
409
 
710
- ### File: `src/index.ts`
410
+ ### Phase 1: Simple Methods (No Dependencies)
711
411
 
712
- Add exports for new DAO and interfaces:
412
+ 1. **CameraDAO.getAllWithSearch()** - Standalone search method
413
+ 2. **VideoDAO.getVideoIdsByFolderId()** - Simple ID retrieval
713
414
 
714
- ```typescript
715
- // Add to DAOs section
716
- export { ReportConfigurationDAO } from "./dao/report-configuration/report-configuration.dao";
717
-
718
- // Add to Interfaces section
719
- export {
720
- IReportConfiguration,
721
- IReportConfigurationInput,
722
- IReportConfigurationData,
723
- ICustomClass,
724
- IConfigurationValidationResult,
725
- } from "./interfaces/report-configuration/report-configuration.interfaces";
726
- ```
415
+ ### Phase 2: Complex Query Methods (JOINs)
727
416
 
728
- ---
417
+ 3. **CameraDAO.getVideosByCamera()** - JOIN with folders
418
+ 4. **VideoDAO.getVideosByCameraIdWithFolder()** - Same as #3, different DAO
729
419
 
730
- ## Implementation Order
420
+ ### Phase 3: Transaction-Aware Methods
731
421
 
732
- ### Phase 1: Database Foundation
422
+ 5. **VideoDAO.bulkUpdateCamera()** - Transaction support required
733
423
 
734
- 1. Create interface file: `report-configuration.interfaces.ts`
735
- 2. Create migration: `YYYYMMDDHHMMSS_create_report_configurations.ts`
736
- 3. Run migration: `npm run migrate:deploy`
737
- 4. Verify seed data exists (1 default configuration)
424
+ ---
738
425
 
739
- ### Phase 2: DAO Implementation
426
+ ## Database Indexing Recommendations
740
427
 
741
- 5. Create DAO: `report-configuration.dao.ts`
742
- 6. Implement standard CRUD methods
743
- 7. Implement validation logic
744
- 8. Implement `applyConfiguration()` transformation logic
745
- 9. Test DAO methods in isolation
428
+ ### Existing Indexes (Already Present)
746
429
 
747
- ### Phase 3: Integration
430
+ - cameras.uuid (migration 20250911000000_migration.ts:13)
431
+ - cameras.(longitude, latitude) (migration 20250911000000_migration.ts:14)
432
+ - video.cameraId (migration 20250911000000_migration.ts:20)
433
+ - folders.cameraId (migration 20250911000000_migration.ts:26)
748
434
 
749
- 10. Modify `VideoMinuteResultDAO.aggregateTMCResults()`
750
- 11. Modify `VideoMinuteResultDAO.aggregateATRResults()`
751
- 12. Remove `normalizeATRVehicleClass()` calls
752
- 13. Add `fetchConfiguration()` helper
753
- 14. Update method signatures to accept `configurationId`
435
+ ### Recommended New Indexes
754
436
 
755
- ### Phase 4: Exports & Build
437
+ **Optional - cameras.name (for search optimization)**
756
438
 
757
- 15. Update `src/index.ts` exports
758
- 16. Run `npm run build` in knex-rel
759
- 17. Verify no TypeScript errors
760
- 18. Test against sample video data
439
+ ```sql
440
+ CREATE INDEX idx_cameras_name ON cameras(name);
441
+ ```
761
442
 
762
- ---
443
+ - Benefits: Speeds up getAllWithSearch() ILIKE queries
444
+ - Tradeoff: ILIKE with leading wildcard still can't use index fully
445
+ - Decision: SKIP for now (camera table likely small)
763
446
 
764
- ## Risks & Concerns
447
+ **High Priority - video.folderId (for cascade operations)**
765
448
 
766
- ### 🔴 CRITICAL: Detection Label Mapping Assumption
449
+ ```sql
450
+ CREATE INDEX idx_video_folder_id ON video(folderId);
451
+ ```
767
452
 
768
- **Risk:** The `DETECTION_LABEL_TO_FHWA` mapping is based on user clarifications, but there's no guarantee that video processing returns exactly these labels.
453
+ - Benefits: Critical for getVideoIdsByFolderId() performance
454
+ - Usage: Heavy usage in cascade operations
455
+ - Decision: **CHECK IF EXISTS** - likely already present
769
456
 
770
- **Mitigation:**
457
+ **Optional - video.(cameraId, created_at) composite**
771
458
 
772
- - Test with actual video processing output
773
- - Add logging when unknown detection labels are encountered
774
- - Provide fallback behavior (map unknown labels to FHWA Class 2 by default)
459
+ ```sql
460
+ CREATE INDEX idx_video_camera_created ON video(cameraId, created_at DESC);
461
+ ```
775
462
 
776
- ### 🟡 MEDIUM: Performance at Minute-Level Granularity
463
+ - Benefits: Optimizes sorted queries in getVideosByCameraIdWithFolder
464
+ - Decision: SKIP for now (single column index sufficient)
777
465
 
778
- **Risk:** Applying configuration transformation at minute-level for 100+ videos could be slow.
466
+ ---
779
467
 
780
- **Current Decision:** Apply transformation on-the-fly (per user clarification Q10.2).
468
+ ## Type Safety & Interfaces
781
469
 
782
- **Future Optimization:**
470
+ All methods already have proper TypeScript interfaces defined:
783
471
 
784
- - Cache configuration object in memory (singleton pattern)
785
- - Pre-compute transformed results and store in separate JSONB column
786
- - Add database-level JSONB transformation functions
472
+ **ICamera** - knex-rel/src/interfaces/camera/camera.interfaces.ts
787
473
 
788
- ### 🟡 MEDIUM: Replacement of normalizeATRVehicleClass()
474
+ - All fields present and correct
475
+ - No modifications needed
789
476
 
790
- **Risk:** Existing API clients may expect normalized class names (`cars`, `mediums`, `heavy_trucks`).
477
+ **IVideo** - knex-rel/src/interfaces/video/video.interfaces.ts
791
478
 
792
- **Mitigation:**
479
+ - Includes optional folder?: IFolder field (line 33)
480
+ - Includes optional cameraId?: number field (line 8)
481
+ - Supports all required fields
482
+ - No modifications needed
793
483
 
794
- - Default configuration seed matches current normalization
795
- - API will return same class names initially (backwards compatible)
796
- - Users can create custom configurations as needed
484
+ **IDataPaginator<T>** - Used consistently across all methods
797
485
 
798
- ### 🟢 LOW: Migration Safety
486
+ - success: boolean
487
+ - data: T[]
488
+ - page: number
489
+ - limit: number
490
+ - count: number
491
+ - totalCount: number
492
+ - totalPages: number
799
493
 
800
- **Risk:** Minimal - only creates new table with seed data.
494
+ ---
801
495
 
802
- **Rollback:** Clean rollback with `down()` migration drops table entirely.
496
+ ## Error Handling Patterns
803
497
 
804
- ---
498
+ ### Pattern 1: Search Methods (getAllWithSearch)
805
499
 
806
- ## Testing Strategy
500
+ ```typescript
501
+ // No try/catch in DAO
502
+ // Let Knex errors propagate to service
503
+ const result = await query...
504
+ return result;
505
+ ```
807
506
 
808
- ### Unit Tests (DAO Level)
507
+ ### Pattern 2: Transaction Methods (bulkUpdateCamera)
809
508
 
810
509
  ```typescript
811
- // Test validation
812
- describe("ReportConfigurationDAO.validateConfiguration", () => {
813
- it("should reject configurations with < 2 custom classes");
814
- it("should reject configurations with > 7 custom classes");
815
- it("should reject duplicate FHWA classes");
816
- it("should reject custom class names > 30 chars");
817
- it("should accept valid configurations");
818
- });
819
-
820
- // Test transformation
821
- describe("ReportConfigurationDAO.applyConfiguration", () => {
822
- it("should map detection labels to FHWA classes");
823
- it("should map FHWA classes to custom classes");
824
- it("should exclude unmapped FHWA classes");
825
- it("should handle multi-mapped detection labels (single_unit_truck)");
826
- });
827
-
828
- // Test deletion validation
829
- describe("ReportConfigurationDAO.deleteWithValidation", () => {
830
- it("should prevent deletion of last configuration");
831
- it("should allow deletion when > 1 configuration exists");
832
- });
510
+ // No try/catch in DAO
511
+ // Let transaction rollback handle errors at service level
512
+ const query = trx || this._knex;
513
+ const result = await query...
514
+ return result.length;
833
515
  ```
834
516
 
835
- ### Integration Tests (VideoMinuteResultDAO)
517
+ ### Pattern 3: Simple Queries (getVideoIdsByFolderId)
836
518
 
837
519
  ```typescript
838
- describe("VideoMinuteResultDAO with ReportConfigurations", () => {
839
- it("should apply configuration to TMC results");
840
- it("should apply configuration to ATR results");
841
- it("should use default configuration when none specified");
842
- it("should handle unmapped vehicle classes correctly");
843
- });
520
+ // No try/catch needed
521
+ // Direct Knex query with error propagation
522
+ const ids = await this._knex...
523
+ return ids.map(row => row.id);
844
524
  ```
845
525
 
846
526
  ---
847
527
 
848
- ## Performance Considerations
528
+ ## Code Pattern Examples
849
529
 
850
- ### Query Optimization
530
+ ### Example 1: getAllWithSearch() Implementation
851
531
 
852
- **Configuration Lookup:**
532
+ ```typescript
533
+ async getAllWithSearch(page: number, limit: number, name?: string): Promise<IDataPaginator<ICamera>> {
534
+ const offset = (page - 1) * limit;
853
535
 
854
- - Index on `uuid` for fast lookup by UUID
855
- - Index on `is_default` for quick default retrieval
856
- - Consider in-memory caching (singleton) if configurations queried frequently
536
+ let query = this._knex("cameras");
857
537
 
858
- **Transformation:**
538
+ // Apply search filter if name provided
539
+ if (name && name.trim().length > 0) {
540
+ query = query.where('name', 'ilike', `%${name.trim()}%`);
541
+ }
859
542
 
860
- - `applyConfiguration()` runs in O(n) time where n = number of detection labels
861
- - For minute-level data (potentially 1000s of minutes), this could be expensive
862
- - **Future optimization:** Move transformation to PostgreSQL JSONB functions
543
+ const [countResult] = await query.clone().count("* as count");
544
+ const totalCount = +countResult.count;
545
+ const cameras = await query.clone().limit(limit).offset(offset).orderBy('name', 'asc');
863
546
 
864
- ### Caching Strategy (Future)
547
+ return {
548
+ success: true,
549
+ data: cameras,
550
+ page,
551
+ limit,
552
+ count: cameras.length,
553
+ totalCount,
554
+ totalPages: Math.ceil(totalCount / limit),
555
+ };
556
+ }
557
+ ```
558
+
559
+ ### Example 2: bulkUpdateCamera() Implementation
865
560
 
866
561
  ```typescript
867
- class ReportConfigurationCache {
868
- private static instance: Map<number, IReportConfigurationData> = new Map();
562
+ async bulkUpdateCamera(videoIds: number[], cameraId: number | null, trx?: Knex.Transaction): Promise<number> {
563
+ if (!videoIds || videoIds.length === 0) {
564
+ return 0;
565
+ }
869
566
 
870
- static get(id: number): IReportConfigurationData | undefined {
871
- return this.instance.get(id);
872
- }
567
+ const query = trx || this._knex;
873
568
 
874
- static set(id: number, config: IReportConfigurationData): void {
875
- this.instance.set(id, config);
876
- }
569
+ const result = await query('video')
570
+ .whereIn('id', videoIds)
571
+ .update({
572
+ cameraId: cameraId,
573
+ updated_at: query.fn.now()
574
+ })
575
+ .returning('id');
877
576
 
878
- static clear(): void {
879
- this.instance.clear();
880
- }
577
+ return result.length;
881
578
  }
882
579
  ```
883
580
 
884
- ---
581
+ ### Example 3: getVideosByCamera() Implementation
885
582
 
886
- ## Open Questions & Future Enhancements
583
+ ```typescript
584
+ async getVideosByCamera(cameraId: number, page: number, limit: number): Promise<IDataPaginator<IVideo>> {
585
+ const offset = (page - 1) * limit;
887
586
 
888
- ### 1. Unmapped FHWA Classes Display
587
+ const query = this._knex("video as v")
588
+ .innerJoin("folders as f", "v.folderId", "f.id")
589
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
590
+ .where("v.cameraId", cameraId);
889
591
 
890
- **Question:** Should the API response indicate which FHWA classes were excluded?
592
+ const [countResult] = await query.clone().clearSelect().count("* as count");
593
+ const totalCount = +countResult.count;
594
+ const videos = await query.clone().limit(limit).offset(offset).orderBy('v.created_at', 'desc');
891
595
 
892
- **Example:**
596
+ return {
597
+ success: true,
598
+ data: videos,
599
+ page,
600
+ limit,
601
+ count: videos.length,
602
+ totalCount,
603
+ totalPages: Math.ceil(totalCount / limit),
604
+ };
605
+ }
606
+ ```
893
607
 
894
- ```json
895
- {
896
- "customClasses": {
897
- "Cars": 100,
898
- "Heavy Trucks": 50
899
- },
900
- "unmappedCounts": {
901
- "fhwaClass4": 10,
902
- "fhwaClass5": 5
903
- }
608
+ ### Example 4: getVideoIdsByFolderId() Implementation
609
+
610
+ ```typescript
611
+ async getVideoIdsByFolderId(folderId: number): Promise<number[]> {
612
+ const rows = await this._knex('video')
613
+ .where({ folderId })
614
+ .select('id')
615
+ .orderBy('id', 'asc');
616
+
617
+ return rows.map(row => row.id);
904
618
  }
905
619
  ```
906
620
 
907
- **Recommendation:** Add this as optional metadata for debugging purposes.
621
+ ### Example 5: getVideosByCameraIdWithFolder() Implementation
908
622
 
909
- ### 2. Configuration Change History
623
+ ```typescript
624
+ async getVideosByCameraIdWithFolder(cameraId: number, page: number, limit: number): Promise<IDataPaginator<IVideo>> {
625
+ const offset = (page - 1) * limit;
910
626
 
911
- **Question:** Should we track configuration changes over time?
627
+ const query = this._knex("video as v")
628
+ .innerJoin("folders as f", "v.folderId", "f.id")
629
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
630
+ .where("v.cameraId", cameraId);
912
631
 
913
- **Future Enhancement:** Add `report_configuration_history` table to track when configurations are created/modified.
632
+ const [countResult] = await query.clone().clearSelect().count("* as count");
633
+ const totalCount = +countResult.count;
634
+ const videos = await query.clone().limit(limit).offset(offset).orderBy('v.created_at', 'desc');
914
635
 
915
- ### 3. Pedestrians & Bicycles
636
+ return {
637
+ success: true,
638
+ data: videos,
639
+ page,
640
+ limit,
641
+ count: videos.length,
642
+ totalCount,
643
+ totalPages: Math.ceil(totalCount / limit),
644
+ };
645
+ }
646
+ ```
916
647
 
917
- **Question:** Current system excludes pedestrians/bicycles from FHWA mapping. Should users be able to create custom classes for non-motorized traffic?
648
+ ---
918
649
 
919
- **Recommendation:** Support this by adding FHWA classes 14-15 (non-motorized) in future iteration.
650
+ ## Migration Requirements
920
651
 
921
- ### 4. Detection Label Variability
652
+ **No new migrations needed** - All schema changes already exist:
922
653
 
923
- **Question:** What if video processing returns different detection labels than expected?
654
+ - cameras table: migration 20250911000000_migration.ts
655
+ - video.cameraId column: migration 20250911000000_migration.ts:19
656
+ - folders.cameraId column: migration 20250911000000_migration.ts:25
657
+ - All indexes already created
924
658
 
925
- **Recommendation:** Add logging and admin dashboard to monitor unmapped detection labels.
659
+ **Optional migration for performance**:
660
+
661
+ - Check if video.folderId index exists (likely already present)
926
662
 
927
663
  ---
928
664
 
929
- ## Validation Checklist
665
+ ## Testing Strategy
666
+
667
+ ### Unit Tests Required
668
+
669
+ **CameraDAO.getAllWithSearch()**
670
+
671
+ - Test: Search with matching name (partial match)
672
+ - Test: Search with no matches (empty result)
673
+ - Test: Search with null/undefined name (return all)
674
+ - Test: Pagination (page 1, page 2, etc.)
675
+ - Test: Case insensitivity (ILIKE behavior)
676
+
677
+ **CameraDAO.getVideosByCamera()**
678
+
679
+ - Test: Camera with videos (verify folder JOIN)
680
+ - Test: Camera with no videos (empty result)
681
+ - Test: Pagination with multiple pages
682
+ - Test: Verify folder data structure in results
683
+
684
+ **VideoDAO.bulkUpdateCamera()**
685
+
686
+ - Test: Update with cameraId (assignment)
687
+ - Test: Update with null cameraId (unassignment)
688
+ - Test: Update with transaction (commit)
689
+ - Test: Update with transaction (rollback)
690
+ - Test: Empty videoIds array (return 0)
691
+ - Test: Invalid videoIds (non-existent IDs)
692
+
693
+ **VideoDAO.getVideoIdsByFolderId()**
930
694
 
931
- Before marking this plan as complete, verify:
695
+ - Test: Folder with videos (return IDs)
696
+ - Test: Folder with no videos (empty array)
697
+ - Test: Non-existent folderId (empty array)
698
+ - Test: Large folder (100+ videos)
932
699
 
933
- - [x] Database schema includes all required fields
934
- - [x] Migration has proper up/down functions
935
- - [x] Seed data matches current system behavior
936
- - [x] Interface definitions match database schema
937
- - [x] DAO implements all IBaseDAO methods
938
- - [x] Validation logic covers all business rules (2-7 classes, no duplicates, 30 char limit)
939
- - [x] DETECTION_LABEL_TO_FHWA mapping is complete
940
- - [x] Integration strategy with VideoMinuteResultDAO is clear
941
- - [x] Export updates are documented
942
- - [x] Implementation order is logical
943
- - [x] Risks are identified and mitigated
944
- - [x] Testing strategy is comprehensive
700
+ **VideoDAO.getVideosByCameraIdWithFolder()**
701
+
702
+ - Test: Camera with videos (verify folder JOIN)
703
+ - Test: Camera with no videos (empty result)
704
+ - Test: Pagination
705
+ - Test: Verify folder data in results
706
+ - Test: Null cameraId handling
945
707
 
946
708
  ---
947
709
 
948
- ## Pattern Compliance
710
+ ## Implementation Checklist
949
711
 
950
- ### Database Patterns ✅
712
+ ### Pre-Implementation
951
713
 
952
- - Primary key: `id` (auto-increment)
953
- - External identifier: `uuid` (unique, not null, auto-generated)
954
- - Timestamps: `created_at`, `updated_at`
955
- - Foreign keys: N/A (system-wide table)
714
+ - [ ] Check if video.folderId index exists in database
715
+ - [ ] Review existing DAO patterns in camera.dao.ts and video.dao.ts
716
+ - [ ] Verify IDataPaginator structure in d.types.ts
956
717
 
957
- ### DAO Patterns ✅
718
+ ### Implementation Phase
958
719
 
959
- - Implements `IBaseDAO<IReportConfiguration>`
960
- - Singleton KnexManager connection
961
- - Methods: create, getById, getByUuid, getAll, update, delete
962
- - Custom methods: deleteWithValidation, getDefault, setDefault, validateConfiguration, applyConfiguration
720
+ - [ ] Implement CameraDAO.getAllWithSearch()
721
+ - [ ] Implement VideoDAO.getVideoIdsByFolderId()
722
+ - [ ] Implement CameraDAO.getVideosByCamera()
723
+ - [ ] Implement VideoDAO.getVideosByCameraIdWithFolder()
724
+ - [ ] Implement VideoDAO.bulkUpdateCamera()
963
725
 
964
- ### File Naming ✅
726
+ ### Testing Phase
965
727
 
966
- - Migration: `YYYYMMDDHHMMSS_create_report_configurations.ts`
967
- - DAO: `report-configuration.dao.ts`
968
- - Interface: `report-configuration.interfaces.ts` (matches folder structure)
728
+ - [ ] Write unit tests for all 5 methods
729
+ - [ ] Test transaction handling in bulkUpdateCamera
730
+ - [ ] Test pagination edge cases (page 1, last page, beyond last page)
731
+ - [ ] Test search with special characters (SQL injection prevention)
969
732
 
970
- ### Performance
733
+ ### Integration Phase
971
734
 
972
- - Indexed UUID for fast lookups
973
- - JOINs eliminated (no foreign keys, system-wide table)
974
- - Pagination implemented in getAll()
975
- - Future: Consider caching for frequently accessed configurations
735
+ - [ ] Build knex-rel package: `npm run build`
736
+ - [ ] Verify no TypeScript compilation errors
737
+ - [ ] Test in api-rel: `npm run start:dev`
738
+ - [ ] Verify all service methods work end-to-end
739
+ - [ ] Test camera bulk assignment workflow
740
+ - [ ] Test folder camera cascade workflow
976
741
 
977
742
  ---
978
743
 
979
- ## Next Steps for Orchestrator
744
+ ## Risk Assessment
980
745
 
981
- This plan is now ready for handoff to the **knex-code-agent** for implementation.
746
+ ### Low Risk
982
747
 
983
- **Orchestrator should:**
748
+ - getAllWithSearch() - Simple extension of getAll()
749
+ - getVideoIdsByFolderId() - Straightforward SELECT query
984
750
 
985
- 1. Review this plan for completeness
986
- 2. Approve DETECTION_LABEL_TO_FHWA mapping accuracy
987
- 3. Confirm migration timestamp format
988
- 4. Hand off to knex-code-agent for execution
751
+ ### Medium Risk
989
752
 
990
- **knex-code-agent should:**
753
+ - getVideosByCamera() - Requires correct JOIN syntax
754
+ - getVideosByCameraIdWithFolder() - Same as above
991
755
 
992
- 1. Implement in exact order specified (Phase 1 → Phase 2 → Phase 3 → Phase 4)
993
- 2. Test each phase before proceeding
994
- 3. Report any conflicts or issues discovered during implementation
995
- 4. Request clarification if detection labels don't match expectations
756
+ ### High Risk
996
757
 
997
- **Blockers:**
758
+ - bulkUpdateCamera() - Transaction handling complexity
759
+ - **Mitigation**: Carefully test both transaction and non-transaction paths
760
+ - **Mitigation**: Validate videoIds array before query
761
+ - **Mitigation**: Let service layer handle rollback logic
998
762
 
999
- - None identified. Plan is complete and ready for implementation.
763
+ ### Performance Risks
764
+
765
+ - getVideoIdsByFolderId() could return large arrays (1000+ IDs)
766
+ - **Mitigation**: Acceptable for cascade operations
767
+ - **Mitigation**: Ensure folderId index exists for speed
768
+ - getAllWithSearch() ILIKE with leading wildcard is slow
769
+ - **Mitigation**: Acceptable for small camera table
770
+ - **Mitigation**: Consider full-text search if needed later
1000
771
 
1001
772
  ---
1002
773
 
1003
- ## Appendix: FHWA 13-Class System Reference
774
+ ## Summary
1004
775
 
1005
- ```
1006
- Class 1: Motorcycles
1007
- Class 2: Passenger Cars
1008
- Class 3: Other Two-Axle, Four-Tire Single Unit Vehicles (Pickups, Vans)
1009
- Class 4: Buses
1010
- Class 5: Two-Axle, Six-Tire Single Unit Trucks (Work Vans)
1011
- Class 6: Three-Axle Single Unit Trucks
1012
- Class 7: Four or More Axle Single Unit Trucks
1013
- Class 8: Four or Less Axle Single Trailer Trucks
1014
- Class 9: Five-Axle Single Trailer Trucks
1015
- Class 10: Six or More Axle Single Trailer Trucks
1016
- Class 11: Five or Less Axle Multi-Trailer Trucks
1017
- Class 12: Six-Axle Multi-Trailer Trucks
1018
- Class 13: Seven or More Axle Multi-Trailer Trucks
1019
- ```
776
+ All 5 missing methods have been thoroughly analyzed:
777
+
778
+ - **Call patterns documented** from 8 different service method locations
779
+ - **Data flow mapped** from API Service → DAO → Database
780
+ - **SQL queries designed** with proper JOINs, indexes, and performance
781
+ - **Transaction handling** specified for bulkUpdateCamera()
782
+ - **Type safety verified** - all interfaces already correct
783
+ - **No migrations needed** - schema already complete
784
+ - **Implementation order prioritized** - simple to complex
785
+ - **Testing strategy defined** - 25+ test cases identified
786
+ - **Risk assessment completed** - mitigations specified
787
+
788
+ **Ready for implementation** - All patterns follow existing DAO conventions in codebase.
789
+
790
+ ---
1020
791
 
1021
- **Detection Label Mapping:**
792
+ ## Files to Modify
1022
793
 
1023
- - Classes 6-8: All map to `single_unit_truck` detection label
1024
- - Classes 9-13: All map to `articulated_truck` detection label
1025
- - This creates a many-to-one relationship at the detection level
794
+ ### knex-rel/src/dao/camera/camera.dao.ts
795
+
796
+ - Add method: `getAllWithSearch(page, limit, name?)`
797
+ - Add method: `getVideosByCamera(cameraId, page, limit)`
798
+
799
+ ### knex-rel/src/dao/video/video.dao.ts
800
+
801
+ - Add method: `bulkUpdateCamera(videoIds, cameraId, trx?)`
802
+ - Add method: `getVideoIdsByFolderId(folderId)`
803
+ - Add method: `getVideosByCameraIdWithFolder(cameraId, page, limit)`
1026
804
 
1027
805
  ---
1028
806
 
1029
- ## Document Version
807
+ ## Pattern Compliance Summary
808
+
809
+ ### Database Patterns ✅
810
+
811
+ - All tables use `id` (auto-increment primary key)
812
+ - All tables use `uuid` (unique, not null) for external references
813
+ - All tables use `created_at`/`updated_at` timestamps
814
+ - Foreign keys use ON DELETE SET NULL for cameras
815
+ - Indexes on UUID and foreign keys
816
+
817
+ ### DAO Patterns ✅
818
+
819
+ - Singleton KnexManager.getConnection()
820
+ - Standard CRUD methods (create, getById, getByUuid, getAll, update, delete)
821
+ - Pagination with IDataPaginator<T> return type
822
+ - JOINs use to_jsonb() for nested objects
823
+ - Transaction support via optional trx parameter
824
+
825
+ ### Query Patterns ✅
1030
826
 
1031
- - **Version:** 1.0
1032
- - **Date:** 2025-09-30
1033
- - **Author:** Database Planning Specialist (Claude)
1034
- - **Status:** FINAL - Ready for Implementation
827
+ - Use `this._knex` for queries
828
+ - Use `query.clone()` for count vs data queries
829
+ - Use `whereIn()` for array filters
830
+ - Use `query.fn.now()` for timestamps
831
+ - Use `returning('id')` for update counts