package service import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "time" "github.com/TopherMayor/unified-media-manager/internal/db" ) type ActivityEvent struct { ID int64 `json:"id"` EventType string `json:"event_type"` MediaID *int64 `json:"media_id,omitempty"` MediaType *string `json:"media_type,omitempty"` Title string `json:"title"` Description *string `json:"description,omitempty"` Data json.RawMessage `json:"data"` CreatedAt time.Time `json:"created_at"` } type ActivityFilters struct { EventType string MediaID *int64 MediaType string Page int PageSize int } type LogEntry struct { EventType string MediaID *int64 MediaType *string Title string Description *string Data json.RawMessage } type ActivityService struct { db *db.DB } func NewActivityService(database *db.DB) *ActivityService { return &ActivityService{db: database} } const activityColumns = `id, event_type, media_id, media_type, title, description, data, created_at` func scanActivityEvent(scanner interface{ Scan(...interface{}) error }) (*ActivityEvent, error) { var event ActivityEvent var mediaID sql.NullInt64 var mediaType sql.NullString var description sql.NullString var data []byte err := scanner.Scan(&event.ID, &event.EventType, &mediaID, &mediaType, &event.Title, &description, &data, &event.CreatedAt) if err != nil { return nil, err } if mediaID.Valid { event.MediaID = &mediaID.Int64 } if mediaType.Valid { event.MediaType = &mediaType.String } if description.Valid { event.Description = &description.String } if data != nil { event.Data = json.RawMessage(data) } return &event, nil } func (s *ActivityService) Log(ctx context.Context, entry LogEntry) (int64, error) { data := entry.Data if data == nil { data = json.RawMessage("{}") } var id int64 err := s.db.Pool.QueryRow(ctx, `INSERT INTO activity_events (event_type, media_id, media_type, title, description, data) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, entry.EventType, entry.MediaID, entry.MediaType, entry.Title, entry.Description, data).Scan(&id) if err != nil { return 0, fmt.Errorf("insert activity event: %w", err) } return id, nil } func (s *ActivityService) LogAsync(entry LogEntry) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if _, err := s.Log(ctx, entry); err != nil { slog.Error("failed to log activity event async", "error", err, "event_type", entry.EventType, "title", entry.Title) } }() } func (s *ActivityService) List(ctx context.Context, filters ActivityFilters) ([]ActivityEvent, int, error) { qb := NewQueryBuilder(1) if filters.EventType != "" { qb.Add("event_type = $%d", filters.EventType) } if filters.MediaID != nil { qb.Add("media_id = $%d", *filters.MediaID) if filters.MediaType != "" { qb.Add("media_type = $%d", filters.MediaType) } } where := qb.Where() var total int countQuery := fmt.Sprintf("SELECT COUNT(*) FROM activity_events%s", where) if err := s.db.Pool.QueryRow(ctx, countQuery, qb.Args()...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count activity events: %w", err) } offset := (filters.Page - 1) * filters.PageSize dataQuery := fmt.Sprintf( "SELECT %s FROM activity_events%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", activityColumns, where, qb.Idx(), qb.Idx()+1) args := append(qb.Args(), filters.PageSize, offset) rows, err := s.db.Pool.Query(ctx, dataQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list activity events: %w", err) } defer rows.Close() var events []ActivityEvent for rows.Next() { event, err := scanActivityEvent(rows) if err != nil { slog.Error("failed to scan activity event", "error", err) continue } events = append(events, *event) } return events, total, nil }