@trafficgroup/knex-rel 0.1.8 → 0.1.10

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 (115) hide show
  1. package/.claude/settings.local.json +5 -2
  2. package/.env.prod +5 -0
  3. package/CLAUDE.md +2 -11
  4. package/dist/constants/video.constants.d.ts +12 -0
  5. package/dist/constants/video.constants.js +18 -0
  6. package/dist/constants/video.constants.js.map +1 -0
  7. package/dist/dao/VideoMinuteResultDAO.d.ts +4 -1
  8. package/dist/dao/VideoMinuteResultDAO.js +28 -31
  9. package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
  10. package/dist/dao/auth/auth.dao.js +1 -4
  11. package/dist/dao/auth/auth.dao.js.map +1 -1
  12. package/dist/dao/batch/batch.dao.js +14 -13
  13. package/dist/dao/batch/batch.dao.js.map +1 -1
  14. package/dist/dao/camera/camera.dao.d.ts +17 -7
  15. package/dist/dao/camera/camera.dao.js +38 -56
  16. package/dist/dao/camera/camera.dao.js.map +1 -1
  17. package/dist/dao/chat/chat.dao.d.ts +1 -1
  18. package/dist/dao/chat/chat.dao.js +35 -25
  19. package/dist/dao/chat/chat.dao.js.map +1 -1
  20. package/dist/dao/folder/folder.dao.js +4 -8
  21. package/dist/dao/folder/folder.dao.js.map +1 -1
  22. package/dist/dao/location/location.dao.d.ts +17 -0
  23. package/dist/dao/location/location.dao.js +116 -0
  24. package/dist/dao/location/location.dao.js.map +1 -0
  25. package/dist/dao/message/message.dao.d.ts +1 -1
  26. package/dist/dao/message/message.dao.js +26 -18
  27. package/dist/dao/message/message.dao.js.map +1 -1
  28. package/dist/dao/report-configuration/report-configuration.dao.js +31 -32
  29. package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
  30. package/dist/dao/study/study.dao.d.ts +1 -1
  31. package/dist/dao/study/study.dao.js +12 -17
  32. package/dist/dao/study/study.dao.js.map +1 -1
  33. package/dist/dao/user/user.dao.js +1 -4
  34. package/dist/dao/user/user.dao.js.map +1 -1
  35. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +8 -26
  36. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
  37. package/dist/dao/video/video.dao.d.ts +9 -1
  38. package/dist/dao/video/video.dao.js +73 -27
  39. package/dist/dao/video/video.dao.js.map +1 -1
  40. package/dist/index.d.ts +8 -4
  41. package/dist/index.js +8 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/interfaces/batch/batch.interfaces.d.ts +1 -1
  44. package/dist/interfaces/camera/camera.interfaces.d.ts +4 -2
  45. package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
  46. package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
  47. package/dist/interfaces/location/location.interfaces.d.ts +9 -0
  48. package/dist/interfaces/location/location.interfaces.js +3 -0
  49. package/dist/interfaces/location/location.interfaces.js.map +1 -0
  50. package/dist/interfaces/message/message.interfaces.d.ts +2 -2
  51. package/dist/interfaces/study/study.interfaces.d.ts +6 -5
  52. package/dist/interfaces/user/user.interfaces.d.ts +1 -1
  53. package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
  54. package/dist/interfaces/video/video.interfaces.d.ts +3 -2
  55. package/migrations/20250717160737_migration.ts +1 -1
  56. package/migrations/20250717160908_migration.ts +2 -5
  57. package/migrations/20250717161310_migration.ts +1 -1
  58. package/migrations/20250717161406_migration.ts +3 -3
  59. package/migrations/20250717162431_migration.ts +1 -1
  60. package/migrations/20250717173228_migration.ts +2 -2
  61. package/migrations/20250717204731_migration.ts +1 -1
  62. package/migrations/20250722210109_migration.ts +4 -8
  63. package/migrations/20250722211019_migration.ts +1 -1
  64. package/migrations/20250723153852_migration.ts +10 -13
  65. package/migrations/20250723162257_migration.ts +7 -4
  66. package/migrations/20250723171109_migration.ts +7 -4
  67. package/migrations/20250723205331_migration.ts +9 -6
  68. package/migrations/20250724191345_migration.ts +11 -8
  69. package/migrations/20250730180932_migration.ts +13 -14
  70. package/migrations/20250730213625_migration.ts +11 -8
  71. package/migrations/20250804124509_migration.ts +21 -26
  72. package/migrations/20250804132053_migration.ts +8 -5
  73. package/migrations/20250804164518_migration.ts +7 -7
  74. package/migrations/20250823223016_migration.ts +21 -32
  75. package/migrations/20250910015452_migration.ts +6 -18
  76. package/migrations/20250911000000_migration.ts +4 -18
  77. package/migrations/20250917144153_migration.ts +7 -14
  78. package/migrations/20250930200521_migration.ts +4 -8
  79. package/migrations/20251010143500_migration.ts +6 -27
  80. package/migrations/20251020225758_migration.ts +15 -51
  81. package/migrations/20251112120000_migration.ts +81 -0
  82. package/migrations/20251112120100_migration.ts +21 -0
  83. package/migrations/20251112120200_migration.ts +38 -0
  84. package/migrations/20251112120300_migration.ts +22 -0
  85. package/package.json +1 -1
  86. package/src/constants/video.constants.ts +19 -0
  87. package/src/d.types.ts +14 -18
  88. package/src/dao/VideoMinuteResultDAO.ts +54 -72
  89. package/src/dao/auth/auth.dao.ts +55 -58
  90. package/src/dao/batch/batch.dao.ts +98 -101
  91. package/src/dao/camera/camera.dao.ts +125 -145
  92. package/src/dao/chat/chat.dao.ts +43 -45
  93. package/src/dao/folder/folder.dao.ts +56 -59
  94. package/src/dao/location/location.dao.ts +101 -0
  95. package/src/dao/message/message.dao.ts +32 -32
  96. package/src/dao/report-configuration/report-configuration.dao.ts +342 -370
  97. package/src/dao/study/study.dao.ts +63 -88
  98. package/src/dao/user/user.dao.ts +50 -52
  99. package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +49 -83
  100. package/src/dao/video/video.dao.ts +339 -313
  101. package/src/entities/BaseEntity.ts +1 -1
  102. package/src/index.ts +24 -26
  103. package/src/interfaces/auth/auth.interfaces.ts +10 -10
  104. package/src/interfaces/batch/batch.interfaces.ts +1 -1
  105. package/src/interfaces/camera/camera.interfaces.ts +9 -7
  106. package/src/interfaces/chat/chat.interfaces.ts +4 -4
  107. package/src/interfaces/folder/folder.interfaces.ts +2 -2
  108. package/src/interfaces/location/location.interfaces.ts +9 -0
  109. package/src/interfaces/message/message.interfaces.ts +3 -3
  110. package/src/interfaces/report-configuration/report-configuration.interfaces.ts +16 -16
  111. package/src/interfaces/study/study.interfaces.ts +7 -6
  112. package/src/interfaces/user/user.interfaces.ts +9 -9
  113. package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
  114. package/src/interfaces/video/video.interfaces.ts +34 -33
  115. package/plan.md +0 -684
@@ -1,10 +1,10 @@
1
1
  import { Knex } from "knex";
2
2
  import { IBaseDAO, IDataPaginator } from "../../d.types";
3
3
  import {
4
- IReportConfiguration,
5
- IReportConfigurationData,
6
- IReportConfigurationInput,
7
- IValidationResult,
4
+ IReportConfiguration,
5
+ IReportConfigurationData,
6
+ IReportConfigurationInput,
7
+ IValidationResult
8
8
  } from "../../interfaces/report-configuration/report-configuration.interfaces";
9
9
  import KnexManager from "../../KnexConnection";
10
10
 
@@ -23,405 +23,377 @@ import KnexManager from "../../KnexConnection";
23
23
  * Non-motorized vehicles (pedestrian, bicycle, non_motorized_vehicle) are EXCLUDED
24
24
  */
25
25
  const DETECTION_LABEL_TO_FHWA: Record<string, number[]> = {
26
- motorcycle: [1],
27
- car: [2],
28
- pickup_truck: [3],
29
- motorized_vehicle: [3], // Maps to Class 3 (same as pickup_truck)
30
- bus: [4],
31
- work_van: [5],
32
- single_unit_truck: [6, 7, 8], // Classes 6-8
33
- articulated_truck: [9, 10, 11, 12, 13], // Classes 9-13
34
- // pedestrian, bicycle, non_motorized_vehicle are EXCLUDED
26
+ 'motorcycle': [1],
27
+ 'car': [2],
28
+ 'pickup_truck': [3],
29
+ 'motorized_vehicle': [3], // Maps to Class 3 (same as pickup_truck)
30
+ 'bus': [4],
31
+ 'work_van': [5],
32
+ 'single_unit_truck': [6, 7, 8], // Classes 6-8
33
+ 'articulated_truck': [9, 10, 11, 12, 13] // Classes 9-13
34
+ // pedestrian, bicycle, non_motorized_vehicle are EXCLUDED
35
35
  };
36
36
 
37
37
  export class ReportConfigurationDAO implements IBaseDAO<IReportConfiguration> {
38
- private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
39
- private tableName = "report_configurations";
40
-
41
- /**
42
- * Create a new report configuration
43
- */
44
- async create(item: IReportConfigurationInput): Promise<IReportConfiguration> {
45
- // Validate configuration before creating
46
- const validation = this.validateConfiguration(item.configuration);
47
- if (!validation.valid) {
48
- throw new Error(`Invalid configuration: ${validation.errors.join(", ")}`);
38
+ private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
39
+ private tableName = "report_configurations";
40
+
41
+ /**
42
+ * Create a new report configuration
43
+ */
44
+ async create(item: IReportConfigurationInput): Promise<IReportConfiguration> {
45
+ // Validate configuration before creating
46
+ const validation = this.validateConfiguration(item.configuration);
47
+ if (!validation.valid) {
48
+ throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`);
49
+ }
50
+
51
+ const [createdConfig] = await this._knex(this.tableName)
52
+ .insert({
53
+ name: item.name,
54
+ description: item.description,
55
+ configuration: JSON.stringify(item.configuration)
56
+ })
57
+ .returning("*");
58
+
59
+ return this._deserialize(createdConfig);
49
60
  }
50
61
 
51
- const [createdConfig] = await this._knex(this.tableName)
52
- .insert({
53
- name: item.name,
54
- description: item.description,
55
- configuration: JSON.stringify(item.configuration),
56
- })
57
- .returning("*");
58
-
59
- return this._deserialize(createdConfig);
60
- }
61
-
62
- /**
63
- * Get configuration by numeric ID
64
- */
65
- async getById(id: number): Promise<IReportConfiguration | null> {
66
- const config = await this._knex(this.tableName).where({ id }).first();
67
- return config ? this._deserialize(config) : null;
68
- }
69
-
70
- /**
71
- * Get configuration by UUID
72
- */
73
- async getByUuid(uuid: string): Promise<IReportConfiguration | null> {
74
- const config = await this._knex(this.tableName).where({ uuid }).first();
75
- return config ? this._deserialize(config) : null;
76
- }
77
-
78
- /**
79
- * Get configuration by name
80
- */
81
- async getByName(name: string): Promise<IReportConfiguration | null> {
82
- const config = await this._knex(this.tableName)
83
- .whereRaw("LOWER(name) = LOWER(?)", [name])
84
- .first();
85
- return config ? this._deserialize(config) : null;
86
- }
87
-
88
- /**
89
- * Update a configuration
90
- */
91
- async update(
92
- id: number,
93
- item: Partial<IReportConfigurationInput>,
94
- ): Promise<IReportConfiguration | null> {
95
- // If configuration is being updated, validate it
96
- if (item.configuration) {
97
- const validation = this.validateConfiguration(item.configuration);
98
- if (!validation.valid) {
99
- throw new Error(
100
- `Invalid configuration: ${validation.errors.join(", ")}`,
101
- );
102
- }
62
+ /**
63
+ * Get configuration by numeric ID
64
+ */
65
+ async getById(id: number): Promise<IReportConfiguration | null> {
66
+ const config = await this._knex(this.tableName).where({ id }).first();
67
+ return config ? this._deserialize(config) : null;
103
68
  }
104
69
 
105
- const updateData: any = {};
106
- if (item.name !== undefined) updateData.name = item.name;
107
- if (item.description !== undefined)
108
- updateData.description = item.description;
109
- if (item.configuration !== undefined)
110
- updateData.configuration = JSON.stringify(item.configuration);
111
-
112
- const [updatedConfig] = await this._knex(this.tableName)
113
- .where({ id })
114
- .update(updateData)
115
- .returning("*");
116
-
117
- return updatedConfig ? this._deserialize(updatedConfig) : null;
118
- }
119
-
120
- /**
121
- * Delete a configuration
122
- * Prevents deletion of the last configuration (business logic protection)
123
- */
124
- async delete(id: number): Promise<boolean> {
125
- // Count total configurations
126
- const [{ count }] = await this._knex(this.tableName).count("* as count");
127
-
128
- if (parseInt(count as string) <= 1) {
129
- throw new Error(
130
- "Cannot delete the last configuration. At least one configuration must exist.",
131
- );
70
+ /**
71
+ * Get configuration by UUID
72
+ */
73
+ async getByUuid(uuid: string): Promise<IReportConfiguration | null> {
74
+ const config = await this._knex(this.tableName).where({ uuid }).first();
75
+ return config ? this._deserialize(config) : null;
132
76
  }
133
77
 
134
- const result = await this._knex(this.tableName).where({ id }).del();
135
- return result > 0;
136
- }
137
-
138
- /**
139
- * Get all configurations with pagination
140
- */
141
- async getAll(
142
- page: number,
143
- limit: number,
144
- ): Promise<IDataPaginator<IReportConfiguration>> {
145
- const offset = (page - 1) * limit;
146
-
147
- const [countResult] = await this._knex(this.tableName).count("* as count");
148
- const totalCount = +countResult.count;
149
- const configs = await this._knex(this.tableName)
150
- .limit(limit)
151
- .offset(offset)
152
- .orderBy("created_at", "desc");
153
-
154
- return {
155
- success: true,
156
- data: configs.map((c) => this._deserialize(c)),
157
- page,
158
- limit,
159
- count: configs.length,
160
- totalCount,
161
- totalPages: Math.ceil(totalCount / limit),
162
- };
163
- }
164
-
165
- /**
166
- * Validate a report configuration
167
- *
168
- * Rules:
169
- * - Minimum 2, maximum 7 custom classes
170
- * - Custom class names must be 1-30 characters
171
- * - FHWA classes must be in range 1-13
172
- * - Each FHWA class can only be mapped to one custom class (no duplicates)
173
- * - Each custom class must have at least one FHWA class
174
- */
175
- validateConfiguration(config: IReportConfigurationData): IValidationResult {
176
- const errors: string[] = [];
177
-
178
- // Validate version exists
179
- if (!config.version) {
180
- errors.push("Configuration version is required");
78
+ /**
79
+ * Get configuration by name
80
+ */
81
+ async getByName(name: string): Promise<IReportConfiguration | null> {
82
+ const config = await this._knex(this.tableName)
83
+ .whereRaw("LOWER(name) = LOWER(?)", [name])
84
+ .first();
85
+ return config ? this._deserialize(config) : null;
181
86
  }
182
87
 
183
- // Validate custom classes array
184
- if (!config.customClasses || !Array.isArray(config.customClasses)) {
185
- errors.push("customClasses must be an array");
186
- return { valid: false, errors };
88
+ /**
89
+ * Update a configuration
90
+ */
91
+ async update(id: number, item: Partial<IReportConfigurationInput>): Promise<IReportConfiguration | null> {
92
+ // If configuration is being updated, validate it
93
+ if (item.configuration) {
94
+ const validation = this.validateConfiguration(item.configuration);
95
+ if (!validation.valid) {
96
+ throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`);
97
+ }
98
+ }
99
+
100
+ const updateData: any = {};
101
+ if (item.name !== undefined) updateData.name = item.name;
102
+ if (item.description !== undefined) updateData.description = item.description;
103
+ if (item.configuration !== undefined) updateData.configuration = JSON.stringify(item.configuration);
104
+
105
+ const [updatedConfig] = await this._knex(this.tableName)
106
+ .where({ id })
107
+ .update(updateData)
108
+ .returning("*");
109
+
110
+ return updatedConfig ? this._deserialize(updatedConfig) : null;
187
111
  }
188
112
 
189
- // Min 2, max 7 custom classes
190
- if (config.customClasses.length < 2) {
191
- errors.push("Minimum 2 custom classes required");
113
+ /**
114
+ * Delete a configuration
115
+ * Prevents deletion of the last configuration (business logic protection)
116
+ */
117
+ async delete(id: number): Promise<boolean> {
118
+ // Count total configurations
119
+ const [{ count }] = await this._knex(this.tableName).count('* as count');
120
+
121
+ if (parseInt(count as string) <= 1) {
122
+ throw new Error('Cannot delete the last configuration. At least one configuration must exist.');
123
+ }
124
+
125
+ const result = await this._knex(this.tableName).where({ id }).del();
126
+ return result > 0;
192
127
  }
193
- if (config.customClasses.length > 7) {
194
- errors.push("Maximum 7 custom classes allowed");
128
+
129
+ /**
130
+ * Get all configurations with pagination
131
+ */
132
+ async getAll(page: number, limit: number): Promise<IDataPaginator<IReportConfiguration>> {
133
+ const offset = (page - 1) * limit;
134
+
135
+ const [countResult] = await this._knex(this.tableName).count("* as count");
136
+ const totalCount = +countResult.count;
137
+ const configs = await this._knex(this.tableName)
138
+ .limit(limit)
139
+ .offset(offset)
140
+ .orderBy("created_at", "desc");
141
+
142
+ return {
143
+ success: true,
144
+ data: configs.map(c => this._deserialize(c)),
145
+ page,
146
+ limit,
147
+ count: configs.length,
148
+ totalCount,
149
+ totalPages: Math.ceil(totalCount / limit),
150
+ };
195
151
  }
196
152
 
197
- // Check name length (max 30 chars) and FHWA classes validity
198
- const allFhwaClasses: number[] = [];
199
- config.customClasses.forEach((cls, idx) => {
200
- if (!cls.name || cls.name.length === 0) {
201
- errors.push(`Custom class ${idx + 1}: name cannot be empty`);
202
- }
203
- if (cls.name && cls.name.toLowerCase() === "total") {
204
- errors.push(
205
- `Custom class ${idx + 1}: "Total" is a reserved name and cannot be used`,
206
- );
207
- }
208
- if (cls.name && cls.name.length > 30) {
209
- errors.push(`Custom class ${idx + 1}: name exceeds 30 characters`);
210
- }
211
-
212
- if (!Array.isArray(cls.fhwaClasses) || cls.fhwaClasses.length === 0) {
213
- errors.push(
214
- `Custom class ${idx + 1}: must have at least one FHWA class`,
215
- );
216
- } else {
217
- cls.fhwaClasses.forEach((fhwa) => {
218
- if (!Number.isInteger(fhwa) || fhwa < 1 || fhwa > 13) {
219
- errors.push(
220
- `Custom class ${idx + 1}: FHWA class ${fhwa} is invalid (must be 1-13)`,
221
- );
222
- }
223
- allFhwaClasses.push(fhwa);
153
+ /**
154
+ * Validate a report configuration
155
+ *
156
+ * Rules:
157
+ * - Minimum 2, maximum 7 custom classes
158
+ * - Custom class names must be 1-30 characters
159
+ * - FHWA classes must be in range 1-13
160
+ * - Each FHWA class can only be mapped to one custom class (no duplicates)
161
+ * - Each custom class must have at least one FHWA class
162
+ */
163
+ validateConfiguration(config: IReportConfigurationData): IValidationResult {
164
+ const errors: string[] = [];
165
+
166
+ // Validate version exists
167
+ if (!config.version) {
168
+ errors.push('Configuration version is required');
169
+ }
170
+
171
+ // Validate custom classes array
172
+ if (!config.customClasses || !Array.isArray(config.customClasses)) {
173
+ errors.push('customClasses must be an array');
174
+ return { valid: false, errors };
175
+ }
176
+
177
+ // Min 2, max 7 custom classes
178
+ if (config.customClasses.length < 2) {
179
+ errors.push('Minimum 2 custom classes required');
180
+ }
181
+ if (config.customClasses.length > 7) {
182
+ errors.push('Maximum 7 custom classes allowed');
183
+ }
184
+
185
+ // Check name length (max 30 chars) and FHWA classes validity
186
+ const allFhwaClasses: number[] = [];
187
+ config.customClasses.forEach((cls, idx) => {
188
+ if (!cls.name || cls.name.length === 0) {
189
+ errors.push(`Custom class ${idx + 1}: name cannot be empty`);
190
+ }
191
+ if (cls.name && cls.name.toLowerCase() === 'total') {
192
+ errors.push(`Custom class ${idx + 1}: "Total" is a reserved name and cannot be used`);
193
+ }
194
+ if (cls.name && cls.name.length > 30) {
195
+ errors.push(`Custom class ${idx + 1}: name exceeds 30 characters`);
196
+ }
197
+
198
+ if (!Array.isArray(cls.fhwaClasses) || cls.fhwaClasses.length === 0) {
199
+ errors.push(`Custom class ${idx + 1}: must have at least one FHWA class`);
200
+ } else {
201
+ cls.fhwaClasses.forEach(fhwa => {
202
+ if (!Number.isInteger(fhwa) || fhwa < 1 || fhwa > 13) {
203
+ errors.push(`Custom class ${idx + 1}: FHWA class ${fhwa} is invalid (must be 1-13)`);
204
+ }
205
+ allFhwaClasses.push(fhwa);
206
+ });
207
+ }
224
208
  });
225
- }
226
- });
227
-
228
- // Check for duplicate FHWA classes (many-to-one only)
229
- const uniqueFhwaClasses = new Set(allFhwaClasses);
230
- if (uniqueFhwaClasses.size !== allFhwaClasses.length) {
231
- const duplicates = allFhwaClasses.filter(
232
- (item, index) => allFhwaClasses.indexOf(item) !== index,
233
- );
234
- errors.push(
235
- `Duplicate FHWA classes detected: ${[...new Set(duplicates)].join(", ")}. Each FHWA class can only be mapped to one custom class.`,
236
- );
209
+
210
+ // Check for duplicate FHWA classes (many-to-one only)
211
+ const uniqueFhwaClasses = new Set(allFhwaClasses);
212
+ if (uniqueFhwaClasses.size !== allFhwaClasses.length) {
213
+ const duplicates = allFhwaClasses.filter((item, index) => allFhwaClasses.indexOf(item) !== index);
214
+ errors.push(`Duplicate FHWA classes detected: ${[...new Set(duplicates)].join(', ')}. Each FHWA class can only be mapped to one custom class.`);
215
+ }
216
+
217
+ return { valid: errors.length === 0, errors };
237
218
  }
238
219
 
239
- return { valid: errors.length === 0, errors };
240
- }
241
-
242
- /**
243
- * Apply configuration transformation to detection results
244
- *
245
- * Two-step transformation:
246
- * 1. Detection labels FHWA classes (using DETECTION_LABEL_TO_FHWA mapping)
247
- * 2. FHWA classes Custom classes (using configuration)
248
- *
249
- * @param detectionResults - Raw detection results with labels as keys and counts as values
250
- * Example: { 'car': 150, 'articulated_truck': 23, 'motorcycle': 5 }
251
- * @param config - The report configuration to apply
252
- * @returns Transformed results with custom class names as keys and counts as values
253
- * Example: { 'Cars': 155, 'Heavy Trucks': 23 }
254
- */
255
- applyConfiguration(
256
- detectionResults: Record<string, number>,
257
- config: IReportConfiguration,
258
- ): Record<string, number> {
259
- // Step 1: Detection labels FHWA classes
260
- const fhwaClassCounts: Record<number, number> = {};
261
-
262
- for (const [label, count] of Object.entries(detectionResults)) {
263
- const fhwaClasses = DETECTION_LABEL_TO_FHWA[label];
264
- if (fhwaClasses && fhwaClasses.length > 0) {
265
- fhwaClasses.forEach((fhwaClass) => {
266
- fhwaClassCounts[fhwaClass] =
267
- (fhwaClassCounts[fhwaClass] || 0) + count;
220
+ /**
221
+ * Apply configuration transformation to detection results
222
+ *
223
+ * Two-step transformation:
224
+ * 1. Detection labels FHWA classes (using DETECTION_LABEL_TO_FHWA mapping)
225
+ * 2. FHWA classes → Custom classes (using configuration)
226
+ *
227
+ * @param detectionResults - Raw detection results with labels as keys and counts as values
228
+ * Example: { 'car': 150, 'articulated_truck': 23, 'motorcycle': 5 }
229
+ * @param config - The report configuration to apply
230
+ * @returns Transformed results with custom class names as keys and counts as values
231
+ * Example: { 'Cars': 155, 'Heavy Trucks': 23 }
232
+ */
233
+ applyConfiguration(
234
+ detectionResults: Record<string, number>,
235
+ config: IReportConfiguration
236
+ ): Record<string, number> {
237
+ // Step 1: Detection labels → FHWA classes
238
+ const fhwaClassCounts: Record<number, number> = {};
239
+
240
+ for (const [label, count] of Object.entries(detectionResults)) {
241
+ const fhwaClasses = DETECTION_LABEL_TO_FHWA[label];
242
+ if (fhwaClasses && fhwaClasses.length > 0) {
243
+ fhwaClasses.forEach(fhwaClass => {
244
+ fhwaClassCounts[fhwaClass] = (fhwaClassCounts[fhwaClass] || 0) + count;
245
+ });
246
+ }
247
+ // Labels not in DETECTION_LABEL_TO_FHWA are silently ignored (e.g., pedestrian, bicycle)
248
+ }
249
+
250
+ // Step 2: FHWA classes → Custom classes
251
+ const customClassCounts: Record<string, number> = {};
252
+
253
+ config.configuration.customClasses.forEach(customClass => {
254
+ let total = 0;
255
+ customClass.fhwaClasses.forEach(fhwaClass => {
256
+ total += fhwaClassCounts[fhwaClass] || 0;
257
+ });
258
+ customClassCounts[customClass.name] = total;
268
259
  });
269
- }
270
- // Labels not in DETECTION_LABEL_TO_FHWA are silently ignored (e.g., pedestrian, bicycle)
260
+
261
+ return customClassCounts;
271
262
  }
272
263
 
273
- // Step 2: FHWA classes → Custom classes
274
- const customClassCounts: Record<string, number> = {};
275
-
276
- config.configuration.customClasses.forEach((customClass) => {
277
- let total = 0;
278
- customClass.fhwaClasses.forEach((fhwaClass) => {
279
- total += fhwaClassCounts[fhwaClass] || 0;
280
- });
281
- customClassCounts[customClass.name] = total;
282
- });
283
-
284
- return customClassCounts;
285
- }
286
-
287
- /**
288
- * Transform nested vehicle structure with custom class mapping
289
- *
290
- * Handles both ATR (lane-based) and TMC (direction/turn-based) formats
291
- * Preserves all nesting levels while transforming detection labels to custom classes
292
- *
293
- * @param vehiclesStructure - Nested vehicles object with detection labels as keys
294
- * ATR: { "car": { "0": 45, "1": 50 }, ... }
295
- * TMC: { "car": { "NORTH": { "straight": 10 }, ... }, ... }
296
- * @param config - Report configuration with custom class mappings
297
- * @returns Transformed structure with custom class names as keys
298
- */
299
- applyConfigurationToNestedStructure(
300
- vehiclesStructure: Record<string, any>,
301
- config: IReportConfiguration,
302
- ): Record<string, any> {
303
- // Build reverse mapping: detection label → custom class name
304
- const detectionToCustomClass: Record<string, string> = {};
305
-
306
- for (const customClass of config.configuration.customClasses) {
307
- // For each FHWA class in this custom class
308
- for (const fhwaClass of customClass.fhwaClasses) {
309
- // Find all detection labels that map to this FHWA class
310
- for (const [label, fhwaClasses] of Object.entries(
311
- DETECTION_LABEL_TO_FHWA,
312
- )) {
313
- if (fhwaClasses.includes(fhwaClass)) {
314
- detectionToCustomClass[label] = customClass.name;
315
- }
264
+ /**
265
+ * Transform nested vehicle structure with custom class mapping
266
+ *
267
+ * Handles both ATR (lane-based) and TMC (direction/turn-based) formats
268
+ * Preserves all nesting levels while transforming detection labels to custom classes
269
+ *
270
+ * @param vehiclesStructure - Nested vehicles object with detection labels as keys
271
+ * ATR: { "car": { "0": 45, "1": 50 }, ... }
272
+ * TMC: { "car": { "NORTH": { "straight": 10 }, ... }, ... }
273
+ * @param config - Report configuration with custom class mappings
274
+ * @returns Transformed structure with custom class names as keys
275
+ */
276
+ applyConfigurationToNestedStructure(
277
+ vehiclesStructure: Record<string, any>,
278
+ config: IReportConfiguration
279
+ ): Record<string, any> {
280
+ // Build reverse mapping: detection label → custom class name
281
+ const detectionToCustomClass: Record<string, string> = {};
282
+
283
+ for (const customClass of config.configuration.customClasses) {
284
+ // For each FHWA class in this custom class
285
+ for (const fhwaClass of customClass.fhwaClasses) {
286
+ // Find all detection labels that map to this FHWA class
287
+ for (const [label, fhwaClasses] of Object.entries(DETECTION_LABEL_TO_FHWA)) {
288
+ if (fhwaClasses.includes(fhwaClass)) {
289
+ detectionToCustomClass[label] = customClass.name;
290
+ }
291
+ }
292
+ }
316
293
  }
317
- }
318
- }
319
294
 
320
- // Initialize empty structure for each custom class
321
- const result: Record<string, any> = {};
322
- for (const customClass of config.configuration.customClasses) {
323
- result[customClass.name] = {};
324
- }
295
+ // Initialize empty structure for each custom class
296
+ const result: Record<string, any> = {};
297
+ for (const customClass of config.configuration.customClasses) {
298
+ result[customClass.name] = {};
299
+ }
325
300
 
326
- // Iterate through detection labels in input structure
327
- for (const [detectionLabel, nestedData] of Object.entries(
328
- vehiclesStructure,
329
- )) {
330
- const customClassName = detectionToCustomClass[detectionLabel];
331
-
332
- // Skip labels not mapped to any custom class (e.g., pedestrian, bicycle)
333
- if (!customClassName) {
334
- continue;
335
- }
336
-
337
- // Deep merge nested data into custom class accumulator
338
- result[customClassName] = this._deepMergeNumericData(
339
- result[customClassName],
340
- nestedData,
341
- );
342
- }
301
+ // Iterate through detection labels in input structure
302
+ for (const [detectionLabel, nestedData] of Object.entries(vehiclesStructure)) {
303
+ const customClassName = detectionToCustomClass[detectionLabel];
343
304
 
344
- // Add "Total" class that aggregates all custom classes
345
- result["Total"] = this._createTotalClass(result);
346
-
347
- return result;
348
- }
349
-
350
- /**
351
- * Deep merge numeric data at arbitrary nesting levels
352
- *
353
- * Recursively merges two nested structures, summing numeric leaf values
354
- * Handles ATR format (2 levels: vehicle → lane → count)
355
- * Handles TMC format (3 levels: vehicle → direction → turn → count)
356
- *
357
- * @param target - Target accumulator object
358
- * @param source - Source data to merge into target
359
- * @returns Merged object with summed numeric values
360
- */
361
- private _deepMergeNumericData(target: any, source: any): any {
362
- // Base case: if source is a number, add it to target
363
- if (typeof source === "number") {
364
- return (typeof target === "number" ? target : 0) + source;
365
- }
305
+ // Skip labels not mapped to any custom class (e.g., pedestrian, bicycle)
306
+ if (!customClassName) {
307
+ continue;
308
+ }
309
+
310
+ // Deep merge nested data into custom class accumulator
311
+ result[customClassName] = this._deepMergeNumericData(
312
+ result[customClassName],
313
+ nestedData
314
+ );
315
+ }
316
+
317
+ // Add "Total" class that aggregates all custom classes
318
+ result["Total"] = this._createTotalClass(result);
366
319
 
367
- // If source is not an object, return target unchanged
368
- if (typeof source !== "object" || source === null) {
369
- return target;
320
+ return result;
370
321
  }
371
322
 
372
- // Ensure target is an object
373
- if (typeof target !== "object" || target === null) {
374
- target = {};
323
+ /**
324
+ * Deep merge numeric data at arbitrary nesting levels
325
+ *
326
+ * Recursively merges two nested structures, summing numeric leaf values
327
+ * Handles ATR format (2 levels: vehicle → lane → count)
328
+ * Handles TMC format (3 levels: vehicle → direction → turn → count)
329
+ *
330
+ * @param target - Target accumulator object
331
+ * @param source - Source data to merge into target
332
+ * @returns Merged object with summed numeric values
333
+ */
334
+ private _deepMergeNumericData(target: any, source: any): any {
335
+ // Base case: if source is a number, add it to target
336
+ if (typeof source === 'number') {
337
+ return (typeof target === 'number' ? target : 0) + source;
338
+ }
339
+
340
+ // If source is not an object, return target unchanged
341
+ if (typeof source !== 'object' || source === null) {
342
+ return target;
343
+ }
344
+
345
+ // Ensure target is an object
346
+ if (typeof target !== 'object' || target === null) {
347
+ target = {};
348
+ }
349
+
350
+ // Recursively merge each key in source
351
+ for (const [key, value] of Object.entries(source)) {
352
+ target[key] = this._deepMergeNumericData(target[key], value);
353
+ }
354
+
355
+ return target;
375
356
  }
376
357
 
377
- // Recursively merge each key in source
378
- for (const [key, value] of Object.entries(source)) {
379
- target[key] = this._deepMergeNumericData(target[key], value);
358
+ /**
359
+ * Create "Total" vehicle class by summing all custom classes
360
+ * Works for both ATR and TMC formats through structure-agnostic deep merge
361
+ *
362
+ * @param customClassesData - Object with custom class names as keys
363
+ * @returns Aggregated nested structure summing all classes
364
+ */
365
+ private _createTotalClass(customClassesData: Record<string, any>): any {
366
+ let total: any = {};
367
+
368
+ for (const [className, nestedData] of Object.entries(customClassesData)) {
369
+ total = this._deepMergeNumericData(total, nestedData);
370
+ }
371
+
372
+ return total;
380
373
  }
381
374
 
382
- return target;
383
- }
384
-
385
- /**
386
- * Create "Total" vehicle class by summing all custom classes
387
- * Works for both ATR and TMC formats through structure-agnostic deep merge
388
- *
389
- * @param customClassesData - Object with custom class names as keys
390
- * @returns Aggregated nested structure summing all classes
391
- */
392
- private _createTotalClass(customClassesData: Record<string, any>): any {
393
- let total: any = {};
394
-
395
- for (const [className, nestedData] of Object.entries(customClassesData)) {
396
- total = this._deepMergeNumericData(total, nestedData);
375
+ /**
376
+ * Get the FHWA mapping constant (for reference/debugging)
377
+ */
378
+ getDetectionLabelToFhwaMapping(): Record<string, number[]> {
379
+ return { ...DETECTION_LABEL_TO_FHWA };
397
380
  }
398
381
 
399
- return total;
400
- }
401
-
402
- /**
403
- * Get the FHWA mapping constant (for reference/debugging)
404
- */
405
- getDetectionLabelToFhwaMapping(): Record<string, number[]> {
406
- return { ...DETECTION_LABEL_TO_FHWA };
407
- }
408
-
409
- /**
410
- * Deserialize database row to IReportConfiguration interface
411
- * Converts snake_case to camelCase and parses JSONB
412
- */
413
- private _deserialize(row: any): IReportConfiguration {
414
- return {
415
- id: row.id,
416
- uuid: row.uuid,
417
- name: row.name,
418
- description: row.description,
419
- configuration:
420
- typeof row.configuration === "string"
421
- ? JSON.parse(row.configuration)
422
- : row.configuration,
423
- created_at: row.created_at,
424
- updated_at: row.updated_at,
425
- };
426
- }
382
+ /**
383
+ * Deserialize database row to IReportConfiguration interface
384
+ * Converts snake_case to camelCase and parses JSONB
385
+ */
386
+ private _deserialize(row: any): IReportConfiguration {
387
+ return {
388
+ id: row.id,
389
+ uuid: row.uuid,
390
+ name: row.name,
391
+ description: row.description,
392
+ configuration: typeof row.configuration === 'string'
393
+ ? JSON.parse(row.configuration)
394
+ : row.configuration,
395
+ created_at: row.created_at,
396
+ updated_at: row.updated_at
397
+ };
398
+ }
427
399
  }