All files / web/src/db/schema vision-problem-videos.ts

100% Statements 100/100
100% Branches 3/3
100% Functions 2/2
100% Lines 100/100

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 1012x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 2x 2x 2x 2x  
import { sqliteTable, text, integer, real, index } from 'drizzle-orm/sqlite-core'
import { createId } from '@paralleldrive/cuid2'
import { players } from './players'
import { sessionPlans } from './session-plans'
 
/**
 * Video status values
 * - recording: Currently capturing frames
 * - processing: Encoding frames to video
 * - ready: Video encoded and available
 * - failed: Encoding failed
 * - no_video: Problem completed but no video frames were captured (camera not enabled)
 */
export type VisionProblemVideoStatus = 'recording' | 'processing' | 'ready' | 'failed' | 'no_video'
 
/**
 * Vision problem videos - per-problem video recordings of abacus camera during practice
 *
 * Each problem attempt gets its own video, allowing teachers to:
 * - Click on any completed problem in the observer nav to watch that specific attempt
 * - Review student technique for individual problems
 * - See exactly what the student was doing from problem shown to answer submitted
 *
 * Videos are encoded incrementally as each problem completes, not at session end.
 * Retained for 7 days (configurable), then automatically cleaned up.
 */
export const visionProblemVideos = sqliteTable(
  'vision_problem_videos',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => createId()),
 
    // Link to session
    sessionId: text('session_id')
      .notNull()
      .references(() => sessionPlans.id, { onDelete: 'cascade' }),
 
    // Denormalized for efficient queries
    playerId: text('player_id')
      .notNull()
      .references(() => players.id, { onDelete: 'cascade' }),
 
    // Problem identification
    problemNumber: integer('problem_number').notNull(), // 1-indexed
    partIndex: integer('part_index').notNull(), // 0, 1, or 2 (abacus, visualization, linear)
 
    // Attempt tracking (for epoch retries and manual redos)
    epochNumber: integer('epoch_number').notNull().default(0), // 0 = initial pass, 1-2 = retry epochs
    attemptNumber: integer('attempt_number').notNull().default(1), // 1-indexed within epoch
    isRetry: integer('is_retry', { mode: 'boolean' }).notNull().default(false),
    isManualRedo: integer('is_manual_redo', { mode: 'boolean' }).notNull().default(false),
 
    // File info
    filename: text('filename').notNull(), // problem_001.mp4 stored on disk
    fileSize: integer('file_size'), // bytes (null while recording/processing)
    durationMs: integer('duration_ms'), // milliseconds (null while recording)
    frameCount: integer('frame_count'), // total frames in video
    avgFps: real('avg_fps'), // average frames per second
 
    // Time range
    startedAt: integer('started_at', { mode: 'timestamp' }).notNull(),
    endedAt: integer('ended_at', { mode: 'timestamp' }), // null while recording
 
    // Problem result (denormalized for display)
    isCorrect: integer('is_correct', { mode: 'boolean' }),
 
    // Recording state
    status: text('status').$type<VisionProblemVideoStatus>().notNull().default('recording'),
    processingError: text('processing_error'), // error message if encoding failed
 
    // Retention management
    expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
 
    // Audit
    createdAt: integer('created_at', { mode: 'timestamp' })
      .notNull()
      .$defaultFn(() => new Date()),
  },
  (table) => [
    // Find videos for a session (for observer to list available replays)
    index('vision_problem_videos_session_id_idx').on(table.sessionId),
    // Find specific problem video (with epoch/attempt for multi-attempt support)
    index('vision_problem_videos_session_problem_idx').on(
      table.sessionId,
      table.problemNumber,
      table.epochNumber,
      table.attemptNumber
    ),
    // Find videos for a player (for history view)
    index('vision_problem_videos_player_id_idx').on(table.playerId),
    // Find expired videos for cleanup
    index('vision_problem_videos_expires_at_idx').on(table.expiresAt),
    // Find videos by status (for processing queue)
    index('vision_problem_videos_status_idx').on(table.status),
  ]
)
 
export type VisionProblemVideo = typeof visionProblemVideos.$inferSelect
export type NewVisionProblemVideo = typeof visionProblemVideos.$inferInsert