feat: add url shortner

This commit is contained in:
Krispire Admin
2026-05-11 16:13:22 +05:30
parent f2889a5679
commit 5e0a484239
17 changed files with 1305 additions and 13 deletions
+131
View File
@@ -0,0 +1,131 @@
package repository
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
)
type Postgres struct {
Pool *pgxpool.Pool
logger *zap.Logger
}
func NewPostgres(ctx context.Context, uri string, logger *zap.Logger) (*Postgres, error) {
if uri == "" {
return nil, fmt.Errorf("postgres URI is empty")
}
cfg, err := pgxpool.ParseConfig(uri)
if err != nil {
return nil, fmt.Errorf("postgres parse config: %w", err)
}
cfg.MaxConns = 20
cfg.MinConns = 2
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
pool, err := pgxpool.NewWithConfig(pingCtx, cfg)
if err != nil {
return nil, fmt.Errorf("postgres connect: %w", err)
}
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("postgres ping: %w", err)
}
logger.Info("connected to Postgres")
return &Postgres{Pool: pool, logger: logger}, nil
}
func (p *Postgres) Close() {
if p == nil || p.Pool == nil {
return
}
p.Pool.Close()
}
// RunMigrations applies any *.up.sql files in dir that haven't been recorded
// in schema_migrations yet, in lexical order. Tiny homegrown runner — no
// external dependency. Down-migrations are not executed automatically.
func (p *Postgres) RunMigrations(ctx context.Context, dir string) error {
if _, err := p.Pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
files, err := filepath.Glob(filepath.Join(dir, "*.up.sql"))
if err != nil {
return fmt.Errorf("glob migrations: %w", err)
}
sort.Strings(files)
for _, f := range files {
version, err := parseMigrationVersion(f)
if err != nil {
p.logger.Warn("skipping unparseable migration filename", zap.String("file", f), zap.Error(err))
continue
}
var exists bool
if err := p.Pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, version,
).Scan(&exists); err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if exists {
continue
}
sqlBytes, err := os.ReadFile(f)
if err != nil {
return fmt.Errorf("read %s: %w", f, err)
}
tx, err := p.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx for %s: %w", f, err)
}
if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("apply %s: %w", f, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, version); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("record %s: %w", f, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit %s: %w", f, err)
}
p.logger.Info("applied migration", zap.String("file", filepath.Base(f)), zap.Int64("version", version))
}
return nil
}
func parseMigrationVersion(path string) (int64, error) {
base := filepath.Base(path)
idx := strings.Index(base, "_")
if idx <= 0 {
return 0, fmt.Errorf("filename %q must start with <version>_", base)
}
v, err := strconv.ParseInt(base[:idx], 10, 64)
if err != nil {
return 0, fmt.Errorf("version prefix not an integer: %w", err)
}
return v, nil
}
+45
View File
@@ -0,0 +1,45 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
type Redis struct {
Client *redis.Client
logger *zap.Logger
}
func NewRedis(ctx context.Context, uri string, logger *zap.Logger) (*Redis, error) {
if uri == "" {
return nil, fmt.Errorf("redis URI is empty")
}
opts, err := redis.ParseURL(uri)
if err != nil {
return nil, fmt.Errorf("redis parse URL: %w", err)
}
client := redis.NewClient(opts)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := client.Ping(pingCtx).Err(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("redis ping: %w", err)
}
logger.Info("connected to Redis")
return &Redis{Client: client, logger: logger}, nil
}
func (r *Redis) Close() error {
if r == nil || r.Client == nil {
return nil
}
return r.Client.Close()
}
+157
View File
@@ -0,0 +1,157 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
)
// ShortenedURLRow mirrors the analytics-relevant columns of shortened_urls.
type ShortenedURLRow struct {
ID int64
Code string
LongURL string
Disabled bool
ExpiresAt *time.Time
ClickCount int64
}
// ShortURLRepo wraps Postgres CRUD for the shortener.
type ShortURLRepo struct {
pg *Postgres
}
func NewShortURLRepo(pg *Postgres) *ShortURLRepo {
return &ShortURLRepo{pg: pg}
}
// Insert persists a new short URL. Returns the new row's id. If a unique
// violation occurs (race: another worker already inserted this code) the
// caller can detect via IsUniqueViolation(err).
func (r *ShortURLRepo) Insert(
ctx context.Context,
code string,
payload shorturl.PendingPayload,
parsed shorturl.Parsed,
) (int64, error) {
const q = `
INSERT INTO shortened_urls (
code, long_url,
path, content_type, book_code, division, chapter, verse, slug,
has_highlight, highlight_param,
creator_ip, creator_ua, creator_referer, creator_country,
created_at
) VALUES (
$1, $2,
$3, $4, $5, $6, $7, $8, $9,
$10, $11,
NULLIF($12,'')::inet, NULLIF($13,''), NULLIF($14,''), NULLIF($15,''),
$16
)
RETURNING id
`
var id int64
err := r.pg.Pool.QueryRow(ctx, q,
code, payload.URL,
parsed.Path, parsed.ContentType,
nullableStr(parsed.BookCode),
nullableStr(parsed.Division),
nullableStr(parsed.Chapter),
nullableStr(parsed.Verse),
nullableStr(parsed.Slug),
parsed.HasHighlight, nullableStr(parsed.HighlightParam),
payload.CreatorIP, payload.CreatorUA, payload.CreatorReferer, payload.CreatorCountry,
payload.CreatedAt,
).Scan(&id)
if err != nil {
return 0, fmt.Errorf("insert shortened_url: %w", err)
}
return id, nil
}
// GetByCode returns the row for code, or pgx.ErrNoRows if missing.
func (r *ShortURLRepo) GetByCode(ctx context.Context, code string) (*ShortenedURLRow, error) {
const q = `
SELECT id, code, long_url, disabled, expires_at, click_count
FROM shortened_urls
WHERE code = $1
LIMIT 1
`
var row ShortenedURLRow
err := r.pg.Pool.QueryRow(ctx, q, code).Scan(
&row.ID, &row.Code, &row.LongURL, &row.Disabled, &row.ExpiresAt, &row.ClickCount,
)
if err != nil {
return nil, err
}
return &row, nil
}
// RecordClick inserts a row in shortened_url_clicks and bumps the aggregate
// counters on the parent row in a single transaction. The parent lookup uses
// the code (not id) so this works even if the click worker is processing an
// event for a URL inserted by a different process.
func (r *ShortURLRepo) RecordClick(ctx context.Context, ev shorturl.ClickEvent) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var id int64
err = tx.QueryRow(ctx, `SELECT id FROM shortened_urls WHERE code = $1`, ev.Code).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Parent row hasn't landed in PG yet (still pending). Skip silently;
// the click is lost. (Rare: only when a resolution and the parent
// insert race within the worker drain interval.)
return nil
}
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO shortened_url_clicks (
short_url_id, code, clicked_at, client_ip, client_ua, referer, country, is_bot
) VALUES (
$1, $2, $3, NULLIF($4,'')::inet, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8
)`,
id, ev.Code, ev.ClickedAt, ev.IP, ev.UA, ev.Referer, ev.Country, ev.IsBot,
); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE shortened_urls
SET click_count = click_count + 1,
human_click_count = human_click_count + CASE WHEN $2 THEN 0 ELSE 1 END,
last_clicked_at = $1,
first_clicked_at = COALESCE(first_clicked_at, $1)
WHERE id = $3
`, ev.ClickedAt, ev.IsBot, id); err != nil {
return err
}
return tx.Commit(ctx)
}
// IsUniqueViolation reports whether err is a Postgres unique constraint error.
func IsUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}
func nullableStr(s string) any {
if s == "" {
return nil
}
return s
}