package sqlite import ( "database/sql" "fmt" "time" "github.com/google/uuid" "task/internal/ticket" ) // TicketStore implements ticket.Store on SQLite. type TicketStore struct { db *DB } // NewTicketStore returns a ticket store. func NewTicketStore(db *DB) *TicketStore { return &TicketStore{db: db} } func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, error) { var t ticket.Ticket var created, updated, synced string var status string var number sql.NullInt64 err := scanner.Scan( &t.ID, &t.Title, &t.Description, &status, &t.ProjectID, &created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced, &number, &t.Key, ) if err != nil { return ticket.Ticket{}, err } t.Status = ticket.Status(status) t.CreatedAt = parseTime(created) t.UpdatedAt = parseTime(updated) t.SyncedAt = parseTime(synced) if number.Valid { t.Number = int(number.Int64) } return t, nil } const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key` // List returns tickets for a project. func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) { rows, err := s.db.SQL.Query(`SELECT `+ticketCols+` FROM tickets WHERE project_id = ? ORDER BY updated_at DESC`, projectID) if err != nil { return nil, err } defer rows.Close() var out []ticket.Ticket for rows.Next() { t, err := scanTicket(rows) if err != nil { return nil, err } out = append(out, t) } if out == nil { out = []ticket.Ticket{} } return out, rows.Err() } // Get finds a ticket by ID. func (s *TicketStore) Get(id string) (ticket.Ticket, error) { row := s.db.SQL.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id) t, err := scanTicket(row) if err == sql.ErrNoRows { return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id) } if err != nil { return ticket.Ticket{}, err } return t, nil } // Create inserts a ticket and assigns the next project key atomically. func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) { if t.ProjectID == "" { return ticket.Ticket{}, fmt.Errorf("projectId is required") } if t.Title == "" { return ticket.Ticket{}, fmt.Errorf("title is required") } if t.ID == "" { t.ID = uuid.NewString() } if t.Status == "" { t.Status = ticket.StatusTodo } now := time.Now().UTC() if t.CreatedAt.IsZero() { t.CreatedAt = now } if t.UpdatedAt.IsZero() { t.UpdatedAt = now } tx, err := s.db.SQL.Begin() if err != nil { return ticket.Ticket{}, err } defer func() { _ = tx.Rollback() }() if t.Number == 0 || t.Key == "" { res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, t.ProjectID) if err != nil { return ticket.Ticket{}, fmt.Errorf("create ticket: bump seq: %w", err) } n, _ := res.RowsAffected() if n == 0 { return ticket.Ticket{}, fmt.Errorf("create ticket: project %s not found", t.ProjectID) } var code string var seq int if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, t.ProjectID).Scan(&code, &seq); err != nil { return ticket.Ticket{}, fmt.Errorf("create ticket: read seq: %w", err) } if code == "" { return ticket.Ticket{}, fmt.Errorf("create ticket: project %s has no code", t.ProjectID) } t.Number = seq t.Key = fmt.Sprintf("%s-%d", code, seq) } var number any if t.Number > 0 { number = t.Number } _, err = tx.Exec(` INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, t.ID, t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), number, t.Key, ) if err != nil { return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err) } if err := tx.Commit(); err != nil { return ticket.Ticket{}, err } return t, nil } // Update replaces a ticket and bumps UpdatedAt. Number and Key are preserved from DB. func (s *TicketStore) Update(t ticket.Ticket) error { if t.ID == "" || t.ProjectID == "" { return fmt.Errorf("id and projectId are required") } var created string var number sql.NullInt64 var key string err := s.db.SQL.QueryRow(`SELECT created_at, number, key FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID). Scan(&created, &number, &key) if err == sql.ErrNoRows { return fmt.Errorf("ticket %s: not found", t.ID) } if err != nil { return err } t.CreatedAt = parseTime(created) t.UpdatedAt = time.Now().UTC() if number.Valid { t.Number = int(number.Int64) } t.Key = key res, err := s.db.SQL.Exec(` UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?, source=?, external_id=?, external_url=?, synced_at=?, number=?, key=? WHERE id=?`, t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), nullInt(t.Number), t.Key, t.ID, ) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("ticket %s: not found", t.ID) } return nil } func nullInt(n int) any { if n == 0 { return nil } return n } // MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt. func (s *TicketStore) MarkSynced(id string) error { if id == "" { return fmt.Errorf("ticket id is required") } res, err := s.db.SQL.Exec(`UPDATE tickets SET synced_at = updated_at WHERE id = ?`, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("ticket %s: not found", id) } return nil } // Move relocates a ticket to another project and assigns a new key there. func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) { if id == "" { return ticket.Ticket{}, fmt.Errorf("ticket id is required") } if toProjectID == "" { return ticket.Ticket{}, fmt.Errorf("projectId is required") } tx, err := s.db.SQL.Begin() if err != nil { return ticket.Ticket{}, err } defer func() { _ = tx.Rollback() }() row := tx.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id) t, err := scanTicket(row) if err == sql.ErrNoRows { return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id) } if err != nil { return ticket.Ticket{}, err } res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, toProjectID) if err != nil { return ticket.Ticket{}, err } n, _ := res.RowsAffected() if n == 0 { return ticket.Ticket{}, fmt.Errorf("project %s: not found", toProjectID) } var code string var seq int if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, toProjectID).Scan(&code, &seq); err != nil { return ticket.Ticket{}, err } t.ProjectID = toProjectID t.Number = seq t.Key = fmt.Sprintf("%s-%d", code, seq) t.UpdatedAt = time.Now().UTC() _, err = tx.Exec(`UPDATE tickets SET project_id=?, updated_at=?, number=?, key=? WHERE id=?`, toProjectID, formatTime(t.UpdatedAt), t.Number, t.Key, id) if err != nil { return ticket.Ticket{}, err } if err := tx.Commit(); err != nil { return ticket.Ticket{}, err } return t, nil } // Delete removes a ticket. func (s *TicketStore) Delete(id string) error { res, err := s.db.SQL.Exec(`DELETE FROM tickets WHERE id = ?`, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("ticket %s: not found", id) } return nil }