148 lines
3.9 KiB
Go
148 lines
3.9 KiB
Go
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
|
|
}
|