feat: first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-15 21:25:57 +07:00
commit 3395ab6dd3
88 changed files with 10034 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
package memory
import (
"context"
"sync"
"time"
"github.com/snip/backend/internal/port"
)
type cacheEntry struct {
res port.Resolution
expires time.Time
}
// Cache is a TTL map standing in for Redis.
type Cache struct {
mu sync.RWMutex
m map[string]cacheEntry
}
func NewCache() *Cache {
return &Cache{m: make(map[string]cacheEntry)}
}
func (c *Cache) GetResolution(_ context.Context, code string) (port.Resolution, bool, error) {
c.mu.RLock()
e, ok := c.m[code]
c.mu.RUnlock()
if !ok || time.Now().After(e.expires) {
return port.Resolution{}, false, nil
}
return e.res, true, nil
}
func (c *Cache) SetResolution(_ context.Context, code string, r port.Resolution, ttl time.Duration) error {
c.mu.Lock()
c.m[code] = cacheEntry{res: r, expires: time.Now().Add(ttl)}
c.mu.Unlock()
return nil
}
func (c *Cache) Invalidate(_ context.Context, code string) error {
c.mu.Lock()
delete(c.m, code)
c.mu.Unlock()
return nil
}
@@ -0,0 +1,171 @@
// Package memory provides in-memory adapters used by tests and for running the
// stack without Postgres/Redis (STORE=memory).
package memory
import (
"context"
"sort"
"strings"
"sync"
"time"
"github.com/snip/backend/internal/domain"
)
type LinkRepo struct {
mu sync.RWMutex
byID map[string]*domain.Link
seq int64
clock func() time.Time
}
func NewLinkRepo() *LinkRepo {
return &LinkRepo{byID: make(map[string]*domain.Link), clock: time.Now}
}
func clone(l *domain.Link) *domain.Link {
cp := *l
cp.Last7Days = append([]domain.DayCount(nil), l.Last7Days...)
return &cp
}
func (r *LinkRepo) Create(_ context.Context, l *domain.Link) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, e := range r.byID {
if e.Code == l.Code {
return domain.ErrCodeTaken
}
}
r.byID[l.ID] = clone(l)
return nil
}
func (r *LinkRepo) GetByID(_ context.Context, id string) (*domain.Link, error) {
r.mu.RLock()
defer r.mu.RUnlock()
l, ok := r.byID[id]
if !ok {
return nil, domain.ErrNotFound
}
return clone(l), nil
}
func (r *LinkRepo) GetByCode(_ context.Context, code string) (*domain.Link, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, l := range r.byID {
if l.Code == code {
return clone(l), nil
}
}
return nil, domain.ErrNotFound
}
func (r *LinkRepo) ListByOwner(_ context.Context, ownerID, query string, limit, offset int) ([]domain.Link, int, error) {
r.mu.RLock()
defer r.mu.RUnlock()
q := strings.ToLower(query)
var matched []domain.Link
for _, l := range r.byID {
if l.OwnerID != ownerID {
continue
}
if q != "" && !strings.Contains(strings.ToLower(l.Code), q) && !strings.Contains(strings.ToLower(l.LongURL), q) {
continue
}
matched = append(matched, *clone(l))
}
sort.Slice(matched, func(i, j int) bool { return matched[i].CreatedAt.After(matched[j].CreatedAt) })
total := len(matched)
if offset > total {
offset = total
}
end := offset + limit
if limit <= 0 || end > total {
end = total
}
return matched[offset:end], total, nil
}
func (r *LinkRepo) StatsByOwner(_ context.Context, ownerID string) (domain.OwnerStats, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var s domain.OwnerStats
for _, l := range r.byID {
if l.OwnerID != ownerID {
continue
}
s.TotalLinks++
s.TotalClicks += l.TotalClicks
for _, d := range l.Last7Days {
s.WeekClicks += d.Count
}
}
return s, nil
}
func (r *LinkRepo) Update(_ context.Context, l *domain.Link) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byID[l.ID]; !ok {
return domain.ErrNotFound
}
r.byID[l.ID] = clone(l)
return nil
}
func (r *LinkRepo) Delete(_ context.Context, id, ownerID string) error {
r.mu.Lock()
defer r.mu.Unlock()
l, ok := r.byID[id]
if !ok || l.OwnerID != ownerID {
return domain.ErrNotFound
}
delete(r.byID, id)
return nil
}
func (r *LinkRepo) ExistsCode(_ context.Context, code string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, l := range r.byID {
if l.Code == code {
return true, nil
}
}
return false, nil
}
func (r *LinkRepo) NextSequence(_ context.Context) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.seq++
return r.seq, nil
}
func (r *LinkRepo) RecordClicks(_ context.Context, code string, day time.Time, n int64) error {
r.mu.Lock()
defer r.mu.Unlock()
key := day.Format("2006-01-02")
for _, l := range r.byID {
if l.Code != code {
continue
}
l.TotalClicks += n
found := false
for i := range l.Last7Days {
if l.Last7Days[i].Date == key {
l.Last7Days[i].Count += n
found = true
break
}
}
if !found {
l.Last7Days = append(l.Last7Days, domain.DayCount{Date: key, Count: n})
}
return nil
}
return domain.ErrNotFound
}
@@ -0,0 +1,60 @@
package memory
import (
"context"
"crypto/rand"
"sync"
"github.com/snip/backend/internal/domain"
)
type UserRepo struct {
mu sync.RWMutex
byID map[string]*domain.User
byKey map[string]string // provider|subject -> id
}
func NewUserRepo() *UserRepo {
return &UserRepo{byID: make(map[string]*domain.User), byKey: make(map[string]string)}
}
func (r *UserRepo) Upsert(_ context.Context, u *domain.User) (*domain.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := u.Provider + "|" + u.Subject
if id, ok := r.byKey[key]; ok {
existing := r.byID[id]
existing.Email = u.Email
existing.Name = u.Name
cp := *existing
return &cp, nil
}
id := randomID()
u.ID = id
r.byID[id] = u
r.byKey[key] = id
cp := *u
return &cp, nil
}
func (r *UserRepo) GetByID(_ context.Context, id string) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
u, ok := r.byID[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *u
return &cp, nil
}
func randomID() string {
const hex = "0123456789abcdef"
b := make([]byte, 16)
_, _ = rand.Read(b)
out := make([]byte, 16)
for i := range out {
out[i] = hex[int(b[i])%16]
}
return "u_" + string(out)
}