package service import ( "context" "fmt" "log/slog" "time" "github.com/TopherMayor/unified-media-manager/internal/db" ) type Request struct { ID int64 `json:"id"` MediaID int64 `json:"media_id"` MediaType string `json:"media_type"` Title string `json:"title"` RequestedBy int64 `json:"requested_by"` Status string `json:"status"` QualityProfileID *int64 `json:"quality_profile_id,omitempty"` RootFolderID *int64 `json:"root_folder_id,omitempty"` Notes string `json:"notes,omitempty"` ReviewedBy *int64 `json:"reviewed_by,omitempty"` ReviewedAt *time.Time `json:"reviewed_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { MediaID int64 `json:"media_id"` MediaType string `json:"media_type"` Title string `json:"title"` QualityProfileID *int64 `json:"quality_profile_id,omitempty"` RootFolderID *int64 `json:"root_folder_id,omitempty"` Notes string `json:"notes,omitempty"` } type RequestFilters struct { Status string `json:"status,omitempty"` RequestedBy *int64 `json:"requested_by,omitempty"` Page int `json:"page"` PageSize int `json:"page_size"` } type RequestStats struct { Total int `json:"total"` Pending int `json:"pending"` Approved int `json:"approved"` Rejected int `json:"rejected"` Fulfilled int `json:"fulfilled"` Withdrawn int `json:"withdrawn"` } const requestColumns = `id, media_id, media_type, title, requested_by, status, quality_profile_id, root_folder_id, notes, reviewed_by, reviewed_at, created_at, updated_at` type RequestService struct { db *db.DB searchSvc *SearchService } func NewRequestService(database *db.DB, searchSvc *SearchService) *RequestService { return &RequestService{db: database, searchSvc: searchSvc} } func scanRequest(scanner interface{ Scan(...interface{}) error }) (*Request, error) { var r Request err := scanner.Scan(&r.ID, &r.MediaID, &r.MediaType, &r.Title, &r.RequestedBy, &r.Status, &r.QualityProfileID, &r.RootFolderID, &r.Notes, &r.ReviewedBy, &r.ReviewedAt, &r.CreatedAt, &r.UpdatedAt) if err != nil { return nil, err } return &r, nil } func (s *RequestService) Create(ctx context.Context, req CreateRequest, userRole string, userID int64) (int64, error) { status := "pending" if userRole == "admin" || userRole == "power_user" { status = "approved" } now := time.Now() var id int64 err := s.db.Pool.QueryRow(ctx, `INSERT INTO requests (media_id, media_type, title, requested_by, status, quality_profile_id, root_folder_id, notes, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, req.MediaID, req.MediaType, req.Title, userID, status, req.QualityProfileID, req.RootFolderID, req.Notes, now, now).Scan(&id) if err != nil { return 0, fmt.Errorf("create request: %w", err) } // Auto-approve: trigger search immediately in background if status == "approved" && s.searchSvc != nil { go func() { bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() s.triggerSearch(bgCtx, req) }() } return id, nil } func (s *RequestService) List(ctx context.Context, filters RequestFilters) ([]Request, int, error) { qb := NewQueryBuilder(1) qb.AddLiteral("1=1") if filters.Status != "" { qb.Add("AND status = $%d", filters.Status) } if filters.RequestedBy != nil { qb.Add("AND requested_by = $%d", *filters.RequestedBy) } countQuery := fmt.Sprintf("SELECT COUNT(*) FROM requests WHERE %s", qb.conditions[0]) countArgs := qb.Args() if len(qb.conditions) > 1 { countQuery = fmt.Sprintf("SELECT COUNT(*) FROM requests %s", qb.Where()) countArgs = qb.Args() } var total int if err := s.db.Pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count requests: %w", err) } offset := (filters.Page - 1) * filters.PageSize dataQuery := fmt.Sprintf("SELECT %s FROM requests %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", requestColumns, qb.Where(), qb.Idx(), qb.Idx()+1) dataArgs := append(qb.Args(), filters.PageSize, offset) rows, err := s.db.Pool.Query(ctx, dataQuery, dataArgs...) if err != nil { return nil, 0, fmt.Errorf("list requests: %w", err) } defer rows.Close() var items []Request for rows.Next() { r, err := scanRequest(rows) if err != nil { slog.Error("failed to scan request", "error", err) continue } items = append(items, *r) } return items, total, nil } func (s *RequestService) GetByID(ctx context.Context, id int64) (*Request, error) { row := s.db.Pool.QueryRow(ctx, fmt.Sprintf("SELECT %s FROM requests WHERE id = $1", requestColumns), id) r, err := scanRequest(row) if err != nil { return nil, fmt.Errorf("request not found") } return r, nil } func (s *RequestService) Approve(ctx context.Context, id int64, reviewerID int64, notes string) error { now := time.Now() tag, err := s.db.Pool.Exec(ctx, `UPDATE requests SET status = 'approved', reviewed_by = $1, reviewed_at = $2, notes = COALESCE(NULLIF($3, ''), notes), updated_at = $4 WHERE id = $5 AND status = 'pending'`, reviewerID, now, notes, now, id) if err != nil { return fmt.Errorf("approve request: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("request not found or not pending") } // Trigger search in background if s.searchSvc != nil { req, err := s.GetByID(ctx, id) if err == nil { go func() { bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() s.triggerSearch(bgCtx, CreateRequest{ MediaID: req.MediaID, MediaType: req.MediaType, Title: req.Title, }) }() } } return nil } func (s *RequestService) Reject(ctx context.Context, id int64, reviewerID int64, notes string) error { now := time.Now() tag, err := s.db.Pool.Exec(ctx, `UPDATE requests SET status = 'rejected', reviewed_by = $1, reviewed_at = $2, notes = COALESCE(NULLIF($3, ''), notes), updated_at = $4 WHERE id = $5 AND status = 'pending'`, reviewerID, now, notes, now, id) if err != nil { return fmt.Errorf("reject request: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("request not found or not pending") } return nil } func (s *RequestService) Withdraw(ctx context.Context, id int64, userID int64) error { now := time.Now() tag, err := s.db.Pool.Exec(ctx, `UPDATE requests SET status = 'withdrawn', updated_at = $1 WHERE id = $2 AND requested_by = $3 AND status IN ('pending', 'approved')`, now, id, userID) if err != nil { return fmt.Errorf("withdraw request: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("request not found, not owned by user, or not withdrawable") } return nil } func (s *RequestService) MarkFulfilled(ctx context.Context, mediaID int64) error { now := time.Now() tag, err := s.db.Pool.Exec(ctx, `UPDATE requests SET status = 'fulfilled', updated_at = $1 WHERE media_id = $2 AND status = 'approved'`, now, mediaID) if err != nil { return fmt.Errorf("mark fulfilled: %w", err) } if tag.RowsAffected() > 0 { slog.Info("request marked fulfilled", "media_id", mediaID) } return nil } func (s *RequestService) Stats(ctx context.Context) (*RequestStats, error) { var stats RequestStats err := s.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FILTER (WHERE 1=1) AS total, COUNT(*) FILTER (WHERE status = 'pending') AS pending, COUNT(*) FILTER (WHERE status = 'approved') AS approved, COUNT(*) FILTER (WHERE status = 'rejected') AS rejected, COUNT(*) FILTER (WHERE status = 'fulfilled') AS fulfilled, COUNT(*) FILTER (WHERE status = 'withdrawn') AS withdrawn FROM requests`).Scan(&stats.Total, &stats.Pending, &stats.Approved, &stats.Rejected, &stats.Fulfilled, &stats.Withdrawn) if err != nil { return nil, fmt.Errorf("request stats: %w", err) } return &stats, nil } func (s *RequestService) triggerSearch(ctx context.Context, req CreateRequest) { if req.Title == "" { return } _, err := s.searchSvc.Search(ctx, SearchRequest{ Query: req.Title, MediaType: req.MediaType, }) if err != nil { slog.Error("auto-search for request failed", "title", req.Title, "error", err) } else { slog.Info("auto-search triggered for request", "title", req.Title) } }