package storage import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) // UploadMeta is the database index record for an uploaded file. // // The .meta.json sidecar remains the source of truth; this struct is the // queryable mirror used by the admin UI. type UploadMeta struct { FileID string `json:"file_id"` Name string `json:"name"` Size int64 `json:"size"` SHA256 string `json:"sha256"` UploadedAt time.Time `json:"uploaded_at"` } // UploadRepo provides CRUD operations for upload file index records. type UploadRepo struct { db *DB } // Uploads returns a repository bound to this DB handle. func (d *DB) Uploads() *UploadRepo { return &UploadRepo{db: d} } // Create inserts a new upload index record. Timestamps are stored as Unix // nanoseconds for stable ORDER BY semantics. func (r *UploadRepo) Create(ctx context.Context, fileID, name string, size int64, sha256Hex string, uploadedAt time.Time) error { _, err := r.db.sqlDB.ExecContext(ctx, ` INSERT INTO upload_files (file_id, name, size, sha256, uploaded_at) VALUES (?, ?, ?, ?, ?)`, fileID, name, size, sha256Hex, uploadedAt.UnixNano(), ) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { return fmt.Errorf("storage: create upload %s: primary key conflict: %w", fileID, err) } return fmt.Errorf("storage: create upload %s: %w", fileID, err) } return nil } // Get retrieves an upload index record by file_id. If the record does not // exist, it returns ErrNotFound. func (r *UploadRepo) Get(ctx context.Context, fileID string) (UploadMeta, error) { row := r.db.sqlDB.QueryRowContext(ctx, ` SELECT file_id, name, size, sha256, uploaded_at FROM upload_files WHERE file_id = ?`, fileID) m, err := scanUpload(row.Scan) if errors.Is(err, sql.ErrNoRows) { return UploadMeta{}, ErrNotFound } if err != nil { return UploadMeta{}, fmt.Errorf("storage: get upload %s: %w", fileID, err) } return m, nil } // List returns upload index records ordered by uploaded_at descending. // // If search is non-empty, name is filtered with a case-insensitive LIKE. // A limit of zero or less falls back to 100; values above 500 are capped. func (r *UploadRepo) List(ctx context.Context, search string, limit int) ([]UploadMeta, error) { if limit <= 0 { limit = 100 } if limit > 500 { limit = 500 } var rows *sql.Rows var err error if search != "" { rows, err = r.db.sqlDB.QueryContext(ctx, ` SELECT file_id, name, size, sha256, uploaded_at FROM upload_files WHERE name LIKE ? ORDER BY uploaded_at DESC LIMIT ?`, "%"+search+"%", limit, ) } else { rows, err = r.db.sqlDB.QueryContext(ctx, ` SELECT file_id, name, size, sha256, uploaded_at FROM upload_files ORDER BY uploaded_at DESC LIMIT ?`, limit) } if err != nil { return nil, fmt.Errorf("storage: list uploads: %w", err) } defer rows.Close() var out []UploadMeta for rows.Next() { m, err := scanUpload(rows.Scan) if err != nil { return nil, fmt.Errorf("storage: list uploads: %w", err) } out = append(out, m) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("storage: list uploads: %w", err) } if out == nil { // Force empty slice (not null) in JSON. out = []UploadMeta{} } return out, nil } // Delete removes an upload index record by file_id. If the record does not // exist, it returns ErrNotFound. func (r *UploadRepo) Delete(ctx context.Context, fileID string) error { res, err := r.db.sqlDB.ExecContext(ctx, `DELETE FROM upload_files WHERE file_id = ?`, fileID) if err != nil { return fmt.Errorf("storage: delete upload %s: %w", fileID, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("storage: delete upload %s: rows affected: %w", fileID, err) } if n == 0 { return ErrNotFound } return nil } func scanUpload(scan scanFunc) (UploadMeta, error) { var m UploadMeta var uploadedAt int64 if err := scan(&m.FileID, &m.Name, &m.Size, &m.SHA256, &uploadedAt); err != nil { return UploadMeta{}, err } m.UploadedAt = time.Unix(0, uploadedAt) return m, nil }