mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user