mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
650 lines
16 KiB
Go
650 lines
16 KiB
Go
package catalog
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Errors returned by the catalog service.
|
||
var (
|
||
ErrNotFound = errors.New("not found")
|
||
ErrAlreadyExists = errors.New("already exists")
|
||
ErrInvalidInput = errors.New("invalid input")
|
||
ErrImmutableSystem = errors.New("system resources are immutable")
|
||
ErrStoreMissing = errors.New("catalog: store not configured")
|
||
)
|
||
|
||
// Validation bounds for the user's setup:
|
||
// - largest room 4.46 m × 3.19 m → tracks ≤ 4.5 m × 3.2 m bounding box
|
||
// - car scale 1/27 → car length 50..200 mm (typical 1/27 touring envelope)
|
||
// - 3D-printer Bambu A1 build volume → 220×220×250 mm
|
||
const (
|
||
maxTrackLengthM = 4.5
|
||
maxTrackWidthM = 3.2
|
||
maxLaneWidthM = 1.0
|
||
minCarLengthMm = 40.0
|
||
maxCarLengthMm = 260.0 // 1/24 F1 ≈ 238 мм
|
||
minCarWidthMm = 20.0
|
||
maxCarWidthMm = 120.0
|
||
)
|
||
|
||
// CreateTrackOptions are the inputs for CreateTrack.
|
||
type CreateTrackOptions struct {
|
||
ID string // optional; auto-generated if empty
|
||
Name string // required
|
||
Description string // optional
|
||
AuthorID string // optional; defaults to "system"
|
||
Visibility Visibility // optional; defaults to VisibilityPublic
|
||
Scale int // optional; defaults to 27
|
||
LengthM float64 // optional; computed from centerline if 0
|
||
WidthM float64 // optional; computed from centerline if 0
|
||
LaneWidthM float64 // optional; defaults to 0.20
|
||
Surface Surface // optional; defaults to carpet
|
||
Tags []string // optional
|
||
Centerline []Waypoint // optional; defaults to a parametric oval
|
||
}
|
||
|
||
// Validate returns an error if opts is incomplete.
|
||
func (o CreateTrackOptions) Validate() error {
|
||
if strings.TrimSpace(o.Name) == "" {
|
||
return fmt.Errorf("%w: name required", ErrInvalidInput)
|
||
}
|
||
if o.LengthM < 0 || o.LengthM > maxTrackLengthM {
|
||
return fmt.Errorf("%w: length_m must be in [0, %.1f]", ErrInvalidInput, maxTrackLengthM)
|
||
}
|
||
if o.WidthM < 0 || o.WidthM > maxTrackWidthM {
|
||
return fmt.Errorf("%w: width_m must be in [0, %.1f]", ErrInvalidInput, maxTrackWidthM)
|
||
}
|
||
if o.LaneWidthM < 0 || o.LaneWidthM > maxLaneWidthM {
|
||
return fmt.Errorf("%w: lane_width_m must be in [0, %.1f]", ErrInvalidInput, maxLaneWidthM)
|
||
}
|
||
if o.Visibility == VisibilitySystem {
|
||
return fmt.Errorf("%w: cannot create system track via API", ErrInvalidInput)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CreateTrack inserts a new user-authored track.
|
||
func (s *Service) CreateTrack(ctx context.Context, opts CreateTrackOptions) (TrackMeta, error) {
|
||
if err := opts.Validate(); err != nil {
|
||
return TrackMeta{}, err
|
||
}
|
||
id := opts.ID
|
||
if id == "" {
|
||
id = slugify(opts.Name)
|
||
}
|
||
vis := opts.Visibility
|
||
if vis == "" {
|
||
vis = VisibilityPublic
|
||
}
|
||
auth := opts.AuthorID
|
||
if auth == "" {
|
||
auth = AuthorSystem
|
||
}
|
||
scale := opts.Scale
|
||
if scale == 0 {
|
||
scale = 27
|
||
}
|
||
sfc := opts.Surface
|
||
if sfc == "" {
|
||
sfc = SurfaceCarpet
|
||
}
|
||
|
||
if _, exists := s.getTrackLocked(id); exists {
|
||
return TrackMeta{}, fmt.Errorf("%w: track %q", ErrAlreadyExists, id)
|
||
}
|
||
|
||
now := time.Now().UnixMilli()
|
||
t := TrackMeta{
|
||
ID: id,
|
||
Name: strings.TrimSpace(opts.Name),
|
||
Description: opts.Description,
|
||
AuthorID: auth,
|
||
Visibility: vis,
|
||
Scale: scale,
|
||
LengthM: opts.LengthM,
|
||
WidthM: opts.WidthM,
|
||
LaneWidthM: opts.LaneWidthM,
|
||
Surface: sfc,
|
||
Tags: append([]string(nil), opts.Tags...),
|
||
Centerline: append([]Waypoint(nil), opts.Centerline...),
|
||
CreatedMs: now,
|
||
UpdatedMs: now,
|
||
}
|
||
s.normalizeTrack(&t)
|
||
|
||
if t.Bounds.MaxX > maxTrackLengthM || t.Bounds.MaxY > maxTrackWidthM {
|
||
return TrackMeta{}, fmt.Errorf("%w: track too large for 446x319 cm room (got %.2fx%.2f m)",
|
||
ErrInvalidInput, t.Bounds.MaxX, t.Bounds.MaxY)
|
||
}
|
||
|
||
if err := s.persistUpsertTrack(ctx, t); err != nil {
|
||
return TrackMeta{}, err
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
// UpdateTrackOptions carries the patch fields for UpdateTrack.
|
||
type UpdateTrackOptions struct {
|
||
Name *string
|
||
Description *string
|
||
Visibility *Visibility
|
||
LengthM *float64
|
||
WidthM *float64
|
||
LaneWidthM *float64
|
||
Surface *Surface
|
||
Tags *[]string
|
||
Centerline *[]Waypoint
|
||
}
|
||
|
||
// UpdateTrack applies a partial update to an existing track.
|
||
func (s *Service) UpdateTrack(ctx context.Context, id string, opts UpdateTrackOptions) (TrackMeta, error) {
|
||
s.mu.Lock()
|
||
t, ok := s.tracks[id]
|
||
if !ok {
|
||
s.mu.Unlock()
|
||
return TrackMeta{}, fmt.Errorf("%w: track %q", ErrNotFound, id)
|
||
}
|
||
if t.Visibility == VisibilitySystem {
|
||
s.mu.Unlock()
|
||
return TrackMeta{}, fmt.Errorf("%w: track %q", ErrImmutableSystem, id)
|
||
}
|
||
|
||
if opts.Name != nil {
|
||
t.Name = strings.TrimSpace(*opts.Name)
|
||
}
|
||
if opts.Description != nil {
|
||
t.Description = *opts.Description
|
||
}
|
||
if opts.Visibility != nil {
|
||
if *opts.Visibility == VisibilitySystem {
|
||
s.mu.Unlock()
|
||
return TrackMeta{}, fmt.Errorf("%w: cannot promote to system", ErrInvalidInput)
|
||
}
|
||
t.Visibility = *opts.Visibility
|
||
}
|
||
if opts.LengthM != nil {
|
||
t.LengthM = *opts.LengthM
|
||
}
|
||
if opts.WidthM != nil {
|
||
t.WidthM = *opts.WidthM
|
||
}
|
||
if opts.LaneWidthM != nil {
|
||
t.LaneWidthM = *opts.LaneWidthM
|
||
}
|
||
if opts.Surface != nil {
|
||
t.Surface = *opts.Surface
|
||
}
|
||
if opts.Tags != nil {
|
||
t.Tags = append([]string(nil), *opts.Tags...)
|
||
}
|
||
if opts.Centerline != nil {
|
||
t.Centerline = append([]Waypoint(nil), *opts.Centerline...)
|
||
t.Bounds = computeBounds(t.Centerline)
|
||
t.LengthM = t.Bounds.LengthM
|
||
t.WidthM = t.Bounds.WidthM
|
||
} else {
|
||
t.Bounds = computeBounds(t.Centerline)
|
||
}
|
||
t.UpdatedMs = time.Now().UnixMilli()
|
||
|
||
if t.Bounds.MaxX > maxTrackLengthM || t.Bounds.MaxY > maxTrackWidthM {
|
||
s.mu.Unlock()
|
||
return TrackMeta{}, fmt.Errorf("%w: track too large (got %.2fx%.2f m)",
|
||
ErrInvalidInput, t.Bounds.MaxX, t.Bounds.MaxY)
|
||
}
|
||
|
||
updated := *t
|
||
s.mu.Unlock()
|
||
|
||
if err := s.persistUpsertTrack(ctx, updated); err != nil {
|
||
return TrackMeta{}, err
|
||
}
|
||
return updated, nil
|
||
}
|
||
|
||
// DeleteTrack removes a track by ID. System tracks are immutable.
|
||
func (s *Service) DeleteTrack(ctx context.Context, id string) error {
|
||
s.mu.RLock()
|
||
t, ok := s.tracks[id]
|
||
s.mu.RUnlock()
|
||
if !ok {
|
||
return fmt.Errorf("%w: track %q", ErrNotFound, id)
|
||
}
|
||
if t.Visibility == VisibilitySystem {
|
||
return fmt.Errorf("%w: track %q", ErrImmutableSystem, id)
|
||
}
|
||
return s.persistDeleteTrack(ctx, id)
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Cars
|
||
// -------------------------------------------------------------------------
|
||
|
||
// CreateCarOptions are the inputs for CreateCar.
|
||
type CreateCarOptions struct {
|
||
ID string
|
||
Name string
|
||
OwnerID string
|
||
Visibility Visibility
|
||
Scale int
|
||
|
||
LengthMm float64
|
||
WidthMm float64
|
||
HeightMm float64
|
||
WeightG float64
|
||
|
||
Chassis ChassisSpec
|
||
Motor MotorSpec
|
||
Battery BatterySpec
|
||
Drive Drivetrain
|
||
|
||
TopSpeedMs float64
|
||
ColorHex string
|
||
AvatarURL string
|
||
Active *bool // optional; defaults to true
|
||
}
|
||
|
||
// Validate sanity-checks dimensions and required fields.
|
||
func (o CreateCarOptions) Validate() error {
|
||
if strings.TrimSpace(o.Name) == "" {
|
||
return fmt.Errorf("%w: name required", ErrInvalidInput)
|
||
}
|
||
if o.LengthMm < minCarLengthMm || o.LengthMm > maxCarLengthMm {
|
||
return fmt.Errorf("%w: length_mm must be in [%.0f, %.0f]", ErrInvalidInput, minCarLengthMm, maxCarLengthMm)
|
||
}
|
||
if o.WidthMm < minCarWidthMm || o.WidthMm > maxCarWidthMm {
|
||
return fmt.Errorf("%w: width_mm must be in [%.0f, %.0f]", ErrInvalidInput, minCarWidthMm, maxCarWidthMm)
|
||
}
|
||
if o.Visibility == VisibilitySystem {
|
||
return fmt.Errorf("%w: cannot create system car via API", ErrInvalidInput)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CreateCar inserts a new car.
|
||
func (s *Service) CreateCar(ctx context.Context, opts CreateCarOptions) (CarMeta, error) {
|
||
if err := opts.Validate(); err != nil {
|
||
return CarMeta{}, err
|
||
}
|
||
id := opts.ID
|
||
if id == "" {
|
||
id = slugify(opts.Name)
|
||
}
|
||
vis := opts.Visibility
|
||
if vis == "" {
|
||
vis = VisibilityPublic
|
||
}
|
||
owner := opts.OwnerID
|
||
if owner == "" {
|
||
owner = AuthorSystem
|
||
}
|
||
scale := opts.Scale
|
||
if scale == 0 {
|
||
scale = 27
|
||
}
|
||
drive := opts.Drive
|
||
if drive == "" {
|
||
drive = Drivetrain2WD
|
||
}
|
||
active := true
|
||
if opts.Active != nil {
|
||
active = *opts.Active
|
||
}
|
||
|
||
if _, exists := s.getCarLocked(id); exists {
|
||
return CarMeta{}, fmt.Errorf("%w: car %q", ErrAlreadyExists, id)
|
||
}
|
||
|
||
now := time.Now().UnixMilli()
|
||
c := CarMeta{
|
||
ID: id,
|
||
Name: strings.TrimSpace(opts.Name),
|
||
OwnerID: owner,
|
||
Visibility: vis,
|
||
Scale: scale,
|
||
LengthMm: opts.LengthMm,
|
||
WidthMm: opts.WidthMm,
|
||
HeightMm: opts.HeightMm,
|
||
WeightG: opts.WeightG,
|
||
Chassis: opts.Chassis,
|
||
Motor: opts.Motor,
|
||
Battery: opts.Battery,
|
||
Drive: drive,
|
||
TopSpeedMs: opts.TopSpeedMs,
|
||
ColorHex: opts.ColorHex,
|
||
AvatarURL: opts.AvatarURL,
|
||
Active: active,
|
||
CreatedMs: now,
|
||
UpdatedMs: now,
|
||
}
|
||
s.normalizeCar(&c)
|
||
|
||
if err := s.persistUpsertCar(ctx, c); err != nil {
|
||
return CarMeta{}, err
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// UpdateCarOptions is the patch for UpdateCar.
|
||
type UpdateCarOptions struct {
|
||
Name *string
|
||
Visibility *Visibility
|
||
LengthMm *float64
|
||
WidthMm *float64
|
||
HeightMm *float64
|
||
WeightG *float64
|
||
TopSpeedMs *float64
|
||
ColorHex *string
|
||
Active *bool
|
||
Chassis *ChassisSpec
|
||
Motor *MotorSpec
|
||
Battery *BatterySpec
|
||
Drive *Drivetrain
|
||
}
|
||
|
||
// UpdateCar applies a partial update.
|
||
func (s *Service) UpdateCar(ctx context.Context, id string, opts UpdateCarOptions) (CarMeta, error) {
|
||
s.mu.Lock()
|
||
c, ok := s.cars[id]
|
||
if !ok {
|
||
s.mu.Unlock()
|
||
return CarMeta{}, fmt.Errorf("%w: car %q", ErrNotFound, id)
|
||
}
|
||
if c.Visibility == VisibilitySystem {
|
||
s.mu.Unlock()
|
||
return CarMeta{}, fmt.Errorf("%w: car %q", ErrImmutableSystem, id)
|
||
}
|
||
|
||
if opts.Name != nil {
|
||
c.Name = strings.TrimSpace(*opts.Name)
|
||
}
|
||
if opts.Visibility != nil {
|
||
if *opts.Visibility == VisibilitySystem {
|
||
s.mu.Unlock()
|
||
return CarMeta{}, fmt.Errorf("%w: cannot promote to system", ErrInvalidInput)
|
||
}
|
||
c.Visibility = *opts.Visibility
|
||
}
|
||
if opts.LengthMm != nil {
|
||
c.LengthMm = *opts.LengthMm
|
||
}
|
||
if opts.WidthMm != nil {
|
||
c.WidthMm = *opts.WidthMm
|
||
}
|
||
if opts.HeightMm != nil {
|
||
c.HeightMm = *opts.HeightMm
|
||
}
|
||
if opts.WeightG != nil {
|
||
c.WeightG = *opts.WeightG
|
||
}
|
||
if opts.TopSpeedMs != nil {
|
||
c.TopSpeedMs = *opts.TopSpeedMs
|
||
}
|
||
if opts.ColorHex != nil {
|
||
c.ColorHex = *opts.ColorHex
|
||
}
|
||
if opts.Active != nil {
|
||
c.Active = *opts.Active
|
||
}
|
||
if opts.Chassis != nil {
|
||
c.Chassis = *opts.Chassis
|
||
}
|
||
if opts.Motor != nil {
|
||
c.Motor = *opts.Motor
|
||
}
|
||
if opts.Battery != nil {
|
||
c.Battery = *opts.Battery
|
||
}
|
||
if opts.Drive != nil {
|
||
c.Drive = *opts.Drive
|
||
}
|
||
c.UpdatedMs = time.Now().UnixMilli()
|
||
|
||
if c.LengthMm < minCarLengthMm || c.LengthMm > maxCarLengthMm ||
|
||
c.WidthMm < minCarWidthMm || c.WidthMm > maxCarWidthMm {
|
||
s.mu.Unlock()
|
||
return CarMeta{}, fmt.Errorf("%w: dimensions out of range", ErrInvalidInput)
|
||
}
|
||
|
||
updated := *c
|
||
s.mu.Unlock()
|
||
|
||
if err := s.persistUpsertCar(ctx, updated); err != nil {
|
||
return CarMeta{}, err
|
||
}
|
||
return updated, nil
|
||
}
|
||
|
||
// DeleteCar removes a car by id.
|
||
func (s *Service) DeleteCar(ctx context.Context, id string) error {
|
||
s.mu.RLock()
|
||
c, ok := s.cars[id]
|
||
s.mu.RUnlock()
|
||
if !ok {
|
||
return fmt.Errorf("%w: car %q", ErrNotFound, id)
|
||
}
|
||
if c.Visibility == VisibilitySystem {
|
||
return fmt.Errorf("%w: car %q", ErrImmutableSystem, id)
|
||
}
|
||
return s.persistDeleteCar(ctx, id)
|
||
}
|
||
|
||
// SetCarStats mutates the read-mostly counters on a car (called by the
|
||
// race engine via catalog hooks). Only updates non-zero inputs.
|
||
func (s *Service) SetCarStats(ctx context.Context, id string, fn func(*CarMeta)) error {
|
||
s.mu.Lock()
|
||
c, ok := s.cars[id]
|
||
if !ok {
|
||
s.mu.Unlock()
|
||
return fmt.Errorf("%w: car %q", ErrNotFound, id)
|
||
}
|
||
fn(c)
|
||
c.UpdatedMs = time.Now().UnixMilli()
|
||
updated := *c
|
||
s.mu.Unlock()
|
||
|
||
if s.store != nil {
|
||
if err := s.store.UpdateCarStats(ctx, id, func(c *CarMeta) {
|
||
fn(c)
|
||
c.UpdatedMs = updated.UpdatedMs
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
s.bump(Event{Kind: EventCarUpsert, CarID: id})
|
||
return nil
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Helpers
|
||
// -------------------------------------------------------------------------
|
||
|
||
// getTrackLocked returns a shallow copy of a track from the cache.
|
||
// MUST be called with s.mu held.
|
||
func (s *Service) getTrackLocked(id string) (TrackMeta, bool) {
|
||
t, ok := s.tracks[id]
|
||
if !ok {
|
||
return TrackMeta{}, false
|
||
}
|
||
return *t, true
|
||
}
|
||
|
||
func (s *Service) getCarLocked(id string) (CarMeta, bool) {
|
||
c, ok := s.cars[id]
|
||
if !ok {
|
||
return CarMeta{}, false
|
||
}
|
||
return *c, true
|
||
}
|
||
|
||
// slugify turns "Sunset Sprint" into "sunset-sprint".
|
||
func slugify(s string) string {
|
||
s = strings.ToLower(strings.TrimSpace(s))
|
||
var b strings.Builder
|
||
prevDash := false
|
||
for _, r := range s {
|
||
switch {
|
||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||
b.WriteRune(r)
|
||
prevDash = false
|
||
default:
|
||
if !prevDash && b.Len() > 0 {
|
||
b.WriteRune('-')
|
||
prevDash = true
|
||
}
|
||
}
|
||
}
|
||
out := strings.TrimRight(b.String(), "-")
|
||
if out == "" {
|
||
out = fmt.Sprintf("item-%d", time.Now().UnixNano()%1_000_000)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// computeBounds derives a Bounds from a closed loop of waypoints.
|
||
func computeBounds(loop []Waypoint) Bounds {
|
||
if len(loop) == 0 {
|
||
return Bounds{}
|
||
}
|
||
b := Bounds{
|
||
MinX: loop[0].X,
|
||
MinY: loop[0].Y,
|
||
MaxX: loop[0].X,
|
||
MaxY: loop[0].Y,
|
||
}
|
||
for _, p := range loop[1:] {
|
||
if p.X < b.MinX {
|
||
b.MinX = p.X
|
||
}
|
||
if p.Y < b.MinY {
|
||
b.MinY = p.Y
|
||
}
|
||
if p.X > b.MaxX {
|
||
b.MaxX = p.X
|
||
}
|
||
if p.Y > b.MaxY {
|
||
b.MaxY = p.Y
|
||
}
|
||
}
|
||
b.LengthM = b.MaxX - b.MinX
|
||
b.WidthM = b.MaxY - b.MinY
|
||
return b
|
||
}
|
||
|
||
// defaultCenterline generates a parametric oval centered in a box of
|
||
// size length_m × width_m. Used when the caller doesn't supply waypoints.
|
||
func defaultCenterline(lengthM, widthM float64) []Waypoint {
|
||
if lengthM <= 0 {
|
||
lengthM = 2.0
|
||
}
|
||
if widthM <= 0 {
|
||
widthM = 1.2
|
||
}
|
||
const samples = 24
|
||
cx, cy := lengthM/2, widthM/2
|
||
rx, ry := lengthM*0.45, widthM*0.45
|
||
pts := make([]Waypoint, samples)
|
||
for i := 0; i < samples; i++ {
|
||
t := float64(i) / float64(samples) * 2 * math.Pi
|
||
x := cx + rx*math.Cos(t)
|
||
y := cy + ry*math.Sin(t)
|
||
// Tangent: perpendicular to the radius.
|
||
heading := t + math.Pi/2
|
||
// Curvature: 1/r at this point (approximation using ellipse radii).
|
||
r := math.Sqrt(rx*rx*math.Sin(t)*math.Sin(t) + ry*ry*math.Cos(t)*math.Cos(t))
|
||
curv := 0.0
|
||
if r > 0 {
|
||
curv = math.Min(rx, ry) / (r * r)
|
||
}
|
||
// Speed: slower on tight curves.
|
||
speed := 4.0 - 2.0*math.Min(curv*5, 1)
|
||
pts[i] = Waypoint{X: x, Y: y, HeadingRad: heading, SpeedMs: speed, Curvature: curv}
|
||
}
|
||
return pts
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Read APIs (Get / List / Snapshot / Stats)
|
||
// -------------------------------------------------------------------------
|
||
|
||
// ListTracksByVisibility returns tracks matching the given visibility.
|
||
func (s *Service) ListTracksByVisibility(v Visibility) []TrackMeta {
|
||
all := s.ListTracks()
|
||
out := all[:0:0]
|
||
for _, t := range all {
|
||
if t.Visibility == v {
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ListCarsByVisibility returns cars matching the given visibility.
|
||
func (s *Service) ListCarsByVisibility(v Visibility) []CarMeta {
|
||
all := s.ListCars()
|
||
out := all[:0:0]
|
||
for _, c := range all {
|
||
if c.Visibility == v {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ListCarsByOwner returns cars owned by the given driver ID.
|
||
func (s *Service) ListCarsByOwner(ownerID string) []CarMeta {
|
||
all := s.ListCars()
|
||
out := all[:0:0]
|
||
for _, c := range all {
|
||
if c.OwnerID == ownerID {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// GetTrackCalendar returns all track calendar entries from the store.
|
||
func (s *Service) GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error) {
|
||
if s.store == nil {
|
||
return nil, ErrStoreMissing
|
||
}
|
||
return s.store.GetTrackCalendar(ctx)
|
||
}
|
||
|
||
// CreateTrackCalendar creates a new calendar entry.
|
||
func (s *Service) CreateTrackCalendar(ctx context.Context, trackID string, startAt, endAt time.Time) (TrackCalendarEntry, error) {
|
||
if s.store == nil {
|
||
return TrackCalendarEntry{}, ErrStoreMissing
|
||
}
|
||
|
||
s.mu.RLock()
|
||
_, trackExists := s.tracks[TrackID(trackID)]
|
||
s.mu.RUnlock()
|
||
if !trackExists {
|
||
return TrackCalendarEntry{}, fmt.Errorf("%w: track %s does not exist", ErrNotFound, trackID)
|
||
}
|
||
|
||
if startAt.After(endAt) || startAt.Equal(endAt) {
|
||
return TrackCalendarEntry{}, fmt.Errorf("%w: start time must be before end time", ErrInvalidInput)
|
||
}
|
||
|
||
entry := TrackCalendarEntry{
|
||
TrackID: trackID,
|
||
StartAt: startAt,
|
||
EndAt: endAt,
|
||
}
|
||
return s.store.CreateTrackCalendar(ctx, entry)
|
||
}
|
||
|
||
// DeleteTrackCalendar deletes a calendar entry.
|
||
func (s *Service) DeleteTrackCalendar(ctx context.Context, id int) error {
|
||
if s.store == nil {
|
||
return ErrStoreMissing
|
||
}
|
||
return s.store.DeleteTrackCalendar(ctx, id)
|
||
} |