@trafficgroup/knex-rel 0.1.3 → 0.1.4

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 +0 -2
  5. package/dist/dao/camera/camera.dao.js +0 -42
  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 +0 -3
  11. package/dist/dao/video/video.dao.js +4 -43
  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 +950 -204
  22. package/src/dao/VideoMinuteResultDAO.ts +7 -64
  23. package/src/dao/camera/camera.dao.ts +0 -55
  24. package/src/dao/report-configuration/report-configuration.dao.ts +402 -0
  25. package/src/dao/video/video.dao.ts +4 -51
  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
package/plan.md CHANGED
@@ -1,288 +1,1034 @@
1
- # Database Optimization Plan for Camera Bulk Operations
1
+ # Report Configurations Database Implementation Plan
2
2
 
3
- ## Current Schema Analysis
3
+ ## Executive Summary
4
4
 
5
- ### Existing Tables & Indexes
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.
6
6
 
7
- **cameras table:**
7
+ **Key Decisions from User Clarifications:**
8
8
 
9
- - Primary key: `id` (auto-increment)
10
- - Unique index: `uuid`
11
- - Composite index: `[longitude, latitude]` for geospatial queries
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
17
+
18
+ ---
19
+
20
+ ## Critical Design Challenge: Detection Label → FHWA Class Mapping
21
+
22
+ ### The Problem
23
+
24
+ The user described a 3-tier mapping system:
25
+
26
+ ```
27
+ Detection Labels → FHWA Classes → Custom Classes
28
+ ```
29
+
30
+ However, **I cannot find a hardcoded mapping** from detection labels to FHWA classes in the codebase.
31
+
32
+ **Detection Labels Found in System:**
12
33
 
13
- **video table:**
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`
14
40
 
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)
41
+ **FHWA 13-Class System (from user clarifications):**
22
42
 
23
- **folders table:**
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
51
+ ```
52
+
53
+ ### The Solution: Hardcoded FHWA Mapping in ReportConfigurationDAO
54
+
55
+ Since no existing mapping exists, we'll create a **static mapping table** in the ReportConfigurationDAO:
56
+
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
+ };
72
+ ```
24
73
 
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`
74
+ **Transformation Flow:**
29
75
 
30
- ### Performance Analysis for Required Operations
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
31
80
 
32
- #### 1. Bulk update videos when folder cameraId changes
81
+ ### Replacement of Existing Normalization
33
82
 
34
- **Query:** `UPDATE video SET cameraId = ? WHERE folderId = ?`
83
+ The `normalizeATRVehicleClass()` function (VideoMinuteResultDAO lines 702-731) will be **completely replaced**. Instead of normalizing to `cars`, `mediums`, `heavy_trucks`, the new system will:
35
84
 
36
- - **Current Performance:** GOOD
37
- - **Existing Index:** `[folderId]` (implicit from FK constraint)
38
- - **Status:** No optimization needed
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
39
88
 
40
- #### 2. ❌ Search cameras by name with pagination
89
+ ---
41
90
 
42
- **Query:** `SELECT * FROM cameras WHERE name ILIKE '%search%' ORDER BY name LIMIT ? OFFSET ?`
91
+ ## Database Schema
43
92
 
44
- - **Current Performance:** POOR - Full table scan
45
- - **Missing Index:** Text search index on `name` column
46
- - **Impact:** Critical performance issue for camera search
93
+ ### Table: `report_configurations`
47
94
 
48
- #### 3. ✅ Find videos by specific camera
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
+ ```
49
111
 
50
- **Query:** `SELECT * FROM video WHERE cameraId = ?`
112
+ **Constraints:**
51
113
 
52
- - **Current Performance:** GOOD
53
- - **Existing Index:** `[cameraId]` from migration `20250911000000_migration.ts`
54
- - **Status:** No optimization needed
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)
55
118
 
56
- #### 4. ⚠️ Bulk assign camera to multiple videos
119
+ ### Configuration JSONB Structure
57
120
 
58
- **Query:** `UPDATE video SET cameraId = ? WHERE id IN (...)`
121
+ ```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
+ }
139
+ ```
59
140
 
60
- - **Current Performance:** ACCEPTABLE but can be optimized
61
- - **Existing Index:** Primary key on `id`
62
- - **Optimization Opportunity:** Batch processing with transaction optimization
141
+ **Validation Rules:**
63
142
 
64
- ## Required Database Optimizations
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)
65
148
 
66
- ### New Migrations Required
149
+ ---
67
150
 
68
- #### Migration: Add camera name search index
151
+ ## Migration
69
152
 
70
- **File:** `20250924000000_camera_name_search_index.ts`
71
- **Purpose:** Optimize camera search by name functionality
153
+ ### File: `YYYYMMDDHHMMSS_create_report_configurations.ts`
72
154
 
73
155
  ```typescript
156
+ import type { Knex } from "knex";
157
+
74
158
  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");
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");
78
175
  });
79
176
 
80
- // Add GIN index for full-text search if needed for fuzzy matching
177
+ // Add case-insensitive name index
81
178
  await knex.raw(`
82
- CREATE INDEX idx_cameras_name_gin
83
- ON cameras
84
- USING GIN (to_tsvector('english', name))
179
+ CREATE INDEX idx_report_configurations_name_lower
180
+ ON report_configurations(LOWER(name))
85
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
+ });
86
206
  }
87
207
 
88
208
  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
- });
209
+ await knex.raw("DROP INDEX IF EXISTS idx_report_configurations_name_lower");
210
+ await knex.schema.dropTableIfExists("report_configurations");
93
211
  }
94
212
  ```
95
213
 
96
- ### DAO Method Optimizations
214
+ **Migration Safety:** LOW RISK
215
+
216
+ - Creates new table (no data loss)
217
+ - Seeds single default configuration
218
+ - Rollback is clean (drop table)
219
+
220
+ ---
97
221
 
98
- #### CameraDAO Enhancements Required
222
+ ## Interface Definition
99
223
 
100
- **New Methods Needed:**
224
+ ### File: `src/interfaces/report-configuration/report-configuration.interfaces.ts`
101
225
 
102
226
  ```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
- };
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
+ }
237
+
238
+ export interface IReportConfigurationData {
239
+ version: string;
240
+ customClasses: ICustomClass[];
129
241
  }
130
242
 
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
- };
243
+ export interface ICustomClass {
244
+ name: string;
245
+ fhwaClasses: number[];
246
+ }
247
+
248
+ export interface IReportConfigurationInput {
249
+ name: string;
250
+ description?: string;
251
+ configuration: IReportConfigurationData;
252
+ isDefault?: boolean;
253
+ }
254
+
255
+ export interface IConfigurationValidationResult {
256
+ valid: boolean;
257
+ errors: string[];
158
258
  }
159
259
  ```
160
260
 
161
- #### VideoDAO Enhancements Required
261
+ ---
262
+
263
+ ## DAO Implementation
162
264
 
163
- **New Methods Needed:**
265
+ ### File: `src/dao/report-configuration/report-configuration.dao.ts`
164
266
 
165
267
  ```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()
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
+ }
306
+
307
+ const dbData = this.mapInputToDbRow(item);
308
+ const [result] = await this.knex(this.tableName)
309
+ .insert(dbData)
310
+ .returning("*");
311
+
312
+ return this.mapDbRowToEntity(result);
313
+ }
314
+
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
+ }
319
+
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
+ }
324
+
325
+ async getAll(
326
+ page: number = 1,
327
+ limit: number = 10,
328
+ ): Promise<IDataPaginator<IReportConfiguration>> {
329
+ const offset = (page - 1) * limit;
330
+
331
+ const [countResult] = await this.knex(this.tableName).count("* as count");
332
+ const totalCount = +countResult.count;
333
+
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);
340
+
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
+ }
365
+
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("*");
371
+
372
+ return result ? this.mapDbRowToEntity(result) : null;
373
+ }
374
+
375
+ async delete(id: number): Promise<boolean> {
376
+ // Use deleteWithValidation to prevent deletion of last config
377
+ return this.deleteWithValidation(id);
378
+ }
379
+
380
+ // ==================== CUSTOM METHODS ====================
381
+
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;
388
+
389
+ if (totalCount <= 1) {
390
+ throw new Error(
391
+ "Cannot delete the last configuration. At least one configuration must exist.",
392
+ );
393
+ }
394
+
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
+ }
414
+
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 });
176
428
  });
177
- return result;
178
- }
179
429
 
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;
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
+ }
446
+
447
+ if (!config.customClasses || !Array.isArray(config.customClasses)) {
448
+ errors.push("customClasses must be an array");
449
+ return { valid: false, errors };
450
+ }
451
+
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
+ }
459
+
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
+ }
504
+
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
+ }
534
+
535
+ // Step 2: Apply user configuration (FHWA classes → Custom classes)
536
+ const customCounts: Record<string, number> = {};
537
+
538
+ for (const customClass of configuration.customClasses) {
539
+ let total = 0;
540
+
541
+ for (const fhwaClass of customClass.fhwaClasses) {
542
+ total += fhwaCounts[fhwaClass] || 0;
543
+ }
544
+
545
+ customCounts[customClass.name] = Math.round(total);
546
+ }
547
+
548
+ return customCounts;
549
+ }
550
+
551
+ // ==================== MAPPING HELPERS ====================
552
+
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
+ }
565
+
566
+ private mapDbRowsToEntities(rows: any[]): IReportConfiguration[] {
567
+ return rows.map((row) => this.mapDbRowToEntity(row));
568
+ }
569
+
570
+ private mapInputToDbRow(input: Partial<IReportConfigurationInput>): any {
571
+ const dbRow: any = {};
572
+
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;
578
+
579
+ return dbRow;
580
+ }
194
581
  }
195
582
 
196
- // Get videos by multiple folder IDs (for folder camera propagation)
197
- async getVideosByFolderIds(folderIds: number[]): Promise<IVideo[]> {
198
- if (folderIds.length === 0) return [];
583
+ export default ReportConfigurationDAO;
584
+ ```
585
+
586
+ ---
587
+
588
+ ## Integration with VideoMinuteResultDAO
199
589
 
200
- return await this._knex("video")
201
- .whereIn("folderId", folderIds)
202
- .select("id", "uuid", "name", "folderId", "cameraId");
590
+ ### Modifications Required
591
+
592
+ The `VideoMinuteResultDAO` must be modified to accept and apply report configurations during aggregation.
593
+
594
+ **File:** `src/dao/VideoMinuteResultDAO.ts`
595
+
596
+ ### Changes to Method Signatures
597
+
598
+ ```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>
607
+ ```
608
+
609
+ ### Integration Strategy
610
+
611
+ 1. **Import ReportConfigurationDAO:**
612
+
613
+ ```typescript
614
+ import { ReportConfigurationDAO } from "./report-configuration/report-configuration.dao";
615
+ ```
616
+
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
621
+
622
+ 3. **Modify aggregateATRResults() (lines 590-642):**
623
+ - Accept `configuration` parameter
624
+ - Remove `normalizeATRVehicleClass()` calls (lines 606, 634)
625
+ - Apply configuration transformation instead
626
+
627
+ 4. **Add new helper method:**
628
+
629
+ ```typescript
630
+ private async fetchConfiguration(configurationId?: number): Promise<IReportConfigurationData> {
631
+ const configDAO = new ReportConfigurationDAO();
632
+
633
+ let config: IReportConfiguration | null;
634
+
635
+ if (configurationId) {
636
+ config = await configDAO.getById(configurationId);
637
+ } else {
638
+ config = await configDAO.getDefault();
639
+ }
640
+
641
+ if (!config) {
642
+ throw new Error("No report configuration found");
643
+ }
644
+
645
+ return config.configuration;
646
+ }
647
+ ```
648
+
649
+ 5. **Update aggregation flow:**
650
+
651
+ ```typescript
652
+ // In getGroupedMinuteResultsByVideoUuid()
653
+ const configuration = await this.fetchConfiguration(configurationId);
654
+
655
+ const aggregatedGroups: IGroupedResult[] = rows.map((row: any) => {
656
+ // ... existing aggregation logic ...
657
+
658
+ let aggregatedResult;
659
+ if (studyType === "TMC") {
660
+ aggregatedResult = this.aggregateTMCResults(allResults, configuration);
661
+ } else {
662
+ aggregatedResult = this.aggregateATRResults(allResults, configuration);
663
+ }
664
+
665
+ // ... return result ...
666
+ });
667
+ ```
668
+
669
+ ### Detailed Transformation Example (ATR)
670
+
671
+ **Before transformation (raw detection labels):**
672
+
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
+ }
203
685
  }
204
686
  ```
205
687
 
206
- ## Query Performance Strategies
688
+ **After applying configuration:**
207
689
 
208
- ### 1. Camera Search Optimization
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
+ ```
209
705
 
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
706
+ ---
214
707
 
215
- ### 2. Bulk Operation Optimization
708
+ ## Export Updates
216
709
 
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
710
+ ### File: `src/index.ts`
220
711
 
221
- ### 3. Folder Camera Cascade Optimization
712
+ Add exports for new DAO and interfaces:
222
713
 
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
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
+ ```
727
+
728
+ ---
226
729
 
227
730
  ## Implementation Order
228
731
 
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
732
+ ### Phase 1: Database Foundation
233
733
 
234
- ## Risk Assessment
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)
235
738
 
236
- ### Migration Safety: LOW RISK
739
+ ### Phase 2: DAO Implementation
237
740
 
238
- - Adding indexes is non-blocking operation
239
- - No data changes, only performance improvements
240
- - Easy rollback with down migration
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
241
746
 
242
- ### Performance Impact: HIGH BENEFIT
747
+ ### Phase 3: Integration
243
748
 
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
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`
247
754
 
248
- ### Pattern Compliance: 100% COMPLIANT
755
+ ### Phase 4: Exports & Build
249
756
 
250
- - Follows existing DAO patterns exactly
251
- - Maintains IBaseDAO interface requirements
252
- - Uses established naming conventions
253
- - Preserves UUID external API pattern
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
254
761
 
255
- ## Validation Strategy
762
+ ---
256
763
 
257
- ### Performance Testing Queries
764
+ ## Risks & Concerns
258
765
 
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;
766
+ ### 🔴 CRITICAL: Detection Label Mapping Assumption
767
+
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.
769
+
770
+ **Mitigation:**
771
+
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)
775
+
776
+ ### 🟡 MEDIUM: Performance at Minute-Level Granularity
777
+
778
+ **Risk:** Applying configuration transformation at minute-level for 100+ videos could be slow.
779
+
780
+ **Current Decision:** Apply transformation on-the-fly (per user clarification Q10.2).
781
+
782
+ **Future Optimization:**
783
+
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
787
+
788
+ ### 🟡 MEDIUM: Replacement of normalizeATRVehicleClass()
789
+
790
+ **Risk:** Existing API clients may expect normalized class names (`cars`, `mediums`, `heavy_trucks`).
791
+
792
+ **Mitigation:**
793
+
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
797
+
798
+ ### 🟢 LOW: Migration Safety
799
+
800
+ **Risk:** Minimal - only creates new table with seed data.
801
+
802
+ **Rollback:** Clean rollback with `down()` migration drops table entirely.
803
+
804
+ ---
805
+
806
+ ## Testing Strategy
807
+
808
+ ### Unit Tests (DAO Level)
809
+
810
+ ```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
+ });
833
+ ```
834
+
835
+ ### Integration Tests (VideoMinuteResultDAO)
836
+
837
+ ```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
+ });
844
+ ```
845
+
846
+ ---
847
+
848
+ ## Performance Considerations
849
+
850
+ ### Query Optimization
851
+
852
+ **Configuration Lookup:**
853
+
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
857
+
858
+ **Transformation:**
859
+
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
863
+
864
+ ### Caching Strategy (Future)
865
+
866
+ ```typescript
867
+ class ReportConfigurationCache {
868
+ private static instance: Map<number, IReportConfigurationData> = new Map();
869
+
870
+ static get(id: number): IReportConfigurationData | undefined {
871
+ return this.instance.get(id);
872
+ }
873
+
874
+ static set(id: number, config: IReportConfigurationData): void {
875
+ this.instance.set(id, config);
876
+ }
877
+
878
+ static clear(): void {
879
+ this.instance.clear();
880
+ }
881
+ }
273
882
  ```
274
883
 
275
- ### Success Metrics
884
+ ---
885
+
886
+ ## Open Questions & Future Enhancements
887
+
888
+ ### 1. Unmapped FHWA Classes Display
889
+
890
+ **Question:** Should the API response indicate which FHWA classes were excluded?
891
+
892
+ **Example:**
893
+
894
+ ```json
895
+ {
896
+ "customClasses": {
897
+ "Cars": 100,
898
+ "Heavy Trucks": 50
899
+ },
900
+ "unmappedCounts": {
901
+ "fhwaClass4": 10,
902
+ "fhwaClass5": 5
903
+ }
904
+ }
905
+ ```
906
+
907
+ **Recommendation:** Add this as optional metadata for debugging purposes.
908
+
909
+ ### 2. Configuration Change History
910
+
911
+ **Question:** Should we track configuration changes over time?
912
+
913
+ **Future Enhancement:** Add `report_configuration_history` table to track when configurations are created/modified.
914
+
915
+ ### 3. Pedestrians & Bicycles
916
+
917
+ **Question:** Current system excludes pedestrians/bicycles from FHWA mapping. Should users be able to create custom classes for non-motorized traffic?
918
+
919
+ **Recommendation:** Support this by adding FHWA classes 14-15 (non-motorized) in future iteration.
920
+
921
+ ### 4. Detection Label Variability
922
+
923
+ **Question:** What if video processing returns different detection labels than expected?
924
+
925
+ **Recommendation:** Add logging and admin dashboard to monitor unmapped detection labels.
926
+
927
+ ---
928
+
929
+ ## Validation Checklist
930
+
931
+ Before marking this plan as complete, verify:
932
+
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
945
+
946
+ ---
947
+
948
+ ## Pattern Compliance
949
+
950
+ ### Database Patterns ✅
951
+
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)
956
+
957
+ ### DAO Patterns ✅
958
+
959
+ - Implements `IBaseDAO<IReportConfiguration>`
960
+ - Singleton KnexManager connection
961
+ - Methods: create, getById, getByUuid, getAll, update, delete
962
+ - Custom methods: deleteWithValidation, getDefault, setDefault, validateConfiguration, applyConfiguration
963
+
964
+ ### File Naming ✅
965
+
966
+ - Migration: `YYYYMMDDHHMMSS_create_report_configurations.ts`
967
+ - DAO: `report-configuration.dao.ts`
968
+ - Interface: `report-configuration.interfaces.ts` (matches folder structure)
969
+
970
+ ### Performance ✅
971
+
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
976
+
977
+ ---
978
+
979
+ ## Next Steps for Orchestrator
980
+
981
+ This plan is now ready for handoff to the **knex-code-agent** for implementation.
982
+
983
+ **Orchestrator should:**
984
+
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
989
+
990
+ **knex-code-agent should:**
991
+
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
996
+
997
+ **Blockers:**
998
+
999
+ - None identified. Plan is complete and ready for implementation.
1000
+
1001
+ ---
1002
+
1003
+ ## Appendix: FHWA 13-Class System Reference
1004
+
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
+ ```
276
1020
 
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
1021
+ **Detection Label Mapping:**
281
1022
 
282
- ## Conclusion
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
283
1026
 
284
- **Current Status:** Schema is well-designed with most needed indexes already present. Only critical gap is camera name search optimization.
1027
+ ---
285
1028
 
286
- **Priority:** HIGH for camera name search index, MEDIUM for DAO method enhancements.
1029
+ ## Document Version
287
1030
 
288
- **Complexity:** LOW - Single migration required, standard DAO patterns.
1031
+ - **Version:** 1.0
1032
+ - **Date:** 2025-09-30
1033
+ - **Author:** Database Planning Specialist (Claude)
1034
+ - **Status:** FINAL - Ready for Implementation