@trafficgroup/knex-rel 0.0.28 → 0.0.29

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 (83) hide show
  1. package/.claude/settings.local.json +2 -5
  2. package/CLAUDE.md +72 -63
  3. package/dist/dao/VideoMinuteResultDAO.d.ts +48 -0
  4. package/dist/dao/VideoMinuteResultDAO.js +238 -0
  5. package/dist/dao/VideoMinuteResultDAO.js.map +1 -0
  6. package/dist/dao/auth/auth.dao.js +4 -1
  7. package/dist/dao/auth/auth.dao.js.map +1 -1
  8. package/dist/dao/chat/chat.dao.d.ts +1 -1
  9. package/dist/dao/chat/chat.dao.js +25 -35
  10. package/dist/dao/chat/chat.dao.js.map +1 -1
  11. package/dist/dao/folder/folder.dao.js +7 -2
  12. package/dist/dao/folder/folder.dao.js.map +1 -1
  13. package/dist/dao/message/message.dao.d.ts +1 -1
  14. package/dist/dao/message/message.dao.js +18 -26
  15. package/dist/dao/message/message.dao.js.map +1 -1
  16. package/dist/dao/study/study.dao.js +7 -2
  17. package/dist/dao/study/study.dao.js.map +1 -1
  18. package/dist/dao/user/user.dao.js +4 -1
  19. package/dist/dao/user/user.dao.js.map +1 -1
  20. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +26 -8
  21. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
  22. package/dist/dao/video/video.dao.d.ts +8 -0
  23. package/dist/dao/video/video.dao.js +54 -7
  24. package/dist/dao/video/video.dao.js.map +1 -1
  25. package/dist/entities/BaseEntity.d.ts +4 -0
  26. package/dist/entities/BaseEntity.js +3 -0
  27. package/dist/entities/BaseEntity.js.map +1 -0
  28. package/dist/entities/VideoMinuteResult.d.ts +21 -0
  29. package/dist/entities/VideoMinuteResult.js +3 -0
  30. package/dist/entities/VideoMinuteResult.js.map +1 -0
  31. package/dist/index.d.ts +4 -2
  32. package/dist/index.js +3 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
  35. package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
  36. package/dist/interfaces/message/message.interfaces.d.ts +2 -2
  37. package/dist/interfaces/study/study.interfaces.d.ts +1 -1
  38. package/dist/interfaces/user/user.interfaces.d.ts +1 -1
  39. package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
  40. package/dist/interfaces/video/video.interfaces.d.ts +3 -2
  41. package/migrations/20250717160737_migration.ts +1 -1
  42. package/migrations/20250717160908_migration.ts +5 -2
  43. package/migrations/20250717161310_migration.ts +1 -1
  44. package/migrations/20250717161406_migration.ts +3 -3
  45. package/migrations/20250717162431_migration.ts +1 -1
  46. package/migrations/20250717173228_migration.ts +2 -2
  47. package/migrations/20250717204731_migration.ts +1 -1
  48. package/migrations/20250722210109_migration.ts +8 -4
  49. package/migrations/20250722211019_migration.ts +1 -1
  50. package/migrations/20250723153852_migration.ts +13 -10
  51. package/migrations/20250723162257_migration.ts +4 -7
  52. package/migrations/20250723171109_migration.ts +4 -7
  53. package/migrations/20250723205331_migration.ts +6 -9
  54. package/migrations/20250724191345_migration.ts +8 -11
  55. package/migrations/20250730180932_migration.ts +14 -13
  56. package/migrations/20250730213625_migration.ts +8 -11
  57. package/migrations/20250804124509_migration.ts +26 -21
  58. package/migrations/20250804132053_migration.ts +5 -8
  59. package/migrations/20250804164518_migration.ts +7 -7
  60. package/migrations/20250823223016_migration.ts +46 -0
  61. package/package.json +47 -47
  62. package/plan.md +304 -0
  63. package/src/d.types.ts +22 -18
  64. package/src/dao/VideoMinuteResultDAO.ts +273 -0
  65. package/src/dao/auth/auth.dao.ts +58 -55
  66. package/src/dao/chat/chat.dao.ts +45 -43
  67. package/src/dao/folder/folder.dao.ts +75 -66
  68. package/src/dao/message/message.dao.ts +32 -32
  69. package/src/dao/study/study.dao.ts +75 -66
  70. package/src/dao/user/user.dao.ts +59 -57
  71. package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +83 -49
  72. package/src/dao/video/video.dao.ts +199 -120
  73. package/src/entities/BaseEntity.ts +4 -0
  74. package/src/entities/VideoMinuteResult.ts +24 -0
  75. package/src/index.ts +37 -23
  76. package/src/interfaces/auth/auth.interfaces.ts +10 -10
  77. package/src/interfaces/chat/chat.interfaces.ts +4 -4
  78. package/src/interfaces/folder/folder.interfaces.ts +13 -13
  79. package/src/interfaces/message/message.interfaces.ts +3 -3
  80. package/src/interfaces/study/study.interfaces.ts +12 -12
  81. package/src/interfaces/user/user.interfaces.ts +11 -11
  82. package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
  83. package/src/interfaces/video/video.interfaces.ts +22 -21
package/plan.md ADDED
@@ -0,0 +1,304 @@
1
+ # Database Implementation Plan: Video Minute Results Storage
2
+
3
+ ## Schema Changes
4
+
5
+ ### New Table: video_minute_results
6
+
7
+ ```sql
8
+ CREATE TABLE video_minute_results (
9
+ id BIGSERIAL PRIMARY KEY,
10
+ uuid UUID NOT NULL DEFAULT gen_random_uuid(),
11
+ video_id INTEGER NOT NULL REFERENCES video(id) ON DELETE CASCADE,
12
+ minute_number INTEGER NOT NULL,
13
+ results JSONB NOT NULL,
14
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
15
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
16
+ CONSTRAINT unique_video_minute UNIQUE (video_id, minute_number),
17
+ CONSTRAINT valid_minute_number CHECK (minute_number >= 0)
18
+ );
19
+ ```
20
+
21
+ **Indexes:**
22
+
23
+ - `idx_video_minute_results_video_id` on `video_id` (frequent JOINs with video table)
24
+ - `idx_video_minute_results_uuid` on `uuid` (external API lookups)
25
+ - `idx_video_minute_results_minute_number` on `minute_number` (range queries)
26
+ - `idx_video_minute_results_video_minute` on `(video_id, minute_number)` (composite lookups)
27
+
28
+ ### Modifications to Existing Tables
29
+
30
+ ```sql
31
+ -- Add duration tracking to video table
32
+ ALTER TABLE video ADD COLUMN duration_seconds INTEGER;
33
+ ```
34
+
35
+ ## Migrations
36
+
37
+ ### 20250823HHMMSS_create_video_minute_results.ts
38
+
39
+ ```typescript
40
+ export async function up(knex: Knex): Promise<void> {
41
+ // Create video_minute_results table
42
+ await knex.schema.createTable("video_minute_results", (table) => {
43
+ table.bigIncrements("id").primary();
44
+ table
45
+ .uuid("uuid")
46
+ .defaultTo(knex.raw("gen_random_uuid()"))
47
+ .notNullable()
48
+ .unique();
49
+ table
50
+ .integer("video_id")
51
+ .unsigned()
52
+ .notNullable()
53
+ .references("id")
54
+ .inTable("video")
55
+ .onDelete("CASCADE");
56
+ table.integer("minute_number").notNullable();
57
+ table.jsonb("results").notNullable();
58
+ table.timestamps(true, true);
59
+
60
+ // Constraints
61
+ table.unique(["video_id", "minute_number"], {
62
+ indexName: "unique_video_minute",
63
+ });
64
+ table.check("minute_number >= 0", [], "valid_minute_number");
65
+
66
+ // Indexes
67
+ table.index("video_id", "idx_video_minute_results_video_id");
68
+ table.index("uuid", "idx_video_minute_results_uuid");
69
+ table.index("minute_number", "idx_video_minute_results_minute_number");
70
+ table.index(
71
+ ["video_id", "minute_number"],
72
+ "idx_video_minute_results_video_minute",
73
+ );
74
+ });
75
+
76
+ // Add duration_seconds to video table
77
+ await knex.schema.alterTable("video", (table) => {
78
+ table.integer("duration_seconds").nullable();
79
+ });
80
+ }
81
+
82
+ export async function down(knex: Knex): Promise<void> {
83
+ await knex.schema.dropTableIfExists("video_minute_results");
84
+ await knex.schema.alterTable("video", (table) => {
85
+ table.dropColumn("duration_seconds");
86
+ });
87
+ }
88
+ ```
89
+
90
+ **Safety Level:** Medium - Creates new table and adds nullable column to existing table
91
+
92
+ ## DAOs
93
+
94
+ ### Update: VideoMinuteResultDAO
95
+
96
+ **File:** `src/dao/VideoMinuteResultDAO.ts` (replace existing mock implementation)
97
+
98
+ **Methods:**
99
+
100
+ ```typescript
101
+ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
102
+ private knex = KnexManager.getConnection();
103
+ private tableName = "video_minute_results";
104
+
105
+ // Standard CRUD operations
106
+ async create(data: IVideoMinuteResultInput): Promise<IVideoMinuteResult>;
107
+ async getById(id: number): Promise<IVideoMinuteResult | null>;
108
+ async getByUuid(uuid: string): Promise<IVideoMinuteResult | null>;
109
+ async getAll(
110
+ page: number,
111
+ limit: number,
112
+ ): Promise<IDataPaginator<IVideoMinuteResult>>;
113
+ async update(
114
+ id: number,
115
+ data: Partial<IVideoMinuteResult>,
116
+ ): Promise<IVideoMinuteResult | null>;
117
+ async delete(id: number): Promise<boolean>;
118
+
119
+ // Specialized batch operations (eliminate N+1 queries)
120
+ async createBatch(
121
+ videoId: number,
122
+ minuteResults: IVideoMinuteResultInput[],
123
+ ): Promise<IVideoMinuteResult[]>;
124
+ async getMinuteResultsForVideo(
125
+ videoId: number,
126
+ startMinute?: number,
127
+ endMinute?: number,
128
+ page?: number,
129
+ limit?: number,
130
+ ): Promise<IDataPaginator<IVideoMinuteResult>>;
131
+ async getMinuteResultsByVideoUuid(
132
+ videoUuid: string,
133
+ startMinute?: number,
134
+ endMinute?: number,
135
+ page?: number,
136
+ limit?: number,
137
+ ): Promise<IDataPaginator<IVideoMinuteResult>>;
138
+ async deleteByVideoId(videoId: number): Promise<boolean>;
139
+ async getVideoMinuteRange(
140
+ videoId: number,
141
+ ): Promise<{ minMinute: number; maxMinute: number } | null>;
142
+ }
143
+ ```
144
+
145
+ **Performance Optimizations:**
146
+
147
+ - Batch inserts using `knex.batchInsert()` for 5-minute batches
148
+ - Single query with JOINs for video+minute data retrieval
149
+ - Range queries with BETWEEN for minute filtering
150
+ - Pagination with proper OFFSET/LIMIT
151
+
152
+ ### Update: VideoDAO
153
+
154
+ **File:** `src/dao/video/video.dao.ts` (add duration_seconds support)
155
+
156
+ **New Methods:**
157
+
158
+ ```typescript
159
+ async updateDuration(id: number, durationSeconds: number): Promise<IVideo | null>
160
+ async getVideosWithoutMinuteData(page: number, limit: number): Promise<IDataPaginator<IVideo>>
161
+ ```
162
+
163
+ ## Interfaces
164
+
165
+ ### Update: IVideoMinuteResult.ts
166
+
167
+ ```typescript
168
+ export interface IVideoMinuteResult extends IBaseEntity {
169
+ id: number;
170
+ uuid: string;
171
+ videoId: number;
172
+ minuteNumber: number; // Renamed from 'minute' for clarity
173
+ results: Record<string, any>;
174
+ createdAt: string;
175
+ updatedAt: string;
176
+ }
177
+
178
+ export interface IVideoMinuteResultInput {
179
+ videoId: number;
180
+ minuteNumber: number;
181
+ results: Record<string, any>;
182
+ }
183
+
184
+ export interface IVideoMinuteBatch {
185
+ videoId: number;
186
+ startMinute: number;
187
+ endMinute: number;
188
+ minuteResults: IVideoMinuteResultInput[];
189
+ }
190
+ ```
191
+
192
+ ### Update: IVideo.ts
193
+
194
+ ```typescript
195
+ export interface IVideo {
196
+ // ... existing fields
197
+ durationSeconds?: number; // Add optional duration field
198
+ }
199
+ ```
200
+
201
+ ### Export Updates
202
+
203
+ Update `src/index.ts` to include new interfaces and updated DAO.
204
+
205
+ ## Implementation Order
206
+
207
+ 1. **Interfaces** → Update IVideoMinuteResult and IVideo interfaces
208
+ 2. **Migration** → Create 20250823HHMMSS_create_video_minute_results.ts
209
+ 3. **DAO Implementation** → Replace VideoMinuteResultDAO mock methods with real database operations
210
+ 4. **DAO Enhancement** → Add duration support to VideoDAO
211
+ 5. **Exports** → Update index.ts exports
212
+ 6. **Build & Test** → Compile TypeScript and run tests
213
+
214
+ ## Query Performance Strategy
215
+
216
+ ### Batch Insert Optimization
217
+
218
+ ```typescript
219
+ async createBatch(videoId: number, minuteResults: IVideoMinuteResultInput[]): Promise<IVideoMinuteResult[]> {
220
+ const batchData = minuteResults.map(result => ({
221
+ ...result,
222
+ videoId,
223
+ uuid: knex.raw('gen_random_uuid()'),
224
+ created_at: knex.fn.now(),
225
+ updated_at: knex.fn.now()
226
+ }));
227
+
228
+ return await this.knex.batchInsert(this.tableName, batchData, 50).returning('*');
229
+ }
230
+ ```
231
+
232
+ ### Optimized Minute Range Query
233
+
234
+ ```typescript
235
+ async getMinuteResultsForVideo(
236
+ videoId: number,
237
+ startMinute: number = 0,
238
+ endMinute?: number,
239
+ page: number = 1,
240
+ limit: number = 100
241
+ ): Promise<IDataPaginator<IVideoMinuteResult>> {
242
+ const query = this.knex(this.tableName + ' as vmr')
243
+ .innerJoin('video as v', 'vmr.video_id', 'v.id')
244
+ .select('vmr.*', 'v.name as video_name', 'v.uuid as video_uuid')
245
+ .where('vmr.video_id', videoId)
246
+ .where('vmr.minute_number', '>=', startMinute);
247
+
248
+ if (endMinute !== undefined) {
249
+ query.where('vmr.minute_number', '<=', endMinute);
250
+ }
251
+
252
+ // Standard pagination logic with count optimization
253
+ const [{ count }] = await query.clone().clearSelect().count('* as count');
254
+ const offset = (page - 1) * limit;
255
+ const data = await query.orderBy('vmr.minute_number', 'asc').limit(limit).offset(offset);
256
+
257
+ return {
258
+ success: true,
259
+ data,
260
+ page,
261
+ limit,
262
+ count: data.length,
263
+ totalCount: parseInt(count as string),
264
+ totalPages: Math.ceil(parseInt(count as string) / limit)
265
+ };
266
+ }
267
+ ```
268
+
269
+ ## Risks & Validation
270
+
271
+ ### Migration Safety
272
+
273
+ - **Risk:** Table creation failure in production
274
+ - **Mitigation:** Test migration on staging with production data volume
275
+ - **Rollback:** `down()` function drops table and column safely
276
+
277
+ ### Pattern Compliance
278
+
279
+ - **UUID-only External Communication:** ✅ All external API methods use UUID lookups
280
+ - **Foreign Key Integrity:** ✅ CASCADE delete ensures orphaned records cleanup
281
+ - **Naming Conventions:** ✅ Follows snake_case for DB, camelCase for TypeScript
282
+ - **DAO Methods:** ✅ Standard CRUD + specialized batch operations
283
+ - **Performance:** ✅ Proper indexing for all query patterns
284
+
285
+ ### Data Integrity Validation
286
+
287
+ ```typescript
288
+ async validateMinuteDataIntegrity(videoId: number): Promise<boolean> {
289
+ const video = await this.knex('video').where('id', videoId).first();
290
+ const minuteResults = await this.knex(this.tableName).where('video_id', videoId);
291
+
292
+ // Aggregate minute results and compare with video.results
293
+ return this.compareAggregatedResults(video.results, minuteResults);
294
+ }
295
+ ```
296
+
297
+ ### Performance Validation
298
+
299
+ - Batch inserts handle 5-minute chunks efficiently (50-item batches)
300
+ - Indexes support all query patterns: video_id lookups, UUID lookups, minute ranges
301
+ - JOIN operations optimized for N+1 query elimination
302
+ - Pagination implemented with proper count optimization
303
+
304
+ This plan ensures 100% pattern compliance while optimizing for the specific use case of minute-by-minute video detection storage with efficient batch processing capabilities.
package/src/d.types.ts CHANGED
@@ -1,18 +1,22 @@
1
- export interface IDataPaginator<T> {
2
- success: boolean;
3
- data: T[];
4
- page: number;
5
- limit: number;
6
- count: number;
7
- totalCount: number;
8
- totalPages: number;
9
- }
10
-
11
- export interface IBaseDAO<T> {
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
- }
1
+ export interface IDataPaginator<T> {
2
+ success: boolean;
3
+ data: T[];
4
+ page: number;
5
+ limit: number;
6
+ count: number;
7
+ totalCount: number;
8
+ totalPages: number;
9
+ }
10
+
11
+ export interface IBaseDAO<T> {
12
+ create(item: T): Promise<T>;
13
+ getById(id: number): Promise<T | null>;
14
+ getByUuid(uuid: string): Promise<T | null>;
15
+ getAll(
16
+ page: number,
17
+ limit: number,
18
+ entityId?: any | null,
19
+ ): Promise<IDataPaginator<T>>;
20
+ update(id: number, item: T): Promise<T | null>;
21
+ delete(id: number): Promise<boolean>;
22
+ }
@@ -0,0 +1,273 @@
1
+ import { IBaseDAO, IDataPaginator } from "../d.types";
2
+ import {
3
+ IVideoMinuteResult,
4
+ IVideoMinuteResultInput,
5
+ IVideoMinuteBatch,
6
+ } from "../entities/VideoMinuteResult";
7
+ import KnexManager from "../KnexConnection";
8
+
9
+ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
10
+ private knex = KnexManager.getConnection();
11
+ private tableName = "video_minute_results";
12
+
13
+ async getAll(
14
+ page: number = 1,
15
+ limit: number = 10,
16
+ ): Promise<IDataPaginator<IVideoMinuteResult>> {
17
+ const offset = (page - 1) * limit;
18
+
19
+ const [countResult] = await this.knex(this.tableName).count("* as count");
20
+ const totalCount = +countResult.count;
21
+
22
+ const data = await this.knex(this.tableName)
23
+ .select("*")
24
+ .orderBy("created_at", "desc")
25
+ .limit(limit)
26
+ .offset(offset);
27
+
28
+ return {
29
+ success: true,
30
+ data: this.mapDbRowsToEntities(data),
31
+ page,
32
+ limit,
33
+ count: data.length,
34
+ totalCount,
35
+ totalPages: Math.ceil(totalCount / limit),
36
+ };
37
+ }
38
+
39
+ async getById(id: number): Promise<IVideoMinuteResult | null> {
40
+ const result = await this.knex(this.tableName).where("id", id).first();
41
+
42
+ return result ? this.mapDbRowToEntity(result) : null;
43
+ }
44
+
45
+ async getByUuid(uuid: string): Promise<IVideoMinuteResult | null> {
46
+ const result = await this.knex(this.tableName).where("uuid", uuid).first();
47
+
48
+ return result ? this.mapDbRowToEntity(result) : null;
49
+ }
50
+
51
+ async create(data: IVideoMinuteResultInput): Promise<IVideoMinuteResult> {
52
+ const dbData = this.mapEntityToDbRow(data);
53
+ const [result] = await this.knex(this.tableName)
54
+ .insert(dbData)
55
+ .returning("*");
56
+
57
+ return this.mapDbRowToEntity(result);
58
+ }
59
+
60
+ async update(
61
+ id: number,
62
+ data: IVideoMinuteResult,
63
+ ): Promise<IVideoMinuteResult | null> {
64
+ const dbData = this.mapEntityToDbRow(data);
65
+ const [result] = await this.knex(this.tableName)
66
+ .where("id", id)
67
+ .update({ ...dbData, updated_at: this.knex.fn.now() })
68
+ .returning("*");
69
+
70
+ return result ? this.mapDbRowToEntity(result) : null;
71
+ }
72
+
73
+ async delete(id: number): Promise<boolean> {
74
+ const result = await this.knex(this.tableName).where("id", id).del();
75
+
76
+ return result > 0;
77
+ }
78
+
79
+ /**
80
+ * Get minute results for a specific video with optional time range filtering
81
+ */
82
+ async getMinuteResultsForVideo(
83
+ videoId: number,
84
+ startMinute?: number,
85
+ endMinute?: number,
86
+ page: number = 1,
87
+ limit: number = 100,
88
+ ): Promise<IDataPaginator<IVideoMinuteResult>> {
89
+ const offset = (page - 1) * limit;
90
+
91
+ const query = this.knex(this.tableName + " as vmr")
92
+ .innerJoin("video as v", "vmr.video_id", "v.id")
93
+ .select("vmr.*", "v.name as video_name", "v.uuid as video_uuid")
94
+ .where("vmr.video_id", videoId);
95
+
96
+ if (startMinute !== undefined) {
97
+ query.where("vmr.minute_number", ">=", startMinute);
98
+ }
99
+
100
+ if (endMinute !== undefined) {
101
+ query.where("vmr.minute_number", "<=", endMinute);
102
+ }
103
+
104
+ // Standard pagination logic with count optimization
105
+ const [{ count }] = await query.clone().clearSelect().count("* as count");
106
+ const data = await query
107
+ .orderBy("vmr.minute_number", "asc")
108
+ .limit(limit)
109
+ .offset(offset);
110
+
111
+ return {
112
+ success: true,
113
+ data: this.mapDbRowsToEntities(data),
114
+ page,
115
+ limit,
116
+ count: data.length,
117
+ totalCount: parseInt(count as string),
118
+ totalPages: Math.ceil(parseInt(count as string) / limit),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Create batch of minute results for efficient bulk insertion
124
+ */
125
+ async createBatch(
126
+ videoId: number,
127
+ minuteResults: IVideoMinuteResultInput[],
128
+ ): Promise<IVideoMinuteResult[]> {
129
+ const batchData = minuteResults.map((result) =>
130
+ this.mapEntityToDbRow({
131
+ ...result,
132
+ videoId,
133
+ }),
134
+ );
135
+
136
+ const results = await this.knex
137
+ .batchInsert(this.tableName, batchData, 50)
138
+ .returning("*");
139
+ return this.mapDbRowsToEntities(results);
140
+ }
141
+
142
+ /**
143
+ * Delete all minute results for a specific video
144
+ */
145
+ async deleteByVideoId(videoId: number): Promise<boolean> {
146
+ const result = await this.knex(this.tableName)
147
+ .where("video_id", videoId)
148
+ .del();
149
+
150
+ return result > 0;
151
+ }
152
+
153
+ /**
154
+ * Get the minute range for a video (min and max minute numbers)
155
+ */
156
+ async getVideoMinuteRange(
157
+ videoId: number,
158
+ ): Promise<{ minMinute: number; maxMinute: number } | null> {
159
+ const result = await this.knex(this.tableName)
160
+ .where("video_id", videoId)
161
+ .min("minute_number as minMinute")
162
+ .max("minute_number as maxMinute")
163
+ .first();
164
+
165
+ if (!result || result.minMinute === null) {
166
+ return null;
167
+ }
168
+
169
+ return {
170
+ minMinute: result.minMinute,
171
+ maxMinute: result.maxMinute,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Map database row to entity (snake_case -> camelCase)
177
+ */
178
+ private mapDbRowToEntity(row: any): IVideoMinuteResult {
179
+ return {
180
+ id: row.id,
181
+ uuid: row.uuid,
182
+ videoId: row.video_id,
183
+ minuteNumber: row.minute_number,
184
+ results: row.results,
185
+ createdAt: row.created_at,
186
+ updatedAt: row.updated_at,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Map multiple database rows to entities
192
+ */
193
+ private mapDbRowsToEntities(rows: any[]): IVideoMinuteResult[] {
194
+ return rows.map((row) => this.mapDbRowToEntity(row));
195
+ }
196
+
197
+ /**
198
+ * Map entity to database row (camelCase -> snake_case)
199
+ */
200
+ private mapEntityToDbRow(
201
+ entity: Partial<IVideoMinuteResult | IVideoMinuteResultInput>,
202
+ ): any {
203
+ const dbRow: any = {};
204
+
205
+ if (entity.videoId !== undefined) dbRow.video_id = entity.videoId;
206
+ if ("minuteNumber" in entity && entity.minuteNumber !== undefined)
207
+ dbRow.minute_number = entity.minuteNumber;
208
+ if (entity.results !== undefined) dbRow.results = entity.results;
209
+ if ("createdAt" in entity && entity.createdAt !== undefined)
210
+ dbRow.created_at = entity.createdAt;
211
+ if ("updatedAt" in entity && entity.updatedAt !== undefined)
212
+ dbRow.updated_at = entity.updatedAt;
213
+
214
+ return dbRow;
215
+ }
216
+
217
+ /**
218
+ * Get minute results by video UUID (convenience method)
219
+ */
220
+ async getMinuteResultsByVideoUuid(
221
+ videoUuid: string,
222
+ startMinute?: number,
223
+ endMinute?: number,
224
+ page: number = 1,
225
+ limit: number = 100,
226
+ ): Promise<IDataPaginator<IVideoMinuteResult>> {
227
+ const offset = (page - 1) * limit;
228
+
229
+ const query = this.knex(this.tableName + " as vmr")
230
+ .innerJoin("video as v", "vmr.video_id", "v.id")
231
+ .select("vmr.*", "v.name as video_name", "v.uuid as video_uuid")
232
+ .where("v.uuid", videoUuid);
233
+
234
+ if (startMinute !== undefined) {
235
+ query.where("vmr.minute_number", ">=", startMinute);
236
+ }
237
+
238
+ if (endMinute !== undefined) {
239
+ query.where("vmr.minute_number", "<=", endMinute);
240
+ }
241
+
242
+ // Check if video exists and get count in single query
243
+ const [{ count }] = await query.clone().clearSelect().count("* as count");
244
+ const totalCount = parseInt(count as string);
245
+
246
+ if (totalCount === 0) {
247
+ // Check if video exists by trying to find it
248
+ const videoExists = await this.knex("video")
249
+ .where("uuid", videoUuid)
250
+ .first();
251
+ if (!videoExists) {
252
+ throw new Error(`Video with UUID ${videoUuid} not found`);
253
+ }
254
+ }
255
+
256
+ const data = await query
257
+ .orderBy("vmr.minute_number", "asc")
258
+ .limit(limit)
259
+ .offset(offset);
260
+
261
+ return {
262
+ success: true,
263
+ data: this.mapDbRowsToEntities(data),
264
+ page,
265
+ limit,
266
+ count: data.length,
267
+ totalCount,
268
+ totalPages: Math.ceil(totalCount / limit),
269
+ };
270
+ }
271
+ }
272
+
273
+ export default VideoMinuteResultDAO;