@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.
- package/dist/dao/VideoMinuteResultDAO.d.ts +0 -4
- package/dist/dao/VideoMinuteResultDAO.js +7 -48
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
- package/dist/dao/camera/camera.dao.d.ts +0 -2
- package/dist/dao/camera/camera.dao.js +0 -42
- package/dist/dao/camera/camera.dao.js.map +1 -1
- package/dist/dao/report-configuration/report-configuration.dao.d.ts +94 -0
- package/dist/dao/report-configuration/report-configuration.dao.js +352 -0
- package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -0
- package/dist/dao/video/video.dao.d.ts +0 -3
- package/dist/dao/video/video.dao.js +4 -43
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/report-configuration/report-configuration.interfaces.d.ts +26 -0
- package/dist/interfaces/report-configuration/report-configuration.interfaces.js +3 -0
- package/dist/interfaces/report-configuration/report-configuration.interfaces.js.map +1 -0
- package/migrations/20250930200521_migration.ts +52 -0
- package/package.json +1 -1
- package/plan.md +950 -204
- package/src/dao/VideoMinuteResultDAO.ts +7 -64
- package/src/dao/camera/camera.dao.ts +0 -55
- package/src/dao/report-configuration/report-configuration.dao.ts +402 -0
- package/src/dao/video/video.dao.ts +4 -51
- package/src/index.ts +8 -0
- package/src/interfaces/report-configuration/report-configuration.interfaces.ts +30 -0
- package/cameras_analysis.md +0 -199
- package/folder_cameraid_analysis.md +0 -167
- package/migrations/20250924000000_camera_name_search_index.ts +0 -22
package/plan.md
CHANGED
|
@@ -1,288 +1,1034 @@
|
|
|
1
|
-
# Database
|
|
1
|
+
# Report Configurations Database Implementation Plan
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Executive Summary
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
**
|
|
7
|
+
**Key Decisions from User Clarifications:**
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
### Replacement of Existing Normalization
|
|
33
82
|
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
89
|
+
---
|
|
41
90
|
|
|
42
|
-
|
|
91
|
+
## Database Schema
|
|
43
92
|
|
|
44
|
-
|
|
45
|
-
- **Missing Index:** Text search index on `name` column
|
|
46
|
-
- **Impact:** Critical performance issue for camera search
|
|
93
|
+
### Table: `report_configurations`
|
|
47
94
|
|
|
48
|
-
|
|
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
|
-
**
|
|
112
|
+
**Constraints:**
|
|
51
113
|
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
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
|
-
|
|
119
|
+
### Configuration JSONB Structure
|
|
57
120
|
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
- **Existing Index:** Primary key on `id`
|
|
62
|
-
- **Optimization Opportunity:** Batch processing with transaction optimization
|
|
141
|
+
**Validation Rules:**
|
|
63
142
|
|
|
64
|
-
|
|
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
|
-
|
|
149
|
+
---
|
|
67
150
|
|
|
68
|
-
|
|
151
|
+
## Migration
|
|
69
152
|
|
|
70
|
-
|
|
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.
|
|
76
|
-
|
|
77
|
-
table
|
|
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
|
|
177
|
+
// Add case-insensitive name index
|
|
81
178
|
await knex.raw(`
|
|
82
|
-
CREATE INDEX
|
|
83
|
-
ON
|
|
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
|
|
90
|
-
await knex.schema.
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
## Interface Definition
|
|
99
223
|
|
|
100
|
-
|
|
224
|
+
### File: `src/interfaces/report-configuration/report-configuration.interfaces.ts`
|
|
101
225
|
|
|
102
226
|
```typescript
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## DAO Implementation
|
|
162
264
|
|
|
163
|
-
|
|
265
|
+
### File: `src/dao/report-configuration/report-configuration.dao.ts`
|
|
164
266
|
|
|
165
267
|
```typescript
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
583
|
+
export default ReportConfigurationDAO;
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Integration with VideoMinuteResultDAO
|
|
199
589
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
688
|
+
**After applying configuration:**
|
|
207
689
|
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
708
|
+
## Export Updates
|
|
216
709
|
|
|
217
|
-
|
|
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
|
-
|
|
712
|
+
Add exports for new DAO and interfaces:
|
|
222
713
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
739
|
+
### Phase 2: DAO Implementation
|
|
237
740
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
###
|
|
747
|
+
### Phase 3: Integration
|
|
243
748
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
###
|
|
755
|
+
### Phase 4: Exports & Build
|
|
249
756
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
762
|
+
---
|
|
256
763
|
|
|
257
|
-
|
|
764
|
+
## Risks & Concerns
|
|
258
765
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1027
|
+
---
|
|
285
1028
|
|
|
286
|
-
|
|
1029
|
+
## Document Version
|
|
287
1030
|
|
|
288
|
-
**
|
|
1031
|
+
- **Version:** 1.0
|
|
1032
|
+
- **Date:** 2025-09-30
|
|
1033
|
+
- **Author:** Database Planning Specialist (Claude)
|
|
1034
|
+
- **Status:** FINAL - Ready for Implementation
|