Files
2026-04-24 10:45:19 -07:00

267 lines
8.3 KiB
Go

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)
}
}