package tenant import ( "context" "database/sql" "encoding/json" "time" ) type PageView struct { ID int64 Path string PostSlug string Referrer string UserAgent string CreatedAt time.Time } type AnalyticsSummary struct { TotalViews int64 `json:"total_views"` TotalPageViews int64 `json:"total_page_views"` UniqueVisitors int64 `json:"unique_visitors"` TotalBandwidth int64 `json:"total_bandwidth"` ViewsChange float64 `json:"views_change"` TopPages []PageStats `json:"top_pages"` TopReferrers []ReferrerStats `json:"top_referrers"` ViewsByDay []DailyStats `json:"views_by_day"` Browsers []NamedStat `json:"browsers"` OS []NamedStat `json:"os"` Devices []NamedStat `json:"devices"` Countries []NamedStat `json:"countries"` } type PageStats struct { Path string `json:"path"` Views int64 `json:"views"` } type ReferrerStats struct { Referrer string `json:"referrer"` Views int64 `json:"views"` } type DailyStats struct { Date string `json:"date"` Views int64 `json:"views"` Visitors int64 `json:"visitors"` } type NamedStat struct { Name string `json:"name"` Count int64 `json:"count"` } type ArchivedDay struct { Date string `json:"date"` Requests int64 `json:"requests"` PageViews int64 `json:"page_views"` UniqueVisitors int64 `json:"unique_visitors"` Bandwidth int64 `json:"bandwidth"` Browsers []NamedStat `json:"browsers"` OS []NamedStat `json:"os"` Devices []NamedStat `json:"devices"` Countries []NamedStat `json:"countries"` Paths []PageStats `json:"paths"` } func (q *Queries) RecordPageView(ctx context.Context, path, postSlug, referrer, userAgent string) error { _, err := q.db.ExecContext(ctx, `INSERT INTO page_views (path, post_slug, referrer, user_agent, visitor_hash, utm_source, utm_medium, utm_campaign, device_type, browser, os, country) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, path, nullStr(postSlug), nullStr(referrer), nullStr(userAgent), sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}) return err } func (q *Queries) GetAnalytics(ctx context.Context, days int) (*AnalyticsSummary, error) { if days <= 0 { days = 30 } since := time.Now().AddDate(0, 0, -days).Format("2006-01-02") var totalCount, uniqueCount int64 err := q.db.QueryRowContext(ctx, `SELECT COUNT(*), COUNT(DISTINCT visitor_hash) FROM page_views WHERE created_at >= ?`, since). Scan(&totalCount, &uniqueCount) if err != nil { return nil, err } topPagesRows, err := q.db.QueryContext(ctx, `SELECT path, COUNT(*) as views FROM page_views WHERE created_at >= ? GROUP BY path ORDER BY views DESC LIMIT ?`, since, 10) if err != nil { return nil, err } defer topPagesRows.Close() var topPages []PageStats for topPagesRows.Next() { var p PageStats if err := topPagesRows.Scan(&p.Path, &p.Views); err != nil { return nil, err } topPages = append(topPages, p) } topRefRows, err := q.db.QueryContext(ctx, `SELECT COALESCE(referrer, 'Direct') as referrer, COUNT(*) as views FROM page_views WHERE created_at >= ? AND referrer != '' GROUP BY referrer ORDER BY views DESC LIMIT ?`, since, 10) if err != nil { return nil, err } defer topRefRows.Close() var topReferrers []ReferrerStats for topRefRows.Next() { var r ReferrerStats if err := topRefRows.Scan(&r.Referrer, &r.Views); err != nil { return nil, err } topReferrers = append(topReferrers, r) } viewsByDayRows, err := q.db.QueryContext(ctx, `SELECT DATE(created_at) as date, COUNT(*) as views FROM page_views WHERE created_at >= ? GROUP BY date ORDER BY date ASC`, since) if err != nil { return nil, err } defer viewsByDayRows.Close() var viewsByDay []DailyStats for viewsByDayRows.Next() { var d DailyStats var date any if err := viewsByDayRows.Scan(&date, &d.Views); err != nil { return nil, err } if s, ok := date.(string); ok { d.Date = s } viewsByDay = append(viewsByDay, d) } return &AnalyticsSummary{ TotalViews: totalCount, TotalPageViews: totalCount, UniqueVisitors: uniqueCount, TopPages: topPages, TopReferrers: topReferrers, ViewsByDay: viewsByDay, }, nil } func (q *Queries) GetPostAnalytics(ctx context.Context, slug string, days int) (*AnalyticsSummary, error) { if days <= 0 { days = 30 } since := time.Now().AddDate(0, 0, -days).Format("2006-01-02") var totalViews int64 err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM page_views WHERE post_slug = ? AND created_at >= ?`, slug, since).Scan(&totalViews) if err != nil { return nil, err } viewsByDayRows, err := q.db.QueryContext(ctx, `SELECT DATE(created_at) as date, COUNT(*) as views FROM page_views WHERE post_slug = ? AND created_at >= ? GROUP BY date ORDER BY date ASC`, slug, since) if err != nil { return nil, err } defer viewsByDayRows.Close() var viewsByDay []DailyStats for viewsByDayRows.Next() { var d DailyStats var date any if err := viewsByDayRows.Scan(&date, &d.Views); err != nil { return nil, err } if s, ok := date.(string); ok { d.Date = s } viewsByDay = append(viewsByDay, d) } refRows, err := q.db.QueryContext(ctx, `SELECT COALESCE(referrer, 'Direct') as referrer, COUNT(*) as views FROM page_views WHERE post_slug = ? AND created_at >= ? AND referrer != '' GROUP BY referrer ORDER BY views DESC LIMIT ?`, slug, since, 10) if err != nil { return nil, err } defer refRows.Close() var topReferrers []ReferrerStats for refRows.Next() { var r ReferrerStats if err := refRows.Scan(&r.Referrer, &r.Views); err != nil { return nil, err } topReferrers = append(topReferrers, r) } return &AnalyticsSummary{ TotalViews: totalViews, TopReferrers: topReferrers, ViewsByDay: viewsByDay, }, nil } func (q *Queries) SaveDailyAnalytics(ctx context.Context, day *ArchivedDay) error { browsers, _ := json.Marshal(day.Browsers) os, _ := json.Marshal(day.OS) devices, _ := json.Marshal(day.Devices) countries, _ := json.Marshal(day.Countries) paths, _ := json.Marshal(day.Paths) _, err := q.db.ExecContext(ctx, `INSERT INTO daily_analytics (date, requests, page_views, unique_visitors, bandwidth, browsers, os, devices, countries, paths) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(date) DO UPDATE SET requests = excluded.requests, page_views = excluded.page_views, unique_visitors = excluded.unique_visitors, bandwidth = excluded.bandwidth, browsers = excluded.browsers, os = excluded.os, devices = excluded.devices, countries = excluded.countries, paths = excluded.paths`, day.Date, day.Requests, day.PageViews, day.UniqueVisitors, day.Bandwidth, nullStr(string(browsers)), nullStr(string(os)), nullStr(string(devices)), nullStr(string(countries)), nullStr(string(paths))) return err } func (q *Queries) GetArchivedAnalytics(ctx context.Context, since, until string) ([]ArchivedDay, error) { rows, err := q.db.QueryContext(ctx, `SELECT date, requests, page_views, unique_visitors, bandwidth, browsers, os, devices, countries, paths FROM daily_analytics WHERE date >= ? AND date <= ? ORDER BY date ASC`, since, until) if err != nil { return nil, err } defer rows.Close() var days []ArchivedDay for rows.Next() { var d ArchivedDay var requests, pageViews, uniqueVisitors, bandwidth sql.NullInt64 var browsers, os, devices, countries, paths sql.NullString if err := rows.Scan(&d.Date, &requests, &pageViews, &uniqueVisitors, &bandwidth, &browsers, &os, &devices, &countries, &paths); err != nil { return nil, err } d.Requests = requests.Int64 d.PageViews = pageViews.Int64 d.UniqueVisitors = uniqueVisitors.Int64 d.Bandwidth = bandwidth.Int64 if browsers.Valid { json.Unmarshal([]byte(browsers.String), &d.Browsers) } if os.Valid { json.Unmarshal([]byte(os.String), &d.OS) } if devices.Valid { json.Unmarshal([]byte(devices.String), &d.Devices) } if countries.Valid { json.Unmarshal([]byte(countries.String), &d.Countries) } if paths.Valid { json.Unmarshal([]byte(paths.String), &d.Paths) } days = append(days, d) } return days, rows.Err() } func (q *Queries) GetOldestArchivedDate(ctx context.Context) (string, error) { var date any err := q.db.QueryRowContext(ctx, `SELECT MIN(date) FROM daily_analytics`).Scan(&date) if err != nil || date == nil { return "", err } if s, ok := date.(string); ok { return s, nil } return "", nil } func (q *Queries) HasArchivedDate(ctx context.Context, date string) (bool, error) { var count int64 err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM daily_analytics WHERE date = ?`, date).Scan(&count) return count > 0, err }