package items import ( "context" "strings" ) // Field length limits enforced on input. const ( maxTitleLen = 200 maxDescriptionLen = 2000 ) // Service holds the business rules for items. It depends only on the // Repository interface; main wires the Bun implementation in. type Service struct { repo Repository } // NewService returns a Service over the given repository. func NewService(repo Repository) *Service { return &Service{repo: repo} } // CreateInput is the request body for creating an item. type CreateInput struct { Title string `json:"title"` Description string `json:"description"` Status Status `json:"status"` } // UpdateInput patches only provided fields (nil = leave untouched), which // is why every field is a pointer. type UpdateInput struct { Title *string `json:"title"` Description *string `json:"description"` Status *Status `json:"status"` } // List validates the filter and delegates to the repository. func (s *Service) List(ctx context.Context, f Filter) ([]Item, error) { f, err := normalizeFilter(f) if err != nil { return nil, err } return s.repo.List(ctx, f) } // Get returns one item. func (s *Service) Get(ctx context.Context, id int64) (Item, error) { return s.repo.Get(ctx, id) } // Create validates input and stores a new item. func (s *Service) Create(ctx context.Context, in CreateInput) (Item, error) { item := Item{ Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), } if item.Title == "" { return Item{}, &ValidationError{Field: "title", Reason: "required"} } if len(item.Title) > maxTitleLen { return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"} } if len(item.Description) > maxDescriptionLen { return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"} } switch in.Status { case "": item.Status = StatusActive case StatusActive, StatusArchived: item.Status = in.Status default: return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"} } if err := s.repo.Create(ctx, &item); err != nil { return Item{}, err } return item, nil } // Update applies a partial patch to an existing item. func (s *Service) Update(ctx context.Context, id int64, in UpdateInput) (Item, error) { if in.Title == nil && in.Description == nil && in.Status == nil { return Item{}, &ValidationError{Field: "body", Reason: "at least one field must be set"} } item, err := s.repo.Get(ctx, id) if err != nil { return Item{}, err } if in.Title != nil { item.Title = strings.TrimSpace(*in.Title) if item.Title == "" { return Item{}, &ValidationError{Field: "title", Reason: "required"} } if len(item.Title) > maxTitleLen { return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"} } } if in.Description != nil { item.Description = strings.TrimSpace(*in.Description) if len(item.Description) > maxDescriptionLen { return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"} } } if in.Status != nil { if !in.Status.Valid() { return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"} } item.Status = *in.Status } if err := s.repo.Update(ctx, &item); err != nil { return Item{}, err } return item, nil } // Delete removes an item. func (s *Service) Delete(ctx context.Context, id int64) error { return s.repo.Delete(ctx, id) } // normalizeFilter applies paging defaults and validates the status filter. func normalizeFilter(f Filter) (Filter, error) { switch f.Status { case "": case StatusActive, StatusArchived: default: return Filter{}, &ValidationError{Field: "status", Reason: "must be active or archived"} } if f.Limit <= 0 { f.Limit = DefaultLimit } if f.Limit > MaxLimit { f.Limit = MaxLimit } if f.Offset < 0 { f.Offset = 0 } return f, nil }