package db import ( "context" "database/sql" "fmt" "time" ) type Ticket struct { ID int64 TicketNumber int UserID string Panel string Type string ChannelID string OpenedAt time.Time ClaimedBy sql.NullString ClaimedAt sql.NullTime ClosedAt sql.NullTime ClosedBy sql.NullString Reason sql.NullString TranscriptPath sql.NullString Status string TicketTitle sql.NullString TicketDescription sql.NullString } type TicketRepo struct{ db *sql.DB } func NewTicketRepo(db *sql.DB) *TicketRepo { return &TicketRepo{db: db} } // nextNumber returns the next ticket_number for the given type within a transaction. func (r *TicketRepo) nextNumber(ctx context.Context, tx *sql.Tx, ticketType string) (int, error) { var n int err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(ticket_number),0)+1 FROM tickets WHERE type=?`, ticketType, ).Scan(&n) return n, err } func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() //nolint n, err := r.nextNumber(ctx, tx, t.Type) if err != nil { return err } t.TicketNumber = n res, err := tx.ExecContext(ctx, ` INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description) VALUES(?,?,?,?,?,?,?,?,?)`, n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription, ) if err != nil { return fmt.Errorf("insert ticket: %w", err) } t.ID, _ = res.LastInsertId() t.Status = "open" return tx.Commit() } // InsertWithNumber inserts a ticket where TicketNumber is already set (e.g. convocations). func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error { res, err := r.db.ExecContext(ctx, ` INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description) VALUES(?,?,?,?,?,?,?,?,?)`, t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription, ) if err != nil { return fmt.Errorf("insert ticket with number: %w", err) } t.ID, _ = res.LastInsertId() t.Status = "open" return nil } func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Ticket, error) { row := r.db.QueryRowContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE channel_id=?`, channelID) return scanTicket(row) } func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) { row := r.db.QueryRowContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE id=?`, id) return scanTicket(row) } func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType string) (*Ticket, error) { row := r.db.QueryRowContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed') LIMIT 1`, userID, ticketType) t, err := scanTicket(row) if err == sql.ErrNoRows { return nil, nil } return t, err } func (r *TicketRepo) SetClaimed(ctx context.Context, id int64, staffID string, at time.Time) error { _, err := r.db.ExecContext(ctx, `UPDATE tickets SET status='claimed', claimed_by=?, claimed_at=? WHERE id=?`, staffID, at.UTC(), id) return err } func (r *TicketRepo) SetClosed(ctx context.Context, id int64, closedBy, reason, transcriptPath string, at time.Time) error { _, err := r.db.ExecContext(ctx, ` UPDATE tickets SET status='closed', closed_at=?, closed_by=?, reason=?, transcript_path=? WHERE id=?`, at.UTC(), closedBy, reason, transcriptPath, id) return err } func (r *TicketRepo) SetClosedByChannel(ctx context.Context, channelID, reason string) error { _, err := r.db.ExecContext(ctx, ` UPDATE tickets SET status='closed', closed_at=?, reason=? WHERE channel_id=? AND status IN('open','claimed')`, time.Now().UTC(), reason, channelID) return err } func (r *TicketRepo) ListOpen(ctx context.Context) ([]*Ticket, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE status='open'`) if err != nil { return nil, err } defer rows.Close() return scanTickets(rows) } func (r *TicketRepo) UpdateChannelID(ctx context.Context, id int64, newChannelID string) error { _, err := r.db.ExecContext(ctx, `UPDATE tickets SET channel_id=? WHERE id=?`, newChannelID, id) return err } func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return 0, err } defer tx.Rollback() //nolint var n int if err := tx.QueryRowContext(ctx, `UPDATE convocation_counter SET count=count+1 WHERE id=1 RETURNING count`).Scan(&n); err != nil { return 0, err } return n, tx.Commit() } // NullStr returns a valid NullString for non-empty strings, invalid (NULL) for empty. func NullStr(s string) sql.NullString { return sql.NullString{String: s, Valid: s != ""} } // SetTitleDescription updates the title and description of a ticket after modal submission. func (r *TicketRepo) SetTitleDescription(ctx context.Context, id int64, title, description string) error { _, err := r.db.ExecContext(ctx, `UPDATE tickets SET ticket_title=?, ticket_description=? WHERE id=?`, title, description, id) return err } func scanTicket(row *sql.Row) (*Ticket, error) { var t Ticket err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription) if err != nil { return nil, err } return &t, nil } func scanTickets(rows *sql.Rows) ([]*Ticket, error) { var result []*Ticket for rows.Next() { var t Ticket if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription); err != nil { return nil, err } result = append(result, &t) } return result, rows.Err() }