Files
llm-browser/backend/internal/items/service.go
T
2026-10-19 16:04:17 +02:00

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
}