@trafficgroup/knex-rel 0.1.11 → 0.1.12

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 (110) hide show
  1. package/.claude/settings.local.json +2 -5
  2. package/CLAUDE.md +11 -2
  3. package/dist/constants/folder.constants.d.ts +12 -0
  4. package/dist/constants/folder.constants.js +28 -0
  5. package/dist/constants/folder.constants.js.map +1 -0
  6. package/dist/constants/video.constants.d.ts +2 -2
  7. package/dist/constants/video.constants.js +9 -5
  8. package/dist/constants/video.constants.js.map +1 -1
  9. package/dist/dao/VideoMinuteResultDAO.d.ts +1 -1
  10. package/dist/dao/VideoMinuteResultDAO.js +29 -23
  11. package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
  12. package/dist/dao/auth/auth.dao.js +4 -1
  13. package/dist/dao/auth/auth.dao.js.map +1 -1
  14. package/dist/dao/batch/batch.dao.js +13 -14
  15. package/dist/dao/batch/batch.dao.js.map +1 -1
  16. package/dist/dao/camera/camera.dao.js +10 -7
  17. package/dist/dao/camera/camera.dao.js.map +1 -1
  18. package/dist/dao/chat/chat.dao.d.ts +1 -1
  19. package/dist/dao/chat/chat.dao.js +27 -40
  20. package/dist/dao/chat/chat.dao.js.map +1 -1
  21. package/dist/dao/folder/folder.dao.d.ts +10 -1
  22. package/dist/dao/folder/folder.dao.js +44 -6
  23. package/dist/dao/folder/folder.dao.js.map +1 -1
  24. package/dist/dao/location/location.dao.js +16 -9
  25. package/dist/dao/location/location.dao.js.map +1 -1
  26. package/dist/dao/message/message.dao.d.ts +1 -1
  27. package/dist/dao/message/message.dao.js +18 -26
  28. package/dist/dao/message/message.dao.js.map +1 -1
  29. package/dist/dao/report-configuration/report-configuration.dao.js +32 -31
  30. package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
  31. package/dist/dao/study/study.dao.js +7 -2
  32. package/dist/dao/study/study.dao.js.map +1 -1
  33. package/dist/dao/user/user.dao.js +4 -1
  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 +26 -8
  36. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
  37. package/dist/dao/video/video.dao.js +30 -28
  38. package/dist/dao/video/video.dao.js.map +1 -1
  39. package/dist/index.d.ts +8 -5
  40. package/dist/index.js +5 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/interfaces/batch/batch.interfaces.d.ts +1 -1
  43. package/dist/interfaces/camera/camera.interfaces.d.ts +1 -1
  44. package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
  45. package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
  46. package/dist/interfaces/message/message.interfaces.d.ts +2 -2
  47. package/dist/interfaces/study/study.interfaces.d.ts +2 -2
  48. package/dist/interfaces/user/user.interfaces.d.ts +1 -1
  49. package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
  50. package/dist/interfaces/video/video.interfaces.d.ts +2 -2
  51. package/migrations/20250717160737_migration.ts +1 -1
  52. package/migrations/20250717160908_migration.ts +5 -2
  53. package/migrations/20250717161310_migration.ts +1 -1
  54. package/migrations/20250717161406_migration.ts +3 -3
  55. package/migrations/20250717162431_migration.ts +1 -1
  56. package/migrations/20250717173228_migration.ts +2 -2
  57. package/migrations/20250717204731_migration.ts +1 -1
  58. package/migrations/20250722210109_migration.ts +8 -4
  59. package/migrations/20250722211019_migration.ts +1 -1
  60. package/migrations/20250723153852_migration.ts +13 -10
  61. package/migrations/20250723162257_migration.ts +4 -7
  62. package/migrations/20250723171109_migration.ts +4 -7
  63. package/migrations/20250723205331_migration.ts +6 -9
  64. package/migrations/20250724191345_migration.ts +8 -11
  65. package/migrations/20250730180932_migration.ts +14 -13
  66. package/migrations/20250730213625_migration.ts +8 -11
  67. package/migrations/20250804124509_migration.ts +26 -21
  68. package/migrations/20250804132053_migration.ts +5 -8
  69. package/migrations/20250804164518_migration.ts +7 -7
  70. package/migrations/20250823223016_migration.ts +32 -21
  71. package/migrations/20250910015452_migration.ts +18 -6
  72. package/migrations/20250911000000_migration.ts +18 -4
  73. package/migrations/20250917144153_migration.ts +14 -7
  74. package/migrations/20250930200521_migration.ts +8 -4
  75. package/migrations/20251010143500_migration.ts +27 -6
  76. package/migrations/20251020225758_migration.ts +51 -15
  77. package/migrations/20251112120000_migration.ts +10 -2
  78. package/migrations/20251112120200_migration.ts +19 -7
  79. package/migrations/20251112120300_migration.ts +7 -2
  80. package/package.json +1 -1
  81. package/src/constants/folder.constants.ts +29 -0
  82. package/src/constants/video.constants.ts +11 -7
  83. package/src/d.types.ts +18 -14
  84. package/src/dao/VideoMinuteResultDAO.ts +72 -49
  85. package/src/dao/auth/auth.dao.ts +58 -55
  86. package/src/dao/batch/batch.dao.ts +101 -98
  87. package/src/dao/camera/camera.dao.ts +124 -121
  88. package/src/dao/chat/chat.dao.ts +45 -45
  89. package/src/dao/folder/folder.dao.ts +112 -55
  90. package/src/dao/location/location.dao.ts +109 -87
  91. package/src/dao/message/message.dao.ts +32 -32
  92. package/src/dao/report-configuration/report-configuration.dao.ts +370 -342
  93. package/src/dao/study/study.dao.ts +88 -63
  94. package/src/dao/user/user.dao.ts +52 -50
  95. package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +80 -48
  96. package/src/dao/video/video.dao.ts +385 -334
  97. package/src/entities/BaseEntity.ts +1 -1
  98. package/src/index.ts +42 -17
  99. package/src/interfaces/auth/auth.interfaces.ts +10 -10
  100. package/src/interfaces/batch/batch.interfaces.ts +1 -1
  101. package/src/interfaces/camera/camera.interfaces.ts +9 -9
  102. package/src/interfaces/chat/chat.interfaces.ts +4 -4
  103. package/src/interfaces/folder/folder.interfaces.ts +2 -2
  104. package/src/interfaces/location/location.interfaces.ts +7 -7
  105. package/src/interfaces/message/message.interfaces.ts +3 -3
  106. package/src/interfaces/report-configuration/report-configuration.interfaces.ts +16 -16
  107. package/src/interfaces/study/study.interfaces.ts +3 -3
  108. package/src/interfaces/user/user.interfaces.ts +9 -9
  109. package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
  110. package/src/interfaces/video/video.interfaces.ts +34 -34
@@ -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,377 +23,405 @@ 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(', ')}`);
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);
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(", ")}`);
60
49
  }
61
50
 
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;
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
+ }
68
103
  }
69
104
 
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;
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
+ );
76
132
  }
77
133
 
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;
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");
86
181
  }
87
182
 
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;
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 };
111
187
  }
112
188
 
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;
189
+ // Min 2, max 7 custom classes
190
+ if (config.customClasses.length < 2) {
191
+ errors.push("Minimum 2 custom classes required");
127
192
  }
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
- };
193
+ if (config.customClasses.length > 7) {
194
+ errors.push("Maximum 7 custom classes allowed");
151
195
  }
152
196
 
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
- }
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);
208
224
  });
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 };
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
+ );
218
237
  }
219
238
 
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;
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;
259
268
  });
260
-
261
- return customClassCounts;
269
+ }
270
+ // Labels not in DETECTION_LABEL_TO_FHWA are silently ignored (e.g., pedestrian, bicycle)
262
271
  }
263
272
 
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
- }
293
- }
294
-
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
- }
300
-
301
- // Iterate through detection labels in input structure
302
- for (const [detectionLabel, nestedData] of Object.entries(vehiclesStructure)) {
303
- const customClassName = detectionToCustomClass[detectionLabel];
304
-
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
- );
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
+ }
315
316
  }
316
-
317
- // Add "Total" class that aggregates all custom classes
318
- result["Total"] = this._createTotalClass(result);
319
-
320
- return result;
317
+ }
321
318
  }
322
319
 
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
- }
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
+ }
349
325
 
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
- }
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
+ }
354
343
 
355
- return target;
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;
356
365
  }
357
366
 
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
- }
367
+ // If source is not an object, return target unchanged
368
+ if (typeof source !== "object" || source === null) {
369
+ return target;
370
+ }
371
371
 
372
- return total;
372
+ // Ensure target is an object
373
+ if (typeof target !== "object" || target === null) {
374
+ target = {};
373
375
  }
374
376
 
375
- /**
376
- * Get the FHWA mapping constant (for reference/debugging)
377
- */
378
- getDetectionLabelToFhwaMapping(): Record<string, number[]> {
379
- return { ...DETECTION_LABEL_TO_FHWA };
377
+ // Recursively merge each key in source
378
+ for (const [key, value] of Object.entries(source)) {
379
+ target[key] = this._deepMergeNumericData(target[key], value);
380
380
  }
381
381
 
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
- };
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);
398
397
  }
398
+
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
+ }
399
427
  }