@trafficgroup/knex-rel 0.0.27 → 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.
- package/.claude/settings.local.json +2 -5
- package/CLAUDE.md +72 -63
- package/dist/dao/VideoMinuteResultDAO.d.ts +48 -0
- package/dist/dao/VideoMinuteResultDAO.js +238 -0
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -0
- package/dist/dao/auth/auth.dao.js +4 -1
- package/dist/dao/auth/auth.dao.js.map +1 -1
- package/dist/dao/chat/chat.dao.d.ts +1 -1
- package/dist/dao/chat/chat.dao.js +25 -35
- package/dist/dao/chat/chat.dao.js.map +1 -1
- package/dist/dao/folder/folder.dao.js +7 -2
- package/dist/dao/folder/folder.dao.js.map +1 -1
- package/dist/dao/message/message.dao.d.ts +1 -1
- package/dist/dao/message/message.dao.js +18 -26
- package/dist/dao/message/message.dao.js.map +1 -1
- package/dist/dao/study/study.dao.js +7 -2
- package/dist/dao/study/study.dao.js.map +1 -1
- package/dist/dao/user/user.dao.js +4 -1
- package/dist/dao/user/user.dao.js.map +1 -1
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +26 -8
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
- package/dist/dao/video/video.dao.d.ts +15 -0
- package/dist/dao/video/video.dao.js +85 -2
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/entities/BaseEntity.d.ts +4 -0
- package/dist/entities/BaseEntity.js +3 -0
- package/dist/entities/BaseEntity.js.map +1 -0
- package/dist/entities/VideoMinuteResult.d.ts +21 -0
- package/dist/entities/VideoMinuteResult.js +3 -0
- package/dist/entities/VideoMinuteResult.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
- package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
- package/dist/interfaces/message/message.interfaces.d.ts +2 -2
- package/dist/interfaces/study/study.interfaces.d.ts +1 -1
- 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 +5 -2
- package/migrations/20250717161310_migration.ts +1 -1
- package/migrations/20250717161406_migration.ts +3 -3
- package/migrations/20250717162431_migration.ts +1 -1
- package/migrations/20250717173228_migration.ts +2 -2
- package/migrations/20250717204731_migration.ts +1 -1
- package/migrations/20250722210109_migration.ts +8 -4
- package/migrations/20250722211019_migration.ts +1 -1
- package/migrations/20250723153852_migration.ts +13 -10
- package/migrations/20250723162257_migration.ts +4 -7
- package/migrations/20250723171109_migration.ts +4 -7
- package/migrations/20250723205331_migration.ts +6 -9
- package/migrations/20250724191345_migration.ts +8 -11
- package/migrations/20250730180932_migration.ts +14 -13
- package/migrations/20250730213625_migration.ts +8 -11
- package/migrations/20250804124509_migration.ts +26 -21
- package/migrations/20250804132053_migration.ts +5 -8
- package/migrations/20250804164518_migration.ts +17 -0
- package/migrations/20250823223016_migration.ts +46 -0
- package/package.json +47 -47
- package/plan.md +304 -0
- package/src/d.types.ts +22 -18
- package/src/dao/VideoMinuteResultDAO.ts +273 -0
- package/src/dao/auth/auth.dao.ts +58 -55
- package/src/dao/chat/chat.dao.ts +45 -43
- package/src/dao/folder/folder.dao.ts +75 -66
- package/src/dao/message/message.dao.ts +32 -32
- package/src/dao/study/study.dao.ts +75 -66
- package/src/dao/user/user.dao.ts +59 -57
- package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +83 -49
- package/src/dao/video/video.dao.ts +199 -71
- package/src/entities/BaseEntity.ts +4 -0
- package/src/entities/VideoMinuteResult.ts +24 -0
- package/src/index.ts +37 -23
- package/src/interfaces/auth/auth.interfaces.ts +10 -10
- package/src/interfaces/chat/chat.interfaces.ts +4 -4
- package/src/interfaces/folder/folder.interfaces.ts +13 -13
- package/src/interfaces/message/message.interfaces.ts +3 -3
- package/src/interfaces/study/study.interfaces.ts +12 -12
- package/src/interfaces/user/user.interfaces.ts +11 -11
- package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
- 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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface IBaseDAO<T> {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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;
|