This commit is contained in:
lfirmin
2026-05-10 18:07:51 +02:00
parent 50af2452b1
commit a5f888b3c1
59 changed files with 6682 additions and 108 deletions
+239 -12
View File
@@ -14,6 +14,11 @@ type Ticket struct {
Panel string
Type string
ChannelID string
GuildID string
ClaimChannelID string
LogChannelID string
StaffRoleID string
ClaimReupMinutes int
OpenedAt time.Time
ClaimedBy sql.NullString
ClaimedAt sql.NullTime
@@ -53,10 +58,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
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,
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
)
if err != nil {
return fmt.Errorf("insert ticket: %w", err)
@@ -69,10 +74,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
// 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,
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,status,ticket_title,ticket_description)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.GuildID, t.ClaimChannelID, t.LogChannelID,
t.StaffRoleID, t.ClaimReupMinutes, t.OpenedAt.UTC(), "open", t.TicketTitle, t.TicketDescription,
)
if err != nil {
return fmt.Errorf("insert ticket with number: %w", err)
@@ -84,7 +89,7 @@ func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
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,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,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)
@@ -92,7 +97,7 @@ func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Tic
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,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,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)
@@ -100,7 +105,7 @@ func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) {
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,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,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)
@@ -111,6 +116,44 @@ func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType strin
return t, err
}
// CountByStatus returns the number of tickets with the given status.
func (r *TicketRepo) CountByStatus(ctx context.Context, status string) (int, error) {
var n int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tickets WHERE status=?`, status).Scan(&n)
return n, err
}
// CountClosedSince returns the number of tickets closed on or after `since`.
func (r *TicketRepo) CountClosedSince(ctx context.Context, since time.Time) (int, error) {
var n int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tickets WHERE status='closed' AND closed_at>=?`, since).Scan(&n)
return n, err
}
// AvgClaimMinutes returns the average minutes between opened_at and claimed_at for tickets claimed since `since`.
func (r *TicketRepo) AvgClaimMinutes(ctx context.Context, since time.Time) (int, error) {
var avg sql.NullFloat64
err := r.db.QueryRowContext(ctx,
`SELECT AVG((julianday(claimed_at)-julianday(opened_at))*1440)
FROM tickets WHERE claimed_at IS NOT NULL AND claimed_at>=?`, since).Scan(&avg)
if err != nil || !avg.Valid {
return 0, err
}
return int(avg.Float64), nil
}
// AvgResolutionMinutes returns the average minutes between opened_at and closed_at for tickets closed since `since`.
func (r *TicketRepo) AvgResolutionMinutes(ctx context.Context, since time.Time) (int, error) {
var avg sql.NullFloat64
err := r.db.QueryRowContext(ctx,
`SELECT AVG((julianday(closed_at)-julianday(opened_at))*1440)
FROM tickets WHERE closed_at IS NOT NULL AND closed_at>=?`, since).Scan(&avg)
if err != nil || !avg.Valid {
return 0, err
}
return int(avg.Float64), nil
}
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=?`,
@@ -136,7 +179,7 @@ func (r *TicketRepo) SetClosedByChannel(ctx context.Context, channelID, reason s
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,
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,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 {
@@ -164,6 +207,188 @@ func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) {
return n, tx.Commit()
}
// DailyCount holds the ticket count for one day.
type DailyCount struct {
Day string // "2006-01-02"
Count int
}
// DailyTicketCounts returns the count of tickets opened per day for the last n days.
func (r *TicketRepo) DailyTicketCounts(ctx context.Context, days int) ([]DailyCount, error) {
since := time.Now().AddDate(0, 0, -days+1).Truncate(24 * time.Hour)
rows, err := r.db.QueryContext(ctx, `
SELECT strftime('%Y-%m-%d', opened_at) AS day, COUNT(*) AS cnt
FROM tickets
WHERE opened_at >= ?
GROUP BY day
ORDER BY day`, since.UTC())
if err != nil {
return nil, err
}
defer rows.Close()
var out []DailyCount
for rows.Next() {
var dc DailyCount
if err := rows.Scan(&dc.Day, &dc.Count); err != nil {
return nil, err
}
out = append(out, dc)
}
return out, rows.Err()
}
// StaffStat holds per-staff ticket stats.
type StaffStat struct {
StaffID string
Claimed int
Closed int
AvgClaimMinutes int
AvgResolutionMinutes int
}
// StaffStats returns per-staff statistics for tickets claimed in the last n days.
func (r *TicketRepo) StaffStats(ctx context.Context, days int) ([]StaffStat, error) {
since := time.Now().AddDate(0, 0, -days)
rows, err := r.db.QueryContext(ctx, `
SELECT
claimed_by,
COUNT(*) AS claimed,
SUM(CASE WHEN status='closed' THEN 1 ELSE 0 END) AS closed,
CAST(AVG(CASE WHEN claimed_at IS NOT NULL
THEN (julianday(claimed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_claim,
CAST(AVG(CASE WHEN closed_at IS NOT NULL AND claimed_at IS NOT NULL
THEN (julianday(closed_at) - julianday(opened_at)) * 1440 END) AS INTEGER) AS avg_resolution
FROM tickets
WHERE claimed_by IS NOT NULL AND claimed_at >= ?
GROUP BY claimed_by
ORDER BY claimed DESC`, since.UTC())
if err != nil {
return nil, err
}
defer rows.Close()
var out []StaffStat
for rows.Next() {
var s StaffStat
var avgClaim, avgRes sql.NullInt64
if err := rows.Scan(&s.StaffID, &s.Claimed, &s.Closed, &avgClaim, &avgRes); err != nil {
return nil, err
}
s.AvgClaimMinutes = int(avgClaim.Int64)
s.AvgResolutionMinutes = int(avgRes.Int64)
out = append(out, s)
}
return out, rows.Err()
}
// ListFiltered returns tickets matching optional filters with pagination.
type TicketFilter struct {
Status string
Type string
StaffID string
GuildID string
Search string
From, To time.Time
Page int
PageSize int
}
func (r *TicketRepo) ListFiltered(ctx context.Context, f TicketFilter) ([]*Ticket, int, error) {
if f.PageSize <= 0 {
f.PageSize = 20
}
if f.Page <= 0 {
f.Page = 1
}
offset := (f.Page - 1) * f.PageSize
args := []any{}
where := "1=1"
if f.Status != "" {
where += " AND status=?"
args = append(args, f.Status)
}
if f.Type != "" {
where += " AND type=?"
args = append(args, f.Type)
}
if f.StaffID != "" {
where += " AND claimed_by=?"
args = append(args, f.StaffID)
}
if f.GuildID != "" {
where += " AND guild_id=?"
args = append(args, f.GuildID)
}
if f.Search != "" {
where += " AND (ticket_title LIKE ? OR user_id LIKE ?)"
args = append(args, "%"+f.Search+"%", "%"+f.Search+"%")
}
if !f.From.IsZero() {
where += " AND opened_at >= ?"
args = append(args, f.From.UTC())
}
if !f.To.IsZero() {
where += " AND opened_at <= ?"
args = append(args, f.To.UTC())
}
var total int
countArgs := make([]any, len(args))
copy(countArgs, args)
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE "+where, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, f.PageSize, offset)
rows, err := r.db.QueryContext(ctx, `
SELECT id,ticket_number,user_id,panel,type,channel_id,guild_id,claim_channel_id,log_channel_id,staff_role_id,claim_reup_minutes,opened_at,
claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description
FROM tickets WHERE `+where+` ORDER BY opened_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
list, err := scanTickets(rows)
return list, total, err
}
// ListDistinctTypes returns all distinct ticket types present in the tickets table.
func (r *TicketRepo) ListDistinctTypes(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT type FROM tickets ORDER BY type`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// ListDistinctStaff returns all distinct staff IDs (claimed_by) from the tickets table.
func (r *TicketRepo) ListDistinctStaff(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT claimed_by FROM tickets WHERE claimed_by IS NOT NULL ORDER BY claimed_by`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// 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 != ""}
@@ -180,6 +405,7 @@ func (r *TicketRepo) SetTitleDescription(ctx context.Context, id int64, title, d
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.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes,
&t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy,
&t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription)
if err != nil {
@@ -193,6 +419,7 @@ func scanTickets(rows *sql.Rows) ([]*Ticket, error) {
for rows.Next() {
var t Ticket
if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID,
&t.GuildID, &t.ClaimChannelID, &t.LogChannelID, &t.StaffRoleID, &t.ClaimReupMinutes,
&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