From 3de7e5faec5d293503e37ae59ca58c62a1293400 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sat, 11 Jul 2026 01:10:35 +0530 Subject: [PATCH] feat: url dashboard --- internal/handler/admin_short_url.go | 50 ++++++++++++ internal/model/short_url.go | 33 ++++++++ internal/repository/admin_short_url_repo.go | 88 +++++++++++++++++++++ internal/server/server.go | 2 + internal/service/admin_short_url.go | 25 ++++++ 5 files changed, 198 insertions(+) create mode 100644 internal/handler/admin_short_url.go create mode 100644 internal/model/short_url.go create mode 100644 internal/repository/admin_short_url_repo.go create mode 100644 internal/service/admin_short_url.go diff --git a/internal/handler/admin_short_url.go b/internal/handler/admin_short_url.go new file mode 100644 index 0000000..0b8880c --- /dev/null +++ b/internal/handler/admin_short_url.go @@ -0,0 +1,50 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +// ListShortURLs — GET /admin/short-urls?q=&limit=&offset= — paginated list of +// shortened URLs, newest first. +func (h *AdminHandler) ListShortURLs(w http.ResponseWriter, r *http.Request) { + q, limit, offset := pageParams(r) + items, total, err := h.svc.ListShortURLs(r.Context(), q, limit, offset) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not list short urls") + return + } + response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset)) +} + +// ShortURLStats — GET /admin/short-urls/stats?from=YYYY-MM-DD&to=YYYY-MM-DD — +// today/week/month/all-time counts, plus a custom range when from+to are given. +func (h *AdminHandler) ShortURLStats(w http.ResponseWriter, r *http.Request) { + from, to := parseDateRange(r) + stats, err := h.svc.ShortURLStats(r.Context(), from, to) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not load short url stats") + return + } + response.JSON(w, http.StatusOK, stats) +} + +// parseDateRange reads ?from=YYYY-MM-DD&to=YYYY-MM-DD (UTC). `to` is treated as +// inclusive, so the returned upper bound is the exclusive start of the next +// day. Returns (nil, nil) unless both dates are present and valid. +func parseDateRange(r *http.Request) (*time.Time, *time.Time) { + fs := r.URL.Query().Get("from") + ts := r.URL.Query().Get("to") + if fs == "" || ts == "" { + return nil, nil + } + from, err1 := time.ParseInLocation("2006-01-02", fs, time.UTC) + to, err2 := time.ParseInLocation("2006-01-02", ts, time.UTC) + if err1 != nil || err2 != nil { + return nil, nil + } + toExclusive := to.AddDate(0, 0, 1) + return &from, &toExclusive +} diff --git a/internal/model/short_url.go b/internal/model/short_url.go new file mode 100644 index 0000000..b33b82c --- /dev/null +++ b/internal/model/short_url.go @@ -0,0 +1,33 @@ +package model + +import "time" + +// AdminShortURL is a row in the admin short-URL table (a projection of +// shortened_urls served only to admins). +type AdminShortURL struct { + ID int64 `json:"id"` + Code string `json:"code"` + LongURL string `json:"long_url"` + ContentType string `json:"content_type"` + ClickCount int64 `json:"click_count"` + HumanClickCount int64 `json:"human_click_count"` + Disabled bool `json:"disabled"` + CreatedAt time.Time `json:"created_at"` +} + +// ShortURLRangeStat is the (created, clicks) pair for one time window. +type ShortURLRangeStat struct { + Created int64 `json:"created"` // short URLs created in the window + Clicks int64 `json:"clicks"` // clicks recorded in the window +} + +// ShortURLStats is the admin dashboard's short-URL summary: fixed today/week/ +// month windows, all-time totals, and an optional custom range. +type ShortURLStats struct { + Today ShortURLRangeStat `json:"today"` + Week ShortURLRangeStat `json:"week"` + Month ShortURLRangeStat `json:"month"` + TotalURLs int64 `json:"total_urls"` + TotalClicks int64 `json:"total_clicks"` + Range *ShortURLRangeStat `json:"range,omitempty"` // present only when from/to given +} diff --git a/internal/repository/admin_short_url_repo.go b/internal/repository/admin_short_url_repo.go new file mode 100644 index 0000000..2bc23b0 --- /dev/null +++ b/internal/repository/admin_short_url_repo.go @@ -0,0 +1,88 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +// ListShortURLs returns a page of shortened URLs filtered by an optional query +// (matched against code or target URL), newest first, with the total count. +func (r *AdminRepo) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) { + limit = clampLimit(limit) + rows, err := r.pg.Pool.Query(ctx, ` + SELECT id, code, long_url, content_type, click_count, human_click_count, + disabled, created_at, + COUNT(*) OVER() AS total + FROM shortened_urls + WHERE ($1 = '' OR code ILIKE '%'||$1||'%' OR long_url ILIKE '%'||$1||'%') + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, q, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("admin list short urls: %w", err) + } + defer rows.Close() + + out := []model.AdminShortURL{} + total := 0 + for rows.Next() { + var s model.AdminShortURL + if err := rows.Scan(&s.ID, &s.Code, &s.LongURL, &s.ContentType, + &s.ClickCount, &s.HumanClickCount, &s.Disabled, &s.CreatedAt, &total); err != nil { + return nil, 0, fmt.Errorf("scan admin short url: %w", err) + } + out = append(out, s) + } + return out, total, rows.Err() +} + +// ShortURLStats computes created + click counts for the three fixed windows +// (thresholds passed in), the all-time totals, and — when from/to is given — +// a custom range (from inclusive, to exclusive). "Created" comes from +// shortened_urls.created_at; "clicks" from the shortened_url_clicks log. +func (r *AdminRepo) ShortURLStats( + ctx context.Context, + todayStart, weekStart, monthStart time.Time, + from, to *time.Time, +) (*model.ShortURLStats, error) { + var s model.ShortURLStats + + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT COUNT(*), + COUNT(*) FILTER (WHERE created_at >= $1), + COUNT(*) FILTER (WHERE created_at >= $2), + COUNT(*) FILTER (WHERE created_at >= $3) + FROM shortened_urls`, todayStart, weekStart, monthStart). + Scan(&s.TotalURLs, &s.Today.Created, &s.Week.Created, &s.Month.Created); err != nil { + return nil, fmt.Errorf("short url created stats: %w", err) + } + + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT COUNT(*), + COUNT(*) FILTER (WHERE clicked_at >= $1), + COUNT(*) FILTER (WHERE clicked_at >= $2), + COUNT(*) FILTER (WHERE clicked_at >= $3) + FROM shortened_url_clicks`, todayStart, weekStart, monthStart). + Scan(&s.TotalClicks, &s.Today.Clicks, &s.Week.Clicks, &s.Month.Clicks); err != nil { + return nil, fmt.Errorf("short url click stats: %w", err) + } + + if from != nil && to != nil { + var rng model.ShortURLRangeStat + if err := r.pg.Pool.QueryRow(ctx, + `SELECT COUNT(*) FROM shortened_urls WHERE created_at >= $1 AND created_at < $2`, + *from, *to).Scan(&rng.Created); err != nil { + return nil, fmt.Errorf("short url range created: %w", err) + } + if err := r.pg.Pool.QueryRow(ctx, + `SELECT COUNT(*) FROM shortened_url_clicks WHERE clicked_at >= $1 AND clicked_at < $2`, + *from, *to).Scan(&rng.Clicks); err != nil { + return nil, fmt.Errorf("short url range clicks: %w", err) + } + s.Range = &rng + } + + return &s, nil +} diff --git a/internal/server/server.go b/internal/server/server.go index db7f6a3..f5d081c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1041,6 +1041,8 @@ func (s *Server) setupRouter() { r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback) r.Get("/reports", s.adminH.ListReports) r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport) + r.Get("/short-urls", s.adminH.ListShortURLs) + r.Get("/short-urls/stats", s.adminH.ShortURLStats) }) }) diff --git a/internal/service/admin_short_url.go b/internal/service/admin_short_url.go new file mode 100644 index 0000000..dd7928f --- /dev/null +++ b/internal/service/admin_short_url.go @@ -0,0 +1,25 @@ +package service + +import ( + "context" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +// ListShortURLs pages the shortened-URL table for the admin panel. +func (s *AdminService) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) { + return s.repo.ListShortURLs(ctx, q, limit, offset) +} + +// ShortURLStats computes the today / this-week / this-month / all-time short-URL +// summary (and an optional custom range). The fixed windows are UTC-calendar +// based: today from 00:00 UTC, week from Monday 00:00 UTC, month from the 1st. +func (s *AdminService) ShortURLStats(ctx context.Context, from, to *time.Time) (*model.ShortURLStats, error) { + now := time.Now().UTC() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + // Monday as the first day of the week (Weekday: Sun=0 … Sat=6). + weekStart := todayStart.AddDate(0, 0, -((int(now.Weekday()) + 6) % 7)) + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + return s.repo.ShortURLStats(ctx, todayStart, weekStart, monthStart, from, to) +}