package postgres import ( "context" "errors" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/snip/backend/internal/domain" ) type LinkRepo struct{ pool *pgxpool.Pool } func NewLinkRepo(pool *pgxpool.Pool) *LinkRepo { return &LinkRepo{pool: pool} } func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } func (r *LinkRepo) Create(ctx context.Context, l *domain.Link) error { _, err := r.pool.Exec(ctx, `INSERT INTO links (id, code, long_url, mode, has_pin, pin_hash, owner_id, total_clicks, created_at) VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),0,$8)`, l.ID, l.Code, l.LongURL, string(l.Mode), l.HasPin, l.PinHash, l.OwnerID, l.CreatedAt) if isUniqueViolation(err) { return domain.ErrCodeTaken } return err } const selectCols = `id, code, long_url, mode, has_pin, pin_hash, COALESCE(owner_id,''), total_clicks, created_at` func scanLink(row pgx.Row) (*domain.Link, error) { var l domain.Link var mode string if err := row.Scan(&l.ID, &l.Code, &l.LongURL, &mode, &l.HasPin, &l.PinHash, &l.OwnerID, &l.TotalClicks, &l.CreatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrNotFound } return nil, err } l.Mode = domain.Mode(mode) return &l, nil } // GetByCode is the redirect hot-path read (cache miss only). It deliberately // skips the 7-day stats join — the redirect doesn't need them. func (r *LinkRepo) GetByCode(ctx context.Context, code string) (*domain.Link, error) { return scanLink(r.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM links WHERE code=$1`, code)) } func (r *LinkRepo) GetByID(ctx context.Context, id string) (*domain.Link, error) { l, err := scanLink(r.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM links WHERE id=$1`, id)) if err != nil { return nil, err } weeks, err := r.weeksFor(ctx, []string{l.Code}) if err != nil { return nil, err } l.Last7Days = fillWeek(weeks[l.Code], time.Now().UTC()) return l, nil } func (r *LinkRepo) ListByOwner(ctx context.Context, ownerID, query string, limit, offset int) ([]domain.Link, int, error) { // `pattern` is NULL when there's no query, so the filter is skipped. var pattern any if query != "" { pattern = "%" + query + "%" } var total int if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM links WHERE owner_id=$1 AND ($2::text IS NULL OR code ILIKE $2 OR long_url ILIKE $2)`, ownerID, pattern).Scan(&total); err != nil { return nil, 0, err } if total == 0 { return nil, 0, nil } rows, err := r.pool.Query(ctx, `SELECT `+selectCols+` FROM links WHERE owner_id=$1 AND ($2::text IS NULL OR code ILIKE $2 OR long_url ILIKE $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4`, ownerID, pattern, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var links []domain.Link var codes []string for rows.Next() { l, err := scanLink(rows) if err != nil { return nil, 0, err } links = append(links, *l) codes = append(codes, l.Code) } if err := rows.Err(); err != nil { return nil, 0, err } weeks, err := r.weeksFor(ctx, codes) if err != nil { return nil, 0, err } for i := range links { links[i].Last7Days = fillWeek(weeks[links[i].Code], time.Now().UTC()) } return links, total, nil } func (r *LinkRepo) StatsByOwner(ctx context.Context, ownerID string) (domain.OwnerStats, error) { var s domain.OwnerStats if err := r.pool.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(total_clicks),0) FROM links WHERE owner_id=$1`, ownerID).Scan(&s.TotalLinks, &s.TotalClicks); err != nil { return s, err } since := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02") if err := r.pool.QueryRow(ctx, `SELECT COALESCE(SUM(cd.count),0) FROM click_daily cd JOIN links l ON l.code = cd.code WHERE l.owner_id=$1 AND cd.day >= $2`, ownerID, since).Scan(&s.WeekClicks); err != nil { return s, err } return s, nil } func (r *LinkRepo) Update(ctx context.Context, l *domain.Link) error { tag, err := r.pool.Exec(ctx, `UPDATE links SET long_url=$2, has_pin=$3, pin_hash=$4 WHERE id=$1`, l.ID, l.LongURL, l.HasPin, l.PinHash) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrNotFound } return nil } func (r *LinkRepo) Delete(ctx context.Context, id, ownerID string) error { tag, err := r.pool.Exec(ctx, `DELETE FROM links WHERE id=$1 AND owner_id=$2`, id, ownerID) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrNotFound } return nil } func (r *LinkRepo) ExistsCode(ctx context.Context, code string) (bool, error) { var exists bool err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM links WHERE code=$1)`, code).Scan(&exists) return exists, err } func (r *LinkRepo) NextSequence(ctx context.Context) (int64, error) { var seq int64 err := r.pool.QueryRow(ctx, `SELECT nextval('link_code_seq')`).Scan(&seq) return seq, err } func (r *LinkRepo) RecordClicks(ctx context.Context, code string, day time.Time, n int64) error { tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `INSERT INTO click_daily (code, day, count) VALUES ($1,$2,$3) ON CONFLICT (code, day) DO UPDATE SET count = click_daily.count + EXCLUDED.count`, code, day, n); err != nil { return err } if _, err := tx.Exec(ctx, `UPDATE links SET total_clicks = total_clicks + $2 WHERE code=$1`, code, n); err != nil { return err } return tx.Commit(ctx) } // weeksFor returns, per code, that code's day->count map for the last 7 days. func (r *LinkRepo) weeksFor(ctx context.Context, codes []string) (map[string]map[string]int64, error) { out := make(map[string]map[string]int64) if len(codes) == 0 { return out, nil } since := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02") rows, err := r.pool.Query(ctx, `SELECT code, to_char(day,'YYYY-MM-DD'), count FROM click_daily WHERE code = ANY($1) AND day >= $2`, codes, since) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var code, day string var count int64 if err := rows.Scan(&code, &day, &count); err != nil { return nil, err } if out[code] == nil { out[code] = make(map[string]int64) } out[code][day] = count } return out, rows.Err() } // fillWeek expands a sparse day->count map into a dense 7-day window. func fillWeek(counts map[string]int64, now time.Time) []domain.DayCount { out := make([]domain.DayCount, 0, 7) for i := 6; i >= 0; i-- { key := now.AddDate(0, 0, -i).Format("2006-01-02") out = append(out, domain.DayCount{Date: key, Count: counts[key]}) } return out }