package service import ( "context" "errors" "strings" "time" "github.com/snip/backend/internal/domain" "github.com/snip/backend/internal/port" ) // cacheTTL is how long a resolution stays hot in the cache. const cacheTTL = 24 * time.Hour // CreateInput is the use-case input for minting a link. type CreateInput struct { LongURL string Mode domain.Mode CustomAlias string Pin string } // LinkService is the application core for owning links. type LinkService struct { repo port.LinkRepository cache port.LinkCache hasher port.PasswordHasher now func() time.Time } func NewLinkService(repo port.LinkRepository, cache port.LinkCache, hasher port.PasswordHasher) *LinkService { return &LinkService{repo: repo, cache: cache, hasher: hasher, now: time.Now} } // Create resolves a code by mode, persists the link and warms the cache. func (s *LinkService) Create(ctx context.Context, in CreateInput, ownerID string) (*domain.Link, error) { longURL := NormalizeURL(in.LongURL) if !ValidURL(longURL) { return nil, domain.ErrInvalidURL } code, err := s.resolveCode(ctx, in, ownerID) if err != nil { return nil, err } link := &domain.Link{ ID: newID(), Code: code, LongURL: longURL, Mode: in.Mode, OwnerID: ownerID, CreatedAt: s.now().UTC(), Last7Days: emptyWeek(s.now()), } // PIN is an authenticated-only feature. if in.Pin != "" && ownerID != "" { if !ValidPin(in.Pin) { return nil, domain.ErrInvalidPin } hash, err := s.hasher.Hash(in.Pin) if err != nil { return nil, err } link.HasPin = true link.PinHash = hash } if err := s.repo.Create(ctx, link); err != nil { return nil, err } s.warm(ctx, link) return link, nil } func (s *LinkService) resolveCode(ctx context.Context, in CreateInput, ownerID string) (string, error) { switch in.Mode { case domain.ModeCustom: if ownerID == "" { return "", domain.ErrUnauthorized } if err := ValidateAlias(in.CustomAlias); err != nil { return "", err } exists, err := s.repo.ExistsCode(ctx, in.CustomAlias) if err != nil { return "", err } if exists { return "", domain.ErrCodeTaken } return in.CustomAlias, nil case domain.ModeMemorable: for i := 0; i < 6; i++ { code := MemorableSlug() exists, err := s.repo.ExistsCode(ctx, code) if err != nil { return "", err } if !exists { return code, nil } } return "", domain.ErrCodeTaken default: // ModeRandom seq, err := s.repo.NextSequence(ctx) if err != nil { return "", err } return CodeFromSequence(seq), nil } } // Page is one page of a link listing. type Page struct { Items []domain.Link Total int Page int PageSize int } const ( defaultPageSize = 10 maxPageSize = 100 ) // List returns one page of the caller's links matching query (server-side // pagination + search). PIN hashes are stripped by the HTTP layer. func (s *LinkService) List(ctx context.Context, ownerID, query string, page, pageSize int) (Page, error) { if ownerID == "" { return Page{}, domain.ErrUnauthorized } if pageSize <= 0 { pageSize = defaultPageSize } if pageSize > maxPageSize { pageSize = maxPageSize } if page <= 0 { page = 1 } items, total, err := s.repo.ListByOwner(ctx, ownerID, strings.TrimSpace(query), pageSize, (page-1)*pageSize) if err != nil { return Page{}, err } return Page{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // Stats returns the caller's aggregate dashboard tiles. func (s *LinkService) Stats(ctx context.Context, ownerID string) (domain.OwnerStats, error) { if ownerID == "" { return domain.OwnerStats{}, domain.ErrUnauthorized } return s.repo.StatsByOwner(ctx, ownerID) } // UpdateDestination changes where an owned link points and invalidates cache. func (s *LinkService) UpdateDestination(ctx context.Context, id, ownerID, rawURL string) (*domain.Link, error) { link, err := s.ownedLink(ctx, id, ownerID) if err != nil { return nil, err } longURL := NormalizeURL(rawURL) if !ValidURL(longURL) { return nil, domain.ErrInvalidURL } link.LongURL = longURL if err := s.repo.Update(ctx, link); err != nil { return nil, err } s.warm(ctx, link) return link, nil } // SetPin sets or replaces a PIN; pass empty to remove it. func (s *LinkService) SetPin(ctx context.Context, id, ownerID, pin string) (*domain.Link, error) { link, err := s.ownedLink(ctx, id, ownerID) if err != nil { return nil, err } if pin == "" { link.HasPin = false link.PinHash = "" } else { if !ValidPin(pin) { return nil, domain.ErrInvalidPin } hash, err := s.hasher.Hash(pin) if err != nil { return nil, err } link.HasPin = true link.PinHash = hash } if err := s.repo.Update(ctx, link); err != nil { return nil, err } s.warm(ctx, link) return link, nil } // Delete removes an owned link and evicts it from cache. func (s *LinkService) Delete(ctx context.Context, id, ownerID string) error { link, err := s.ownedLink(ctx, id, ownerID) if err != nil { return err } if err := s.repo.Delete(ctx, id, ownerID); err != nil { return err } _ = s.cache.Invalidate(ctx, link.Code) return nil } func (s *LinkService) ownedLink(ctx context.Context, id, ownerID string) (*domain.Link, error) { if ownerID == "" { return nil, domain.ErrUnauthorized } link, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } if link.OwnerID != ownerID { return nil, domain.ErrForbidden } return link, nil } func (s *LinkService) warm(ctx context.Context, l *domain.Link) { _ = s.cache.SetResolution(ctx, l.Code, port.Resolution{ LongURL: l.LongURL, HasPin: l.HasPin, PinHash: l.PinHash, Found: true, }, cacheTTL) } // emptyWeek builds a zeroed 7-day window ending today. func emptyWeek(now time.Time) []domain.DayCount { out := make([]domain.DayCount, 0, 7) for i := 6; i >= 0; i-- { d := now.AddDate(0, 0, -i) out = append(out, domain.DayCount{Date: d.Format("2006-01-02"), Count: 0}) } return out } // IsConflict helps the HTTP layer pick a status code. func IsConflict(err error) bool { return errors.Is(err, domain.ErrCodeTaken) || errors.Is(err, domain.ErrReserved) }