Sync from /srv/compose/unified-media-manager
This commit is contained in:
266
internal/service/request.go
Normal file
266
internal/service/request.go
Normal file
@@ -0,0 +1,266 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user