Store uploads on disk under DataDir and expose them in the web ticket view so .xlsx files can be previewed and saved as cell values without YouTrack or TUI sync. Co-authored-by: Cursor <cursoragent@cursor.com>
132 lines
3.3 KiB
Go
132 lines
3.3 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Attachment is metadata for a file attached to a ticket.
|
|
type Attachment struct {
|
|
ID string `json:"id"`
|
|
TicketID string `json:"ticketId"`
|
|
ProjectID string `json:"projectId"`
|
|
Filename string `json:"filename"`
|
|
ContentType string `json:"contentType"`
|
|
SizeBytes int64 `json:"sizeBytes"`
|
|
CreatedBy string `json:"createdBy"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// AttachmentStore persists attachment metadata.
|
|
type AttachmentStore struct {
|
|
db *DB
|
|
}
|
|
|
|
// NewAttachmentStore returns an attachment metadata store.
|
|
func NewAttachmentStore(db *DB) *AttachmentStore {
|
|
return &AttachmentStore{db: db}
|
|
}
|
|
|
|
const attachmentCols = `id, ticket_id, project_id, filename, content_type, size_bytes, created_by, created_at`
|
|
|
|
func scanAttachment(scanner interface{ Scan(dest ...any) error }) (Attachment, error) {
|
|
var a Attachment
|
|
var created string
|
|
err := scanner.Scan(
|
|
&a.ID, &a.TicketID, &a.ProjectID, &a.Filename, &a.ContentType,
|
|
&a.SizeBytes, &a.CreatedBy, &created,
|
|
)
|
|
if err != nil {
|
|
return Attachment{}, err
|
|
}
|
|
a.CreatedAt = parseTime(created)
|
|
return a, nil
|
|
}
|
|
|
|
// ListByTicket returns attachments for a ticket, newest first.
|
|
func (s *AttachmentStore) ListByTicket(ticketID string) ([]Attachment, error) {
|
|
rows, err := s.db.SQL.Query(
|
|
`SELECT `+attachmentCols+` FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at DESC`,
|
|
ticketID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Attachment
|
|
for rows.Next() {
|
|
a, err := scanAttachment(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
if out == nil {
|
|
out = []Attachment{}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Get returns one attachment by id.
|
|
func (s *AttachmentStore) Get(id string) (Attachment, error) {
|
|
row := s.db.SQL.QueryRow(`SELECT `+attachmentCols+` FROM ticket_attachments WHERE id = ?`, id)
|
|
a, err := scanAttachment(row)
|
|
if err == sql.ErrNoRows {
|
|
return Attachment{}, fmt.Errorf("attachment not found")
|
|
}
|
|
return a, err
|
|
}
|
|
|
|
// Create inserts attachment metadata.
|
|
func (s *AttachmentStore) Create(a Attachment) (Attachment, error) {
|
|
if a.ID == "" {
|
|
a.ID = uuid.NewString()
|
|
}
|
|
if a.Filename == "" {
|
|
return Attachment{}, fmt.Errorf("filename required")
|
|
}
|
|
if a.ContentType == "" {
|
|
a.ContentType = "application/octet-stream"
|
|
}
|
|
if a.CreatedAt.IsZero() {
|
|
a.CreatedAt = time.Now().UTC()
|
|
}
|
|
_, err := s.db.SQL.Exec(
|
|
`INSERT INTO ticket_attachments (`+attachmentCols+`) VALUES (?,?,?,?,?,?,?,?)`,
|
|
a.ID, a.TicketID, a.ProjectID, a.Filename, a.ContentType, a.SizeBytes, a.CreatedBy, formatTime(a.CreatedAt),
|
|
)
|
|
if err != nil {
|
|
return Attachment{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// UpdateSize updates size after a replace/overwrite.
|
|
func (s *AttachmentStore) UpdateSize(id string, sizeBytes int64) error {
|
|
res, err := s.db.SQL.Exec(`UPDATE ticket_attachments SET size_bytes = ? WHERE id = ?`, sizeBytes, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("attachment not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes attachment metadata.
|
|
func (s *AttachmentStore) Delete(id string) error {
|
|
res, err := s.db.SQL.Exec(`DELETE FROM ticket_attachments WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("attachment not found")
|
|
}
|
|
return nil
|
|
}
|