@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.
- package/.claude/settings.local.json +2 -5
- package/CLAUDE.md +11 -2
- package/dist/constants/folder.constants.d.ts +12 -0
- package/dist/constants/folder.constants.js +28 -0
- package/dist/constants/folder.constants.js.map +1 -0
- package/dist/constants/video.constants.d.ts +2 -2
- package/dist/constants/video.constants.js +9 -5
- package/dist/constants/video.constants.js.map +1 -1
- package/dist/dao/VideoMinuteResultDAO.d.ts +1 -1
- package/dist/dao/VideoMinuteResultDAO.js +29 -23
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
- package/dist/dao/auth/auth.dao.js +4 -1
- package/dist/dao/auth/auth.dao.js.map +1 -1
- package/dist/dao/batch/batch.dao.js +13 -14
- package/dist/dao/batch/batch.dao.js.map +1 -1
- package/dist/dao/camera/camera.dao.js +10 -7
- package/dist/dao/camera/camera.dao.js.map +1 -1
- package/dist/dao/chat/chat.dao.d.ts +1 -1
- package/dist/dao/chat/chat.dao.js +27 -40
- package/dist/dao/chat/chat.dao.js.map +1 -1
- package/dist/dao/folder/folder.dao.d.ts +10 -1
- package/dist/dao/folder/folder.dao.js +44 -6
- package/dist/dao/folder/folder.dao.js.map +1 -1
- package/dist/dao/location/location.dao.js +16 -9
- package/dist/dao/location/location.dao.js.map +1 -1
- package/dist/dao/message/message.dao.d.ts +1 -1
- package/dist/dao/message/message.dao.js +18 -26
- package/dist/dao/message/message.dao.js.map +1 -1
- package/dist/dao/report-configuration/report-configuration.dao.js +32 -31
- package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
- package/dist/dao/study/study.dao.js +7 -2
- package/dist/dao/study/study.dao.js.map +1 -1
- package/dist/dao/user/user.dao.js +4 -1
- package/dist/dao/user/user.dao.js.map +1 -1
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +26 -8
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
- package/dist/dao/video/video.dao.js +30 -28
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/batch/batch.interfaces.d.ts +1 -1
- package/dist/interfaces/camera/camera.interfaces.d.ts +1 -1
- package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
- package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
- package/dist/interfaces/message/message.interfaces.d.ts +2 -2
- package/dist/interfaces/study/study.interfaces.d.ts +2 -2
- package/dist/interfaces/user/user.interfaces.d.ts +1 -1
- package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
- package/dist/interfaces/video/video.interfaces.d.ts +2 -2
- package/migrations/20250717160737_migration.ts +1 -1
- package/migrations/20250717160908_migration.ts +5 -2
- package/migrations/20250717161310_migration.ts +1 -1
- package/migrations/20250717161406_migration.ts +3 -3
- package/migrations/20250717162431_migration.ts +1 -1
- package/migrations/20250717173228_migration.ts +2 -2
- package/migrations/20250717204731_migration.ts +1 -1
- package/migrations/20250722210109_migration.ts +8 -4
- package/migrations/20250722211019_migration.ts +1 -1
- package/migrations/20250723153852_migration.ts +13 -10
- package/migrations/20250723162257_migration.ts +4 -7
- package/migrations/20250723171109_migration.ts +4 -7
- package/migrations/20250723205331_migration.ts +6 -9
- package/migrations/20250724191345_migration.ts +8 -11
- package/migrations/20250730180932_migration.ts +14 -13
- package/migrations/20250730213625_migration.ts +8 -11
- package/migrations/20250804124509_migration.ts +26 -21
- package/migrations/20250804132053_migration.ts +5 -8
- package/migrations/20250804164518_migration.ts +7 -7
- package/migrations/20250823223016_migration.ts +32 -21
- package/migrations/20250910015452_migration.ts +18 -6
- package/migrations/20250911000000_migration.ts +18 -4
- package/migrations/20250917144153_migration.ts +14 -7
- package/migrations/20250930200521_migration.ts +8 -4
- package/migrations/20251010143500_migration.ts +27 -6
- package/migrations/20251020225758_migration.ts +51 -15
- package/migrations/20251112120000_migration.ts +10 -2
- package/migrations/20251112120200_migration.ts +19 -7
- package/migrations/20251112120300_migration.ts +7 -2
- package/package.json +1 -1
- package/src/constants/folder.constants.ts +29 -0
- package/src/constants/video.constants.ts +11 -7
- package/src/d.types.ts +18 -14
- package/src/dao/VideoMinuteResultDAO.ts +72 -49
- package/src/dao/auth/auth.dao.ts +58 -55
- package/src/dao/batch/batch.dao.ts +101 -98
- package/src/dao/camera/camera.dao.ts +124 -121
- package/src/dao/chat/chat.dao.ts +45 -45
- package/src/dao/folder/folder.dao.ts +112 -55
- package/src/dao/location/location.dao.ts +109 -87
- package/src/dao/message/message.dao.ts +32 -32
- package/src/dao/report-configuration/report-configuration.dao.ts +370 -342
- package/src/dao/study/study.dao.ts +88 -63
- package/src/dao/user/user.dao.ts +52 -50
- package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +80 -48
- package/src/dao/video/video.dao.ts +385 -334
- package/src/entities/BaseEntity.ts +1 -1
- package/src/index.ts +42 -17
- package/src/interfaces/auth/auth.interfaces.ts +10 -10
- package/src/interfaces/batch/batch.interfaces.ts +1 -1
- package/src/interfaces/camera/camera.interfaces.ts +9 -9
- package/src/interfaces/chat/chat.interfaces.ts +4 -4
- package/src/interfaces/folder/folder.interfaces.ts +2 -2
- package/src/interfaces/location/location.interfaces.ts +7 -7
- package/src/interfaces/message/message.interfaces.ts +3 -3
- package/src/interfaces/report-configuration/report-configuration.interfaces.ts +16 -16
- package/src/interfaces/study/study.interfaces.ts +3 -3
- package/src/interfaces/user/user.interfaces.ts +9 -9
- package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
- 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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
269
|
+
}
|
|
270
|
+
// Labels not in DETECTION_LABEL_TO_FHWA are silently ignored (e.g., pedestrian, bicycle)
|
|
262
271
|
}
|
|
263
272
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
372
|
+
// Ensure target is an object
|
|
373
|
+
if (typeof target !== "object" || target === null) {
|
|
374
|
+
target = {};
|
|
373
375
|
}
|
|
374
376
|
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
}
|