@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
@@ -2,264 +2,210 @@ import { Knex } from "knex";
2
2
  import { IBaseDAO, IDataPaginator } from "../../d.types";
3
3
  import { IVideo } from "../../interfaces/video/video.interfaces";
4
4
  import KnexManager from "../../KnexConnection";
5
- import {
6
- VALID_VIDEO_SORT_FIELDS,
7
- VALID_SORT_ORDERS,
8
- VIDEO_SORT_COLUMN_MAP,
9
- VideoSortField,
10
- SortOrder,
11
- } from "../../constants/video.constants";
5
+ import { VALID_VIDEO_SORT_FIELDS, VALID_SORT_ORDERS, VIDEO_SORT_COLUMN_MAP, VideoSortField, SortOrder } from "../../constants/video.constants";
12
6
 
13
7
  export class VideoDAO implements IBaseDAO<IVideo> {
14
- private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
15
-
16
- static getInstance(): Knex<any, unknown[]> {
17
- return KnexManager.getConnection();
18
- }
19
-
20
- async create(item: IVideo): Promise<IVideo> {
21
- const [createdVideo] = await this._knex("video")
22
- .insert(item)
23
- .returning("*");
24
- return createdVideo;
25
- }
26
-
27
- async getById(id: number): Promise<IVideo | null> {
28
- const video = await this._knex("video as v")
29
- .innerJoin("folders as f", "v.folderId", "f.id")
30
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
31
- .where("v.id", id)
32
- .first();
33
- return video || null;
34
- }
35
-
36
- async getByUuid(uuid: string): Promise<IVideo | null> {
37
- const video = await this._knex("video as v")
38
- .innerJoin("folders as f", "v.folderId", "f.id")
39
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
40
- .where("v.uuid", uuid)
41
- .first();
42
- return video || null;
43
- }
44
-
45
- async update(id: number, item: Partial<IVideo>): Promise<IVideo | null> {
46
- const [updatedVideo] = await this._knex("video")
47
- .where({ id })
48
- .update(item)
49
- .returning("*");
50
- return updatedVideo || null;
51
- }
52
-
53
- async delete(id: number): Promise<boolean> {
54
- const result = await this._knex("video").where({ id }).del();
55
- return result > 0;
56
- }
57
-
58
- // entityId corresponde al id del folder al que pertenece el video
59
- async getAll(
60
- page: number,
61
- limit: number,
62
- folderId?: number | null,
63
- sortBy?: VideoSortField,
64
- sortOrder?: SortOrder,
65
- ): Promise<IDataPaginator<IVideo>> {
66
- const offset = (page - 1) * limit;
67
-
68
- // Validate and set defaults for sorting (defensive programming)
69
- const sortField = (sortBy || "name") as VideoSortField;
70
-
71
- if (!VALID_VIDEO_SORT_FIELDS.includes(sortField as any)) {
72
- throw new Error(
73
- `Invalid sortBy value: ${sortField}. Must be one of: ${VALID_VIDEO_SORT_FIELDS.join(", ")}`,
74
- );
8
+ private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
9
+
10
+ static getInstance(): Knex<any, unknown[]> {
11
+ return KnexManager.getConnection();
75
12
  }
76
13
 
77
- const sortDirection = (sortOrder || "DESC").toUpperCase() as SortOrder;
14
+ async create(item: IVideo): Promise<IVideo> {
15
+ const [createdVideo] = await this._knex("video").insert(item).returning("*");
16
+ return createdVideo;
17
+ }
78
18
 
79
- if (!VALID_SORT_ORDERS.includes(sortDirection as any)) {
80
- throw new Error(
81
- `Invalid sortOrder value: ${sortOrder}. Must be one of: ${VALID_SORT_ORDERS.join(", ")}`,
82
- );
19
+ async getById(id: number): Promise<IVideo | null> {
20
+ const video = await this._knex("video as v")
21
+ .innerJoin("folders as f", "v.folderId", "f.id")
22
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
23
+ .where("v.id", id)
24
+ .first();
25
+ return video || null;
83
26
  }
84
27
 
85
- // Map sortBy to database column with table alias
86
- const sortColumn = VIDEO_SORT_COLUMN_MAP[sortField];
87
-
88
- const query = this._knex("video as v")
89
- .innerJoin("folders as f", "v.folderId", "f.id")
90
- .leftJoin("cameras as c", "v.cameraId", "c.id")
91
- .select(
92
- "v.*",
93
- this._knex.raw("to_jsonb(f.*) as folder"),
94
- "c.name as cameraName",
95
- "c.uuid as cameraUuid",
96
- );
97
- if (folderId !== undefined && folderId !== null) {
98
- query.where("v.folderId", folderId);
28
+ async getByUuid(uuid: string): Promise<IVideo | null> {
29
+ const video = await this._knex("video as v")
30
+ .innerJoin("folders as f", "v.folderId", "f.id")
31
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
32
+ .where("v.uuid", uuid)
33
+ .first();
34
+ return video || null;
99
35
  }
100
36
 
101
- const [countResult] = await query.clone().clearSelect().count("* as count");
102
- const totalCount = +countResult.count;
103
- const videos = await query
104
- .clone()
105
- .orderBy(sortColumn, sortDirection.toLowerCase() as "asc" | "desc")
106
- .limit(limit)
107
- .offset(offset);
108
-
109
- return {
110
- success: true,
111
- data: videos,
112
- page,
113
- limit,
114
- count: videos.length,
115
- totalCount,
116
- totalPages: Math.ceil(totalCount / limit),
117
- };
118
- }
119
-
120
- async getVideoStatsByFolderIds(folderIds: number[]): Promise<{
121
- total_videos: number;
122
- completed_videos: number;
123
- failed_videos: number;
124
- processing_videos: number;
125
- }> {
126
- if (!folderIds || folderIds.length === 0) {
127
- return {
128
- total_videos: 0,
129
- completed_videos: 0,
130
- failed_videos: 0,
131
- processing_videos: 0,
132
- };
37
+ async update(id: number, item: Partial<IVideo>): Promise<IVideo | null> {
38
+ const [updatedVideo] = await this._knex("video").where({ id }).update(item).returning("*");
39
+ return updatedVideo || null;
133
40
  }
134
41
 
135
- const result = (await this._knex("video")
136
- .whereIn("folderId", folderIds)
137
- .select(
138
- this._knex.raw("COUNT(*) as total_videos"),
139
- this._knex.raw(
140
- "COUNT(CASE WHEN status = ? THEN 1 END) as completed_videos",
141
- ["COMPLETED"],
142
- ),
143
- this._knex.raw(
144
- "COUNT(CASE WHEN status = ? THEN 1 END) as failed_videos",
145
- ["FAILED"],
146
- ),
147
- this._knex.raw(
148
- "COUNT(CASE WHEN status = ? THEN 1 END) as processing_videos",
149
- ["PROCESSING"],
150
- ),
151
- )
152
- .first()) as any;
153
-
154
- return {
155
- total_videos: parseInt(result?.total_videos) || 0,
156
- completed_videos: parseInt(result?.completed_videos) || 0,
157
- failed_videos: parseInt(result?.failed_videos) || 0,
158
- processing_videos: parseInt(result?.processing_videos) || 0,
159
- };
160
- }
161
-
162
- async getCompletedVideosByFolderIds(
163
- folderIds: number[],
164
- videoType?: string,
165
- ): Promise<IVideo[]> {
166
- if (!folderIds || folderIds.length === 0) {
167
- return [];
42
+ async delete(id: number): Promise<boolean> {
43
+ const result = await this._knex("video").where({ id }).del();
44
+ return result > 0;
168
45
  }
169
46
 
170
- const query = this._knex("video")
171
- .whereIn("folderId", folderIds)
172
- .where("status", "COMPLETED");
47
+ // entityId corresponde al id del folder al que pertenece el video
48
+ async getAll(page: number, limit: number, folderId?: number | null, sortBy?: VideoSortField, sortOrder?: SortOrder): Promise<IDataPaginator<IVideo>> {
49
+ const offset = (page - 1) * limit;
50
+
51
+ // Validate and set defaults for sorting (defensive programming)
52
+ const sortField = (sortBy || 'name') as VideoSortField;
53
+
54
+ if (!VALID_VIDEO_SORT_FIELDS.includes(sortField as any)) {
55
+ throw new Error(`Invalid sortBy value: ${sortField}. Must be one of: ${VALID_VIDEO_SORT_FIELDS.join(', ')}`);
56
+ }
57
+
58
+ const sortDirection = ((sortOrder || 'DESC').toUpperCase()) as SortOrder;
59
+
60
+ if (!VALID_SORT_ORDERS.includes(sortDirection as any)) {
61
+ throw new Error(`Invalid sortOrder value: ${sortOrder}. Must be one of: ${VALID_SORT_ORDERS.join(', ')}`);
62
+ }
63
+
64
+ // Map sortBy to database column with table alias
65
+ const sortColumn = VIDEO_SORT_COLUMN_MAP[sortField];
66
+
67
+ const query = this._knex("video as v")
68
+ .innerJoin("folders as f", "v.folderId", "f.id")
69
+ .leftJoin("cameras as c", "v.cameraId", "c.id")
70
+ .select(
71
+ "v.*",
72
+ this._knex.raw("to_jsonb(f.*) as folder"),
73
+ "c.name as cameraName",
74
+ "c.uuid as cameraUuid"
75
+ );
76
+ if (folderId !== undefined && folderId !== null) {
77
+ query.where("v.folderId", folderId);
78
+ }
79
+
80
+ const [countResult] = await query.clone().clearSelect().count("* as count");
81
+ const totalCount = +countResult.count;
82
+ const videos = await query
83
+ .clone()
84
+ .orderBy(sortColumn, sortDirection.toLowerCase() as 'asc' | 'desc')
85
+ .limit(limit)
86
+ .offset(offset);
87
+
88
+ return {
89
+ success: true,
90
+ data: videos,
91
+ page,
92
+ limit,
93
+ count: videos.length,
94
+ totalCount,
95
+ totalPages: Math.ceil(totalCount / limit),
96
+ };
97
+ }
173
98
 
174
- if (videoType) {
175
- query.where("videoType", videoType);
99
+ async getVideoStatsByFolderIds(folderIds: number[]): Promise<{
100
+ total_videos: number;
101
+ completed_videos: number;
102
+ failed_videos: number;
103
+ processing_videos: number;
104
+ }> {
105
+ if (!folderIds || folderIds.length === 0) {
106
+ return {
107
+ total_videos: 0,
108
+ completed_videos: 0,
109
+ failed_videos: 0,
110
+ processing_videos: 0
111
+ };
112
+ }
113
+
114
+ const result = await this._knex("video")
115
+ .whereIn("folderId", folderIds)
116
+ .select(
117
+ this._knex.raw('COUNT(*) as total_videos'),
118
+ this._knex.raw("COUNT(CASE WHEN status = ? THEN 1 END) as completed_videos", ['COMPLETED']),
119
+ this._knex.raw("COUNT(CASE WHEN status = ? THEN 1 END) as failed_videos", ['FAILED']),
120
+ this._knex.raw("COUNT(CASE WHEN status = ? THEN 1 END) as processing_videos", ['PROCESSING'])
121
+ )
122
+ .first() as any;
123
+
124
+ return {
125
+ total_videos: parseInt(result?.total_videos) || 0,
126
+ completed_videos: parseInt(result?.completed_videos) || 0,
127
+ failed_videos: parseInt(result?.failed_videos) || 0,
128
+ processing_videos: parseInt(result?.processing_videos) || 0
129
+ };
176
130
  }
177
131
 
178
- return await query.select(
179
- "id",
180
- "name",
181
- "metadata",
182
- "results",
183
- "created_at",
184
- this._knex.raw(
185
- "EXTRACT(EPOCH FROM (updated_at - created_at)) as duration",
186
- ),
187
- );
188
- }
189
-
190
- /**
191
- * Update the duration in seconds for a video
192
- */
193
- async updateDuration(
194
- id: number,
195
- durationSeconds: number,
196
- ): Promise<IVideo | null> {
197
- const [updatedVideo] = await this._knex("video")
198
- .where({ id })
199
- .update({
200
- duration_seconds: durationSeconds,
201
- updated_at: this._knex.fn.now(),
202
- })
203
- .returning("*");
204
- return updatedVideo || null;
205
- }
206
-
207
- /**
208
- * Get videos that don't have minute-by-minute data yet
209
- */
210
- async getVideosWithoutMinuteData(
211
- page: number,
212
- limit: number,
213
- ): Promise<IDataPaginator<IVideo>> {
214
- const offset = (page - 1) * limit;
215
-
216
- const query = this._knex("video as v")
217
- .leftJoin("video_minute_results as vmr", "v.id", "vmr.video_id")
218
- .whereNull("vmr.video_id")
219
- .where("v.status", "COMPLETED")
220
- .innerJoin("folders as f", "v.folderId", "f.id")
221
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
222
- .groupBy("v.id", "f.id");
223
-
224
- const [countResult] = await query.clone().clearSelect().count("* as count");
225
- const totalCount = +countResult.count;
226
- const videos = await query.clone().limit(limit).offset(offset);
227
-
228
- return {
229
- success: true,
230
- data: videos,
231
- page,
232
- limit,
233
- count: videos.length,
234
- totalCount,
235
- totalPages: Math.ceil(totalCount / limit),
236
- };
237
- }
238
-
239
- /**
240
- * Get videos from same folder with same type that have metadata and are COMPLETED
241
- * Suitable for use as lane annotation templates
242
- */
243
- async getTemplateVideos(
244
- folderId: number,
245
- videoType: string,
246
- ): Promise<IVideo[]> {
247
- try {
248
- let query = this._knex("video")
249
- .where("folderId", folderId)
250
- .where("videoType", videoType)
251
- .where("status", "COMPLETED")
252
- .whereNotNull("metadata")
253
- .whereRaw("metadata != '{}'");
254
-
255
- // Apply video type specific metadata validation
256
- if (videoType === "ATR") {
257
- // ATR videos use lanes array structure
258
- query = query.whereRaw("jsonb_array_length(metadata->'lanes') > 0");
259
- } else {
260
- // TMC/JUNCTION/ROUNDABOUT/PATHWAY videos use objects with pt1/pt2 structure
261
- // Check if metadata has at least one key with pt1 and pt2 properties
262
- query = query.whereRaw(`
132
+ async getCompletedVideosByFolderIds(folderIds: number[], videoType?: string): Promise<IVideo[]> {
133
+ if (!folderIds || folderIds.length === 0) {
134
+ return [];
135
+ }
136
+
137
+ const query = this._knex("video")
138
+ .whereIn("folderId", folderIds)
139
+ .where("status", "COMPLETED");
140
+
141
+ if (videoType) {
142
+ query.where("videoType", videoType);
143
+ }
144
+
145
+ return await query.select("id", "name", "metadata", "results", "created_at", this._knex.raw("EXTRACT(EPOCH FROM (updated_at - created_at)) as duration"));
146
+ }
147
+
148
+ /**
149
+ * Update the duration in seconds for a video
150
+ */
151
+ async updateDuration(id: number, durationSeconds: number): Promise<IVideo | null> {
152
+ const [updatedVideo] = await this._knex("video")
153
+ .where({ id })
154
+ .update({ duration_seconds: durationSeconds, updated_at: this._knex.fn.now() })
155
+ .returning("*");
156
+ return updatedVideo || null;
157
+ }
158
+
159
+ /**
160
+ * Get videos that don't have minute-by-minute data yet
161
+ */
162
+ async getVideosWithoutMinuteData(page: number, limit: number): Promise<IDataPaginator<IVideo>> {
163
+ const offset = (page - 1) * limit;
164
+
165
+ const query = this._knex("video as v")
166
+ .leftJoin("video_minute_results as vmr", "v.id", "vmr.video_id")
167
+ .whereNull("vmr.video_id")
168
+ .where("v.status", "COMPLETED")
169
+ .innerJoin("folders as f", "v.folderId", "f.id")
170
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
171
+ .groupBy("v.id", "f.id");
172
+
173
+ const [countResult] = await query.clone().clearSelect().count("* as count");
174
+ const totalCount = +countResult.count;
175
+ const videos = await query.clone().limit(limit).offset(offset);
176
+
177
+ return {
178
+ success: true,
179
+ data: videos,
180
+ page,
181
+ limit,
182
+ count: videos.length,
183
+ totalCount,
184
+ totalPages: Math.ceil(totalCount / limit),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Get videos from same folder with same type that have metadata and are COMPLETED
190
+ * Suitable for use as lane annotation templates
191
+ */
192
+ async getTemplateVideos(folderId: number, videoType: string): Promise<IVideo[]> {
193
+ try {
194
+ let query = this._knex("video")
195
+ .where("folderId", folderId)
196
+ .where("videoType", videoType)
197
+ .where("status", "COMPLETED")
198
+ .whereNotNull("metadata")
199
+ .whereRaw("metadata != '{}'");
200
+
201
+ // Apply video type specific metadata validation
202
+ if (videoType === "ATR") {
203
+ // ATR videos use lanes array structure
204
+ query = query.whereRaw("jsonb_array_length(metadata->'lanes') > 0");
205
+ } else {
206
+ // TMC/JUNCTION/ROUNDABOUT/PATHWAY videos use objects with pt1/pt2 structure
207
+ // Check if metadata has at least one key with pt1 and pt2 properties
208
+ query = query.whereRaw(`
263
209
  EXISTS (
264
210
  SELECT 1
265
211
  FROM jsonb_each(metadata) as entry(key, value)
@@ -274,165 +220,168 @@ export class VideoDAO implements IBaseDAO<IVideo> {
274
220
  AND jsonb_array_length(value->'pt2') = 2
275
221
  )
276
222
  `);
277
- }
223
+ }
278
224
 
279
- const videos = await query.orderBy("updated_at", "desc").select("*");
225
+ const videos = await query
226
+ .orderBy("updated_at", "desc")
227
+ .select("*");
280
228
 
281
- return videos;
282
- } catch (error) {
283
- console.error("Error fetching template videos:", error);
284
- throw error;
229
+ return videos;
230
+ } catch (error) {
231
+ console.error("Error fetching template videos:", error);
232
+ throw error;
233
+ }
285
234
  }
286
- }
287
-
288
- /**
289
- * Get all videos for a specific folder (used for S3 cleanup on folder deletion)
290
- */
291
- async getByFolderId(folderId: number): Promise<IVideo[]> {
292
- return this._knex("video as v")
293
- .innerJoin("folders as f", "v.folderId", "f.id")
294
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
295
- .where("v.folderId", folderId)
296
- .orderBy("v.id", "asc");
297
- }
298
-
299
- /**
300
- * Get all video IDs for a specific folder (used for cascade operations)
301
- */
302
- async getVideoIdsByFolderId(folderId: number): Promise<number[]> {
303
- const rows = await this._knex("video")
304
- .where({ folderId })
305
- .select("id")
306
- .orderBy("id", "asc");
307
-
308
- return rows.map((row) => row.id);
309
- }
310
-
311
- /**
312
- * Get videos by batch ID
313
- */
314
- async getByBatchId(batchId: number): Promise<IVideo[]> {
315
- return this._knex("video as v")
316
- .innerJoin("folders as f", "v.folderId", "f.id")
317
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
318
- .where("v.batchId", batchId)
319
- .orderBy("v.created_at", "asc");
320
- }
321
-
322
- /**
323
- * Get videos sorted chronologically by recording start time for a study
324
- */
325
- async getChronologicalVideos(
326
- studyId: number,
327
- startDate?: Date,
328
- endDate?: Date,
329
- ): Promise<IDataPaginator<IVideo>> {
330
- let query = this._knex("video as v")
331
- .innerJoin("folders as f", "v.folderId", "f.id")
332
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
333
- .where("f.studyId", studyId)
334
- .whereNotNull("v.recordingStartedAt");
335
-
336
- if (startDate) {
337
- query = query.where("v.recordingStartedAt", ">=", startDate);
235
+
236
+ /**
237
+ * Get all videos for a specific folder (used for S3 cleanup on folder deletion)
238
+ */
239
+ async getByFolderId(folderId: number): Promise<IVideo[]> {
240
+ return this._knex("video as v")
241
+ .innerJoin("folders as f", "v.folderId", "f.id")
242
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
243
+ .where("v.folderId", folderId)
244
+ .orderBy("v.id", "asc");
338
245
  }
339
246
 
340
- if (endDate) {
341
- query = query.where("v.recordingStartedAt", "<=", endDate);
247
+ /**
248
+ * Get all video IDs for a specific folder (used for cascade operations)
249
+ */
250
+ async getVideoIdsByFolderId(folderId: number): Promise<number[]> {
251
+ const rows = await this._knex('video')
252
+ .where({ folderId })
253
+ .select('id')
254
+ .orderBy('id', 'asc');
255
+
256
+ return rows.map(row => row.id);
257
+ }
258
+
259
+ /**
260
+ * Get videos by batch ID
261
+ */
262
+ async getByBatchId(batchId: number): Promise<IVideo[]> {
263
+ return this._knex("video as v")
264
+ .innerJoin("folders as f", "v.folderId", "f.id")
265
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
266
+ .where("v.batchId", batchId)
267
+ .orderBy("v.created_at", "asc");
268
+ }
269
+
270
+ /**
271
+ * Get videos sorted chronologically by recording start time for a study
272
+ */
273
+ async getChronologicalVideos(
274
+ studyId: number,
275
+ startDate?: Date,
276
+ endDate?: Date
277
+ ): Promise<IDataPaginator<IVideo>> {
278
+ let query = this._knex("video as v")
279
+ .innerJoin("folders as f", "v.folderId", "f.id")
280
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
281
+ .where("f.studyId", studyId)
282
+ .whereNotNull("v.recordingStartedAt");
283
+
284
+ if (startDate) {
285
+ query = query.where("v.recordingStartedAt", ">=", startDate);
286
+ }
287
+
288
+ if (endDate) {
289
+ query = query.where("v.recordingStartedAt", "<=", endDate);
290
+ }
291
+
292
+ const [countResult] = await query.clone().clearSelect().count("* as count");
293
+ const totalCount = +countResult.count;
294
+ const videos = await query.clone().orderBy("v.recordingStartedAt", "asc");
295
+
296
+ return {
297
+ success: true,
298
+ data: videos,
299
+ page: 1,
300
+ limit: totalCount,
301
+ count: videos.length,
302
+ totalCount,
303
+ totalPages: 1,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Update recording start time for a video
309
+ */
310
+ async updateRecordingStartTime(id: number, recordingStartedAt: Date): Promise<void> {
311
+ await this._knex("video")
312
+ .where({ id })
313
+ .update({
314
+ recordingStartedAt,
315
+ updated_at: this._knex.fn.now()
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Update trim settings for a video
321
+ */
322
+ async updateTrimSettings(
323
+ id: number,
324
+ trimEnabled: boolean,
325
+ trimPeriods: { startTime: string; endTime: string }[] | null
326
+ ): Promise<void> {
327
+ await this._knex("video")
328
+ .where({ id })
329
+ .update({
330
+ trimEnabled,
331
+ trimPeriods: trimPeriods ? JSON.stringify(trimPeriods) : null,
332
+ updated_at: this._knex.fn.now()
333
+ });
342
334
  }
343
335
 
344
- const [countResult] = await query.clone().clearSelect().count("* as count");
345
- const totalCount = +countResult.count;
346
- const videos = await query.clone().orderBy("v.recordingStartedAt", "asc");
347
-
348
- return {
349
- success: true,
350
- data: videos,
351
- page: 1,
352
- limit: totalCount,
353
- count: videos.length,
354
- totalCount,
355
- totalPages: 1,
356
- };
357
- }
358
-
359
- /**
360
- * Update recording start time for a video
361
- */
362
- async updateRecordingStartTime(
363
- id: number,
364
- recordingStartedAt: Date,
365
- ): Promise<void> {
366
- await this._knex("video").where({ id }).update({
367
- recordingStartedAt,
368
- updated_at: this._knex.fn.now(),
369
- });
370
- }
371
-
372
- /**
373
- * Update trim settings for a video
374
- */
375
- async updateTrimSettings(
376
- id: number,
377
- trimEnabled: boolean,
378
- trimPeriods: { startTime: string; endTime: string }[] | null,
379
- ): Promise<void> {
380
- await this._knex("video")
381
- .where({ id })
382
- .update({
383
- trimEnabled,
384
- trimPeriods: trimPeriods ? JSON.stringify(trimPeriods) : null,
385
- updated_at: this._knex.fn.now(),
386
- });
387
- }
388
-
389
- /**
390
- * Get videos with trimming enabled for a folder
391
- */
392
- async getVideosWithTrimming(folderId: number): Promise<IVideo[]> {
393
- return this._knex("video as v")
394
- .innerJoin("folders as f", "v.folderId", "f.id")
395
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
396
- .where("v.folderId", folderId)
397
- .where("v.trimEnabled", true)
398
- .orderBy("v.recordingStartedAt", "asc");
399
- }
400
-
401
- /**
402
- * Get paginated videos for a specific camera
403
- * @param cameraId - The camera ID
404
- * @param page - Page number
405
- * @param limit - Items per page
406
- */
407
- async getVideosByCameraId(
408
- cameraId: number,
409
- page: number,
410
- limit: number,
411
- ): Promise<IDataPaginator<IVideo>> {
412
- const offset = (page - 1) * limit;
413
-
414
- const query = this._knex("video as v")
415
- .innerJoin("folders as f", "v.folderId", "f.id")
416
- .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
417
- .where("v.cameraId", cameraId);
418
-
419
- const [countResult] = await query.clone().clearSelect().count("* as count");
420
- const totalCount = +countResult.count;
421
-
422
- const videos = await query
423
- .clone()
424
- .limit(limit)
425
- .offset(offset)
426
- .orderBy("v.created_at", "desc");
427
-
428
- return {
429
- success: true,
430
- data: videos,
431
- page,
432
- limit,
433
- count: videos.length,
434
- totalCount,
435
- totalPages: Math.ceil(totalCount / limit),
436
- };
437
- }
336
+ /**
337
+ * Get videos with trimming enabled for a folder
338
+ */
339
+ async getVideosWithTrimming(folderId: number): Promise<IVideo[]> {
340
+ return this._knex("video as v")
341
+ .innerJoin("folders as f", "v.folderId", "f.id")
342
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
343
+ .where("v.folderId", folderId)
344
+ .where("v.trimEnabled", true)
345
+ .orderBy("v.recordingStartedAt", "asc");
346
+ }
347
+
348
+ /**
349
+ * Get paginated videos for a specific camera
350
+ * @param cameraId - The camera ID
351
+ * @param page - Page number
352
+ * @param limit - Items per page
353
+ */
354
+ async getVideosByCameraId(
355
+ cameraId: number,
356
+ page: number,
357
+ limit: number
358
+ ): Promise<IDataPaginator<IVideo>> {
359
+ const offset = (page - 1) * limit;
360
+
361
+ const query = this._knex("video as v")
362
+ .innerJoin("folders as f", "v.folderId", "f.id")
363
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
364
+ .where("v.cameraId", cameraId);
365
+
366
+ const [countResult] = await query.clone()
367
+ .clearSelect()
368
+ .count("* as count");
369
+ const totalCount = +countResult.count;
370
+
371
+ const videos = await query
372
+ .clone()
373
+ .limit(limit)
374
+ .offset(offset)
375
+ .orderBy("v.created_at", "desc");
376
+
377
+ return {
378
+ success: true,
379
+ data: videos,
380
+ page,
381
+ limit,
382
+ count: videos.length,
383
+ totalCount,
384
+ totalPages: Math.ceil(totalCount / limit)
385
+ };
386
+ }
438
387
  }