All files / web/src/db/schema practice-attachments.ts

100% Statements 114/114
100% Branches 4/4
100% Functions 3/3
100% Lines 114/114

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 101 102 103 104 105 106 107 108 109 110 111 112 113 114 1152x 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 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  
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
import { createId } from '@paralleldrive/cuid2'
import { players } from './players'
import { sessionPlans } from './session-plans'
import { users } from './users'
import type { WorksheetParsingResult, ReviewProgress } from '@/lib/worksheet-parsing'
 
/**
 * Parsing workflow status
 *
 * Note: 'processing' is deprecated - in-progress state is now tracked via
 * background_tasks table. This value is kept for backwards compatibility
 * but should not be set by new code.
 */
export type ParsingStatus = 'pending' | 'processing' | 'needs_review' | 'approved' | 'failed'
 
/**
 * Practice attachments - photos of student work
 *
 * Used primarily for offline practice sessions where parents/teachers
 * upload photos of the student's physical abacus work.
 *
 * Now also supports LLM-powered parsing of worksheet images to extract
 * problems and student answers automatically.
 */
export const practiceAttachments = sqliteTable('practice_attachments', {
  id: text('id')
    .primaryKey()
    .$defaultFn(() => createId()),
 
  // Who this photo belongs to
  playerId: text('player_id')
    .notNull()
    .references(() => players.id, { onDelete: 'cascade' }),
 
  // Link to session (required for offline sessions)
  sessionId: text('session_id')
    .notNull()
    .references(() => sessionPlans.id, { onDelete: 'cascade' }),
 
  // File info
  filename: text('filename').notNull(), // UUID.ext stored on disk (cropped version)
  originalFilename: text('original_filename'), // Original uncropped file (null if no cropping applied)
  mimeType: text('mime_type').notNull(), // image/jpeg, image/png, etc.
  fileSize: integer('file_size').notNull(), // bytes (of cropped file)
 
  // Crop corners (JSON array of 4 {x, y} points in original image coordinates)
  // Used to restore crop position when re-editing
  corners: text('corners', { mode: 'json' }).$type<Array<{
    x: number
    y: number
  }> | null>(),
 
  // Rotation in degrees (0, 90, 180, or 270) - applied after cropping
  rotation: integer('rotation').$type<0 | 90 | 180 | 270>().default(0),
 
  // ============================================================================
  // LLM Parsing Workflow
  // ============================================================================
 
  // Parsing status
  parsingStatus: text('parsing_status').$type<ParsingStatus>(),
  parsedAt: text('parsed_at'), // ISO timestamp when parsing completed
  parsingError: text('parsing_error'), // Error message if parsing failed
 
  // LLM parsing results (raw from LLM, before user corrections)
  rawParsingResult: text('raw_parsing_result', {
    mode: 'json',
  }).$type<WorksheetParsingResult | null>(),
 
  // Approved results (after user corrections)
  approvedResult: text('approved_result', {
    mode: 'json',
  }).$type<WorksheetParsingResult | null>(),
 
  // Confidence and review indicators
  confidenceScore: real('confidence_score'), // 0-1, from LLM
  needsReview: integer('needs_review', { mode: 'boolean' }), // True if any problems need manual review
 
  // Review workflow progress (for resumable reviews)
  // Tracks where the user left off and review statistics
  reviewProgress: text('review_progress', {
    mode: 'json',
  }).$type<ReviewProgress | null>(),
 
  // LLM call metadata (for debugging/transparency)
  llmProvider: text('llm_provider'), // e.g., "openai", "anthropic"
  llmModel: text('llm_model'), // e.g., "gpt-4o", "claude-sonnet-4"
  llmPromptUsed: text('llm_prompt_used'), // The actual prompt sent to the LLM
  llmRawResponse: text('llm_raw_response'), // Raw JSON response from the LLM (before parsing)
  llmJsonSchema: text('llm_json_schema'), // JSON Schema sent to the LLM (with field descriptions)
  llmImageSource: text('llm_image_source').$type<'cropped' | 'original'>(), // Which image was sent
  llmAttempts: integer('llm_attempts'), // How many retries were needed
  llmPromptTokens: integer('llm_prompt_tokens'),
  llmCompletionTokens: integer('llm_completion_tokens'),
  llmTotalTokens: integer('llm_total_tokens'),
 
  // Session linkage (for parsed worksheets that created sessions)
  sessionCreated: integer('session_created', { mode: 'boolean' }), // True if session was created from this parsing
  createdSessionId: text('created_session_id').references(() => sessionPlans.id, {
    onDelete: 'set null',
  }),
 
  // Audit
  uploadedBy: text('uploaded_by')
    .notNull()
    .references(() => users.id),
  uploadedAt: text('uploaded_at')
    .notNull()
    .$defaultFn(() => new Date().toISOString()),
})
 
export type PracticeAttachment = typeof practiceAttachments.$inferSelect
export type NewPracticeAttachment = typeof practiceAttachments.$inferInsert