package sqlite import ( "context" "database/sql" "encoding/json" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // FileStore implements store.FileStore against the `files` table. // type FileStore struct{} func NewFileStore() *FileStore { return &FileStore{} } const fileCols = `id, channel_id, user_id, message_id, project_id, origin, filename, content_type, size_bytes, storage_key, display_hint, extracted_text, metadata, created_at, updated_at` func scanFile(row interface{ Scan(dest ...interface{}) error }) (*models.File, error) { var f models.File var messageID sql.NullString var projectID sql.NullString var extractedText sql.NullString var metadataJSON []byte err := row.Scan( &f.ID, &f.ChannelID, &f.UserID, &messageID, &projectID, &f.Origin, &f.Filename, &f.ContentType, &f.SizeBytes, &f.StorageKey, &f.DisplayHint, &extractedText, &metadataJSON, st(&f.CreatedAt), st(&f.UpdatedAt), ) if err != nil { return nil, err } f.MessageID = NullableStringPtr(messageID) f.ProjectID = NullableStringPtr(projectID) if extractedText.Valid { f.ExtractedText = &extractedText.String } if len(metadataJSON) > 0 { json.Unmarshal(metadataJSON, &f.Metadata) } return &f, nil } func (s *FileStore) Create(ctx context.Context, f *models.File) error { f.ID = store.NewID() now := time.Now().UTC() f.CreatedAt = now f.UpdatedAt = now if f.Origin == "" { f.Origin = models.FileOriginUserUpload } if f.DisplayHint == "" { f.DisplayHint = models.FileHintDownload } _, err := DB.ExecContext(ctx, ` INSERT INTO files (id, channel_id, user_id, message_id, project_id, origin, filename, content_type, size_bytes, storage_key, display_hint, extracted_text, metadata, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, f.ID, f.ChannelID, f.UserID, models.NullString(f.MessageID), models.NullString(f.ProjectID), f.Origin, f.Filename, f.ContentType, f.SizeBytes, f.StorageKey, f.DisplayHint, models.NullString(f.ExtractedText), ToJSON(f.Metadata), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *FileStore) GetByID(ctx context.Context, id string) (*models.File, error) { row := DB.QueryRowContext(ctx, `SELECT `+fileCols+` FROM files WHERE id = ?`, id) return scanFile(row) } func (s *FileStore) GetByChannel(ctx context.Context, channelID string, origin string) ([]models.File, error) { var rows *sql.Rows var err error if origin != "" { rows, err = DB.QueryContext(ctx, `SELECT `+fileCols+` FROM files WHERE channel_id = ? AND origin = ? ORDER BY created_at`, channelID, origin) } else { rows, err = DB.QueryContext(ctx, `SELECT `+fileCols+` FROM files WHERE channel_id = ? ORDER BY created_at`, channelID) } if err != nil { return nil, err } defer rows.Close() var out []models.File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } func (s *FileStore) GetByMessage(ctx context.Context, messageID string) ([]models.File, error) { rows, err := DB.QueryContext(ctx, `SELECT `+fileCols+` FROM files WHERE message_id = ? ORDER BY created_at`, messageID) if err != nil { return nil, err } defer rows.Close() var out []models.File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } func (s *FileStore) GetByProject(ctx context.Context, projectID string) ([]models.File, error) { rows, err := DB.QueryContext(ctx, `SELECT `+fileCols+` FROM files WHERE project_id = ? ORDER BY created_at`, projectID) if err != nil { return nil, err } defer rows.Close() var out []models.File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } func (s *FileStore) GetByUser(ctx context.Context, userID string, page, perPage int) ([]models.File, int, error) { var total int err := DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM files WHERE user_id = ?`, userID).Scan(&total) if err != nil { return nil, 0, err } offset := (page - 1) * perPage rows, err := DB.QueryContext(ctx, `SELECT `+fileCols+` FROM files WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`, userID, perPage, offset) if err != nil { return nil, 0, err } defer rows.Close() var out []models.File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, total, err } out = append(out, *f) } return out, total, rows.Err() } func (s *FileStore) SetMessageID(ctx context.Context, fileID, messageID string) error { _, err := DB.ExecContext(ctx, `UPDATE files SET message_id = ?, updated_at = ? WHERE id = ?`, messageID, time.Now().UTC().Format(timeFmt), fileID) return err } func (s *FileStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error { metaJSON, err := json.Marshal(metadata) if err != nil { return err } _, err = DB.ExecContext(ctx, `UPDATE files SET metadata = json_patch(metadata, ?), updated_at = ? WHERE id = ?`, string(metaJSON), time.Now().UTC().Format(timeFmt), id) return err } func (s *FileStore) SetExtractedText(ctx context.Context, id string, text string) error { _, err := DB.ExecContext(ctx, `UPDATE files SET extracted_text = ?, updated_at = ? WHERE id = ?`, text, time.Now().UTC().Format(timeFmt), id) return err } func (s *FileStore) Delete(ctx context.Context, id string) (*models.File, error) { row := DB.QueryRowContext(ctx, `DELETE FROM files WHERE id = ? RETURNING `+fileCols, id) return scanFile(row) } func (s *FileStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) { rows, err := DB.QueryContext(ctx, `DELETE FROM files WHERE channel_id = ? RETURNING storage_key`, channelID) if err != nil { return nil, err } defer rows.Close() var keys []string for rows.Next() { var key string if err := rows.Scan(&key); err != nil { return keys, err } keys = append(keys, key) } return keys, rows.Err() } func (s *FileStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) { var total sql.NullInt64 err := DB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM files WHERE user_id = ?`, userID).Scan(&total) if err != nil { return 0, err } return total.Int64, nil } func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) { cutoff := time.Now().Add(-olderThan) rows, err := DB.QueryContext(ctx, `SELECT `+fileCols+` FROM files WHERE message_id IS NULL AND origin = 'user_upload' AND created_at < ? ORDER BY created_at`, cutoff) if err != nil { return nil, err } defer rows.Close() var out []models.File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() }