@trafficgroup/knex-rel 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +5 -2
- package/.env.prod +5 -0
- package/CLAUDE.md +2 -11
- package/dist/constants/video.constants.d.ts +12 -0
- package/dist/constants/video.constants.js +18 -0
- package/dist/constants/video.constants.js.map +1 -0
- package/dist/dao/VideoMinuteResultDAO.d.ts +4 -1
- package/dist/dao/VideoMinuteResultDAO.js +28 -31
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
- package/dist/dao/auth/auth.dao.js +1 -4
- package/dist/dao/auth/auth.dao.js.map +1 -1
- package/dist/dao/batch/batch.dao.js +14 -13
- package/dist/dao/batch/batch.dao.js.map +1 -1
- package/dist/dao/camera/camera.dao.d.ts +17 -7
- package/dist/dao/camera/camera.dao.js +38 -56
- 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 +35 -25
- package/dist/dao/chat/chat.dao.js.map +1 -1
- package/dist/dao/folder/folder.dao.js +4 -8
- package/dist/dao/folder/folder.dao.js.map +1 -1
- package/dist/dao/location/location.dao.d.ts +17 -0
- package/dist/dao/location/location.dao.js +116 -0
- package/dist/dao/location/location.dao.js.map +1 -0
- package/dist/dao/message/message.dao.d.ts +1 -1
- package/dist/dao/message/message.dao.js +26 -18
- package/dist/dao/message/message.dao.js.map +1 -1
- package/dist/dao/report-configuration/report-configuration.dao.js +31 -32
- package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
- package/dist/dao/study/study.dao.d.ts +1 -1
- package/dist/dao/study/study.dao.js +12 -17
- package/dist/dao/study/study.dao.js.map +1 -1
- package/dist/dao/user/user.dao.js +1 -4
- package/dist/dao/user/user.dao.js.map +1 -1
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +8 -26
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
- package/dist/dao/video/video.dao.d.ts +9 -1
- package/dist/dao/video/video.dao.js +73 -27
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -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 +4 -2
- package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
- package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
- package/dist/interfaces/location/location.interfaces.d.ts +9 -0
- package/dist/interfaces/location/location.interfaces.js +3 -0
- package/dist/interfaces/location/location.interfaces.js.map +1 -0
- package/dist/interfaces/message/message.interfaces.d.ts +2 -2
- package/dist/interfaces/study/study.interfaces.d.ts +6 -5
- 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 +3 -2
- package/migrations/20250717160737_migration.ts +1 -1
- package/migrations/20250717160908_migration.ts +2 -5
- 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 +4 -8
- package/migrations/20250722211019_migration.ts +1 -1
- package/migrations/20250723153852_migration.ts +10 -13
- package/migrations/20250723162257_migration.ts +7 -4
- package/migrations/20250723171109_migration.ts +7 -4
- package/migrations/20250723205331_migration.ts +9 -6
- package/migrations/20250724191345_migration.ts +11 -8
- package/migrations/20250730180932_migration.ts +13 -14
- package/migrations/20250730213625_migration.ts +11 -8
- package/migrations/20250804124509_migration.ts +21 -26
- package/migrations/20250804132053_migration.ts +8 -5
- package/migrations/20250804164518_migration.ts +7 -7
- package/migrations/20250823223016_migration.ts +21 -32
- package/migrations/20250910015452_migration.ts +6 -18
- package/migrations/20250911000000_migration.ts +4 -18
- package/migrations/20250917144153_migration.ts +7 -14
- package/migrations/20250930200521_migration.ts +4 -8
- package/migrations/20251010143500_migration.ts +6 -27
- package/migrations/20251020225758_migration.ts +15 -51
- package/migrations/20251112120000_migration.ts +81 -0
- package/migrations/20251112120100_migration.ts +21 -0
- package/migrations/20251112120200_migration.ts +38 -0
- package/migrations/20251112120300_migration.ts +22 -0
- package/package.json +1 -1
- package/src/constants/video.constants.ts +19 -0
- package/src/d.types.ts +14 -18
- package/src/dao/VideoMinuteResultDAO.ts +54 -72
- package/src/dao/auth/auth.dao.ts +55 -58
- package/src/dao/batch/batch.dao.ts +98 -101
- package/src/dao/camera/camera.dao.ts +125 -145
- package/src/dao/chat/chat.dao.ts +43 -45
- package/src/dao/folder/folder.dao.ts +56 -59
- package/src/dao/location/location.dao.ts +101 -0
- package/src/dao/message/message.dao.ts +32 -32
- package/src/dao/report-configuration/report-configuration.dao.ts +342 -370
- package/src/dao/study/study.dao.ts +63 -88
- package/src/dao/user/user.dao.ts +50 -52
- package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +49 -83
- package/src/dao/video/video.dao.ts +339 -313
- package/src/entities/BaseEntity.ts +1 -1
- package/src/index.ts +24 -26
- 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 -7
- 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 +9 -0
- 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 +7 -6
- 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 -33
- package/plan.md +0 -684
|
@@ -13,49 +13,28 @@ import type { Knex } from "knex";
|
|
|
13
13
|
export async function up(knex: Knex): Promise<void> {
|
|
14
14
|
// Step 1: Create video_batch table with proper id/uuid pattern
|
|
15
15
|
await knex.schema.createTable("video_batch", (table) => {
|
|
16
|
-
table
|
|
17
|
-
.increments("id")
|
|
18
|
-
.primary()
|
|
16
|
+
table.increments("id").primary()
|
|
19
17
|
.comment("Primary key for internal foreign key relationships");
|
|
20
18
|
|
|
21
|
-
table
|
|
22
|
-
.uuid("uuid")
|
|
23
|
-
.notNullable()
|
|
24
|
-
.unique()
|
|
25
|
-
.defaultTo(knex.raw("uuid_generate_v4()"))
|
|
19
|
+
table.uuid("uuid").notNullable().unique().defaultTo(knex.raw('uuid_generate_v4()'))
|
|
26
20
|
.comment("UUID for external API communication");
|
|
27
21
|
|
|
28
|
-
table
|
|
29
|
-
.
|
|
30
|
-
.unsigned()
|
|
31
|
-
.notNullable()
|
|
32
|
-
.references("id")
|
|
33
|
-
.inTable("folders")
|
|
34
|
-
.onDelete("CASCADE")
|
|
22
|
+
table.integer("folderId").unsigned().notNullable()
|
|
23
|
+
.references("id").inTable("folders").onDelete("CASCADE")
|
|
35
24
|
.comment("Foreign key to folders table");
|
|
36
25
|
|
|
37
|
-
table
|
|
38
|
-
.enu("status", ["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"])
|
|
26
|
+
table.enu("status", ["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"])
|
|
39
27
|
.notNullable()
|
|
40
28
|
.defaultTo("PENDING")
|
|
41
29
|
.comment("Status of the batch upload");
|
|
42
30
|
|
|
43
|
-
table
|
|
44
|
-
.integer("totalVideos")
|
|
45
|
-
.notNullable()
|
|
46
|
-
.defaultTo(0)
|
|
31
|
+
table.integer("totalVideos").notNullable().defaultTo(0)
|
|
47
32
|
.comment("Total number of videos in batch");
|
|
48
33
|
|
|
49
|
-
table
|
|
50
|
-
.integer("completedVideos")
|
|
51
|
-
.notNullable()
|
|
52
|
-
.defaultTo(0)
|
|
34
|
+
table.integer("completedVideos").notNullable().defaultTo(0)
|
|
53
35
|
.comment("Number of successfully created videos");
|
|
54
36
|
|
|
55
|
-
table
|
|
56
|
-
.integer("failedVideos")
|
|
57
|
-
.notNullable()
|
|
58
|
-
.defaultTo(0)
|
|
37
|
+
table.integer("failedVideos").notNullable().defaultTo(0)
|
|
59
38
|
.comment("Number of failed videos");
|
|
60
39
|
|
|
61
40
|
table.timestamps(true, true);
|
|
@@ -63,32 +42,17 @@ export async function up(knex: Knex): Promise<void> {
|
|
|
63
42
|
|
|
64
43
|
// Step 2: Add recording time, trimming, and batch columns to video table
|
|
65
44
|
await knex.schema.alterTable("video", (table) => {
|
|
66
|
-
table
|
|
67
|
-
.timestamp("recordingStartedAt", { useTz: true })
|
|
68
|
-
.nullable()
|
|
45
|
+
table.timestamp("recordingStartedAt", { useTz: true }).nullable()
|
|
69
46
|
.comment("Recording start time in UTC - null for backward compatibility");
|
|
70
47
|
|
|
71
|
-
table
|
|
72
|
-
.boolean("trimEnabled")
|
|
73
|
-
.notNullable()
|
|
74
|
-
.defaultTo(false)
|
|
48
|
+
table.boolean("trimEnabled").notNullable().defaultTo(false)
|
|
75
49
|
.comment("Whether video trimming is enabled");
|
|
76
50
|
|
|
77
|
-
table
|
|
78
|
-
.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
table
|
|
85
|
-
.integer("batchId")
|
|
86
|
-
.unsigned()
|
|
87
|
-
.nullable()
|
|
88
|
-
.references("id")
|
|
89
|
-
.inTable("video_batch")
|
|
90
|
-
.withKeyName("fk_video_batch")
|
|
91
|
-
.onDelete("SET NULL")
|
|
51
|
+
table.jsonb("trimPeriods").nullable()
|
|
52
|
+
.comment("Array of trim periods with startTime and endTime in ISO 8601 format");
|
|
53
|
+
|
|
54
|
+
table.integer("batchId").unsigned().nullable()
|
|
55
|
+
.references("id").inTable("video_batch").withKeyName("fk_video_batch").onDelete("SET NULL")
|
|
92
56
|
.comment("Foreign key to video_batch table (numerical ID, NOT UUID)");
|
|
93
57
|
});
|
|
94
58
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: Rename cameras table to locations
|
|
5
|
+
*
|
|
6
|
+
* Purpose: Rename existing 'cameras' table to 'locations' to represent physical study sites.
|
|
7
|
+
* This is the first step in separating locations (physical sites) from cameras (devices at those sites).
|
|
8
|
+
*/
|
|
9
|
+
export async function up(knex: Knex): Promise<void> {
|
|
10
|
+
// 1. Rename the cameras table to locations
|
|
11
|
+
await knex.schema.renameTable("cameras", "locations");
|
|
12
|
+
|
|
13
|
+
// 2. Rename the uuid unique constraint to match new table name (frees up name for new cameras table)
|
|
14
|
+
await knex.raw(`
|
|
15
|
+
ALTER TABLE locations
|
|
16
|
+
RENAME CONSTRAINT cameras_uuid_unique TO locations_uuid_unique
|
|
17
|
+
`);
|
|
18
|
+
|
|
19
|
+
// 3. Rename the foreign key column in study table
|
|
20
|
+
await knex.schema.alterTable("study", (table) => {
|
|
21
|
+
table.renameColumn("cameraId", "locationId");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// 4. Drop the old foreign key constraint
|
|
25
|
+
await knex.raw(`
|
|
26
|
+
ALTER TABLE study
|
|
27
|
+
DROP CONSTRAINT IF EXISTS study_cameraid_foreign
|
|
28
|
+
`);
|
|
29
|
+
|
|
30
|
+
// 5. Add new foreign key constraint with updated name
|
|
31
|
+
await knex.schema.alterTable("study", (table) => {
|
|
32
|
+
table.foreign("locationId").references("id").inTable("locations").onDelete("SET NULL");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// 6. Drop old index if it exists
|
|
36
|
+
await knex.raw(`
|
|
37
|
+
DROP INDEX IF EXISTS study_cameraid_index
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
// 7. Create new index with updated name
|
|
41
|
+
await knex.schema.alterTable("study", (table) => {
|
|
42
|
+
table.index(["locationId"], "study_locationid_index");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function down(knex: Knex): Promise<void> {
|
|
47
|
+
// 1. Drop the new foreign key constraint
|
|
48
|
+
await knex.raw(`
|
|
49
|
+
ALTER TABLE study
|
|
50
|
+
DROP CONSTRAINT IF EXISTS study_locationid_foreign
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
// 2. Drop new index
|
|
54
|
+
await knex.raw(`
|
|
55
|
+
DROP INDEX IF EXISTS study_locationid_index
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
// 3. Rename column back
|
|
59
|
+
await knex.schema.alterTable("study", (table) => {
|
|
60
|
+
table.renameColumn("locationId", "cameraId");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 4. Restore original foreign key constraint
|
|
64
|
+
await knex.schema.alterTable("study", (table) => {
|
|
65
|
+
table.foreign("cameraId").references("id").inTable("locations").onDelete("SET NULL");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// 5. Restore original index
|
|
69
|
+
await knex.schema.alterTable("study", (table) => {
|
|
70
|
+
table.index(["cameraId"], "study_cameraid_index");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// 6. Rename constraint back to original name
|
|
74
|
+
await knex.raw(`
|
|
75
|
+
ALTER TABLE locations
|
|
76
|
+
RENAME CONSTRAINT locations_uuid_unique TO cameras_uuid_unique
|
|
77
|
+
`);
|
|
78
|
+
|
|
79
|
+
// 7. Rename the table back to cameras
|
|
80
|
+
await knex.schema.renameTable("locations", "cameras");
|
|
81
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: Add isMultiCamera field to study table
|
|
5
|
+
*
|
|
6
|
+
* Purpose: Add boolean flag to indicate whether a study uses multiple cameras.
|
|
7
|
+
* Defaults to false for backward compatibility with existing single-camera studies.
|
|
8
|
+
*/
|
|
9
|
+
export async function up(knex: Knex): Promise<void> {
|
|
10
|
+
await knex.schema.alterTable("study", (table) => {
|
|
11
|
+
table.boolean("isMultiCamera").notNullable().defaultTo(false);
|
|
12
|
+
table.index(["isMultiCamera"], "study_ismulticamera_index");
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function down(knex: Knex): Promise<void> {
|
|
17
|
+
await knex.schema.alterTable("study", (table) => {
|
|
18
|
+
table.dropIndex(["isMultiCamera"], "study_ismulticamera_index");
|
|
19
|
+
table.dropColumn("isMultiCamera");
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: Create cameras table
|
|
5
|
+
*
|
|
6
|
+
* Purpose: Create new 'cameras' table to represent camera devices at locations.
|
|
7
|
+
* This allows multiple cameras to be associated with a single location.
|
|
8
|
+
*/
|
|
9
|
+
export async function up(knex: Knex): Promise<void> {
|
|
10
|
+
await knex.schema.createTable("cameras", (table) => {
|
|
11
|
+
table.increments("id").primary();
|
|
12
|
+
table.uuid("uuid").defaultTo(knex.raw("uuid_generate_v4()")).unique().notNullable();
|
|
13
|
+
table.integer("locationId").unsigned().notNullable()
|
|
14
|
+
.references("id").inTable("locations").onDelete("CASCADE");
|
|
15
|
+
table.string("name", 100).notNullable();
|
|
16
|
+
table.text("description").nullable();
|
|
17
|
+
table.enu("status", ["ACTIVE", "INACTIVE", "MAINTENANCE"], {
|
|
18
|
+
useNative: true,
|
|
19
|
+
enumName: "camera_status_enum"
|
|
20
|
+
}).defaultTo("ACTIVE").notNullable();
|
|
21
|
+
table.jsonb("metadata").defaultTo("{}").notNullable();
|
|
22
|
+
table.timestamps(true, true);
|
|
23
|
+
|
|
24
|
+
// Unique constraint: camera names must be unique within a location
|
|
25
|
+
table.unique(["locationId", "name"], "cameras_locationid_name_unique");
|
|
26
|
+
|
|
27
|
+
// Index for faster lookups
|
|
28
|
+
table.index(["locationId"], "cameras_locationid_index");
|
|
29
|
+
table.index(["status"], "cameras_status_index");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function down(knex: Knex): Promise<void> {
|
|
34
|
+
await knex.schema.dropTableIfExists("cameras");
|
|
35
|
+
|
|
36
|
+
// Drop the enum type
|
|
37
|
+
await knex.raw("DROP TYPE IF EXISTS camera_status_enum");
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: Add cameraId column to video table
|
|
5
|
+
*
|
|
6
|
+
* Purpose: Link videos to specific cameras in multi-camera studies.
|
|
7
|
+
* Nullable to maintain backward compatibility with existing videos.
|
|
8
|
+
*/
|
|
9
|
+
export async function up(knex: Knex): Promise<void> {
|
|
10
|
+
await knex.schema.alterTable("video", (table) => {
|
|
11
|
+
table.integer("cameraId").unsigned().nullable()
|
|
12
|
+
.references("id").inTable("cameras").onDelete("SET NULL");
|
|
13
|
+
table.index(["cameraId"], "video_cameraid_index");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function down(knex: Knex): Promise<void> {
|
|
18
|
+
await knex.schema.alterTable("video", (table) => {
|
|
19
|
+
table.dropIndex(["cameraId"], "video_cameraid_index");
|
|
20
|
+
table.dropColumn("cameraId");
|
|
21
|
+
});
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video sorting constants
|
|
3
|
+
* Shared between DAO and API controller to ensure validation consistency
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const VALID_VIDEO_SORT_FIELDS = ['created_at', 'name', 'status'] as const;
|
|
7
|
+
export type VideoSortField = typeof VALID_VIDEO_SORT_FIELDS[number];
|
|
8
|
+
|
|
9
|
+
export const VALID_SORT_ORDERS = ['ASC', 'DESC'] as const;
|
|
10
|
+
export type SortOrder = typeof VALID_SORT_ORDERS[number];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Maps API sort field names to database column names with table aliases
|
|
14
|
+
*/
|
|
15
|
+
export const VIDEO_SORT_COLUMN_MAP: Record<VideoSortField, string> = {
|
|
16
|
+
'created_at': 'v.created_at',
|
|
17
|
+
'name': 'v.name',
|
|
18
|
+
'status': 'v.status'
|
|
19
|
+
};
|
package/src/d.types.ts
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
export interface IDataPaginator<T> {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
success: boolean;
|
|
3
|
+
data: T[];
|
|
4
|
+
page: number;
|
|
5
|
+
limit: number;
|
|
6
|
+
count: number;
|
|
7
|
+
totalCount: number;
|
|
8
|
+
totalPages: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface IBaseDAO<T> {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
): Promise<IDataPaginator<T>>;
|
|
20
|
-
update(id: number, item: T): Promise<T | null>;
|
|
21
|
-
delete(id: number): Promise<boolean>;
|
|
22
|
-
}
|
|
12
|
+
create(item: T): Promise<T>;
|
|
13
|
+
getById(id: number): Promise<T | null>;
|
|
14
|
+
getByUuid(uuid: string): Promise<T | null>;
|
|
15
|
+
getAll(page: number, limit: number, entityId?: any | null): Promise<IDataPaginator<T>>;
|
|
16
|
+
update(id: number, item: T): Promise<T | null>;
|
|
17
|
+
delete(id: number): Promise<boolean>;
|
|
18
|
+
}
|
|
@@ -69,13 +69,15 @@ interface IGroupedResponse {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
interface IStudyTimeGroupResult {
|
|
72
|
-
absoluteTime: string;
|
|
72
|
+
absoluteTime: string; // ISO 8601 start of bucket
|
|
73
73
|
groupIndex: number;
|
|
74
|
-
|
|
74
|
+
startMinute: number; // Start minute number (0-based from video start)
|
|
75
|
+
endMinute: number; // End minute number (0-based from video start)
|
|
76
|
+
label: string; // Formatted label
|
|
75
77
|
results: ITMCResult | IATRResult;
|
|
76
78
|
minuteCount: number;
|
|
77
79
|
videoCount: number;
|
|
78
|
-
contributingVideos: string[];
|
|
80
|
+
contributingVideos: string[]; // Video UUIDs
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
interface IGroupedStudyResponse {
|
|
@@ -85,7 +87,7 @@ interface IGroupedStudyResponse {
|
|
|
85
87
|
study: {
|
|
86
88
|
uuid: string;
|
|
87
89
|
name: string;
|
|
88
|
-
type:
|
|
90
|
+
type: 'TMC' | 'ATR';
|
|
89
91
|
status: string;
|
|
90
92
|
};
|
|
91
93
|
videoCount: number;
|
|
@@ -421,41 +423,37 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
421
423
|
// Use Knex query builder for safe parameter binding
|
|
422
424
|
const groupingQuery = this.knex(this.tableName)
|
|
423
425
|
.select(
|
|
424
|
-
this.knex.raw(
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
this.knex.raw(
|
|
428
|
-
this.knex.raw(
|
|
429
|
-
this.knex.raw("COUNT(*) as minute_count"),
|
|
430
|
-
this.knex.raw(
|
|
431
|
-
"array_agg(results ORDER BY minute_number) as all_results",
|
|
432
|
-
),
|
|
426
|
+
this.knex.raw('FLOOR(minute_number / ?) as group_index', [groupingMinutes]),
|
|
427
|
+
this.knex.raw('MIN(minute_number) as start_minute'),
|
|
428
|
+
this.knex.raw('MAX(minute_number) as end_minute'),
|
|
429
|
+
this.knex.raw('COUNT(*) as minute_count'),
|
|
430
|
+
this.knex.raw('array_agg(results ORDER BY minute_number) as all_results')
|
|
433
431
|
)
|
|
434
|
-
.where(
|
|
432
|
+
.where('video_id', video.id);
|
|
435
433
|
|
|
436
434
|
if (startMinute !== undefined) {
|
|
437
|
-
groupingQuery.where(
|
|
435
|
+
groupingQuery.where('minute_number', '>=', startMinute);
|
|
438
436
|
}
|
|
439
437
|
|
|
440
438
|
if (endMinute !== undefined) {
|
|
441
|
-
groupingQuery.where(
|
|
439
|
+
groupingQuery.where('minute_number', '<=', endMinute);
|
|
442
440
|
}
|
|
443
441
|
|
|
444
442
|
const rows = await groupingQuery
|
|
445
|
-
.groupBy(
|
|
446
|
-
.orderBy(
|
|
443
|
+
.groupBy('group_index')
|
|
444
|
+
.orderBy('group_index');
|
|
447
445
|
|
|
448
446
|
// Aggregate the results in TypeScript based on video type
|
|
449
447
|
const aggregatedGroups: IGroupedResult[] = rows.map((row: any) => {
|
|
450
|
-
if (!row || typeof row !==
|
|
451
|
-
throw new Error(
|
|
448
|
+
if (!row || typeof row !== 'object') {
|
|
449
|
+
throw new Error('Invalid row data received from database query');
|
|
452
450
|
}
|
|
453
451
|
|
|
454
452
|
const allResults = Array.isArray(row.all_results) ? row.all_results : [];
|
|
455
|
-
|
|
453
|
+
|
|
456
454
|
// Determine video type based on multiple factors
|
|
457
|
-
let studyType = video.videoType ||
|
|
458
|
-
|
|
455
|
+
let studyType = video.videoType || 'ATR'; // Default fallback to ATR
|
|
456
|
+
|
|
459
457
|
// Check if minute data has study_type field (ATR usually does)
|
|
460
458
|
if (allResults.length > 0 && allResults[0].study_type) {
|
|
461
459
|
studyType = allResults[0].study_type;
|
|
@@ -467,24 +465,20 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
467
465
|
const vehicleKeys = Object.keys(sampleResult.vehicles);
|
|
468
466
|
if (vehicleKeys.length > 0) {
|
|
469
467
|
const firstVehicleType = sampleResult.vehicles[vehicleKeys[0]];
|
|
470
|
-
if (firstVehicleType && typeof firstVehicleType ===
|
|
468
|
+
if (firstVehicleType && typeof firstVehicleType === 'object') {
|
|
471
469
|
const directions = Object.keys(firstVehicleType);
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
directions.includes("EAST") ||
|
|
476
|
-
directions.includes("WEST")
|
|
477
|
-
) {
|
|
478
|
-
studyType = "TMC";
|
|
470
|
+
if (directions.includes('NORTH') || directions.includes('SOUTH') ||
|
|
471
|
+
directions.includes('EAST') || directions.includes('WEST')) {
|
|
472
|
+
studyType = 'TMC';
|
|
479
473
|
}
|
|
480
474
|
}
|
|
481
475
|
}
|
|
482
476
|
}
|
|
483
477
|
}
|
|
484
|
-
|
|
478
|
+
|
|
485
479
|
// Aggregate based on determined video type
|
|
486
480
|
let aggregatedResult;
|
|
487
|
-
if (studyType ===
|
|
481
|
+
if (studyType === 'TMC') {
|
|
488
482
|
aggregatedResult = this.aggregateTMCResults(allResults);
|
|
489
483
|
} else {
|
|
490
484
|
aggregatedResult = this.aggregateATRResults(allResults);
|
|
@@ -575,8 +569,8 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
575
569
|
"v.uuid as videoUuid",
|
|
576
570
|
"v.recordingStartedAt",
|
|
577
571
|
this.knex.raw(
|
|
578
|
-
'"v"."recordingStartedAt" + (vmr.minute_number || \' minutes\')::INTERVAL as "absoluteTime"'
|
|
579
|
-
)
|
|
572
|
+
'"v"."recordingStartedAt" + (vmr.minute_number || \' minutes\')::INTERVAL as "absoluteTime"'
|
|
573
|
+
)
|
|
580
574
|
)
|
|
581
575
|
.whereIn("v.id", videoIds)
|
|
582
576
|
.orderBy("absoluteTime", "asc");
|
|
@@ -614,7 +608,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
614
608
|
for (const minute of minuteResults) {
|
|
615
609
|
const absoluteTime = new Date(minute.absoluteTime);
|
|
616
610
|
const minutesSinceEarliest = Math.floor(
|
|
617
|
-
(absoluteTime.getTime() - earliestTime.getTime()) / (1000 * 60)
|
|
611
|
+
(absoluteTime.getTime() - earliestTime.getTime()) / (1000 * 60)
|
|
618
612
|
);
|
|
619
613
|
const bucketIndex = Math.floor(minutesSinceEarliest / groupingMinutes);
|
|
620
614
|
|
|
@@ -622,7 +616,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
622
616
|
// Calculate bucket start time
|
|
623
617
|
const bucketStartMinutes = bucketIndex * groupingMinutes;
|
|
624
618
|
const bucketStartTime = new Date(
|
|
625
|
-
earliestTime.getTime() + bucketStartMinutes * 60 * 1000
|
|
619
|
+
earliestTime.getTime() + bucketStartMinutes * 60 * 1000
|
|
626
620
|
);
|
|
627
621
|
|
|
628
622
|
buckets.set(bucketIndex, {
|
|
@@ -639,14 +633,12 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
639
633
|
}
|
|
640
634
|
|
|
641
635
|
// Step 5: Aggregate using existing methods based on study type
|
|
642
|
-
const aggregatedGroups: IStudyTimeGroupResult[] = Array.from(
|
|
643
|
-
buckets.values(),
|
|
644
|
-
)
|
|
636
|
+
const aggregatedGroups: IStudyTimeGroupResult[] = Array.from(buckets.values())
|
|
645
637
|
.sort((a, b) => a.groupIndex - b.groupIndex)
|
|
646
638
|
.map((bucket) => {
|
|
647
639
|
let aggregatedResult: ITMCResult | IATRResult;
|
|
648
640
|
|
|
649
|
-
if (study.type ===
|
|
641
|
+
if (study.type === 'TMC') {
|
|
650
642
|
aggregatedResult = this.aggregateTMCResults(bucket.results);
|
|
651
643
|
} else {
|
|
652
644
|
aggregatedResult = this.aggregateATRResults(bucket.results);
|
|
@@ -655,10 +647,9 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
655
647
|
return {
|
|
656
648
|
absoluteTime: bucket.absoluteTime.toISOString(),
|
|
657
649
|
groupIndex: bucket.groupIndex,
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
),
|
|
650
|
+
startMinute: bucket.groupIndex * groupingMinutes,
|
|
651
|
+
endMinute: (bucket.groupIndex + 1) * groupingMinutes - 1,
|
|
652
|
+
label: this.formatStudyTimeLabel(bucket.absoluteTime, groupingMinutes),
|
|
662
653
|
results: aggregatedResult,
|
|
663
654
|
minuteCount: bucket.results.length,
|
|
664
655
|
videoCount: bucket.videoUuids.size,
|
|
@@ -721,27 +712,25 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
721
712
|
vehicles: {},
|
|
722
713
|
counts: {
|
|
723
714
|
total_vehicles: 0,
|
|
724
|
-
entry_vehicles: 0
|
|
715
|
+
entry_vehicles: 0
|
|
725
716
|
},
|
|
726
717
|
total: 0,
|
|
727
718
|
totalcount: 0,
|
|
728
719
|
detected_classes: {},
|
|
729
|
-
study_type: "TMC"
|
|
720
|
+
study_type: "TMC"
|
|
730
721
|
};
|
|
731
722
|
|
|
732
723
|
for (const minute of minutes) {
|
|
733
724
|
const results = minute; // minute is already the results object from array_agg
|
|
734
725
|
|
|
735
726
|
// Aggregate vehicle movements by class and direction
|
|
736
|
-
if (results.vehicles && typeof results.vehicles ===
|
|
737
|
-
for (const [vehicleClass, directions] of Object.entries(
|
|
738
|
-
results.vehicles,
|
|
739
|
-
)) {
|
|
727
|
+
if (results.vehicles && typeof results.vehicles === 'object') {
|
|
728
|
+
for (const [vehicleClass, directions] of Object.entries(results.vehicles)) {
|
|
740
729
|
// Skip the 'total' pseudo vehicle class - validate it's actually aggregate data
|
|
741
|
-
if (vehicleClass ===
|
|
730
|
+
if (vehicleClass === 'total' && typeof directions === 'number') {
|
|
742
731
|
continue; // This is aggregate total data, not a vehicle class
|
|
743
732
|
}
|
|
744
|
-
|
|
733
|
+
|
|
745
734
|
if (!aggregated.vehicles[vehicleClass]) {
|
|
746
735
|
aggregated.vehicles[vehicleClass] = {};
|
|
747
736
|
}
|
|
@@ -764,12 +753,11 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
764
753
|
for (const [turnType, count] of Object.entries(turns)) {
|
|
765
754
|
const turnCount = (count as number) || 0;
|
|
766
755
|
aggregated.vehicles[vehicleClass][direction][turnType] =
|
|
767
|
-
(aggregated.vehicles[vehicleClass][direction][turnType] ||
|
|
768
|
-
|
|
769
|
-
|
|
756
|
+
(aggregated.vehicles[vehicleClass][direction][turnType] || 0) + turnCount;
|
|
757
|
+
|
|
770
758
|
// Add to detected_classes count for this vehicle type
|
|
771
759
|
aggregated.detected_classes[vehicleClass] += turnCount;
|
|
772
|
-
|
|
760
|
+
|
|
773
761
|
// Add to total counts
|
|
774
762
|
aggregated.total += turnCount;
|
|
775
763
|
aggregated.totalcount += turnCount;
|
|
@@ -777,12 +765,10 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
777
765
|
}
|
|
778
766
|
}
|
|
779
767
|
}
|
|
780
|
-
|
|
768
|
+
|
|
781
769
|
// Also process the 'total' entry for validation but don't count it as a vehicle class
|
|
782
770
|
if (results.vehicles.total) {
|
|
783
|
-
for (const [direction, turns] of Object.entries(
|
|
784
|
-
results.vehicles.total as any,
|
|
785
|
-
)) {
|
|
771
|
+
for (const [direction, turns] of Object.entries(results.vehicles.total as any)) {
|
|
786
772
|
if (typeof turns === "object" && turns !== null) {
|
|
787
773
|
for (const [turnType, count] of Object.entries(turns)) {
|
|
788
774
|
const turnCount = (count as number) || 0;
|
|
@@ -795,8 +781,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
795
781
|
}
|
|
796
782
|
|
|
797
783
|
// Set entry_vehicles same as total_vehicles for TMC
|
|
798
|
-
aggregated.counts.entry_vehicles =
|
|
799
|
-
aggregated.counts.total_vehicles || aggregated.total;
|
|
784
|
+
aggregated.counts.entry_vehicles = aggregated.counts.total_vehicles || aggregated.total;
|
|
800
785
|
|
|
801
786
|
return aggregated;
|
|
802
787
|
}
|
|
@@ -820,7 +805,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
820
805
|
if (results.vehicles) {
|
|
821
806
|
for (const [vehicleClass, lanes] of Object.entries(results.vehicles)) {
|
|
822
807
|
// Skip 'total' pseudo-class if present
|
|
823
|
-
if (vehicleClass ===
|
|
808
|
+
if (vehicleClass === 'total') {
|
|
824
809
|
continue;
|
|
825
810
|
}
|
|
826
811
|
|
|
@@ -829,8 +814,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
829
814
|
}
|
|
830
815
|
|
|
831
816
|
for (const [laneId, count] of Object.entries(lanes as any)) {
|
|
832
|
-
const numericCount =
|
|
833
|
-
typeof count === "number" ? count : parseInt(String(count)) || 0;
|
|
817
|
+
const numericCount = typeof count === 'number' ? count : (parseInt(String(count)) || 0);
|
|
834
818
|
aggregated.vehicles[vehicleClass][laneId] =
|
|
835
819
|
(aggregated.vehicles[vehicleClass][laneId] || 0) + numericCount;
|
|
836
820
|
}
|
|
@@ -935,16 +919,14 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
935
919
|
/**
|
|
936
920
|
* Format time label for study-level results (time only, no dates)
|
|
937
921
|
* Used when results are already grouped by date in the UI
|
|
922
|
+
* Uses UTC time for consistency with absoluteTime field
|
|
938
923
|
*/
|
|
939
|
-
private formatStudyTimeLabel(
|
|
940
|
-
startTime: Date,
|
|
941
|
-
durationMinutes: number,
|
|
942
|
-
): string {
|
|
924
|
+
private formatStudyTimeLabel(startTime: Date, durationMinutes: number): string {
|
|
943
925
|
const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000);
|
|
944
926
|
|
|
945
927
|
const formatTime = (date: Date): string => {
|
|
946
|
-
const hours = date.
|
|
947
|
-
const minutes = date.
|
|
928
|
+
const hours = date.getUTCHours().toString().padStart(2, "0");
|
|
929
|
+
const minutes = date.getUTCMinutes().toString().padStart(2, "0");
|
|
948
930
|
return `${hours}:${minutes}`;
|
|
949
931
|
};
|
|
950
932
|
|