179 lines
5.3 KiB
Go
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
|
|
}
|