@trafficgroup/knex-rel 0.1.21 → 0.1.23-rc.0

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