hiling_go/db/repository/location_visits.go
2026-06-14 05:52:04 +03:00

179 lines
5.3 KiB
Go

package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
)
// All queries in this file are hand-written and intentionally NOT defined in
// `db/queries/*.sql`, so they are not regenerated by sqlc.
// TrendingWindow is the trailing time-window over which visits are summed
// to compute "trending". Values are expressed in days so they map to exact
// row ranges in the daily-bucket location_page_visits table.
type TrendingWindow string
const (
TrendingWindowWeek TrendingWindow = "week" // 7 days
TrendingWindowMonth TrendingWindow = "month" // 30 days
TrendingWindow3Month TrendingWindow = "3month" // 90 days
TrendingWindowSemester TrendingWindow = "semester" // 180 days
TrendingWindowYear TrendingWindow = "year" // 365 days
)
// Days returns the trailing window length in days. Returns (0, false) for
// unknown values.
func (w TrendingWindow) Days() (int, bool) {
switch w {
case TrendingWindowWeek:
return 7, true
case TrendingWindowMonth:
return 30, true
case TrendingWindow3Month:
return 90, true
case TrendingWindowSemester:
return 180, true
case TrendingWindowYear:
return 365, true
default:
return 0, false
}
}
// ParseTrendingWindow returns the typed window for a string, or false if
// the string isn't recognised.
func ParseTrendingWindow(s string) (TrendingWindow, bool) {
w := TrendingWindow(s)
if _, ok := w.Days(); !ok {
return "", false
}
return w, true
}
// RecordLocationVisit increments today's visit bucket for the given location,
// creating the row if it does not yet exist. Idempotent within Postgres
// transactions; the (location_id, day) UNIQUE constraint guarantees a single
// row per location per day.
func (q *Queries) RecordLocationVisit(ctx context.Context, locationID int32) error {
const query = `
INSERT INTO location_page_visits (location_id, day, visit_count, updated_at)
VALUES ($1, CURRENT_DATE, 1, now())
ON CONFLICT (location_id, day)
DO UPDATE SET
visit_count = location_page_visits.visit_count + 1,
updated_at = now()
`
_, err := q.db.Exec(ctx, query, locationID)
return err
}
// GetLocationLifetimeVisits returns the all-time sum of visit_count for a
// single location. Cheap because the (location_id, day) index covers it.
func (q *Queries) GetLocationLifetimeVisits(ctx context.Context, locationID int32) (int64, error) {
const query = `
SELECT COALESCE(SUM(visit_count), 0)::bigint AS total
FROM location_page_visits
WHERE location_id = $1
`
row := q.db.QueryRow(ctx, query, locationID)
var total int64
err := row.Scan(&total)
return total, err
}
type GetTrendingLocationsParams struct {
Window TrendingWindow `json:"window"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type GetTrendingLocationsRow struct {
RowNumber int32 `json:"row_number"`
ID int32 `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
LocationType string `json:"location_type"`
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
Thumbnail pgtype.Text `json:"thumbnail"`
RegencyName string `json:"regency_name"`
ProvinceName string `json:"province_name"`
RegionName string `json:"region_name"`
WindowVisits int64 `json:"window_visits"`
}
// GetTrendingLocations ranks approved, non-deleted locations by total page
// visits over the trailing window. The query is keyed off the
// idx_location_page_visits_day_count index (day, visit_count DESC).
//
// The bound day count is bound as a parameter; the planner still treats it as
// a constant for the row (current_date - $1::int), so the index range scan is
// effective.
func (q *Queries) GetTrendingLocations(ctx context.Context, arg GetTrendingLocationsParams) ([]GetTrendingLocationsRow, error) {
days, ok := arg.Window.Days()
if !ok {
return nil, fmt.Errorf("invalid trending window %q", arg.Window)
}
const query = `
SELECT
row_number() OVER (ORDER BY window_visits DESC, l.id ASC) AS row_number,
l.id,
l.name,
l.address,
l.location_type::text AS location_type,
l.google_maps_link,
l.thumbnail,
COALESCE(re.regency_name, '') AS regency_name,
COALESCE(prov.province_name, '') AS province_name,
COALESCE(reg.region_name, '') AS region_name,
window_visits
FROM (
SELECT v.location_id, SUM(v.visit_count)::bigint AS window_visits
FROM location_page_visits v
WHERE v.day >= (CURRENT_DATE - $1::int)
GROUP BY v.location_id
) agg
JOIN locations l ON l.id = agg.location_id
JOIN regencies re ON re.id = l.regency_id
JOIN provinces prov ON prov.id = re.province_id
JOIN regions reg ON reg.id = prov.region_id
WHERE l.is_deleted = false
AND l.approved_by IS NOT NULL
ORDER BY window_visits DESC, l.id ASC
LIMIT $2 OFFSET $3
`
rows, err := q.db.Query(ctx, query, days, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetTrendingLocationsRow{}
for rows.Next() {
var i GetTrendingLocationsRow
if err := rows.Scan(
&i.RowNumber,
&i.ID,
&i.Name,
&i.Address,
&i.LocationType,
&i.GoogleMapsLink,
&i.Thumbnail,
&i.RegencyName,
&i.ProvinceName,
&i.RegionName,
&i.WindowVisits,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}