@trafficgroup/knex-rel 0.1.7 → 0.1.8

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.
@@ -68,6 +68,33 @@ interface IGroupedResponse {
68
68
  };
69
69
  }
70
70
 
71
+ interface IStudyTimeGroupResult {
72
+ absoluteTime: string; // ISO 8601 start of bucket
73
+ groupIndex: number;
74
+ label: string; // Formatted label
75
+ results: ITMCResult | IATRResult;
76
+ minuteCount: number;
77
+ videoCount: number;
78
+ contributingVideos: string[]; // Video UUIDs
79
+ }
80
+
81
+ interface IGroupedStudyResponse {
82
+ success: boolean;
83
+ data: IStudyTimeGroupResult[];
84
+ groupingMinutes: number;
85
+ study: {
86
+ uuid: string;
87
+ name: string;
88
+ type: "TMC" | "ATR";
89
+ status: string;
90
+ };
91
+ videoCount: number;
92
+ dateRange: {
93
+ earliest: string;
94
+ latest: string;
95
+ };
96
+ }
97
+
71
98
  export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
72
99
  private knex = KnexManager.getConnection();
73
100
  private tableName = "video_minute_results";
@@ -486,6 +513,182 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
486
513
  };
487
514
  }
488
515
 
516
+ /**
517
+ * Get grouped minute results by study UUID with time block aggregation across all videos
518
+ * @param studyUuid - The UUID of the study
519
+ * @param groupingMinutes - Number of minutes to group together (default: 15)
520
+ */
521
+ async getGroupedMinuteResultsByStudyUuid(
522
+ studyUuid: string,
523
+ groupingMinutes: number = 15,
524
+ ): Promise<IGroupedStudyResponse> {
525
+ // Step 1: Fetch study and validate
526
+ const study = await this.knex("study").where("uuid", studyUuid).first();
527
+
528
+ if (!study) {
529
+ throw new Error(`Study with UUID ${studyUuid} not found`);
530
+ }
531
+
532
+ // Step 2: Fetch all COMPLETED videos in the study with recordingStartedAt
533
+ const videos = await this.knex("video as v")
534
+ .innerJoin("folders as f", "v.folderId", "f.id")
535
+ .select("v.id", "v.uuid", "v.name", "v.recordingStartedAt", "v.videoType")
536
+ .where("f.studyId", study.id)
537
+ .where("v.status", "COMPLETED")
538
+ .whereNotNull("v.recordingStartedAt")
539
+ .orderBy("v.recordingStartedAt", "asc");
540
+
541
+ // If no completed videos with recordingStartedAt, return empty result
542
+ if (videos.length === 0) {
543
+ return {
544
+ success: true,
545
+ data: [],
546
+ groupingMinutes,
547
+ study: {
548
+ uuid: study.uuid,
549
+ name: study.name,
550
+ type: study.type,
551
+ status: study.status,
552
+ },
553
+ videoCount: 0,
554
+ dateRange: {
555
+ earliest: "",
556
+ latest: "",
557
+ },
558
+ };
559
+ }
560
+
561
+ // Get video IDs for minute results query
562
+ const videoIds = videos.map((v) => v.id);
563
+
564
+ // Calculate date range
565
+ const earliestVideo = videos[0];
566
+ const latestVideo = videos[videos.length - 1];
567
+ const earliestTime = new Date(earliestVideo.recordingStartedAt);
568
+
569
+ // Step 3: Fetch and normalize all minute results with absolute times
570
+ const minuteResults = await this.knex("video_minute_results as vmr")
571
+ .innerJoin("video as v", "vmr.video_id", "v.id")
572
+ .select(
573
+ "vmr.minute_number",
574
+ "vmr.results",
575
+ "v.uuid as videoUuid",
576
+ "v.recordingStartedAt",
577
+ this.knex.raw(
578
+ '"v"."recordingStartedAt" + (vmr.minute_number || \' minutes\')::INTERVAL as "absoluteTime"',
579
+ ),
580
+ )
581
+ .whereIn("v.id", videoIds)
582
+ .orderBy("absoluteTime", "asc");
583
+
584
+ // If no minute results found, return empty result
585
+ if (minuteResults.length === 0) {
586
+ return {
587
+ success: true,
588
+ data: [],
589
+ groupingMinutes,
590
+ study: {
591
+ uuid: study.uuid,
592
+ name: study.name,
593
+ type: study.type,
594
+ status: study.status,
595
+ },
596
+ videoCount: videos.length,
597
+ dateRange: {
598
+ earliest: earliestVideo.recordingStartedAt.toISOString(),
599
+ latest: latestVideo.recordingStartedAt.toISOString(),
600
+ },
601
+ };
602
+ }
603
+
604
+ // Step 4: Group by time buckets in TypeScript
605
+ interface IBucket {
606
+ groupIndex: number;
607
+ absoluteTime: Date;
608
+ results: any[];
609
+ videoUuids: Set<string>;
610
+ }
611
+
612
+ const buckets = new Map<number, IBucket>();
613
+
614
+ for (const minute of minuteResults) {
615
+ const absoluteTime = new Date(minute.absoluteTime);
616
+ const minutesSinceEarliest = Math.floor(
617
+ (absoluteTime.getTime() - earliestTime.getTime()) / (1000 * 60),
618
+ );
619
+ const bucketIndex = Math.floor(minutesSinceEarliest / groupingMinutes);
620
+
621
+ if (!buckets.has(bucketIndex)) {
622
+ // Calculate bucket start time
623
+ const bucketStartMinutes = bucketIndex * groupingMinutes;
624
+ const bucketStartTime = new Date(
625
+ earliestTime.getTime() + bucketStartMinutes * 60 * 1000,
626
+ );
627
+
628
+ buckets.set(bucketIndex, {
629
+ groupIndex: bucketIndex,
630
+ absoluteTime: bucketStartTime,
631
+ results: [],
632
+ videoUuids: new Set<string>(),
633
+ });
634
+ }
635
+
636
+ const bucket = buckets.get(bucketIndex)!;
637
+ bucket.results.push(minute.results);
638
+ bucket.videoUuids.add(minute.videoUuid);
639
+ }
640
+
641
+ // Step 5: Aggregate using existing methods based on study type
642
+ const aggregatedGroups: IStudyTimeGroupResult[] = Array.from(
643
+ buckets.values(),
644
+ )
645
+ .sort((a, b) => a.groupIndex - b.groupIndex)
646
+ .map((bucket) => {
647
+ let aggregatedResult: ITMCResult | IATRResult;
648
+
649
+ if (study.type === "TMC") {
650
+ aggregatedResult = this.aggregateTMCResults(bucket.results);
651
+ } else {
652
+ aggregatedResult = this.aggregateATRResults(bucket.results);
653
+ }
654
+
655
+ return {
656
+ absoluteTime: bucket.absoluteTime.toISOString(),
657
+ groupIndex: bucket.groupIndex,
658
+ label: this.formatStudyTimeLabel(
659
+ bucket.absoluteTime,
660
+ groupingMinutes,
661
+ ),
662
+ results: aggregatedResult,
663
+ minuteCount: bucket.results.length,
664
+ videoCount: bucket.videoUuids.size,
665
+ contributingVideos: Array.from(bucket.videoUuids),
666
+ };
667
+ });
668
+
669
+ // Calculate latest time from last minute result
670
+ const lastMinute = minuteResults[minuteResults.length - 1];
671
+ const latestTime = new Date(lastMinute.absoluteTime);
672
+
673
+ // Step 6: Format response
674
+ return {
675
+ success: true,
676
+ data: aggregatedGroups,
677
+ groupingMinutes,
678
+ study: {
679
+ uuid: study.uuid,
680
+ name: study.name,
681
+ type: study.type,
682
+ status: study.status,
683
+ },
684
+ videoCount: videos.length,
685
+ dateRange: {
686
+ earliest: earliestTime.toISOString(),
687
+ latest: latestTime.toISOString(),
688
+ },
689
+ };
690
+ }
691
+
489
692
  /**
490
693
  * Aggregate minute results based on video type (TMC or ATR)
491
694
  */
@@ -728,6 +931,35 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
728
931
 
729
932
  return `${formatMinute(startMinute)}:00 - ${formatMinute(endMinute)}:59`;
730
933
  }
934
+
935
+ /**
936
+ * Format time label for study-level results (time only, no dates)
937
+ * Used when results are already grouped by date in the UI
938
+ */
939
+ private formatStudyTimeLabel(
940
+ startTime: Date,
941
+ durationMinutes: number,
942
+ ): string {
943
+ const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000);
944
+
945
+ const formatTime = (date: Date): string => {
946
+ const hours = date.getHours().toString().padStart(2, "0");
947
+ const minutes = date.getMinutes().toString().padStart(2, "0");
948
+ return `${hours}:${minutes}`;
949
+ };
950
+
951
+ return `${formatTime(startTime)} - ${formatTime(endTime)}`;
952
+ }
731
953
  }
732
954
 
955
+ // Export interfaces for external use
956
+ export type {
957
+ IStudyTimeGroupResult,
958
+ IGroupedStudyResponse,
959
+ IGroupedResponse,
960
+ IGroupedResult,
961
+ ITMCResult,
962
+ IATRResult,
963
+ };
964
+
733
965
  export default VideoMinuteResultDAO;
@@ -0,0 +1,121 @@
1
+ import { Knex } from "knex";
2
+ import { IBaseDAO, IDataPaginator } from "../../d.types";
3
+ import { IBatch } from "../../interfaces/batch/batch.interfaces";
4
+ import KnexManager from "../../KnexConnection";
5
+
6
+ export class BatchDAO implements IBaseDAO<IBatch> {
7
+ private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
8
+
9
+ async create(item: IBatch): Promise<IBatch> {
10
+ const [createdBatch] = await this._knex("video_batch")
11
+ .insert(item)
12
+ .returning("*");
13
+ return createdBatch;
14
+ }
15
+
16
+ async getById(id: number): Promise<IBatch | null> {
17
+ const batch = await this._knex("video_batch as b")
18
+ .innerJoin("folders as f", "b.folderId", "f.id")
19
+ .select("b.*", this._knex.raw("to_jsonb(f.*) as folder"))
20
+ .where("b.id", id)
21
+ .first();
22
+ return batch || null;
23
+ }
24
+
25
+ async getByUuid(uuid: string): Promise<IBatch | null> {
26
+ const batch = await this._knex("video_batch as b")
27
+ .innerJoin("folders as f", "b.folderId", "f.id")
28
+ .select("b.*", this._knex.raw("to_jsonb(f.*) as folder"))
29
+ .where("b.uuid", uuid)
30
+ .first();
31
+ return batch || null;
32
+ }
33
+
34
+ async update(id: number, item: Partial<IBatch>): Promise<IBatch | null> {
35
+ const [updatedBatch] = await this._knex("video_batch")
36
+ .where({ id })
37
+ .update(item)
38
+ .returning("*");
39
+ return updatedBatch || null;
40
+ }
41
+
42
+ async delete(id: number): Promise<boolean> {
43
+ const result = await this._knex("video_batch").where({ id }).del();
44
+ return result > 0;
45
+ }
46
+
47
+ async getAll(
48
+ page: number,
49
+ limit: number,
50
+ folderId?: number | null,
51
+ ): Promise<IDataPaginator<IBatch>> {
52
+ const offset = (page - 1) * limit;
53
+
54
+ const query = this._knex("video_batch as b")
55
+ .innerJoin("folders as f", "b.folderId", "f.id")
56
+ .select("b.*", this._knex.raw("to_jsonb(f.*) as folder"));
57
+ if (folderId !== undefined && folderId !== null) {
58
+ query.where("b.folderId", folderId);
59
+ }
60
+
61
+ const [countResult] = await query.clone().clearSelect().count("* as count");
62
+ const totalCount = +countResult.count;
63
+ const batches = await query.clone().limit(limit).offset(offset);
64
+
65
+ return {
66
+ success: true,
67
+ data: batches,
68
+ page,
69
+ limit,
70
+ count: batches.length,
71
+ totalCount,
72
+ totalPages: Math.ceil(totalCount / limit),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Get batches by folder ID
78
+ */
79
+ async getByFolderId(folderId: number): Promise<IBatch[]> {
80
+ return this._knex("video_batch as b")
81
+ .innerJoin("folders as f", "b.folderId", "f.id")
82
+ .select("b.*", this._knex.raw("to_jsonb(f.*) as folder"))
83
+ .where("b.folderId", folderId)
84
+ .orderBy("b.created_at", "desc");
85
+ }
86
+
87
+ /**
88
+ * Update batch progress
89
+ */
90
+ async updateProgress(
91
+ id: number,
92
+ completedVideos: number,
93
+ failedVideos: number,
94
+ ): Promise<void> {
95
+ await this._knex("video_batch").where({ id }).update({
96
+ completedVideos,
97
+ failedVideos,
98
+ updated_at: this._knex.fn.now(),
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Mark batch as completed
104
+ */
105
+ async markCompleted(id: number): Promise<void> {
106
+ await this._knex("video_batch").where({ id }).update({
107
+ status: "COMPLETED",
108
+ updated_at: this._knex.fn.now(),
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Mark batch as failed
114
+ */
115
+ async markFailed(id: number): Promise<void> {
116
+ await this._knex("video_batch").where({ id }).update({
117
+ status: "FAILED",
118
+ updated_at: this._knex.fn.now(),
119
+ });
120
+ }
121
+ }
@@ -257,4 +257,94 @@ export class VideoDAO implements IBaseDAO<IVideo> {
257
257
 
258
258
  return rows.map((row) => row.id);
259
259
  }
260
+
261
+ /**
262
+ * Get videos by batch ID
263
+ */
264
+ async getByBatchId(batchId: number): Promise<IVideo[]> {
265
+ return this._knex("video as v")
266
+ .innerJoin("folders as f", "v.folderId", "f.id")
267
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
268
+ .where("v.batchId", batchId)
269
+ .orderBy("v.created_at", "asc");
270
+ }
271
+
272
+ /**
273
+ * Get videos sorted chronologically by recording start time for a study
274
+ */
275
+ async getChronologicalVideos(
276
+ studyId: number,
277
+ startDate?: Date,
278
+ endDate?: Date,
279
+ ): Promise<IDataPaginator<IVideo>> {
280
+ let query = this._knex("video as v")
281
+ .innerJoin("folders as f", "v.folderId", "f.id")
282
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
283
+ .where("f.studyId", studyId)
284
+ .whereNotNull("v.recordingStartedAt");
285
+
286
+ if (startDate) {
287
+ query = query.where("v.recordingStartedAt", ">=", startDate);
288
+ }
289
+
290
+ if (endDate) {
291
+ query = query.where("v.recordingStartedAt", "<=", endDate);
292
+ }
293
+
294
+ const [countResult] = await query.clone().clearSelect().count("* as count");
295
+ const totalCount = +countResult.count;
296
+ const videos = await query.clone().orderBy("v.recordingStartedAt", "asc");
297
+
298
+ return {
299
+ success: true,
300
+ data: videos,
301
+ page: 1,
302
+ limit: totalCount,
303
+ count: videos.length,
304
+ totalCount,
305
+ totalPages: 1,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Update recording start time for a video
311
+ */
312
+ async updateRecordingStartTime(
313
+ id: number,
314
+ recordingStartedAt: Date,
315
+ ): Promise<void> {
316
+ await this._knex("video").where({ id }).update({
317
+ recordingStartedAt,
318
+ updated_at: this._knex.fn.now(),
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Update trim settings for a video
324
+ */
325
+ async updateTrimSettings(
326
+ id: number,
327
+ trimEnabled: boolean,
328
+ trimPeriods: { startTime: string; endTime: string }[] | null,
329
+ ): Promise<void> {
330
+ await this._knex("video")
331
+ .where({ id })
332
+ .update({
333
+ trimEnabled,
334
+ trimPeriods: trimPeriods ? JSON.stringify(trimPeriods) : null,
335
+ updated_at: this._knex.fn.now(),
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Get videos with trimming enabled for a folder
341
+ */
342
+ async getVideosWithTrimming(folderId: number): Promise<IVideo[]> {
343
+ return this._knex("video as v")
344
+ .innerJoin("folders as f", "v.folderId", "f.id")
345
+ .select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
346
+ .where("v.folderId", folderId)
347
+ .where("v.trimEnabled", true)
348
+ .orderBy("v.recordingStartedAt", "asc");
349
+ }
260
350
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // DAOs
2
2
  export { AuthDAO } from "./dao/auth/auth.dao";
3
+ export { BatchDAO } from "./dao/batch/batch.dao";
3
4
  export { CameraDAO } from "./dao/camera/camera.dao";
4
5
  export { ChatDAO } from "./dao/chat/chat.dao";
5
6
  export { FolderDAO } from "./dao/folder/folder.dao";
@@ -14,6 +15,7 @@ export { VideoMinuteResultDAO } from "./dao/VideoMinuteResultDAO";
14
15
  // Interfaces
15
16
  export { IDataPaginator } from "./d.types";
16
17
  export { IAuth } from "./interfaces/auth/auth.interfaces";
18
+ export { IBatch } from "./interfaces/batch/batch.interfaces";
17
19
  export { ICamera } from "./interfaces/camera/camera.interfaces";
18
20
  export {
19
21
  IChat,
@@ -36,12 +38,20 @@ export {
36
38
  export { IStudy } from "./interfaces/study/study.interfaces";
37
39
  export { IUser } from "./interfaces/user/user.interfaces";
38
40
  export { IUserPushNotificationToken } from "./interfaces/user-push-notification-token/user-push-notification-token.interfaces";
39
- export { IVideo } from "./interfaces/video/video.interfaces";
41
+ export { IVideo, ITrimPeriod } from "./interfaces/video/video.interfaces";
40
42
  export {
41
43
  IVideoMinuteResult,
42
44
  IVideoMinuteResultInput,
43
45
  IVideoMinuteBatch,
44
46
  } from "./entities/VideoMinuteResult";
47
+ export type {
48
+ IStudyTimeGroupResult,
49
+ IGroupedStudyResponse,
50
+ IGroupedResponse,
51
+ IGroupedResult,
52
+ ITMCResult,
53
+ IATRResult,
54
+ } from "./dao/VideoMinuteResultDAO";
45
55
 
46
56
  import KnexManager from "./KnexConnection";
47
57
  export { KnexManager };
@@ -0,0 +1,14 @@
1
+ import type { IFolder } from "../folder/folder.interfaces";
2
+
3
+ export interface IBatch {
4
+ id: number;
5
+ uuid: string;
6
+ folderId: number;
7
+ status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED";
8
+ totalVideos: number;
9
+ completedVideos: number;
10
+ failedVideos: number;
11
+ created_at: string;
12
+ updated_at: string;
13
+ folder?: IFolder;
14
+ }
@@ -1,5 +1,10 @@
1
1
  import type { IFolder } from "../folder/folder.interfaces";
2
2
 
3
+ export interface ITrimPeriod {
4
+ startTime: string;
5
+ endTime: string;
6
+ }
7
+
3
8
  export interface IVideo {
4
9
  id: number;
5
10
  uuid: string;
@@ -16,7 +21,7 @@ export interface IVideo {
16
21
  progress: number;
17
22
  remainingTime: string;
18
23
  results: Record<string, any>;
19
- durationSeconds?: number; // Add optional duration field
24
+ durationSeconds?: number;
20
25
 
21
26
  // HLS streaming support
22
27
  isHlsEnabled: boolean;
@@ -26,6 +31,12 @@ export interface IVideo {
26
31
  // Enhanced metadata for hybrid streaming
27
32
  streamingMetadata: Record<string, any> | null;
28
33
 
34
+ // Recording start time and video trimming support
35
+ recordingStartedAt?: Date;
36
+ trimEnabled?: boolean;
37
+ trimPeriods?: ITrimPeriod[] | null;
38
+ batchId?: number | null;
39
+
29
40
  created_at: string;
30
41
  updated_at: string;
31
42
  folder?: IFolder;