Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
9dec3f4
admin stuff
BenHall-1 Mar 11, 2026
5154ced
Unmarshal ButtonStyle as JSON string
biast12 Mar 21, 2026
ae86408
sku & pol poc
BenHall-1 Mar 22, 2026
0757728
kb
BenHall-1 Apr 7, 2026
658595e
Add AuditActionTicketCloseRequest constant
biast12 Apr 8, 2026
e455514
kb cust
BenHall-1 Apr 10, 2026
5043826
kb settings
BenHall-1 Apr 11, 2026
f346e7e
gallery & integrations admin
BenHall-1 Apr 18, 2026
6975703
Remove global panel settings
biast12 Apr 23, 2026
b231631
Merge branch 'master' into admin-stuff
biast12 Apr 23, 2026
bf2981d
analytics
BenHall-1 Apr 26, 2026
14f2c8e
+g support for t&f
BenHall-1 Apr 26, 2026
6788adf
centralise bot staff
BenHall-1 Apr 26, 2026
0ff8524
stats
BenHall-1 Apr 27, 2026
eb571b1
Panel: add PanelAutoClose and new panel settings
biast12 May 10, 2026
68cf132
onboarding
BenHall-1 May 14, 2026
dd36fea
Merge branch 'admin-stuff' of github.com:TicketsBot-cloud/database in…
BenHall-1 May 14, 2026
4674dde
Remove ThreadArchiveDuration field from Panel
biast12 May 14, 2026
b0f97fe
Replace DeleteMentions with MentionBehaviour
biast12 May 15, 2026
6f43c8b
Remove legacy DB tables and add emoji_animated
biast12 May 15, 2026
6d35a72
feat: aff & not
BenHall-1 May 17, 2026
685f7bd
form input apis
BenHall-1 May 18, 2026
1ec684c
analytics
BenHall-1 May 19, 2026
7a12766
feat: edit affiliate codes
BenHall-1 May 24, 2026
51bc7cc
Include emoji_animated in panel SELECT
biast12 May 26, 2026
103fd22
DB schema safety, context propagation, and resource fixes
biast12 May 28, 2026
6aee1cb
update currency items
BenHall-1 Jun 20, 2026
8fc151a
fix: use live guild counts for integrations instead of stale material…
biast12 Jun 20, 2026
2681dcb
extra method for wl bots
BenHall-1 Jun 21, 2026
76cd273
Merge branch 'admin-stuff' of github.com:TicketsBot-cloud/database in…
BenHall-1 Jun 21, 2026
7a463be
Merge branch 'master' into admin-stuff
biast12 Jun 24, 2026
3bf71f5
Add public integration owner lookup
biast12 Jul 1, 2026
080a754
Add whitelabel resync audit action
biast12 Jul 4, 2026
c59120e
feat: add description to kb article
BenHall-1 Jul 15, 2026
dea9a17
add article feedback
BenHall-1 Jul 18, 2026
e9df4ef
Add GetAll method for whitelabel bots
biast12 Jul 22, 2026
a374b92
Merge branch 'admin-stuff' of github.com:TicketsBot-cloud/database in…
BenHall-1 Jul 26, 2026
dcfee8b
Add atomic ticket claim support
biast12 Jul 27, 2026
318a922
Add ticket transfer audit action
biast12 Jul 27, 2026
26f68fb
Filter notifications by category list
biast12 Aug 2, 2026
9af08a0
Order daily ticket counts chronologically
biast12 Aug 5, 2026
c3f3c23
feat: feature flags
BenHall-1 Aug 6, 2026
be4c9ce
feat: panel performance & filtering
BenHall-1 Aug 6, 2026
c82111a
Add case-insensitive close reason aggregation
biast12 Aug 6, 2026
5c547e0
Add KB article helpful vote counters
biast12 Aug 10, 2026
1aeb6e8
Add bulk KB article position updater
biast12 Aug 10, 2026
81b78d7
Add count query for available integrations
biast12 Aug 13, 2026
487d0d5
rm kb custom domains
BenHall-1 Aug 13, 2026
94230d4
Merge branch 'admin-stuff' of github.com:TicketsBot-cloud/database in…
BenHall-1 Aug 13, 2026
4d1d712
Add body_template to API config storage
biast12 Aug 15, 2026
c19fa05
audit log
BenHall-1 Aug 17, 2026
c9762dc
Add admin global_view support to bot staff
biast12 Aug 17, 2026
f146495
Add staff/global filters to audit log queries
biast12 Aug 17, 2026
612e01f
Add ticket sorting by rating and close reason
biast12 Aug 18, 2026
1492d38
Exclude ticket creators from participant counts
biast12 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions adminanalytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package database

import (
"context"
stdjson "encoding/json"
"time"

"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)

type AdminAnalyticsTable struct {
*pgxpool.Pool
}

func newAdminAnalyticsTable(db *pgxpool.Pool) *AdminAnalyticsTable {
return &AdminAnalyticsTable{db}
}

func (a AdminAnalyticsTable) Schema() string {
return `
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_usage AS
SELECT
COUNT(*) FILTER (WHERE open_time > NOW() - INTERVAL '1 day') AS tickets_created_today,
COUNT(DISTINCT guild_id) FILTER (WHERE open_time > NOW() - INTERVAL '1 day') AS active_guilds_daily,
COUNT(DISTINCT guild_id) FILTER (WHERE open_time > NOW() - INTERVAL '7 days') AS active_guilds_weekly,
COUNT(DISTINCT guild_id) FILTER (WHERE open_time > NOW() - INTERVAL '30 days') AS active_guilds_monthly,
COUNT(DISTINCT guild_id) AS total_guilds
FROM tickets;

CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_tickets_per_day AS
SELECT date_trunc('day', open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*)::int AS count
FROM tickets
WHERE open_time > NOW() - INTERVAL '90 days'
GROUP BY 1
ORDER BY 1;

CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_feature_adoption AS
SELECT 'Panels' AS feature, COUNT(DISTINCT guild_id)::int AS guild_count FROM panels
UNION ALL SELECT 'Multipanels', COUNT(DISTINCT guild_id)::int FROM multi_panels
UNION ALL SELECT 'Forms', COUNT(DISTINCT guild_id)::int FROM forms
UNION ALL SELECT 'Teams', COUNT(DISTINCT guild_id)::int FROM support_team
UNION ALL SELECT 'Labels', COUNT(DISTINCT guild_id)::int FROM ticket_labels
UNION ALL SELECT 'Feedback', COUNT(DISTINCT guild_id)::int FROM service_ratings
UNION ALL SELECT 'Integrations', COUNT(DISTINCT guild_id)::int FROM custom_integration_guilds
UNION ALL SELECT 'Close Reasons', COUNT(DISTINCT guild_id)::int FROM close_reason WHERE close_reason IS NOT NULL;

CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_retention AS
WITH active AS (
SELECT DISTINCT guild_id FROM tickets WHERE open_time > NOW() - INTERVAL '30 days'
), previously_active AS (
SELECT DISTINCT guild_id FROM tickets
WHERE open_time > NOW() - INTERVAL '60 days' AND open_time <= NOW() - INTERVAL '30 days'
), churned AS (
SELECT pa.guild_id, (SELECT MAX(open_time) FROM tickets t WHERE t.guild_id = pa.guild_id) AS last_ticket_time
FROM previously_active pa
WHERE NOT EXISTS (SELECT 1 FROM active a WHERE a.guild_id = pa.guild_id)
)
SELECT
(SELECT COUNT(*) FROM active) AS active_guilds_30d,
(SELECT COUNT(*) FROM churned) AS churned_guilds_30d,
COALESCE(json_agg(json_build_object('guild_id', c.guild_id, 'last_ticket_time', c.last_ticket_time) ORDER BY c.last_ticket_time DESC) FILTER (WHERE c.guild_id IS NOT NULL), '[]'::json) AS recently_churned
FROM (SELECT * FROM churned ORDER BY last_ticket_time DESC LIMIT 50) c;

CREATE MATERIALIZED VIEW IF NOT EXISTS mv_admin_config_patterns AS
SELECT setting, value, count FROM (
SELECT 'Feedback Enabled' AS setting, 'Yes' AS value, COUNT(DISTINCT guild_id)::int AS count FROM service_ratings
UNION ALL
SELECT 'Panels per Guild', panel_bucket, COUNT(*)::int FROM (
SELECT CASE
WHEN cnt = 1 THEN '1'
WHEN cnt <= 3 THEN '2-3'
WHEN cnt <= 5 THEN '4-5'
WHEN cnt <= 10 THEN '6-10'
ELSE '11+'
END AS panel_bucket
FROM (SELECT guild_id, COUNT(*)::int AS cnt FROM panels GROUP BY guild_id) sub
) bucketed GROUP BY panel_bucket
) combined;

CREATE UNIQUE INDEX IF NOT EXISTS mv_admin_tickets_per_day_date ON mv_admin_tickets_per_day(date);
CREATE UNIQUE INDEX IF NOT EXISTS mv_admin_feature_adoption_feature ON mv_admin_feature_adoption(feature);
CREATE UNIQUE INDEX IF NOT EXISTS mv_admin_config_patterns_idx ON mv_admin_config_patterns(setting, value);
`
}

func (a *AdminAnalyticsTable) RefreshViews(ctx context.Context) error {
views := []string{
"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_admin_tickets_per_day",
"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_admin_feature_adoption",
"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_admin_config_patterns",
"REFRESH MATERIALIZED VIEW mv_admin_usage",
"REFRESH MATERIALIZED VIEW mv_admin_retention",
}

for _, q := range views {
if _, err := a.Exec(ctx, q); err != nil {
return err
}
}

return nil
}

type GlobalUsageMetrics struct {
TicketsCreatedToday int `json:"tickets_created_today"`
ActiveGuildsDaily int `json:"active_guilds_daily"`
ActiveGuildsWeekly int `json:"active_guilds_weekly"`
ActiveGuildsMonthly int `json:"active_guilds_monthly"`
TotalGuilds int `json:"total_guilds"`
}

func (a *AdminAnalyticsTable) GetGlobalUsageMetrics(ctx context.Context) (metrics GlobalUsageMetrics, e error) {
query := `SELECT tickets_created_today, active_guilds_daily, active_guilds_weekly, active_guilds_monthly, total_guilds FROM mv_admin_usage;`
err := a.QueryRow(ctx, query).Scan(
&metrics.TicketsCreatedToday,
&metrics.ActiveGuildsDaily,
&metrics.ActiveGuildsWeekly,
&metrics.ActiveGuildsMonthly,
&metrics.TotalGuilds,
)
if err != nil && err != pgx.ErrNoRows {
e = err
}
return
}

type GlobalTicketsPerDay struct {
Date time.Time `json:"date"`
Count int `json:"count"`
}

func (a *AdminAnalyticsTable) GetGlobalTicketsPerDay(ctx context.Context) ([]GlobalTicketsPerDay, error) {
query := `SELECT date, count FROM mv_admin_tickets_per_day ORDER BY date;`
rows, err := a.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()

var results []GlobalTicketsPerDay
for rows.Next() {
var r GlobalTicketsPerDay
if err := rows.Scan(&r.Date, &r.Count); err != nil {
return nil, err
}
results = append(results, r)
}
return results, nil
}

type FeatureAdoption struct {
Feature string `json:"feature"`
GuildCount int `json:"guild_count"`
}

func (a *AdminAnalyticsTable) GetFeatureAdoption(ctx context.Context) ([]FeatureAdoption, error) {
query := `SELECT feature, guild_count FROM mv_admin_feature_adoption ORDER BY guild_count DESC;`
rows, err := a.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()

var results []FeatureAdoption
for rows.Next() {
var r FeatureAdoption
if err := rows.Scan(&r.Feature, &r.GuildCount); err != nil {
return nil, err
}
results = append(results, r)
}
return results, nil
}

type ChurnedGuild struct {
GuildId uint64 `json:"guild_id"`
LastTicketTime time.Time `json:"last_ticket_time"`
}

type RetentionMetrics struct {
ActiveGuilds30d int `json:"active_guilds_30d"`
ChurnedGuilds30d int `json:"churned_guilds_30d"`
RecentlyChurned []ChurnedGuild `json:"recently_churned"`
}

func (a *AdminAnalyticsTable) GetRetentionMetrics(ctx context.Context) (metrics RetentionMetrics, e error) {
var churnedJson []byte
query := `SELECT active_guilds_30d, churned_guilds_30d, recently_churned::text FROM mv_admin_retention;`
err := a.QueryRow(ctx, query).Scan(&metrics.ActiveGuilds30d, &metrics.ChurnedGuilds30d, &churnedJson)
if err != nil {
if err == pgx.ErrNoRows {
metrics.RecentlyChurned = make([]ChurnedGuild, 0)
return metrics, nil
}
return metrics, err
}

if len(churnedJson) > 0 {
if err := stdjson.Unmarshal(churnedJson, &metrics.RecentlyChurned); err != nil {
return metrics, err
}
}

if metrics.RecentlyChurned == nil {
metrics.RecentlyChurned = make([]ChurnedGuild, 0)
}
return
}

type ConfigPatternEntry struct {
Setting string `json:"setting"`
Value string `json:"value"`
Count int `json:"count"`
}

func (a *AdminAnalyticsTable) GetConfigPatterns(ctx context.Context) ([]ConfigPatternEntry, error) {
query := `SELECT setting, value, count FROM mv_admin_config_patterns ORDER BY setting, count DESC;`
rows, err := a.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()

var results []ConfigPatternEntry
for rows.Next() {
var r ConfigPatternEntry
if err := rows.Scan(&r.Setting, &r.Value, &r.Count); err != nil {
return nil, err
}
results = append(results, r)
}
return results, nil
}
Loading
Loading