feat: url dashboard
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1041,6 +1041,8 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||||
r.Get("/reports", s.adminH.ListReports)
|
r.Get("/reports", s.adminH.ListReports)
|
||||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||||
|
r.Get("/short-urls", s.adminH.ListShortURLs)
|
||||||
|
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user