Files
2026-06-15 21:25:57 +07:00

256 lines
6.1 KiB
Go

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)
}